Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 31 additions & 29 deletions scripts/aidlc-traceability/docs/threat-model.md

Large diffs are not rendered by default.

83 changes: 63 additions & 20 deletions scripts/aidlc-traceability/src/traceability/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,25 +62,59 @@ def list_artifact_files(directory: str) -> str:
return json.dumps(files, indent=2)


@tool
def read_source_code_file(file_path: str, max_lines: int = 200) -> str:
"""Read the contents of a source code file.
def _resolve_within_project(project_root: Path, file_path: str) -> Path:
"""Resolve a source path within the project root, preventing traversal.

Args:
file_path: Path to the source code file to read.
max_lines: Maximum number of lines to return (default 200).
Accepts absolute paths or paths relative to the project root. Strips an
optional "CODE:" artifact-ID prefix. Rejects any path (including symlink
targets) that resolves outside the project boundary.
"""
p = Path(file_path)
if not p.exists():
return f"Error: File not found: {file_path}"
try:
content = p.read_text(encoding="utf-8", errors="ignore")
lines = content.split("\n")
if len(lines) > max_lines:
return "\n".join(lines[:max_lines]) + f"\n\n... ({len(lines) - max_lines} more lines truncated)"
return content
except Exception as e:
return f"Error reading file: {e}"
root = project_root.resolve()
raw = file_path[len("CODE:"):] if file_path.startswith("CODE:") else file_path
candidate = Path(raw)
combined = candidate if candidate.is_absolute() else (root / candidate)
resolved = combined.resolve()
if resolved != root and root not in resolved.parents:
raise ValueError(f"Path outside project root denied: {file_path}")
return resolved


def make_source_code_reader(project_root: Path):
"""Create a source-code reading tool bound to a specific project root.

All file access is confined to ``project_root``; requests that resolve
outside the boundary (via traversal or symlinks) are rejected. Binding the
root in a closure prevents the AI agent from being directed to read
arbitrary host files via prompt injection in artifact content.
"""
root = project_root.resolve()

@tool
def read_source_code_file(file_path: str, max_lines: int = 200) -> str:
"""Read the contents of a source code file within the project.

Args:
file_path: Path to the source code file, relative to the project root.
max_lines: Maximum number of lines to return (default 200).
"""
try:
p = _resolve_within_project(root, file_path)
except ValueError as e:
return f"Error: {e}"
if not p.exists():
return f"Error: File not found: {file_path}"
if not p.is_file():
return f"Error: Not a file: {file_path}"
try:
content = p.read_text(encoding="utf-8", errors="ignore")
lines = content.split("\n")
if len(lines) > max_lines:
return "\n".join(lines[:max_lines]) + f"\n\n... ({len(lines) - max_lines} more lines truncated)"
return content
except Exception as e:
return f"Error reading file: {e}"

return read_source_code_file


def create_req_story_agent(profile_name: str | None = None, region: str = "us-east-1") -> Agent:
Expand Down Expand Up @@ -156,8 +190,17 @@ def create_unit_component_agent(profile_name: str | None = None, region: str = "
return Agent(model=model, tools=[], system_prompt=system_prompt)


def create_component_code_agent(profile_name: str | None = None, region: str = "us-east-1") -> Agent:
"""Create an agent focused on mapping components to code files (can read actual code)."""
def create_component_code_agent(
project_root: Path,
profile_name: str | None = None,
region: str = "us-east-1",
) -> Agent:
"""Create an agent focused on mapping components to code files (can read actual code).

The agent's file-reading tool is confined to ``project_root`` so that
prompt injection in artifact content cannot direct it to read host files
outside the project boundary.
"""
model = create_bedrock_model(profile_name, region)

system_prompt = """You are a component-to-code traceability specialist. Your ONLY job is to map logical components to the source code files that implement them.
Expand All @@ -181,7 +224,7 @@ def create_component_code_agent(profile_name: str | None = None, region: str = "

return Agent(
model=model,
tools=[read_source_code_file],
tools=[make_source_code_reader(project_root)],
system_prompt=system_prompt,
)

Expand Down
14 changes: 14 additions & 0 deletions scripts/aidlc-traceability/src/traceability/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,19 @@ def discover_source_code(project_root: Path) -> list[Path]:
# File extensions to include
code_extensions = {".py", ".js", ".ts", ".tsx", ".jsx"}

root_resolved = project_root.resolve()

for dir_name in source_dirs:
src_dir = project_root / dir_name
if not src_dir.is_dir():
continue

for code_file in src_dir.rglob("*"):
# Skip symlinks: a symlink under src/ could point at a sensitive
# host file outside the project (CWE-59). Do not follow it.
if code_file.is_symlink():
continue

# Skip if not a file
if not code_file.is_file():
continue
Expand All @@ -108,6 +115,13 @@ def discover_source_code(project_root: Path) -> list[Path]:
if any(excl in code_file.parts for excl in exclude_patterns):
continue

# Defense in depth: ensure the resolved path stays within the
# project root before reading it (guards against symlinked
# parent directories that rglob may have traversed).
resolved = code_file.resolve()
if resolved != root_resolved and root_resolved not in resolved.parents:
continue

# Skip test files (but keep them if they're in tests/ for dedicated test parsing)
rel_path = str(code_file.relative_to(project_root)).lower()
if "test_" in code_file.name or "_test." in code_file.name or ".test." in code_file.name:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ def generate_html(report: TraceabilityReport, G: nx.DiGraph) -> str:

<script>
// Artifact data
const artifacts = """ + json.dumps(artifact_data, indent=2) + """;
const artifacts = """ + json.dumps(artifact_data, indent=2).replace("</", "<\\/") + """;

let currentArtifact = null;

Expand Down
2 changes: 1 addition & 1 deletion scripts/aidlc-traceability/src/traceability/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def run_pipeline(
# Stage 3d: Components → Code
if components and code_artifacts:
console.print(f" [bold cyan]Stage 3d:[/] Mapping {len(components)} components to {len(code_artifacts)} code files...")
agent_cc = create_component_code_agent(profile_name=aws_profile, region=aws_region)
agent_cc = create_component_code_agent(project_root, profile_name=aws_profile, region=aws_region)
cc_rels, cc_insights = run_component_code_analysis(agent_cc, components, code_artifacts)
all_relationships.extend(cc_rels)
ai_insights.extend(cc_insights)
Expand Down
76 changes: 76 additions & 0 deletions scripts/aidlc-traceability/tests/test_agent_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 AIDLC Traceability Tool Contributors
"""Security regression tests for the AI agent file-reading boundary (Finding 2)."""

from __future__ import annotations

from pathlib import Path

import pytest

from traceability.agent import _resolve_within_project, make_source_code_reader


class TestResolveWithinProject:
def test_relative_path_inside_root(self, tmp_path: Path):
(tmp_path / "src").mkdir()
target = tmp_path / "src" / "app.py"
target.write_text("x = 1\n")
resolved = _resolve_within_project(tmp_path, "src/app.py")
assert resolved == target.resolve()

def test_strips_code_prefix(self, tmp_path: Path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").write_text("")
resolved = _resolve_within_project(tmp_path, "CODE:src/app.py")
assert resolved == (tmp_path / "src" / "app.py").resolve()

def test_rejects_parent_traversal(self, tmp_path: Path):
project = tmp_path / "project"
project.mkdir()
with pytest.raises(ValueError):
_resolve_within_project(project, "../secret.txt")

def test_rejects_absolute_outside(self, tmp_path: Path):
project = tmp_path / "project"
project.mkdir()
with pytest.raises(ValueError):
_resolve_within_project(project, "/etc/passwd")


class TestMakeSourceCodeReader:
def test_reads_file_within_root(self, tmp_path: Path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").write_text("hello = 1\n")
reader = make_source_code_reader(tmp_path)
result = reader("src/app.py")
assert "hello = 1" in result

def test_blocks_traversal_read(self, tmp_path: Path):
project = tmp_path / "project"
project.mkdir()
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET\n")
reader = make_source_code_reader(project)
result = reader("../secret.txt")
assert "TOP SECRET" not in result
assert "denied" in result.lower()

def test_blocks_absolute_path_read(self, tmp_path: Path):
project = tmp_path / "project"
project.mkdir()
reader = make_source_code_reader(project)
result = reader("/etc/hosts")
assert "denied" in result.lower()

def test_blocks_symlink_escape(self, tmp_path: Path):
project = tmp_path / "project"
(project / "src").mkdir(parents=True)
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET\n")
link = project / "src" / "leak.py"
link.symlink_to(secret)
reader = make_source_code_reader(project)
result = reader("src/leak.py")
assert "TOP SECRET" not in result
assert "denied" in result.lower()
38 changes: 38 additions & 0 deletions scripts/aidlc-traceability/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,41 @@ def test_includes_js_ts(self, tmp_path: Path):
(src / "Component.tsx").write_text("")
files = discover_source_code(tmp_path)
assert len(files) == 3

def test_skips_symlink_to_outside_file(self, tmp_path: Path):
"""Finding 3 (CWE-59): a symlink in src/ pointing at a host file
outside the project must not be followed and read."""
project = tmp_path / "project"
src = project / "src"
src.mkdir(parents=True)
(src / "real.py").write_text("# real code\n")

# Sensitive file outside the project root
secret = tmp_path / "secret.py"
secret.write_text("SECRET = 'do-not-read'\n")

link = src / "leak.py"
link.symlink_to(secret)

files = discover_source_code(project)
names = [f.name for f in files]
assert "real.py" in names
assert "leak.py" not in names
assert secret not in [f.resolve() for f in files]

def test_skips_symlinked_directory(self, tmp_path: Path):
"""A symlinked directory under src/ must not be traversed into."""
project = tmp_path / "project"
src = project / "src"
src.mkdir(parents=True)
(src / "real.py").write_text("")

outside = tmp_path / "outside"
outside.mkdir()
(outside / "leak.py").write_text("SECRET = 1\n")

(src / "linkdir").symlink_to(outside, target_is_directory=True)

files = discover_source_code(project)
resolved = [f.resolve() for f in files]
assert (outside / "leak.py").resolve() not in resolved
28 changes: 28 additions & 0 deletions scripts/aidlc-traceability/tests/test_generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,34 @@ def test_escapes_special_chars(self):
# The artifact title should be escaped (not rendered as raw HTML)
assert '&lt;script&gt;alert("xss")&lt;/script&gt;' in html

def test_script_block_no_breakout(self):
"""Finding 1 (CWE-79): a title containing </script> must not break out
of the embedded JSON <script> block."""
from traceability.models import Artifact, ArtifactType, TraceabilityReport, CoverageMetrics
arts = [
Artifact(
id="REQ-XSS",
title='</script><script>alert(1)</script>',
artifact_type=ArtifactType.REQUIREMENT,
description='foo </SCRIPT> bar',
)
]
report = TraceabilityReport(
project_name="Proj",
artifacts=arts,
metrics=CoverageMetrics(total_requirements=1),
)
G, _ = build_graph(arts, [])
html = generate_html(report, G)

# The raw closing-tag sequences from the malicious title must never
# appear verbatim in the output; they must be neutralized to <\/ so the
# browser cannot terminate the <script> block early.
assert "</script><script>alert(1)" not in html
assert "</SCRIPT>" not in html
assert "<\\/script><script>alert(1)<\\/script>" in html
assert "foo <\\/SCRIPT> bar" in html

def test_empty_report(self):
from traceability.models import TraceabilityReport
report = TraceabilityReport()
Expand Down
Loading