Skip to content
Open
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
238 changes: 237 additions & 1 deletion src/vuln_analysis/functions/code_agent_graph_defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ class CodeAgentState(MessagesState):
thought: NotRequired[CheckerThought | None]
observation: NotRequired[Observation | None]
vulnerability_intel: NotRequired["VulnerabilityIntel | None"]
# New intel flow result (Issue 08: per-file focused observation context)
intel_gathering_result: NotRequired["IntelGatheringResult | None"]
arch_mismatch_reason: NotRequired[str | None]
# Reference Mining state (populated when no patch found via existing flows)
reference_hints: NotRequired["ReferenceHints | None"]
Expand Down Expand Up @@ -140,6 +142,240 @@ class ParsedPatch(BaseModel):
files: list[PatchFile]


class FileChangeSummary(BaseModel):
"""Per-file summary of changes from a patch for tracing/debugging."""
file_path: str
is_new_file: bool = False
is_deleted_file: bool = False
removed_lines_count: int = 0
added_lines_count: int = 0
hunks_count: int = 0
functions_touched: list[str] = Field(default_factory=list)
key_removed_patterns: list[str] = Field(default_factory=list)
key_added_patterns: list[str] = Field(default_factory=list)
estimated_tokens: int = 0


class CVEUnderstanding(BaseModel):
"""CVE-level intelligence extracted without patch (Prompt 1 output)."""
vulnerability_type: str = Field(
default="",
description="Category: buffer_overflow, integer_overflow, use_after_free, etc."
)
affected_component: str = Field(
default="",
description="High-level component affected (e.g., 'memory allocator', 'XML parser')"
)
attack_vector: Literal["network", "local", "physical", "adjacent", "unknown"] = Field(
default="unknown",
description="How the vulnerability is triggered"
)
cve_keywords: list[str] = Field(
default_factory=list,
description="Search terms ordered by specificity (most specific first)"
)
root_cause: str = Field(
default="",
description="Technical explanation of why the code is vulnerable"
)


class PerFileIntel(BaseModel):
"""Per-file intelligence from patch analysis (Prompt 2/3 output)."""
file_path: str
vulnerable_functions: list[str] = Field(default_factory=list)
vulnerable_patterns: list[str] = Field(default_factory=list)
fix_patterns: list[str] = Field(default_factory=list)
search_keywords: list[str] = Field(default_factory=list)


class IntelGatheringResult(BaseModel):
"""Internal object holding all gathered intelligence, queryable per-file.

This is the NEW internal representation. It can generate a VulnerabilityIntel
for backward compatibility with existing consumers.
"""

cve_understanding: CVEUnderstanding | None = None
per_file_intel: dict[str, PerFileIntel] = Field(default_factory=dict)
file_summaries: list[FileChangeSummary] = Field(default_factory=list)
patch_source: str = ""
total_files_in_patch: int = 0
files_analyzed_by_llm: int = 0
prompt_count: int = 0

def get_intel_for_file(self, file_path: str) -> PerFileIntel | None:
"""Query intel for a specific file (for focused observation context).

Matches by exact path or basename equality only. Does not use substring
containment (avoids 'rsync' matching grep target 'rsync.c').
"""
if not file_path:
return None

query = file_path.replace("\\", "/").lower().lstrip("./")
query_basename = Path(query).name

if file_path in self.per_file_intel:
return self.per_file_intel[file_path]

for key, intel in self.per_file_intel.items():
key_norm = key.replace("\\", "/").lower().lstrip("./")
if query == key_norm:
return intel
if query_basename == Path(key_norm).name:
return intel

return None

def get_functions_for_file(self, file_path: str) -> list[str]:
"""Get all function names relevant to a file (hunk headers + LLM)."""
functions: list[str] = []

query_basename = Path(file_path).name.lower()
for fs in self.file_summaries:
summary_basename = Path(fs.file_path).name.lower()
if query_basename == summary_basename:
functions.extend(fs.functions_touched)

file_intel = self.get_intel_for_file(file_path)
if file_intel:
functions.extend(file_intel.vulnerable_functions)

seen: set[str] = set()
return [f for f in functions if not (f in seen or seen.add(f))]

def to_vulnerability_intel(self) -> VulnerabilityIntel:
"""Convert to VulnerabilityIntel for backward compatibility.

Aggregates all per-file intel into the flat structure expected by
existing consumers.
"""
affected_files = list(self.per_file_intel.keys())

all_functions: list[str] = []
all_vulnerable_patterns: list[str] = []
all_fix_patterns: list[str] = []
all_keywords: list[str] = []

for intel in self.per_file_intel.values():
all_functions.extend(intel.vulnerable_functions)
all_vulnerable_patterns.extend(intel.vulnerable_patterns)
all_fix_patterns.extend(intel.fix_patterns)
all_keywords.extend(intel.search_keywords)

return VulnerabilityIntel(
affected_files=affected_files,
vulnerable_functions=list(dict.fromkeys(all_functions)),
vulnerable_patterns=all_vulnerable_patterns[:10],
fix_patterns=all_fix_patterns[:10],
search_keywords=list(dict.fromkeys(all_keywords))[:10],
vulnerability_type=self.cve_understanding.vulnerability_type if self.cve_understanding else "",
root_cause=self.cve_understanding.root_cause if self.cve_understanding else "",
)

def format_focused_context(self, grep_target: str) -> str:
"""Format intel for a specific file for inclusion in observation prompt."""
file_intel = self.get_intel_for_file(grep_target)
if not file_intel:
return ""

lines = [f"**Patch context for `{file_intel.file_path}`:**"]

functions = self.get_functions_for_file(grep_target)
if functions:
lines.append(f"- Functions modified: {', '.join(functions[:5])}")

if file_intel.vulnerable_patterns:
lines.append("- Vulnerable patterns (removed lines):")
for p in file_intel.vulnerable_patterns[:3]:
lines.append(f" - `{p[:80]}`")

if file_intel.fix_patterns:
lines.append("- Fix patterns (added lines):")
for p in file_intel.fix_patterns[:3]:
lines.append(f" - `{p[:80]}`")

return "\n".join(lines)


DEFAULT_MAX_PATTERNS = 3
DEFAULT_MIN_PATTERN_LENGTH = 10


def extract_file_change_summaries(
parsed_patch: ParsedPatch | None,
max_patterns: int = DEFAULT_MAX_PATTERNS,
min_pattern_length: int = DEFAULT_MIN_PATTERN_LENGTH,
) -> list[FileChangeSummary]:
"""Extract per-file change summaries from a parsed patch.

Args:
parsed_patch: Parsed patch structure
max_patterns: Max sample patterns per file
min_pattern_length: Min chars for a pattern to be "non-trivial"

Returns:
List of FileChangeSummary, one per file in patch
"""
if not parsed_patch:
return []

summaries = []
for pf in parsed_patch.files:
removed_lines: list[str] = []
added_lines: list[str] = []
for hunk in pf.hunks:
removed_lines.extend(hunk.removed_lines or [])
added_lines.extend(hunk.added_lines or [])

functions = [
hunk.section_header.strip()
for hunk in pf.hunks
if hunk.section_header and hunk.section_header.strip()
]

key_removed = [
line.strip()
for line in removed_lines
if len(line.strip()) >= min_pattern_length
][:max_patterns]

key_added = [
line.strip()
for line in added_lines
if len(line.strip()) >= min_pattern_length
][:max_patterns]

file_content = "\n".join(removed_lines + added_lines)
estimated_tokens = count_tokens(file_content)

summaries.append(
FileChangeSummary(
file_path=pf.clean_target_path,
is_new_file=pf.is_new_file,
is_deleted_file=pf.is_deleted_file,
removed_lines_count=len(removed_lines),
added_lines_count=len(added_lines),
hunks_count=len(pf.hunks),
functions_touched=functions,
key_removed_patterns=key_removed,
key_added_patterns=key_added,
estimated_tokens=estimated_tokens,
)
)

return summaries


def estimate_patch_tokens(parsed_patch: ParsedPatch | None) -> int:
"""Estimate total tokens in a parsed patch."""
if not parsed_patch:
return 0
summaries = extract_file_change_summaries(parsed_patch)
return sum(s.estimated_tokens for s in summaries)


class OSVPatchResult(BaseModel):
"""Result of fetching a patch from OSV/GitHub or intel references."""
cve_id: str
Expand Down Expand Up @@ -348,7 +584,7 @@ class AdvisoryContent(BaseModel):
CONFIDENCE_REVISION_FALLBACK = 0.85
CONFIDENCE_FUNCTION_MESSAGE = 0.75
CONFIDENCE_FUNCTION_PICKAXE = 0.70
CONFIDENCE_THRESHOLD_MIN = 0.50



class CommitSearchResult(BaseModel):
Expand Down
7 changes: 6 additions & 1 deletion src/vuln_analysis/functions/cve_build_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,12 @@ async def observation_node(state: BuildAgentState) -> dict:
tool_output_for_llm = tool_message.content

# Check for empty/error outputs - bypass LLM if so to prevent hallucination
empty_findings, _ = check_empty_output(tool_output_for_llm, tool_used, tool_input_detail)
empty_findings, _ = check_empty_output(
tool_output_for_llm,
tool_used,
tool_input_detail,
tool_status=getattr(tool_message, "status", None),
)
if empty_findings:
# Build-specific: empty grep for file in logs = NOT_COMPILED evidence
if tool_used == "Source Grep" and "logs:" in tool_input_detail:
Expand Down
3 changes: 1 addition & 2 deletions src/vuln_analysis/functions/cve_checker_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,7 @@ def _summarize_parsed_patch_lines(parsed_patch: ParsedPatch | None) -> list[str]
if not parsed_patch:
return []
lines: list[str] = []
if parsed_patch.patch_filename:
lines.append(f"- **Patch file:** `{parsed_patch.patch_filename}`")

for pf in parsed_patch.files[:2]:
lines.append(f"- **File in patch:** `{pf.clean_target_path}`")
for hunk in pf.hunks[:1]:
Expand Down
Loading