diff --git a/src/exploit_iq_commons/data_models/checker_status.py b/src/exploit_iq_commons/data_models/checker_status.py
index 447feebcd..04c6f6249 100644
--- a/src/exploit_iq_commons/data_models/checker_status.py
+++ b/src/exploit_iq_commons/data_models/checker_status.py
@@ -192,10 +192,16 @@ class VulnerabilityIntel(BaseModel):
description="Human-readable rationale behind identify_phase_fix_signal (e.g. NVRs compared)."
)
- def format_for_prompt(self) -> str:
+ def format_for_prompt(self, *, include_pattern_lists: bool = True) -> str:
"""Format VulnerabilityIntel for injection into L1 agent runtime prompt.
-
+
Uses UPPERCASE labels so they can be referenced as anchors in thought prompts.
+
+ Args:
+ include_pattern_lists: When False, omit VULNERABLE_PATTERNS / FIX_PATTERNS
+ call-site lists (e.g. when FILE_CHANGES already carries per-file
+ removed/added patterns). Object fields are unchanged; functions and
+ keywords still emit. Default True preserves legacy full formatting.
"""
lines = []
if self.is_downstream_patch_available:
@@ -209,11 +215,11 @@ def format_for_prompt(self) -> str:
lines.append(f"VULNERABLE_FUNCTIONS: {', '.join(self.vulnerable_functions)}")
if self.vulnerable_variables:
lines.append(f"VULNERABLE_VARIABLES: {', '.join(self.vulnerable_variables)}")
- if self.vulnerable_patterns:
+ if include_pattern_lists and self.vulnerable_patterns:
lines.append("VULNERABLE_PATTERNS:")
for p in self.vulnerable_patterns:
lines.append(f" - {p}")
- if self.fix_patterns:
+ if include_pattern_lists and self.fix_patterns:
lines.append("FIX_PATTERNS:")
for p in self.fix_patterns:
lines.append(f" - {p}")
diff --git a/src/vuln_analysis/functions/code_agent_graph_defs.py b/src/vuln_analysis/functions/code_agent_graph_defs.py
index 53531fdb8..32d4943c0 100644
--- a/src/vuln_analysis/functions/code_agent_graph_defs.py
+++ b/src/vuln_analysis/functions/code_agent_graph_defs.py
@@ -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"]
@@ -140,6 +142,327 @@ 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"
+ )
+ component_names: list[str] = Field(
+ default_factory=list,
+ description=(
+ "Module/component names mentioned verbatim in CVE/advisory "
+ "(e.g. mod_http2). Empty if none explicit."
+ ),
+ )
+ affected_bitness: Literal["32-bit", "64-bit", "both"] = Field(
+ default="both",
+ description="Which bitness is affected: 32-bit only, 64-bit only, or both",
+ )
+ affected_architectures: list[str] | None = Field(
+ default=None,
+ description="CPU families affected (e.g. ['x86']). None means all architectures.",
+ )
+
+
+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)
+
+ cve = self.cve_understanding
+ 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=cve.vulnerability_type if cve else "",
+ root_cause=cve.root_cause if cve else "",
+ component_names=list(cve.component_names) if cve else [],
+ affected_bitness=cve.affected_bitness if cve else "both",
+ affected_architectures=(
+ list(cve.affected_architectures)
+ if cve and cve.affected_architectures is not None
+ else None
+ ),
+ )
+
+ 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
+FILE_CHANGE_PATTERN_MAX_CHARS = 100
+FILE_CHANGES_THOUGHT_HEADER = (
+ "FILE_CHANGES (from fixed patch — orient UNPATCHED search):"
+)
+# Soft cap for thought prompt only; matches L1 max_iterations default.
+# Full file_summaries stay in memory for focused observation context.
+DEFAULT_FILE_CHANGES_THOUGHT_MAX_FILES = 10
+
+
+def _file_change_sort_key(summary: FileChangeSummary) -> tuple[int, int]:
+ """Sort key: largest total churn first, then highest removals (pre-move homes)."""
+ total = summary.removed_lines_count + summary.added_lines_count
+ return (total, summary.removed_lines_count)
+
+
+def format_file_changes_for_thought(
+ summaries: list[FileChangeSummary] | None,
+ max_files: int | None = DEFAULT_FILE_CHANGES_THOUGHT_MAX_FILES,
+) -> str:
+ """Format per-file patch summaries for the L1 thought intel block.
+
+ Preserves delete/rename locality that flat VULNERABLE_PATTERNS / FIX_PATTERNS
+ lose (e.g. pure deletion in disk-io.c vs rewrite in extent_io.c).
+
+ Soft-caps to ``max_files`` (typically ``config.max_iterations``) after sorting by
+ change size so the agent sees the most actionable files first. Pass ``None`` for
+ no cap. Does not mutate the input list.
+ """
+ if not summaries:
+ return ""
+
+ ordered = sorted(summaries, key=_file_change_sort_key, reverse=True)
+ omitted = 0
+ if max_files is not None and max_files >= 0 and len(ordered) > max_files:
+ omitted = len(ordered) - max_files
+ ordered = ordered[:max_files]
+
+ lines = [FILE_CHANGES_THOUGHT_HEADER]
+ for summary in ordered:
+ lines.append(
+ f" {summary.file_path}: "
+ f"-{summary.removed_lines_count} +{summary.added_lines_count}"
+ )
+ removed = summary.key_removed_patterns[:DEFAULT_MAX_PATTERNS]
+ added = summary.key_added_patterns[:DEFAULT_MAX_PATTERNS]
+ removed_text = (
+ "; ".join(p[:FILE_CHANGE_PATTERN_MAX_CHARS] for p in removed)
+ if removed
+ else "(none)"
+ )
+ added_text = (
+ "; ".join(p[:FILE_CHANGE_PATTERN_MAX_CHARS] for p in added)
+ if added
+ else "(none)"
+ )
+ lines.append(f" removed: {removed_text}")
+ lines.append(f" added: {added_text}")
+
+ if omitted:
+ lines.append(
+ f" ... +{omitted} more FILE_CHANGES omitted (cap={max_files})"
+ )
+
+ return "\n".join(lines)
+
+
+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
@@ -348,7 +671,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):
diff --git a/src/vuln_analysis/functions/cve_build_agent.py b/src/vuln_analysis/functions/cve_build_agent.py
index d33cb24ba..31c7858df 100644
--- a/src/vuln_analysis/functions/cve_build_agent.py
+++ b/src/vuln_analysis/functions/cve_build_agent.py
@@ -70,6 +70,7 @@
L2_CONFIG_SPEC_ONLY_SYS_PROMPT,
L2_CONFIG_SPEC_ONLY_THOUGHT_INSTRUCTIONS,
L2_COMPREHENSION_PROMPT,
+ L2_HARDENING_COMPREHENSION_PROMPT,
L2_MEMORY_UPDATE_PROMPT,
L2_FINDINGS_REFINEMENT_PROMPT,
L2_HARDENING_PROMPT_TEMPLATE,
@@ -87,6 +88,13 @@
import tiktoken
logger = LoggingFactory.get_agent_logger(__name__)
+EMPTY_LOGS_NOT_COMPILED_HINT = (
+ "This indicates the file was NOT compiled in this build"
+)
+EMPTY_LOGS_FLAG_ABSENT_HINT = (
+ "This indicates the searched hardening flag was ABSENT from the build log"
+)
+
class CVEBuildAgentConfig(FunctionBaseConfig, name="cve_build_agent"):
"""
@@ -455,23 +463,39 @@ async def observation_node(state: BuildAgentState) -> dict:
harvest_report = state.get("harvest_report") or BuildHarvestReport()
target_package_name = target_package.name if target_package else "unknown"
+ current_phase = investigation_stack[-1] if investigation_stack else None
+ is_hardening_phase = current_phase == L2InvestigationPhase.HARDENING
with tracer.push_active_function("observation_node", input_data=f"tool used:{tool_used} + {tool_input_detail}") as span:
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:
- file_searched = tool_input_detail.replace("logs:", "")
- empty_findings = CodeFindings(
- findings=[
- f"Source Grep for '{file_searched}' returned EMPTY - file not in build log",
- "This indicates the file was NOT compiled in this build"
- ],
- tool_outcome=f"CALLED: Source Grep with {tool_input_detail} -> EMPTY (not compiled)"
- )
+ pattern_searched = tool_input_detail.replace("logs:", "")
+ if is_hardening_phase:
+ empty_findings = CodeFindings(
+ findings=[
+ f"FLAG_ABSENT: {pattern_searched} - searched but not found in build log",
+ EMPTY_LOGS_FLAG_ABSENT_HINT,
+ ],
+ tool_outcome=f"CALLED: Source Grep with {tool_input_detail} -> EMPTY (flag absent)",
+ )
+ else:
+ # Config phase: empty file/pattern grep = NOT_COMPILED evidence
+ empty_findings = CodeFindings(
+ findings=[
+ f"Source Grep for '{pattern_searched}' returned EMPTY - file not in build log",
+ EMPTY_LOGS_NOT_COMPILED_HINT,
+ ],
+ tool_outcome=f"CALLED: Source Grep with {tool_input_detail} -> EMPTY (not compiled)",
+ )
code_findings = empty_findings
else:
# Step 1: Comprehension - split into chunks and process each
@@ -480,19 +504,32 @@ async def observation_node(state: BuildAgentState) -> dict:
best_tool_outcome = ""
for chunk in chunks:
- comp_prompt = L2_COMPREHENSION_PROMPT.format(
- vuln_id=vuln_id,
- target_package=target_package_name,
- vulnerability_intel=vulnerability_intel_str,
- disabled_features=", ".join(harvest_report.disabled_features) if harvest_report.disabled_features else "None",
- spec_disabled_features=", ".join(harvest_report.spec_disabled_features) if harvest_report.spec_disabled_features else "None",
- enabled_features=", ".join(harvest_report.enabled_features) if harvest_report.enabled_features else "None",
- spec_enabled_features=", ".join(harvest_report.spec_enabled_features) if harvest_report.spec_enabled_features else "None",
- tool_used=tool_used,
- tool_input=tool_input_detail,
- last_thought=last_thought_text,
- tool_output=chunk,
- )
+ if is_hardening_phase:
+ expected = harvest_report.expected_hardening or "None"
+ comp_prompt = L2_HARDENING_COMPREHENSION_PROMPT.format(
+ vuln_id=vuln_id,
+ target_package=target_package_name,
+ cwe_id=cwe_id or "unknown",
+ expected_hardening=expected,
+ tool_used=tool_used,
+ tool_input=tool_input_detail,
+ last_thought=last_thought_text,
+ tool_output=chunk,
+ )
+ else:
+ comp_prompt = L2_COMPREHENSION_PROMPT.format(
+ vuln_id=vuln_id,
+ target_package=target_package_name,
+ vulnerability_intel=vulnerability_intel_str,
+ disabled_features=", ".join(harvest_report.disabled_features) if harvest_report.disabled_features else "None",
+ spec_disabled_features=", ".join(harvest_report.spec_disabled_features) if harvest_report.spec_disabled_features else "None",
+ enabled_features=", ".join(harvest_report.enabled_features) if harvest_report.enabled_features else "None",
+ spec_enabled_features=", ".join(harvest_report.spec_enabled_features) if harvest_report.spec_enabled_features else "None",
+ tool_used=tool_used,
+ tool_input=tool_input_detail,
+ last_thought=last_thought_text,
+ tool_output=chunk,
+ )
chunk_findings: CodeFindings = await invoke_comprehension(
comprehension_llm, comp_prompt, tool_used, tool_input_detail, chunk, agent_label="L2",
)
@@ -510,14 +547,21 @@ async def observation_node(state: BuildAgentState) -> dict:
)
findings_text = "\n".join(f"- {f}" for f in code_findings.findings)
- # Step 2: Findings Refinement LLM — bounded input only (no previous_memory)
- ref_prompt = L2_FINDINGS_REFINEMENT_PROMPT.format(
- vuln_id=vuln_id,
- target_package=target_package_name,
- findings=findings_text,
- tool_outcome=code_findings.tool_outcome,
- )
- refined: RefinedFindings = await refinement_llm.ainvoke([SystemMessage(content=ref_prompt)])
+ # Step 2: Findings refinement — config uses compile labels; hardening
+ # skips that prompt so FLAG_* evidence is not rewritten as FILE_*.
+ if is_hardening_phase:
+ refined = RefinedFindings(
+ results=list(code_findings.findings),
+ tool_record=code_findings.tool_outcome,
+ )
+ else:
+ ref_prompt = L2_FINDINGS_REFINEMENT_PROMPT.format(
+ vuln_id=vuln_id,
+ target_package=target_package_name,
+ findings=findings_text,
+ tool_outcome=code_findings.tool_outcome,
+ )
+ refined = await refinement_llm.ainvoke([SystemMessage(content=ref_prompt)])
# Step 3: InternalMemory — deterministic dedup, eviction, token-budget enforcement
internal_memory: InternalMemory = state.get("internal_memory") or InternalMemory()
@@ -725,8 +769,13 @@ async def investigation_phase_node(state: BuildAgentState) -> dict:
prune_messages = []
for msg in messages:
prune_messages.append(RemoveMessage(id=msg.id))
+ # Preserve compile verdict on the span; do not overwrite with the
+ # hardening runtime prompt (that made config-phase traces misleading).
span.set_output({
- "runtime_prompt": runtime_prompt,
+ "compilation_status": verdict.compilation_status,
+ "confidence": verdict.confidence,
+ "reasoning": verdict.reasoning,
+ "next_phase": L2InvestigationPhase.HARDENING,
})
return {
"runtime_prompt": runtime_prompt,
diff --git a/src/vuln_analysis/functions/cve_checker_report.py b/src/vuln_analysis/functions/cve_checker_report.py
index 493c7fcf1..61e958404 100644
--- a/src/vuln_analysis/functions/cve_checker_report.py
+++ b/src/vuln_analysis/functions/cve_checker_report.py
@@ -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]:
diff --git a/src/vuln_analysis/functions/cve_package_code_agent.py b/src/vuln_analysis/functions/cve_package_code_agent.py
index 36e4b8260..e7277a49f 100644
--- a/src/vuln_analysis/functions/cve_package_code_agent.py
+++ b/src/vuln_analysis/functions/cve_package_code_agent.py
@@ -42,13 +42,17 @@
upstream_search_preprocess,
extract_l1_verdict,
VulnerabilityIntel,
- format_patch_data_chunks_for_intel,
- merge_vulnerability_intel_chunks,
get_relevant_hunks,
ReferenceHints,
AdvisoryContent,
GitSearchReport,
- CONFIDENCE_THRESHOLD_MIN,
+ extract_file_change_summaries,
+ format_file_changes_for_thought,
+ FileChangeSummary,
+ IntelGatheringResult,
+ CVEUnderstanding,
+ PerFileIntel,
+ _truncate_diff_by_tokens,
)
from vuln_analysis.utils.rpm_checker_prompts import (
L1_AGENT_SYS_PROMPT_PATCH_AVAILABLE,
@@ -64,17 +68,27 @@
L1_MEMORY_UPDATE_PROMPT,
L1_FINDINGS_REFINEMENT_PROMPT,
L1_EMPTY_RESULT_CLASSIFICATION_PROMPT,
- VULNERABILITY_INTEL_EXTRACTION_PROMPT,
+ CVE_UNDERSTANDING_PROMPT,
+ PATCH_ANALYSIS_PROMPT,
+ format_functions_touched_summary,
)
from vuln_analysis.tools.brew_downloader import BrewDownloader, BrewDownloaderError, resolve_brew_profile
from vuln_analysis.utils.package_identifier import _extract_dist_tag, extract_nvd_version_info
from vuln_analysis.functions.react_internals import (
CheckerThought, CodeFindings, Observation, FORCED_FINISH_PROMPT,
- check_empty_output, invoke_comprehension,
+ check_empty_output, invoke_comprehension, try_deterministic_empty_grep_findings,
RefinedFindings, InternalMemory, MemoryItem, parse_to_memory_item,
)
-from vuln_analysis.utils.intel_utils import extract_commit_url_candidates, extract_advisory_urls
+from vuln_analysis.utils.intel_utils import (
+ TEST_FILE_RE,
+ extract_commit_url_candidates,
+ extract_advisory_urls,
+)
+from vuln_analysis.functions.cve_checker_report import (
+ NON_CODE_LANGUAGES,
+ infer_language_from_path,
+)
from vuln_analysis.utils.vulnerability_intel_sanitizer import VulnerabilityIntelSanitizer
from vuln_analysis.utils.reference_fetcher import ReferenceFetcher
from vuln_analysis.utils.reference_parser import ReflectiveReferenceParser, ParserConfig
@@ -112,6 +126,50 @@
"s390x": "s390",
}
+SOURCE_GREP_TOOL_NAME = "Source Grep"
+GREP_QUERY_FILE_SEPARATOR = ","
+SPAN_OUTPUT_PREVIEW_CHARS = 500
+PATCH_CONTEXT_REFERENCE_EMPTY = "No per-file patch context for this grep target."
+
+
+def _extract_file_from_grep_query(grep_query: str) -> str | None:
+ """Extract the target file filter from a Source Grep query.
+
+ Matches get_relevant_hunks() parsing: use the segment after the last comma.
+ Examples: "pattern,filename.c" -> "filename.c"; "pattern" -> None.
+ """
+ if not grep_query or GREP_QUERY_FILE_SEPARATOR not in grep_query:
+ return None
+
+ file_filter = grep_query.split(GREP_QUERY_FILE_SEPARATOR)[-1].strip()
+ return file_filter or None
+
+
+def _resolve_focused_intel_context(
+ intel_result: IntelGatheringResult | None,
+ tool_used: str,
+ tool_input_detail: str,
+) -> str:
+ """Return per-file intel markdown for Source Grep, or empty if not applicable."""
+ if tool_used != SOURCE_GREP_TOOL_NAME or not intel_result:
+ return ""
+
+ grep_file = _extract_file_from_grep_query(tool_input_detail)
+ if not grep_file:
+ return ""
+
+ return intel_result.format_focused_context(grep_file)
+
+
+def _format_tool_output_with_focused_context(tool_output: str, focused_context: str) -> str:
+ """Return tool output unchanged (focused intel is not mixed into NEW OUTPUT).
+
+ ``focused_context`` is kept in the signature for call-site compatibility; it is
+ supplied separately to the observation prompt as ``patch_context_reference``.
+ """
+ _ = focused_context
+ return tool_output
+
def _parse_fix_info_from_context(ctx, target_name: str, target_release: str | None = None) -> dict:
"""Extract {name, version, release} from checker_context.identify_result.fixed_rpm_list.
@@ -175,6 +233,146 @@ def _apply_identify_phase_fix_signal(
return vulnerability_intel
+FORCED_FINISH_RESPONSE_MARKER = "RESPONSE:"
+
+FORCED_FINISH_VULNERABLE_OVERRIDE = (
+ "VULNERABLE. Primary fix marker absent from source (FIX_CODE_ABSENT). "
+ "Identify phase fixed=no; no FIX_APPLIED_AT_CALL_SITE. "
+ "Forced-finish override of UNCERTAIN under strong VULNERABLE signal."
+)
+
+FORCED_FINISH_IDENTIFY_CONTEXT = """IDENTIFY_PHASE_CONTEXT (advisory only — clear source evidence always wins):
+- affected: {affected}
+- fixed: {fixed}
+- note: {note}
+
+MAX-STEPS DECISION ORDER (apply in order; stop at first match):
+A. If fixed=yes AND KNOWLEDGE has strong fix-applied evidence → PATCHED via rebase.
+B. If fixed=no AND KNOWLEDGE has FIX_CODE_ABSENT for a primary FILE_CHANGES.added /
+ FIX_ONLY marker AND no FIX_APPLIED_AT_CALL_SITE for that marker → VULNERABLE.
+ This is mandatory. Do NOT answer UNCERTAIN when B matches.
+C. Otherwise follow the numbered rules below.
+
+IDENTIFY-NOTE OVERRIDE AT MAX-STEPS:
+The identify note may say "confirm the vulnerable pattern is present before concluding
+vulnerable." That applies during investigation steps only. At forced finish, rule B/4
+wins: primary FIX_CODE_ABSENT alone is enough for VULNERABLE when fixed=no.
+
+EXAMPLE matching B (copy this shape, not the EMPTY_VULN UNCERTAIN example):
+KNOWLEDGE: FIX_CODE_ABSENT for FILE_CHANGES.added marker; VULNERABLE_CODE_ABSENT for some
+other removed fragment; NOT_IN_DIFF noise; fixed=no
+→ final_answer: "VULNERABLE. Primary fix marker absent (FIX_CODE_ABSENT). Identify fixed=no."
+
+Rules when finishing under max-steps / incomplete coverage:
+1. Strong PATCHED-via-rebase signal — use when ALL of these hold:
+ - fixed=yes
+ - KNOWLEDGE has multiple FIX_APPLIED_AT_CALL_SITE and/or FIX_ONLY_FOUND entries
+ (fix present across several files/sites), OR clear vulnerable-absent with fix-present
+ → Finish as PATCHED via rebase / not-vulnerable.
+ → Incomplete AFFECTED_FILES coverage does NOT force UNCERTAIN in this case.
+2. Use UNCERTAIN only when fixed=yes but fix evidence is weak or conflicting:
+ - definition-only (no call-site), single ambiguous hit, or mostly vulnerable-present.
+3. Do NOT use version-range membership to force VULNERABLE when fixed=yes.
+4. Strong VULNERABLE signal — same as decision B above:
+ - fixed=no (and/or affected=yes)
+ - KNOWLEDGE has VULNERABLE_CODE_FOUND and/or FIX_CODE_ABSENT for a primary
+ FILE_CHANGES.added / FIX_ONLY marker
+ - No FIX_APPLIED_AT_CALL_SITE for that primary fix marker
+ → Finish as VULNERABLE.
+ → FIX_CODE_ABSENT for a primary FILE_CHANGES.added marker is sufficient by itself
+ for this rule — even if some other removed fragment is VULNERABLE_CODE_ABSENT.
+ → Do NOT treat “one removed fragment absent + primary fix absent” as a wash;
+ primary FIX_CODE_ABSENT wins over a noisy/partial VULNERABLE_CODE_ABSENT.
+ → Do NOT use EXAMPLE_FINISH_EMPTY_VULN_PATTERNS_UNCERTAIN when primary
+ FIX_CODE_ABSENT is present.
+ → NOT_IN_DIFF / AMBIGUOUS_PATTERN entries never justify UNCERTAIN and never
+ count as fix-applied evidence.
+ → Do NOT let stray FIX_ONLY_FOUND noise (unrelated APIs, ambiguous hits, or
+ patterns not from FILE_CHANGES.added) force UNCERTAIN.
+5. Prefer UNCERTAIN ONLY when decision B/rule 4 does NOT match and evidence remains
+ inconclusive. Never prefer UNCERTAIN when primary FIX_CODE_ABSENT + fixed=no."""
+
+
+def _memory_has_fix_code_absent(memory: list[str]) -> bool:
+ """True if KNOWLEDGE records absence of a fix / FILE_CHANGES.added marker."""
+ return any(entry.startswith("FIX_CODE_ABSENT:") for entry in memory)
+
+
+def _memory_has_fix_applied_at_call_site(memory: list[str]) -> bool:
+ """True if KNOWLEDGE records fix applied at a call site."""
+ return any(entry.startswith("FIX_APPLIED_AT_CALL_SITE:") for entry in memory)
+
+
+def _maybe_override_uncertain_forced_finish(
+ final_answer: str,
+ memory: list[str] | None,
+ identify_result: PackageIdentifyResult | None,
+) -> str:
+ """Override UNCERTAIN → VULNERABLE when rule B evidence is already in KNOWLEDGE.
+
+ Prompt-only guidance is unreliable at max-steps; enforce the strong VULNERABLE
+ signal deterministically when fixed=no and primary fix is absent.
+ """
+ if not final_answer or not memory or not identify_result:
+ return final_answer
+ if not final_answer.lstrip().upper().startswith("UNCERTAIN"):
+ return final_answer
+ if identify_result.is_target_package_fixed != EnumIdentifyResult.NO:
+ return final_answer
+ if _memory_has_fix_applied_at_call_site(memory):
+ return final_answer
+ if not _memory_has_fix_code_absent(memory):
+ return final_answer
+
+ logger.info(
+ "forced_finish: overriding UNCERTAIN -> VULNERABLE "
+ "(FIX_CODE_ABSENT present, fixed=no, no FIX_APPLIED_AT_CALL_SITE)"
+ )
+ return FORCED_FINISH_VULNERABLE_OVERRIDE
+
+
+NOT_IN_DIFF_PREFIX = "NOT_IN_DIFF:"
+
+
+def _is_not_in_diff_finding(entry: str) -> bool:
+ """True for observation findings that are unrelated to the CVE patch delta."""
+ return entry.startswith(NOT_IN_DIFF_PREFIX)
+
+
+def _knowledge_entries_for_thought(entries: list[str] | None) -> list[str]:
+ """Drop NOT_IN_DIFF noise from thought/finish KNOWLEDGE; keep TOOL_CALL_RECORD."""
+ if not entries:
+ return []
+ return [entry for entry in entries if not _is_not_in_diff_finding(entry)]
+
+
+def _build_forced_finish_human_prompt(
+ identify_result: PackageIdentifyResult | None,
+) -> str:
+ """Build forced-finish human message; re-surface identify signals next to the finish order."""
+ head, sep, tail = FORCED_FINISH_PROMPT.partition(FORCED_FINISH_RESPONSE_MARKER)
+ if not sep:
+ return FORCED_FINISH_PROMPT
+ if not identify_result:
+ return FORCED_FINISH_PROMPT
+
+ affected = identify_result.is_target_package_affected
+ fixed = identify_result.is_target_package_fixed
+ if (
+ affected == EnumIdentifyResult.UNKNOWN
+ and fixed == EnumIdentifyResult.UNKNOWN
+ and not identify_result.conclusion_reason
+ ):
+ return FORCED_FINISH_PROMPT
+
+ context = FORCED_FINISH_IDENTIFY_CONTEXT.format(
+ affected=affected.value,
+ fixed=fixed.value,
+ note=identify_result.conclusion_reason or "No identify-phase note available.",
+ )
+ return f"{head.rstrip()}\n\n{context}\n\n{sep}{tail}"
+
+
def _build_tool_strategy(tool_names: list[str]) -> str:
"""Generate tool usage guidance based on available tools."""
strategies = []
@@ -309,37 +507,428 @@ def _merge_hints(existing: ReferenceHints, new: ReferenceHints) -> ReferenceHint
)
-def merge_reference_hints_to_intel(
- intel: VulnerabilityIntel,
- hints: ReferenceHints,
-) -> VulnerabilityIntel:
- """Merge extracted reference hints into VulnerabilityIntel.
+def _apply_reference_hints_to_new_intel(
+ result: IntelGatheringResult,
+ hints: ReferenceHints | None,
+) -> None:
+ """Attach reference_mining hints onto IntelGatheringResult.
+
+ Ensures reference_mining hints are available via to_vulnerability_intel()
+ and per-file focused observations (especially on no-patch CVEs).
+ """
+ if not hints:
+ return
+
+ for file_path in hints.file_hints:
+ if file_path not in result.per_file_intel:
+ result.per_file_intel[file_path] = PerFileIntel(file_path=file_path)
+
+ targets = list(result.per_file_intel.values())
+ if not targets:
+ # No file hints and no patch intel — still attach function/revision hints
+ # so to_vulnerability_intel() can surface them for comparison.
+ synthetic = PerFileIntel(file_path=_REFERENCE_HINTS_SYNTHETIC_FILE)
+ result.per_file_intel[_REFERENCE_HINTS_SYNTHETIC_FILE] = synthetic
+ targets = [synthetic]
+
+ existing_funcs = {
+ func
+ for intel in result.per_file_intel.values()
+ for func in intel.vulnerable_functions
+ }
+ new_funcs = [f for f in hints.function_hints if f not in existing_funcs]
+ if new_funcs:
+ targets[0].vulnerable_functions.extend(new_funcs)
+
+ if hints.revision_hint:
+ existing_keywords = {
+ keyword
+ for intel in result.per_file_intel.values()
+ for keyword in intel.search_keywords
+ }
+ if hints.revision_hint not in existing_keywords:
+ targets[0].search_keywords.append(hints.revision_hint)
+
+
+# ---------------------------------------------------------------------------
+# New Intel Flow helpers (Issue 06)
+# ---------------------------------------------------------------------------
+
+# Synthetic per-file key used when reference_mining yields function/revision
+# hints but no file paths and no patch-derived per_file_intel yet.
+_REFERENCE_HINTS_SYNTHETIC_FILE = "__reference_hints__"
+
+# File extension priority for analysis (lower = higher priority)
+_FILE_PRIORITY_IMPL = 0 # .c, .cpp, .cc, .rs, .go, .py, .java - implementation files
+_FILE_PRIORITY_HEADER = 1 # .h, .hpp - headers
+_FILE_PRIORITY_OTHER = 2 # everything else
+
+
+def _is_code_file_for_analysis(file_path: str) -> bool:
+ """Return True when a patch path is worth LLM patch analysis.
+
+ Matches legacy patch formatting: keep recognized source languages, drop
+ docs/config/text and test paths.
+ """
+ lang = infer_language_from_path(file_path)
+ if not lang or lang in NON_CODE_LANGUAGES:
+ return False
+ if TEST_FILE_RE.search(file_path):
+ return False
+ return True
+
- Reference mining hints are lower confidence than patch-derived intel,
- so they are appended (not prepended) to existing lists.
+def _filter_code_files_for_analysis(
+ summaries: list[FileChangeSummary],
+) -> list[FileChangeSummary]:
+ """Drop non-code / test paths before patch-analysis batching."""
+ return [fs for fs in summaries if _is_code_file_for_analysis(fs.file_path)]
+
+
+def _prioritize_files_for_analysis(summaries: list[FileChangeSummary]) -> list[FileChangeSummary]:
+ """Sort files by analysis priority (implementation files first).
+
+ Priority order:
+ 1. Implementation files (.c, .cpp, .cc, .rs, .go, .py, .java) - most likely to contain vuln
+ 2. Header files (.h, .hpp) - may contain inline code or macros
+ 3. Other files - lower priority
+
+ Within each tier, larger files (more tokens) come first.
+ """
+ def priority_key(fs: FileChangeSummary) -> tuple[int, int]:
+ path = fs.file_path.lower()
+ if path.endswith(('.c', '.cpp', '.cc', '.rs', '.go', '.py', '.java')):
+ return (_FILE_PRIORITY_IMPL, -fs.estimated_tokens)
+ if path.endswith(('.h', '.hpp')):
+ return (_FILE_PRIORITY_HEADER, -fs.estimated_tokens)
+ return (_FILE_PRIORITY_OTHER, -fs.estimated_tokens)
+
+ return sorted(summaries, key=priority_key)
+
+
+def _split_files_by_budget(
+ summaries: list[FileChangeSummary],
+ max_tokens: int,
+) -> tuple[list[FileChangeSummary], list[FileChangeSummary]]:
+ """Split files into two batches based on token budget.
Args:
- intel: Existing VulnerabilityIntel (may have data from patch analysis)
- hints: ReferenceHints from reference mining
+ summaries: Pre-sorted list of FileChangeSummary (by priority)
+ max_tokens: Maximum tokens for first batch
Returns:
- Updated VulnerabilityIntel with merged hints
+ (batch1, batch2) where batch1 fits within max_tokens
"""
- existing_funcs = set(intel.vulnerable_functions)
- new_funcs = [f for f in hints.function_hints if f not in existing_funcs]
- intel.vulnerable_functions = intel.vulnerable_functions + new_funcs
+ batch1: list[FileChangeSummary] = []
+ batch2: list[FileChangeSummary] = []
+ current_tokens = 0
+
+ for fs in summaries:
+ if current_tokens + fs.estimated_tokens <= max_tokens:
+ batch1.append(fs)
+ current_tokens += fs.estimated_tokens
+ else:
+ batch2.append(fs)
+
+ return batch1, batch2
+
+
+def _format_patch_content_for_files(
+ parsed_patch: ParsedPatch,
+ file_summaries: list[FileChangeSummary],
+) -> str:
+ """Extract and format patch content for specific files.
+
+ Args:
+ parsed_patch: Full parsed patch (structured)
+ file_summaries: Files to include in output
+
+ Returns:
+ Formatted patch content string for prompt
+ """
+ if not parsed_patch or not parsed_patch.files:
+ return "No patch content available."
+
+ target_paths = {fs.file_path for fs in file_summaries}
+ target_basenames = {Path(fs.file_path).name for fs in file_summaries}
+
+ lines: list[str] = []
+
+ for pf in parsed_patch.files:
+ # Check if this file matches any target
+ clean_path = pf.clean_target_path
+ basename = Path(clean_path).name
+
+ if clean_path not in target_paths and basename not in target_basenames:
+ # Also check if target_paths contain this path (partial match)
+ matched = False
+ for tp in target_paths:
+ if basename in tp or clean_path in tp:
+ matched = True
+ break
+ if not matched:
+ continue
+
+ # Format file header
+ lines.append(f"--- {pf.source_path}")
+ lines.append(f"+++ {pf.target_path}")
+
+ # Format hunks
+ for hunk in pf.hunks:
+ header = f"@@ -{hunk.source_start},{hunk.source_length} +{hunk.target_start},{hunk.target_length} @@"
+ if hunk.section_header:
+ header += f" {hunk.section_header}"
+ lines.append(header)
+
+ # Interleave context, removed, and added lines
+ # Simplified: show removed then added (actual diff order would need more state)
+ for line in hunk.context_lines:
+ lines.append(f" {line}")
+ for line in hunk.removed_lines:
+ lines.append(f"-{line}")
+ for line in hunk.added_lines:
+ lines.append(f"+{line}")
+
+ lines.append("") # Blank line between files
+
+ return "\n".join(lines) if lines else "No matching patch content for selected files."
+
+
+def _fit_patch_analysis_prompt_to_context(
+ *,
+ vuln_id: str,
+ vulnerability_type: str,
+ affected_component: str,
+ root_cause: str,
+ functions_touched_summary: str,
+ patch_content: str,
+ file_path: str,
+ context_window_token_limit: int,
+ reserved_tokens: int,
+) -> str | None:
+ """Build PATCH_ANALYSIS_PROMPT within context_window_token_limit.
+
+ Truncates patch_content when the full prompt would exceed
+ context_window_token_limit - reserved_tokens. Returns None when even an
+ empty patch cannot fit (caller should skip the file).
+ """
+ max_prompt_tokens = context_window_token_limit - reserved_tokens
+ if max_prompt_tokens <= 0:
+ logger.error(
+ "Invalid token budget for patch analysis: context_window_token_limit=%d "
+ "reserved_tokens=%d",
+ context_window_token_limit,
+ reserved_tokens,
+ )
+ return None
- existing_files = set(intel.affected_files)
- new_files = [f for f in hints.file_hints if f not in existing_files]
- intel.affected_files = intel.affected_files + new_files
+ def _build(patch: str) -> str:
+ return PATCH_ANALYSIS_PROMPT.format(
+ vuln_id=vuln_id,
+ vulnerability_type=vulnerability_type,
+ affected_component=affected_component,
+ root_cause=root_cause,
+ functions_touched_summary=functions_touched_summary,
+ patch_content=patch,
+ )
+
+ file_prompt = _build(patch_content)
+ prompt_tokens = count_tokens(file_prompt)
+ if prompt_tokens <= max_prompt_tokens:
+ return file_prompt
+
+ wrapper_tokens = count_tokens(_build(""))
+ patch_budget = max_prompt_tokens - wrapper_tokens
+ if patch_budget <= 0:
+ logger.warning(
+ "Skipping patch analysis for %s: prompt wrapper (%d tokens) exceeds "
+ "budget (%d = context_window_token_limit %d - reserved %d)",
+ file_path,
+ wrapper_tokens,
+ max_prompt_tokens,
+ context_window_token_limit,
+ reserved_tokens,
+ )
+ return None
+
+ truncated_patch = _truncate_diff_by_tokens(patch_content, patch_budget)
+ fitted_prompt = _build(truncated_patch)
+ logger.warning(
+ "Truncated patch analysis prompt for %s: %d -> %d tokens "
+ "(context_window_token_limit=%d, reserved=%d)",
+ file_path,
+ prompt_tokens,
+ count_tokens(fitted_prompt),
+ context_window_token_limit,
+ reserved_tokens,
+ )
+ return fitted_prompt
+
+
+async def gather_intel_new_flow(
+ llm,
+ vuln_id: str,
+ target_package_name: str,
+ cve_description: str,
+ vendor_advisory: str,
+ parsed_patch: ParsedPatch | None,
+ file_summaries: list[FileChangeSummary],
+ tracer,
+ max_patch_tokens: int,
+ context_window_token_limit: int,
+ reference_hints: ReferenceHints | None = None,
+) -> IntelGatheringResult:
+ """New 3-prompt intel gathering flow.
+
+ Prompt 1: CVE understanding (always runs, no patch needed)
+ Prompt 2: Patch analysis for first N files (if patch exists)
+ Prompt 3: Patch analysis for remaining files (if needed, parallel with 2)
+
+ Args:
+ llm: LLM with structured output for CVEUnderstanding
+ vuln_id: CVE identifier
+ target_package_name: Package being analyzed
+ cve_description: CVE description text
+ vendor_advisory: Vendor advisory/mitigation text
+ parsed_patch: Parsed patch data (may be None)
+ file_summaries: FileChangeSummary list from patch
+ tracer: Tracing context
+ max_patch_tokens: Token budget per patch analysis prompt
+ context_window_token_limit: Model/agent context window; prompts are
+ fitted so prompt + reserved overhead stay within this limit
+ reference_hints: Optional hints from reference_mining (same as legacy merge)
+
+ Returns:
+ IntelGatheringResult containing all gathered intel
+ """
+ import asyncio
+
+ result = IntelGatheringResult(
+ file_summaries=file_summaries,
+ total_files_in_patch=len(file_summaries),
+ )
+ # Reserved for completion / overhead; same relationship as L1 call site:
+ # max_patch_tokens = context_window_token_limit - reference_mining_prompt_overhead
+ reserved_tokens = max(context_window_token_limit - max_patch_tokens, 0)
+
+ # --- Prompt 1: CVE Understanding (always runs) ---
+ cve_llm = llm.with_structured_output(CVEUnderstanding)
+ with tracer.push_active_function("new_intel_prompt1_cve", input_data={"vuln_id": vuln_id}) as span:
+ cve_prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id=vuln_id,
+ target_package=target_package_name,
+ cve_description=cve_description,
+ vendor_advisory=vendor_advisory or "No vendor advisory available.",
+ )
+ cve_understanding: CVEUnderstanding = await cve_llm.ainvoke([SystemMessage(content=cve_prompt)])
+ result.cve_understanding = cve_understanding
+ result.prompt_count = 1
+
+ span.set_output({
+ "vulnerability_type": cve_understanding.vulnerability_type,
+ "affected_component": cve_understanding.affected_component,
+ "cve_keywords": cve_understanding.cve_keywords[:5],
+ })
+
+ # Early exit if no patch data
+ if not parsed_patch or not file_summaries:
+ logger.info("gather_intel_new_flow: No patch data, returning CVE-only intel")
+ _apply_reference_hints_to_new_intel(result, reference_hints)
+ return result
+
+ # --- Prepare patch analysis (code files only; docs/config/tests skipped) ---
+ code_summaries = _filter_code_files_for_analysis(file_summaries)
+ skipped = len(file_summaries) - len(code_summaries)
+ if skipped:
+ logger.info(
+ "gather_intel_new_flow: Filtered out %d non-code/test file(s); %d remain for analysis",
+ skipped, len(code_summaries),
+ )
+ if not code_summaries:
+ logger.info("gather_intel_new_flow: No code files in patch, returning CVE-only intel")
+ _apply_reference_hints_to_new_intel(result, reference_hints)
+ return result
- if hints.revision_hint and hints.revision_hint not in intel.search_keywords:
- intel.search_keywords = intel.search_keywords + [hints.revision_hint]
+ total_tokens = sum(fs.estimated_tokens for fs in code_summaries)
- if not intel.fixed_version and hints.version_hints:
- intel.fixed_version = hints.version_hints[0]
+ if total_tokens <= max_patch_tokens:
+ files_batch1 = _prioritize_files_for_analysis(code_summaries)
+ files_batch2 = []
+ else:
+ prioritized = _prioritize_files_for_analysis(code_summaries)
+ files_batch1, files_batch2 = _split_files_by_budget(prioritized, max_patch_tokens)
+ logger.info(
+ "gather_intel_new_flow: Splitting %d files (%d tokens) into batches: %d + %d",
+ len(code_summaries), total_tokens, len(files_batch1), len(files_batch2),
+ )
- return intel
+ # --- Prompt 2/3: Patch Analysis ---
+ per_file_llm = llm.with_structured_output(PerFileIntel)
+
+ async def run_patch_analysis(files: list[FileChangeSummary], prompt_name: str) -> list[PerFileIntel]:
+ if not files:
+ return []
+
+ with tracer.push_active_function(prompt_name, input_data={"file_count": len(files)}) as span:
+ # Invoke once per file for structured PerFileIntel output
+ results: list[PerFileIntel] = []
+ for fs in files:
+ file_prompt = _fit_patch_analysis_prompt_to_context(
+ vuln_id=vuln_id,
+ vulnerability_type=(
+ result.cve_understanding.vulnerability_type if result.cve_understanding else ""
+ ),
+ affected_component=(
+ result.cve_understanding.affected_component if result.cve_understanding else ""
+ ),
+ root_cause=(
+ result.cve_understanding.root_cause if result.cve_understanding else ""
+ ),
+ functions_touched_summary=format_functions_touched_summary([fs]),
+ patch_content=_format_patch_content_for_files(parsed_patch, [fs]),
+ file_path=fs.file_path,
+ context_window_token_limit=context_window_token_limit,
+ reserved_tokens=reserved_tokens,
+ )
+ if file_prompt is None:
+ continue
+
+ file_intel: PerFileIntel = await per_file_llm.ainvoke(
+ [SystemMessage(content=file_prompt)]
+ )
+ file_intel.file_path = fs.file_path # Ensure path is set
+ results.append(file_intel)
+
+ span.set_output({
+ "files_analyzed": len(results),
+ "files": [r.file_path for r in results],
+ })
+ return results
+
+ # Run batches (parallel if both exist)
+ if files_batch2:
+ batch_results = await asyncio.gather(
+ run_patch_analysis(files_batch1, "new_intel_prompt2_patch"),
+ run_patch_analysis(files_batch2, "new_intel_prompt3_patch"),
+ )
+ all_intel = batch_results[0] + batch_results[1]
+ result.prompt_count = 3
+ else:
+ all_intel = await run_patch_analysis(files_batch1, "new_intel_prompt2_patch")
+ result.prompt_count = 2
+
+ # Store per-file intel
+ for intel in all_intel:
+ result.per_file_intel[intel.file_path] = intel
+
+ result.files_analyzed_by_llm = len(result.per_file_intel)
+ _apply_reference_hints_to_new_intel(result, reference_hints)
+
+ logger.info(
+ "gather_intel_new_flow: Completed - %d prompts, %d files analyzed",
+ result.prompt_count, result.files_analyzed_by_llm,
+ )
+ return result
class CVEPackageCodeAgentConfig(FunctionBaseConfig, name="cve_package_code_agent"):
@@ -452,7 +1041,6 @@ async def create_graph_code_agent(config: CVEPackageCodeAgentConfig, builder: Bu
structured_comprehension_llm = llm.with_structured_output(CodeFindings)
observation_llm = llm.with_structured_output(Observation)
refinement_llm = llm.with_structured_output(RefinedFindings)
- vulnerability_intel_llm = llm.with_structured_output(VulnerabilityIntel)
# Get tool names after filtering for dynamic guidance
enabled_tool_names = [tool.name for tool in tools]
tool_descriptions_list = [t.name + ": " + t.description for t in tools]
@@ -487,6 +1075,39 @@ def _estimate_tokens(runtime_prompt: str, messages: list, observation: Observati
for item in (observation.results or []):
parts.append(item)
return _count_tokens("\n".join(parts))
+
+ def _prune_messages_to_fit(messages: list, keep_tail: int, step_num: int, caller: str) -> int:
+ """Drop oldest conversation messages so the local LLM payload fits the token budget.
+
+ Preserves messages[0] (system prompt) and the last ``keep_tail`` messages.
+ Mutates ``messages`` in place. Returns the number of messages removed.
+ """
+ max_tokens = config.context_window_token_limit
+ total = sum(
+ _count_tokens(m.content)
+ for m in messages
+ if hasattr(m, "content") and isinstance(m.content, str)
+ )
+ min_count = 1 + keep_tail
+ if total <= max_tokens or len(messages) <= min_count:
+ return 0
+
+ orig_len = len(messages)
+ for msg in list(messages[1:-keep_tail]):
+ if total <= max_tokens:
+ break
+ if hasattr(msg, "content") and isinstance(msg.content, str):
+ total -= _count_tokens(msg.content)
+ messages.remove(msg)
+
+ removed = orig_len - len(messages)
+ if removed:
+ logger.info(
+ "%s pruning: removed %d messages, estimated tokens now ~%d (limit %d) at step %d",
+ caller, removed, total, max_tokens, step_num,
+ )
+ return removed
+
# -- Locate setup: fix info + BrewDownloader + paths -----------------------
aIntel = intel[0]
fix_info = _parse_fix_info_from_context(ctx, target_package.name, target_package.release)
@@ -550,21 +1171,6 @@ async def L1_agent(state: CodeAgentState) -> dict:
else:
parsed_patch = None
- max_patch_chunk_tokens = (
- config.context_window_token_limit - config.reference_mining_prompt_overhead
- )
- patch_chunks = (
- format_patch_data_chunks_for_intel(parsed_patch, max_tokens=max_patch_chunk_tokens)
- if parsed_patch
- else [""]
- )
- if len(patch_chunks) > 1:
- logger.info(
- "L1_agent: chunking patch intel extraction into %d chunks (max_tokens=%d)",
- len(patch_chunks),
- max_patch_chunk_tokens,
- )
-
# Extract vendor mitigations from intel (OSIDB preferred, fallback to RHSA)
vendor_mitigations = ""
if aIntel.osidb and aIntel.osidb.mitigation:
@@ -576,24 +1182,38 @@ async def L1_agent(state: CodeAgentState) -> dict:
if mit_text:
vendor_mitigations = mit_text
- chunk_intel_results: list[VulnerabilityIntel] = []
- for patch_chunk in patch_chunks:
- vul_prompt = VULNERABILITY_INTEL_EXTRACTION_PROMPT.format(
- vuln_id=vuln_id,
- target_package=target_package.name,
- cve_description=cve_description,
- vendor_mitigations=vendor_mitigations or "No vendor mitigations available.",
- patch_data=patch_chunk,
- )
- chunk_intel_results.append(
- await vulnerability_intel_llm.ainvoke([SystemMessage(content=vul_prompt)])
- )
- vulnerability_intel = merge_vulnerability_intel_chunks(chunk_intel_results)
+ reference_hints = state.get("reference_hints")
+ file_summaries = extract_file_change_summaries(parsed_patch)
+ max_patch_tokens = (
+ config.context_window_token_limit - config.reference_mining_prompt_overhead
+ )
+ intel_result = await gather_intel_new_flow(
+ llm=llm,
+ vuln_id=vuln_id,
+ target_package_name=target_package.name,
+ cve_description=cve_description,
+ vendor_advisory=vendor_mitigations,
+ parsed_patch=parsed_patch,
+ file_summaries=file_summaries,
+ tracer=tracer,
+ max_patch_tokens=max_patch_tokens,
+ context_window_token_limit=config.context_window_token_limit,
+ reference_hints=reference_hints,
+ )
+ vulnerability_intel = intel_result.to_vulnerability_intel()
vulnerability_intel = VulnerabilityIntelSanitizer(parsed_patch).apply(
vulnerability_intel
)
+
+ # Prepend CVE-understanding keywords (critical for no-patch file discovery)
+ if intel_result.cve_understanding and intel_result.cve_understanding.cve_keywords:
+ cve_kw = intel_result.cve_understanding.cve_keywords
+ existing_kw = set(vulnerability_intel.search_keywords)
+ new_keywords = [k for k in cve_kw if k not in existing_kw]
+ if new_keywords:
+ vulnerability_intel.search_keywords = new_keywords + vulnerability_intel.search_keywords
+
# Prepend component_names to search_keywords for file discovery prioritization
- # This ensures L1 Agent sees module names (e.g., "mod_http2") as first search patterns
if vulnerability_intel.component_names:
vulnerability_intel.search_keywords = (
vulnerability_intel.component_names + vulnerability_intel.search_keywords
@@ -601,40 +1221,46 @@ async def L1_agent(state: CodeAgentState) -> dict:
# Preserve vendor mitigations in the intel object if not already extracted
if vendor_mitigations and not vulnerability_intel.known_mitigations:
vulnerability_intel.known_mitigations = vendor_mitigations
-
+
if downstream_report:
vulnerability_intel.is_downstream_patch_available = downstream_report.is_patch_file_available
vulnerability_intel.is_patch_applied_in_build = downstream_report.is_patch_applied_in_build
vulnerability_intel.patch_file_name = downstream_report.patch_file_name or ""
- # Populate version range context from Identify phase
+ # Populate version range context from Identify phase (if available)
if ctx and ctx.identify_result:
identify_result = ctx.identify_result
if identify_result.is_target_package_affected == EnumIdentifyResult.YES:
vulnerability_intel.target_version_in_vulnerable_range = True
elif identify_result.is_target_package_affected == EnumIdentifyResult.NO:
vulnerability_intel.target_version_in_vulnerable_range = False
-
_apply_identify_phase_fix_signal(vulnerability_intel, identify_result)
- affected_range, fixed_ver = extract_nvd_version_info(aIntel, target_package.name)
- if affected_range:
- vulnerability_intel.affected_version_range = affected_range
- if fixed_ver:
- vulnerability_intel.fixed_version = fixed_ver
+ # Always extract version info from NVD (even without Identify phase)
+ affected_range, fixed_ver = extract_nvd_version_info(aIntel, target_package.name)
+ if affected_range:
+ vulnerability_intel.affected_version_range = affected_range
+ if fixed_ver:
+ vulnerability_intel.fixed_version = fixed_ver
+
+ # Fallback: compute target_version_in_vulnerable_range from NVD if not set by Identify phase
+ if vulnerability_intel.target_version_in_vulnerable_range is None and fixed_ver:
+ try:
+ from packaging import version as pkg_version
+ target_ver = pkg_version.parse(target_package.version.split("-")[0])
+ fixed_parsed = pkg_version.parse(fixed_ver)
+ # If target version < fixed version, it's in the vulnerable range
+ vulnerability_intel.target_version_in_vulnerable_range = target_ver < fixed_parsed
+ except Exception:
+ # If version comparison fails, leave as None (unknown)
+ pass
# Always set target version for LLM reasoning (not dependent on identify phase)
vulnerability_intel.target_package_version = target_package.version
- # Merge reference hints if available from reference mining
- reference_hints = state.get("reference_hints")
if reference_hints:
- vulnerability_intel = merge_reference_hints_to_intel(
- intel=vulnerability_intel,
- hints=reference_hints,
- )
logger.info(
- "L1_agent: Merged reference hints - %d functions, %d files, revision=%s",
+ "L1_agent: Applied reference hints - %d functions, %d files, revision=%s",
len(reference_hints.function_hints),
len(reference_hints.file_hints),
reference_hints.revision_hint,
@@ -661,6 +1287,17 @@ async def L1_agent(state: CodeAgentState) -> dict:
f"target is {target_family} ({target_arch})"
)
+ def _intel_trace_output() -> dict:
+ return {
+ "prompt_count": intel_result.prompt_count,
+ "files_in_patch": intel_result.total_files_in_patch,
+ "files_analyzed": intel_result.files_analyzed_by_llm,
+ "cve_understanding": (
+ intel_result.cve_understanding.model_dump()
+ if intel_result.cve_understanding else None
+ ),
+ }
+
if arch_mismatch_reason:
logger.info(
"L1_agent: %s - %s. Skipping further investigation.",
@@ -669,9 +1306,12 @@ async def L1_agent(state: CodeAgentState) -> dict:
span.set_output({
"vulnerability_intel": vulnerability_intel.model_dump(),
"arch_mismatch_reason": arch_mismatch_reason,
+ "file_changes": [],
+ **_intel_trace_output(),
})
return {
"vulnerability_intel": vulnerability_intel,
+ "intel_gathering_result": intel_result,
"arch_mismatch_reason": arch_mismatch_reason,
"runtime_prompt": "",
"messages": [],
@@ -679,15 +1319,31 @@ async def L1_agent(state: CodeAgentState) -> dict:
span.set_output({
"vulnerability_intel": vulnerability_intel.model_dump(),
+ "file_changes": [s.model_dump() for s in file_summaries],
+ **_intel_trace_output(),
})
+ # Per-file FILE_CHANGES carries removed/added patterns; omit fat flat
+ # VULNERABLE_PATTERNS / FIX_PATTERNS from thought when present (no double-tell).
+ # Object fields stay intact for Case A/B routing, sanitizer, L2, observation.
+ # Soft-cap thought FILE_CHANGES to max_iterations (agent cannot cover more).
+ file_changes_str = format_file_changes_for_thought(
+ file_summaries,
+ max_files=config.max_iterations,
+ )
+ intel_str = vulnerability_intel.format_for_prompt(
+ include_pattern_lists=not bool(file_changes_str),
+ )
+ if file_changes_str:
+ intel_str = f"{intel_str}\n\n{file_changes_str}" if intel_str else file_changes_str
+
# Use case 1: Downstream patch file is available
if downstream_report and downstream_report.is_patch_file_available:
runtime_prompt = L1_AGENT_PROMPT_TEMPLATE.format(
sys_prompt=L1_AGENT_SYS_PROMPT_PATCH_AVAILABLE,
vuln_id=vuln_id,
target_package=target_package.name,
- vulnerability_intel=vulnerability_intel.format_for_prompt(),
+ vulnerability_intel=intel_str,
tools=tools_str,
tool_selection_strategy=tool_strategy,
tool_instructions=L1_AGENT_THOUGHT_INSTRUCTIONS,
@@ -696,6 +1352,8 @@ async def L1_agent(state: CodeAgentState) -> dict:
span.set_output({
"mode": "patch_available",
"patch_filename": downstream_report.patch_file_name,
+ "file_changes": [s.model_dump() for s in file_summaries],
+ **_intel_trace_output(),
})
# Use case 2: code is fixed by rebase
elif upstream_report and upstream_report.is_code_fixed_by_rebase == "yes":
@@ -706,7 +1364,7 @@ async def L1_agent(state: CodeAgentState) -> dict:
sys_prompt=L1_AGENT_SYS_PROMPT_REBASE_FIX,
vuln_id=vuln_id,
target_package=target_package.name,
- vulnerability_intel=vulnerability_intel.format_for_prompt(),
+ vulnerability_intel=intel_str,
tools=tools_str,
tool_selection_strategy=tool_strategy,
tool_instructions=L1_AGENT_THOUGHT_REBASE_INSTRUCTIONS,
@@ -715,6 +1373,8 @@ async def L1_agent(state: CodeAgentState) -> dict:
span.set_output({
"mode": "rebase_fix_verification",
"spec_log_change": upstream_report.spec_file_log_change[:200] if upstream_report.spec_file_log_change else "",
+ "file_changes": [s.model_dump() for s in extract_file_change_summaries(upstream_report.fixed_parsed_patch)],
+ **_intel_trace_output(),
})
else:
# No patch context - use CVE description-based verification
@@ -722,7 +1382,7 @@ async def L1_agent(state: CodeAgentState) -> dict:
sys_prompt=L1_AGENT_SYS_PROMPT_REBASE_NO_PATCH,
vuln_id=vuln_id,
target_package=target_package.name,
- vulnerability_intel=vulnerability_intel.format_for_prompt(),
+ vulnerability_intel=intel_str,
tools=tools_str,
tool_selection_strategy=tool_strategy,
tool_instructions=L1_AGENT_THOUGHT_CVE_DESC_INSTRUCTIONS,
@@ -731,6 +1391,8 @@ async def L1_agent(state: CodeAgentState) -> dict:
span.set_output({
"mode": "rebase_fix_cve_description",
"spec_log_change": upstream_report.spec_file_log_change[:200] if upstream_report.spec_file_log_change else "",
+ "file_changes": [],
+ **_intel_trace_output(),
})
# use case 3: in target patch was not found but patch is found in the rpm that was mention in cve that is fixed
elif upstream_report and upstream_report.fixed_parsed_patch:
@@ -741,7 +1403,7 @@ async def L1_agent(state: CodeAgentState) -> dict:
sys_prompt=sys_prompt,
vuln_id=vuln_id,
target_package=target_package.name,
- vulnerability_intel=vulnerability_intel.format_for_prompt(),
+ vulnerability_intel=intel_str,
tools=tools_str,
tool_selection_strategy=tool_strategy,
tool_instructions=tool_instructions,
@@ -751,6 +1413,8 @@ async def L1_agent(state: CodeAgentState) -> dict:
"mode": "upstream_patch_verification",
"patch_filename": upstream_report.fixed_srpm_file_name,
"prompt_variant": "case_b" if not vulnerability_intel.vulnerable_patterns else "case_a",
+ "file_changes": [s.model_dump() for s in extract_file_change_summaries(upstream_report.fixed_parsed_patch)],
+ **_intel_trace_output(),
})
# use case 4: Fix commit discovered via git search
elif git_search_report and git_search_report.parsed_patch:
@@ -761,7 +1425,7 @@ async def L1_agent(state: CodeAgentState) -> dict:
sys_prompt=sys_prompt,
vuln_id=vuln_id,
target_package=target_package.name,
- vulnerability_intel=vulnerability_intel.format_for_prompt(),
+ vulnerability_intel=intel_str,
tools=tools_str,
tool_selection_strategy=tool_strategy,
tool_instructions=tool_instructions,
@@ -773,6 +1437,8 @@ async def L1_agent(state: CodeAgentState) -> dict:
"confidence": git_search_report.best_result.confidence if git_search_report.best_result else 0,
"search_method": git_search_report.best_result.search_method if git_search_report.best_result else "",
"prompt_variant": "case_b" if not vulnerability_intel.vulnerable_patterns else "case_a",
+ "file_changes": [s.model_dump() for s in extract_file_change_summaries(git_search_report.parsed_patch)],
+ **_intel_trace_output(),
})
else:
# Use case 4: Default prompt - no patch context, use VulnerabilityIntel from CVE description
@@ -780,13 +1446,15 @@ async def L1_agent(state: CodeAgentState) -> dict:
sys_prompt=L1_AGENT_SYS_PROMPT_REBASE_NO_PATCH,
vuln_id=vuln_id,
target_package=target_package.name,
- vulnerability_intel=vulnerability_intel.format_for_prompt(),
+ vulnerability_intel=intel_str,
tools=tools_str,
tool_selection_strategy=tool_strategy,
tool_instructions=L1_AGENT_THOUGHT_CVE_DESC_INSTRUCTIONS,
)
span.set_output({
"mode": "no_patch",
+ "file_changes": [],
+ **_intel_trace_output(),
})
messages = state.get("messages", [])
@@ -795,6 +1463,7 @@ async def L1_agent(state: CodeAgentState) -> dict:
return {
"runtime_prompt": runtime_prompt,
"vulnerability_intel": vulnerability_intel,
+ "intel_gathering_result": intel_result,
"messages": remove_messages,
}
@@ -1046,13 +1715,16 @@ def should_run_repo_resolution(state: CodeAgentState) -> str:
logger.debug("should_run_repo_resolution: no reference hints")
return L1_AGENT_NODE
- # Check hint strength
+ # Check hint strength - skip git search if hints are too weak
hint_strength = getattr(reference_hints, "hint_strength", "none")
- if hint_strength == "none":
- logger.debug("should_run_repo_resolution: hint strength is none")
+ if hint_strength in ("none", "weak"):
+ logger.info(
+ "should_run_repo_resolution: hint strength is %s, skipping git search",
+ hint_strength,
+ )
return L1_AGENT_NODE
- logger.info("should_run_repo_resolution: routing to repo_resolution")
+ logger.info("should_run_repo_resolution: routing to repo_resolution (hint_strength=%s)", hint_strength)
return REPO_RESOLUTION_NODE
async def repo_resolution_node(state: CodeAgentState) -> dict:
@@ -1266,8 +1938,12 @@ async def thought_node(state: CodeAgentState) -> dict:
with tracer.push_active_function("thought_node", input_data={}) as span:
obs = state.get("observation", None)
if obs is not None:
- memory_list = obs.memory if obs.memory else ["No prior knowledge."]
- recent_findings = obs.results if obs.results else ["No recent findings."]
+ memory_list = _knowledge_entries_for_thought(
+ obs.memory if obs.memory else None,
+ ) or ["No prior knowledge."]
+ recent_findings = _knowledge_entries_for_thought(
+ obs.results if obs.results else None,
+ ) or ["No recent findings."]
memory_context = "\n".join(f"- {m}" for m in memory_list)
findings_context = "\n".join(f"- {f}" for f in recent_findings)
context_block = f"KNOWLEDGE:\n{memory_context}\nLATEST FINDINGS:\n{findings_context}"
@@ -1300,36 +1976,66 @@ async def forced_finish_node(state: CodeAgentState) -> dict:
"""Force finish when max iterations reached.
Invokes the LLM with FORCED_FINISH_PROMPT to generate a final answer
- based on evidence gathered so far.
+ based on evidence gathered so far. Re-surfaces Identify-phase signals
+ next to the finish order so they are not drowned by vulnerable-by-default framing.
"""
step_num = state.get("step", 0)
with tracer.push_active_function("forced_finish_node", input_data=f"step:{step_num}") as span:
try:
active_prompt = state.get("runtime_prompt")
messages = [SystemMessage(content=active_prompt)] + state["messages"]
- messages.append(HumanMessage(content=FORCED_FINISH_PROMPT))
+ identify_result = ctx.identify_result if ctx else None
+ finish_prompt = _build_forced_finish_human_prompt(identify_result)
+ messages.append(HumanMessage(content=finish_prompt))
obs = state.get("observation")
+ keep_tail = 1
if obs is not None and obs.memory:
- memory_context = "\n".join(f"- {m}" for m in obs.memory)
- messages.append(SystemMessage(content=f"KNOWLEDGE:\n{memory_context}"))
-
+ memory_for_prompt = _knowledge_entries_for_thought(list(obs.memory))
+ if memory_for_prompt:
+ memory_context = "\n".join(f"- {m}" for m in memory_for_prompt)
+ messages.append(SystemMessage(content=f"KNOWLEDGE:\n{memory_context}"))
+ keep_tail = 2
+
+ # Keep system prompt + finish prompt (+ KNOWLEDGE). Drop oldest tool/AI
+ # turns so identify context cannot push the request over the model limit.
+ messages_pruned = _prune_messages_to_fit(
+ messages, keep_tail=keep_tail, step_num=step_num, caller="forced_finish_node",
+ )
+
response: CheckerThought = await thought_llm.ainvoke(messages)
if response.mode == "finish" and response.final_answer:
- ai_message = AIMessage(content=response.final_answer)
final_answer = response.final_answer
else:
final_answer = "Failed to generate a final answer within the maximum allowed steps."
- ai_message = AIMessage(content=final_answer)
response = CheckerThought(
thought=response.thought or "Max steps exceeded",
mode="finish",
actions=None,
final_answer=final_answer,
)
+
+ memory_entries = list(obs.memory) if obs is not None and obs.memory else []
+ overridden = _maybe_override_uncertain_forced_finish(
+ final_answer, memory_entries, identify_result,
+ )
+ if overridden != final_answer:
+ final_answer = overridden
+ response = CheckerThought(
+ thought=(response.thought or "") + " [overridden: strong VULNERABLE signal]",
+ mode="finish",
+ actions=None,
+ final_answer=final_answer,
+ )
+ ai_message = AIMessage(content=final_answer)
- span.set_output({"final_answer_length": len(final_answer), "step": step_num})
+ span.set_output({
+ "final_answer_length": len(final_answer),
+ "step": step_num,
+ "identify_context_injected": finish_prompt != FORCED_FINISH_PROMPT,
+ "messages_pruned": messages_pruned,
+ })
return {
"messages": [ai_message],
"thought": response,
@@ -1362,8 +2068,23 @@ async def observation_node(state: CodeAgentState) -> dict:
tool_input_detail = last_thought.actions.query
vulnerability_intel = state.get("vulnerability_intel")
- intel_formatted = vulnerability_intel.format_for_prompt() if vulnerability_intel else "No intel available"
+ intel_result = state.get("intel_gathering_result")
+ # Match thought: skip fat flat pattern lists when per-file summaries exist;
+ # focused_context (Issue 08) supplies file-local patterns for the grep target.
+ has_file_summaries = bool(intel_result and intel_result.file_summaries)
+ intel_formatted = (
+ vulnerability_intel.format_for_prompt(
+ include_pattern_lists=not has_file_summaries,
+ )
+ if vulnerability_intel
+ else "No intel available"
+ )
target_package_name = target_package.name if target_package else "unknown"
+ focused_context = _resolve_focused_intel_context(
+ intel_result,
+ tool_used,
+ tool_input_detail,
+ )
with tracer.push_active_function("observation node", input_data=f"tool used:{tool_used} + {tool_input_detail}") as span:
tool_output_for_llm = tool_message.content
@@ -1373,7 +2094,8 @@ async def observation_node(state: CodeAgentState) -> dict:
# to distinguish VULNERABLE_CODE_ABSENT vs FIX_CODE_ABSENT
empty_findings, needs_llm_classification = check_empty_output(
tool_output_for_llm, tool_used, tool_input_detail,
- allow_llm_classification=True
+ allow_llm_classification=True,
+ tool_status=getattr(tool_message, "status", None),
)
# Get parsed_patch from state for raw diff context (needed for classification and comprehension)
@@ -1408,79 +2130,112 @@ async def observation_node(state: CodeAgentState) -> dict:
# Extract relevant hunks based on grep target file (chunked by token limit)
patch_diff_chunks = [""]
- if tool_used == "Source Grep" and parsed_patch:
+ if tool_used == SOURCE_GREP_TOOL_NAME and parsed_patch:
patch_diff_chunks = get_relevant_hunks(parsed_patch, tool_input_detail)
if empty_findings:
code_findings = empty_findings
elif needs_llm_classification:
- # Empty source grep - use classification prompt to determine meaning
- # Loop over patch chunks to stay within context window
- all_classification_findings: list[str] = []
- last_tool_outcome = "No matches found"
- for patch_chunk in patch_diff_chunks:
- classification_prompt = L1_EMPTY_RESULT_CLASSIFICATION_PROMPT.format(
- tool_used=tool_used,
- last_thought=last_thought_text,
- tool_input=tool_input_detail,
- raw_patch_diff=patch_chunk if patch_chunk else "No patch diff available",
+ # Prefer FILE_CHANGES polarity over thought-based LLM labeling.
+ file_summaries = (
+ intel_result.file_summaries if intel_result else None
+ )
+ deterministic = try_deterministic_empty_grep_findings(
+ tool_used, tool_input_detail, file_summaries,
+ )
+ if deterministic is not None:
+ code_findings = deterministic
+ logger.debug(
+ "Empty source grep classified deterministically: %s",
+ code_findings.findings,
)
- chunk_result = await structured_comprehension_llm.ainvoke(
- [SystemMessage(content=classification_prompt)]
+ else:
+ # Fallback: LLM classification against RAW_PATCH_DIFF
+ all_classification_findings: list[str] = []
+ last_tool_outcome = "No matches found"
+ for patch_chunk in patch_diff_chunks:
+ classification_prompt = L1_EMPTY_RESULT_CLASSIFICATION_PROMPT.format(
+ tool_used=tool_used,
+ last_thought=last_thought_text,
+ tool_input=tool_input_detail,
+ raw_patch_diff=patch_chunk if patch_chunk else "No patch diff available",
+ )
+ chunk_result = await structured_comprehension_llm.ainvoke(
+ [SystemMessage(content=classification_prompt)]
+ )
+ all_classification_findings.extend(chunk_result.findings)
+ last_tool_outcome = chunk_result.tool_outcome
+ code_findings = CodeFindings(
+ findings=all_classification_findings,
+ tool_outcome=last_tool_outcome
)
- all_classification_findings.extend(chunk_result.findings)
- last_tool_outcome = chunk_result.tool_outcome
- code_findings = CodeFindings(
- findings=all_classification_findings,
- tool_outcome=last_tool_outcome
- )
- logger.debug("Empty source grep classified: %s", code_findings.findings)
+ logger.debug("Empty source grep classified: %s", code_findings.findings)
else:
- # Has actual content - double loop over patch chunks and tool output chunks
+ # One patch context × each tool chunk. Nesting patch×tool was an N×M
+ # cartesian product that duplicated findings and overflowed the context
+ # window on large single-file diffs (see traces: identical NOT_IN_DIFF).
+ # Extra patch chunks are dropped — PATCH_CONTEXT_REFERENCE already orients
+ # file-local symbols; joining chunks would only increase prompt size.
tool_chunks = truncate_tool_output_list(tool_output_for_llm, tool_used, max_tokens=1000)
all_findings: list[str] = []
best_tool_outcome = ""
- for patch_chunk in patch_diff_chunks:
- for tool_chunk in tool_chunks:
- logger.debug(
- "Comprehension token breakdown: "
- "intel=%d, patch_chunk=%d, tool_chunk=%d, last_thought=%d, "
- "tool_input=%d, total_parts=%d",
- count_tokens(intel_formatted),
- count_tokens(patch_chunk),
- count_tokens(tool_chunk),
- count_tokens(last_thought_text),
- count_tokens(tool_input_detail),
- count_tokens(intel_formatted) + count_tokens(patch_chunk) +
- count_tokens(tool_chunk) + count_tokens(last_thought_text) +
- count_tokens(tool_input_detail),
- )
- comp_prompt = L1_COMPREHENSION_PROMPT.format(
- vuln_id=vuln_id,
- target_package=target_package_name,
- vulnerability_intel=intel_formatted,
- raw_patch_diff=patch_chunk,
- tool_used=tool_used,
- tool_input=tool_input_detail,
- last_thought=last_thought_text,
- tool_output=tool_chunk,
- )
- logger.debug("Comprehension total prompt tokens: %d", count_tokens(comp_prompt))
- chunk_findings = await invoke_comprehension(
- structured_comprehension_llm,
- comp_prompt,
- tool_used,
- tool_input_detail,
- tool_chunk,
- agent_label="L1",
- )
- all_findings.extend(chunk_findings.findings)
- if not best_tool_outcome or (
- chunk_findings.findings and
- not any("FAILED" in f for f in chunk_findings.findings)
- ):
- best_tool_outcome = chunk_findings.tool_outcome
+ patch_chunk = patch_diff_chunks[0] if patch_diff_chunks else ""
+ if len(patch_diff_chunks) > 1:
+ logger.debug(
+ "Comprehension using 1/%d patch chunks to avoid cartesian LLM calls",
+ len(patch_diff_chunks),
+ )
+ patch_context_reference = (
+ focused_context or PATCH_CONTEXT_REFERENCE_EMPTY
+ )
+
+ for tool_chunk in tool_chunks:
+ tool_output_for_prompt = _format_tool_output_with_focused_context(
+ tool_chunk,
+ focused_context,
+ )
+ logger.debug(
+ "Comprehension token breakdown: "
+ "intel=%d, patch_chunk=%d, tool_chunk=%d, last_thought=%d, "
+ "tool_input=%d, focused_context=%d, total_parts=%d",
+ count_tokens(intel_formatted),
+ count_tokens(patch_chunk),
+ count_tokens(tool_chunk),
+ count_tokens(last_thought_text),
+ count_tokens(tool_input_detail),
+ count_tokens(patch_context_reference),
+ count_tokens(intel_formatted) + count_tokens(patch_chunk) +
+ count_tokens(tool_output_for_prompt) + count_tokens(last_thought_text) +
+ count_tokens(tool_input_detail) +
+ count_tokens(patch_context_reference),
+ )
+ comp_prompt = L1_COMPREHENSION_PROMPT.format(
+ vuln_id=vuln_id,
+ target_package=target_package_name,
+ vulnerability_intel=intel_formatted,
+ raw_patch_diff=patch_chunk,
+ patch_context_reference=patch_context_reference,
+ tool_used=tool_used,
+ tool_input=tool_input_detail,
+ last_thought=last_thought_text,
+ tool_output=tool_output_for_prompt,
+ )
+ logger.debug("Comprehension total prompt tokens: %d", count_tokens(comp_prompt))
+ chunk_findings = await invoke_comprehension(
+ structured_comprehension_llm,
+ comp_prompt,
+ tool_used,
+ tool_input_detail,
+ tool_chunk,
+ agent_label="L1",
+ )
+ all_findings.extend(chunk_findings.findings)
+ if not best_tool_outcome or (
+ chunk_findings.findings and
+ not any("FAILED" in f for f in chunk_findings.findings)
+ ):
+ best_tool_outcome = chunk_findings.tool_outcome
code_findings = CodeFindings(
findings=all_findings,
@@ -1501,6 +2256,9 @@ async def observation_node(state: CodeAgentState) -> dict:
internal_memory: InternalMemory = state.get("internal_memory") or InternalMemory()
all_evicted: list[str] = []
for finding_str in refined.results:
+ if _is_not_in_diff_finding(finding_str):
+ # Keep TOOL_CALL_RECORD below; omit NOT_IN_DIFF from thought KNOWLEDGE.
+ continue
all_evicted.extend(internal_memory.add(parse_to_memory_item(finding_str)))
all_evicted.extend(internal_memory.add(MemoryItem(
key=f"TOOL:{refined.tool_record[:50]}",
@@ -1541,7 +2299,9 @@ async def observation_node(state: CodeAgentState) -> dict:
span_output: dict = {
"last_thought_text": last_thought_text,
- "tool_output_for_llm": tool_output_for_llm[:500],
+ "tool_output_for_llm": tool_output_for_llm[:SPAN_OUTPUT_PREVIEW_CHARS],
+ "focused_context": focused_context[:SPAN_OUTPUT_PREVIEW_CHARS] if focused_context else "",
+ "focused_context_used": bool(focused_context),
"findings": code_findings.findings,
"refined_results": refined.results,
"tool_outcome": code_findings.tool_outcome,
diff --git a/src/vuln_analysis/functions/react_internals.py b/src/vuln_analysis/functions/react_internals.py
index 6c468e8d5..de99a23a9 100644
--- a/src/vuln_analysis/functions/react_internals.py
+++ b/src/vuln_analysis/functions/react_internals.py
@@ -16,6 +16,7 @@
import re
from collections import OrderedDict
from dataclasses import dataclass, field
+from enum import Enum
from langchain_core.messages import SystemMessage
from openai import LengthFinishReasonError
@@ -30,6 +31,19 @@
logger = LoggingFactory.get_agent_logger(__name__)
+TOOL_MESSAGE_STATUS_ERROR = "error"
+TOOL_ERROR_DETAILS_PREVIEW_CHARS = 150
+# Only match failure markers at the start of tool output (not mid-content source text).
+TOOL_FAILURE_CONTENT_PREFIXES = (
+ "Error:",
+ "Failed:",
+ "Exception:",
+ "Traceback",
+ "Intercepted an error from tool",
+)
+# Source Grep query: pattern[,file_filter] — last comma segment is the file filter.
+GREP_QUERY_FILE_SEPARATOR = ","
+
# ---- Pydantic Schemas ---- #
class ToolCall(BaseModel):
@@ -113,11 +127,38 @@ class CodeFindings(BaseModel):
)
+def _normalize_tool_status(tool_status: str | None) -> str | None:
+ """Normalize ToolMessage.status to a lowercase string, if present."""
+ if tool_status is None:
+ return None
+ return str(tool_status).strip().lower()
+
+
+def _content_has_tool_failure_prefix(tool_output: str) -> bool:
+ """True when output begins with a known tool-failure marker."""
+ stripped = tool_output.lstrip()
+ return any(stripped.startswith(prefix) for prefix in TOOL_FAILURE_CONTENT_PREFIXES)
+
+
+def _is_tool_error(tool_output: str | list, tool_status: str | None) -> bool:
+ """Detect tool failures via status first, then content prefix allowlist.
+
+ Avoids false positives from successful greps whose matched source contains
+ substrings like 'error:' (e.g. format strings in log.c).
+ """
+ if _normalize_tool_status(tool_status) == TOOL_MESSAGE_STATUS_ERROR:
+ return True
+ if not isinstance(tool_output, str) or not tool_output:
+ return False
+ return _content_has_tool_failure_prefix(tool_output)
+
+
def check_empty_output(
tool_output: str | list,
tool_used: str,
tool_input: str,
allow_llm_classification: bool = False,
+ tool_status: str | None = None,
) -> tuple[CodeFindings | None, bool]:
"""Check if tool output is empty or an error, returning factual CodeFindings if so.
@@ -130,6 +171,9 @@ def check_empty_output(
tool_input: The input/query passed to the tool.
allow_llm_classification: If True, signal that empty Source Grep on source
code needs LLM classification against the diff.
+ tool_status: Optional ToolMessage.status ("success" / "error"). Status
+ "error" forces a FAILED finding. Content is only treated as an error
+ when it begins with a known failure prefix (not mid-content matches).
Returns:
Tuple of (CodeFindings | None, needs_llm_classification: bool).
@@ -143,19 +187,15 @@ def check_empty_output(
or (isinstance(tool_output, list) and len(tool_output) == 0)
)
- is_error = (
- isinstance(tool_output, str)
- and any(m in tool_output for m in ["Error:", "error:", "Failed:", "Exception:", "Traceback"])
- )
-
- if is_error:
+ if _is_tool_error(tool_output, tool_status):
+ details = str(tool_output)[:TOOL_ERROR_DETAILS_PREVIEW_CHARS]
return (
CodeFindings(
findings=[
f"FAILED: {tool_used} [{tool_input}] - tool error",
- f"Details: {str(tool_output)[:150]}"
+ f"Details: {details}",
],
- tool_outcome=f"FAILED: {tool_used} with {tool_input} -> ERROR"
+ tool_outcome=f"FAILED: {tool_used} with {tool_input} -> ERROR",
),
False,
)
@@ -172,7 +212,7 @@ def check_empty_output(
return (
CodeFindings(
findings=[f"{tool_used} for '{tool_input}' returned empty - no matches found"],
- tool_outcome=f"CALLED: {tool_used} with {tool_input} -> EMPTY (no results)"
+ tool_outcome=f"CALLED: {tool_used} with {tool_input} -> EMPTY (no results)",
),
False,
)
@@ -180,6 +220,112 @@ def check_empty_output(
return (None, False)
+class PatchPatternRole(str, Enum):
+ """Role of a grepped pattern relative to FILE_CHANGES removed/added lists."""
+
+ REMOVED_ONLY = "removed_only"
+ ADDED_ONLY = "added_only"
+ AMBIGUOUS = "ambiguous"
+ UNKNOWN = "unknown"
+
+
+def _normalize_pattern_text(text: str) -> str:
+ """Collapse whitespace for stable substring matching against FILE_CHANGES lines."""
+ return " ".join(text.strip().split())
+
+
+def _extract_grep_pattern(tool_input: str) -> str:
+ """Return the Source Grep pattern (everything before the last comma file filter)."""
+ if not tool_input:
+ return ""
+ if GREP_QUERY_FILE_SEPARATOR not in tool_input:
+ return tool_input.strip()
+ parts = tool_input.split(GREP_QUERY_FILE_SEPARATOR)
+ return GREP_QUERY_FILE_SEPARATOR.join(parts[:-1]).strip()
+
+
+def _pattern_matches_line(pattern: str, line: str) -> bool:
+ """True when normalized pattern and FILE_CHANGES line contain each other."""
+ normalized_line = _normalize_pattern_text(line)
+ if not normalized_line:
+ return False
+ return pattern in normalized_line or normalized_line in pattern
+
+
+def classify_pattern_against_file_changes(
+ tool_input: str,
+ file_summaries: Any | None,
+) -> PatchPatternRole:
+ """Classify grepped pattern as removed-only, added-only, ambiguous, or unknown.
+
+ Duck-types file_summaries entries for key_removed_patterns / key_added_patterns
+ to avoid importing FileChangeSummary (circular import with code_agent_graph_defs).
+ """
+ pattern = _normalize_pattern_text(_extract_grep_pattern(tool_input))
+ if not pattern or not file_summaries:
+ return PatchPatternRole.UNKNOWN
+
+ in_removed = False
+ in_added = False
+ for summary in file_summaries:
+ for line in getattr(summary, "key_removed_patterns", None) or []:
+ if _pattern_matches_line(pattern, line):
+ in_removed = True
+ for line in getattr(summary, "key_added_patterns", None) or []:
+ if _pattern_matches_line(pattern, line):
+ in_added = True
+ if in_removed and in_added:
+ return PatchPatternRole.AMBIGUOUS
+
+ if in_removed:
+ return PatchPatternRole.REMOVED_ONLY
+ if in_added:
+ return PatchPatternRole.ADDED_ONLY
+ return PatchPatternRole.UNKNOWN
+
+
+def try_deterministic_empty_grep_findings(
+ tool_used: str,
+ tool_input: str,
+ file_summaries: Any | None,
+) -> CodeFindings | None:
+ """Label an empty Source Grep from FILE_CHANGES polarity when decisive.
+
+ Returns CodeFindings for added-only (FIX_CODE_ABSENT) or removed-only
+ (VULNERABLE_CODE_ABSENT). Returns None for ambiguous/unknown so the caller
+ can fall back to LLM empty-result classification.
+ """
+ role = classify_pattern_against_file_changes(tool_input, file_summaries)
+ pattern = _extract_grep_pattern(tool_input) or tool_input
+
+ if role == PatchPatternRole.ADDED_ONLY:
+ return CodeFindings(
+ findings=[
+ f"FIX_CODE_ABSENT: {pattern} not found - "
+ "pattern is FIX_ONLY (FILE_CHANGES.added); fix not applied"
+ ],
+ tool_outcome=(
+ f"{tool_used} [{tool_input}] -> NO MATCHES "
+ "(fix code absent = fix NOT applied)"
+ ),
+ )
+
+ if role == PatchPatternRole.REMOVED_ONLY:
+ return CodeFindings(
+ findings=[
+ f"VULNERABLE_CODE_ABSENT: {pattern} not found - "
+ "pattern is VULNERABLE_ONLY (FILE_CHANGES.removed); "
+ "vulnerable code absent = fix likely applied"
+ ],
+ tool_outcome=(
+ f"{tool_used} [{tool_input}] -> NO MATCHES "
+ "(vulnerable code absent = fix likely applied)"
+ ),
+ )
+
+ return None
+
+
async def invoke_comprehension(
structured_comprehension_llm,
prompt: str,
@@ -245,6 +391,32 @@ class Observation(BaseModel):
"fl_package_present", "fl_package_absent", "fl_subpackage_ambiguous",
})
+# Trailing ":line" or ":line,line,..." plus optional "(xN)" hit count on memory content.
+_AGGREGATED_LINE_SUFFIX_RE = re.compile(r':\d+(?:,\d+)*(?:\s*\(x\d+\))?\s*$')
+_HIT_COUNT_SUFFIX_RE = re.compile(r'\s*\(x\d+\)\s*$')
+
+
+def _format_aggregated_memory_content(content: str, sample_lines: list[int], occurrence_count: int) -> str:
+ """Rewrite finding content so multi-hit evidence keeps all line numbers / counts.
+
+ Dedup keys strip line numbers (same pattern@file → one item). Without this,
+ later hits only bump occurrence_count and KNOWLEDGE loses lines 2..N.
+ """
+ if sample_lines:
+ lines_str = ','.join(str(n) for n in sorted(sample_lines))
+ suffix = f':{lines_str}'
+ if occurrence_count > 1:
+ suffix = f'{suffix} (x{occurrence_count})'
+ if _AGGREGATED_LINE_SUFFIX_RE.search(content):
+ return _AGGREGATED_LINE_SUFFIX_RE.sub(suffix, content)
+ return f'{content} {suffix}'
+
+ if occurrence_count <= 1:
+ return content
+ if _HIT_COUNT_SUFFIX_RE.search(content):
+ return _HIT_COUNT_SUFFIX_RE.sub(f' (x{occurrence_count})', content)
+ return f'{content} (x{occurrence_count})'
+
# Map regex prefix → (category, priority). Order matters: first match wins.
CATEGORY_PATTERNS: dict[str, tuple[_MemoryCategory, int]] = {
# L1 Code Agent: Patch mode
@@ -256,6 +428,7 @@ class Observation(BaseModel):
r'^VULNERABLE_CODE_FOUND:': ('vuln_found', 3),
r'^VULNERABLE_CODE_ABSENT:': ('vuln_absent', 2),
r'^AMBIGUOUS_PATTERN:': ('ambiguous', 1),
+ r'^NOT_IN_DIFF:': ('ambiguous', 1),
# L2 Build Agent: Compilation
r'^FILE_COMPILED:': ('file_compiled', 3),
r'^FILE_NOT_COMPILED:': ('file_not_compiled', 3),
@@ -317,11 +490,21 @@ def is_danger_state(self) -> bool:
def add(self, item: MemoryItem) -> list[str]:
"""Add item with dedup by key.
- If the key already exists, increments occurrence_count instead of
- inserting a duplicate. Returns list of evicted keys if over budget.
+ If the key already exists, aggregate multi-hit evidence: bump
+ occurrence_count, merge sample_lines, and rewrite content so KNOWLEDGE
+ retains all line numbers. Returns list of evicted keys if over budget.
"""
if item.key in self.items:
- self.items[item.key].occurrence_count += 1
+ existing = self.items[item.key]
+ existing.occurrence_count += 1
+ for line in item.sample_lines:
+ if line not in existing.sample_lines:
+ existing.sample_lines.append(line)
+ existing.content = _format_aggregated_memory_content(
+ existing.content,
+ existing.sample_lines,
+ existing.occurrence_count,
+ )
return []
self.items[item.key] = item
return self._evict_if_over_budget()
diff --git a/src/vuln_analysis/functions/tests/test_check_empty_output.py b/src/vuln_analysis/functions/tests/test_check_empty_output.py
new file mode 100644
index 000000000..b3f25deb9
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_check_empty_output.py
@@ -0,0 +1,182 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for check_empty_output and deterministic empty-grep labeling."""
+
+from types import SimpleNamespace
+
+from vuln_analysis.functions.react_internals import (
+ PatchPatternRole,
+ check_empty_output,
+ classify_pattern_against_file_changes,
+ try_deterministic_empty_grep_findings,
+)
+
+SOURCE_GREP_TOOL_NAME = "Source Grep"
+
+
+def _summary(*, removed: list[str] | None = None, added: list[str] | None = None):
+ """Duck-typed FileChangeSummary stand-in for classifier tests."""
+ return SimpleNamespace(
+ key_removed_patterns=removed or [],
+ key_added_patterns=added or [],
+ )
+
+
+class TestCheckEmptyOutputToolErrors:
+ """Hybrid error detection: ToolMessage.status + failure-prefix allowlist."""
+
+ def test_status_error_marks_failed(self):
+ findings, needs_classification = check_empty_output(
+ "some tool payload",
+ SOURCE_GREP_TOOL_NAME,
+ "rsync",
+ tool_status="error",
+ )
+ assert findings is not None
+ assert findings.findings[0].startswith("FAILED:")
+ assert needs_classification is False
+
+ def test_prefix_error_marks_failed_without_status(self):
+ findings, _ = check_empty_output(
+ "Error: target directory does not exist",
+ SOURCE_GREP_TOOL_NAME,
+ "logs:foo.c",
+ )
+ assert findings is not None
+ assert "tool error" in findings.findings[0]
+
+ def test_intercepted_tool_error_prefix(self):
+ findings, _ = check_empty_output(
+ "Intercepted an error from tool Source Grep, in function=_arun",
+ SOURCE_GREP_TOOL_NAME,
+ "pattern",
+ )
+ assert findings is not None
+ assert findings.findings[0].startswith("FAILED:")
+
+ def test_success_with_mid_content_error_is_not_tool_error(self):
+ """Regression: rsync grep hit containing 'error:' in source must not FAILED."""
+ tool_output = (
+ './rsync.spec:8:Name: rsync\n'
+ './rsync-3.0/rsync-3.0.9/log.c:834:\t\trprintf(FERROR, "rsync error: %s (code %d)\\n",\n'
+ )
+ findings, needs_classification = check_empty_output(
+ tool_output,
+ SOURCE_GREP_TOOL_NAME,
+ "rsync",
+ allow_llm_classification=True,
+ tool_status="success",
+ )
+ assert findings is None
+ assert needs_classification is False
+
+ def test_lowercase_error_prefix_alone_is_not_tool_error(self):
+ findings, needs_classification = check_empty_output(
+ "error: something went wrong",
+ SOURCE_GREP_TOOL_NAME,
+ "pattern",
+ tool_status="success",
+ )
+ assert findings is None
+ assert needs_classification is False
+
+ def test_empty_source_grep_requests_classification(self):
+ findings, needs_classification = check_empty_output(
+ "",
+ SOURCE_GREP_TOOL_NAME,
+ "s2length,checksum.c",
+ allow_llm_classification=True,
+ tool_status="success",
+ )
+ assert findings is None
+ assert needs_classification is True
+
+
+class TestDeterministicEmptyGrepFindings:
+ """FILE_CHANGES polarity labels empty greps without LLM when decisive."""
+
+ def test_added_only_empty_is_fix_code_absent(self):
+ summaries = [
+ _summary(
+ removed=["#define MALLOC(parser, s) (parser->m_mem.malloc_fcn((s)))"],
+ added=[
+ "typedef struct MALLOC_TRACKER {",
+ "XML_SetAllocTrackerActivationThreshold",
+ ],
+ )
+ ]
+ tool_input = "XML_SetAllocTrackerActivationThreshold,expat/lib/xmlparse.c"
+ role = classify_pattern_against_file_changes(tool_input, summaries)
+ assert role == PatchPatternRole.ADDED_ONLY
+
+ findings = try_deterministic_empty_grep_findings(
+ SOURCE_GREP_TOOL_NAME, tool_input, summaries,
+ )
+ assert findings is not None
+ assert findings.findings[0].startswith("FIX_CODE_ABSENT:")
+ assert "fix NOT applied" in findings.tool_outcome
+
+ def test_removed_only_empty_is_vulnerable_code_absent(self):
+ summaries = [
+ _summary(
+ removed=["#define MALLOC(parser, s) (parser->m_mem.malloc_fcn((s)))"],
+ added=["typedef struct MALLOC_TRACKER {"],
+ )
+ ]
+ tool_input = "#define MALLOC(parser,expat/lib/xmlparse.c"
+ role = classify_pattern_against_file_changes(tool_input, summaries)
+ assert role == PatchPatternRole.REMOVED_ONLY
+
+ findings = try_deterministic_empty_grep_findings(
+ SOURCE_GREP_TOOL_NAME, tool_input, summaries,
+ )
+ assert findings is not None
+ assert findings.findings[0].startswith("VULNERABLE_CODE_ABSENT:")
+ assert "fix likely applied" in findings.tool_outcome
+
+ def test_unknown_pattern_returns_none_for_llm_fallback(self):
+ summaries = [
+ _summary(added=["XML_SetAllocTrackerActivationThreshold"]),
+ ]
+ findings = try_deterministic_empty_grep_findings(
+ SOURCE_GREP_TOOL_NAME,
+ "XML_ParserCreate,xmlparse.c",
+ summaries,
+ )
+ assert findings is None
+ assert (
+ classify_pattern_against_file_changes(
+ "XML_ParserCreate,xmlparse.c", summaries,
+ )
+ == PatchPatternRole.UNKNOWN
+ )
+
+ def test_ambiguous_pattern_returns_none_for_llm_fallback(self):
+ summaries = [
+ _summary(
+ removed=["shared_helper();"],
+ added=["shared_helper();"],
+ )
+ ]
+ tool_input = "shared_helper"
+ assert (
+ classify_pattern_against_file_changes(tool_input, summaries)
+ == PatchPatternRole.AMBIGUOUS
+ )
+ assert (
+ try_deterministic_empty_grep_findings(
+ SOURCE_GREP_TOOL_NAME, tool_input, summaries,
+ )
+ is None
+ )
+
+ def test_missing_file_summaries_returns_none(self):
+ assert (
+ try_deterministic_empty_grep_findings(
+ SOURCE_GREP_TOOL_NAME,
+ "XML_SetAllocTrackerActivationThreshold",
+ None,
+ )
+ is None
+ )
diff --git a/src/vuln_analysis/functions/tests/test_file_change_summary.py b/src/vuln_analysis/functions/tests/test_file_change_summary.py
new file mode 100644
index 000000000..e2b86fdf8
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_file_change_summary.py
@@ -0,0 +1,361 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for FileChangeSummary model and extraction functions."""
+
+import pytest
+
+from vuln_analysis.functions.code_agent_graph_defs import (
+ FileChangeSummary,
+ ParsedPatch,
+ PatchFile,
+ PatchHunk,
+ extract_file_change_summaries,
+ estimate_patch_tokens,
+ format_file_changes_for_thought,
+ DEFAULT_MAX_PATTERNS,
+ DEFAULT_MIN_PATTERN_LENGTH,
+ FILE_CHANGE_PATTERN_MAX_CHARS,
+ FILE_CHANGES_THOUGHT_HEADER,
+)
+from vuln_analysis.utils.token_utils import count_tokens
+
+
+def _make_hunk(
+ removed: list[str] | None = None,
+ added: list[str] | None = None,
+ section_header: str = "",
+) -> PatchHunk:
+ """Helper to create a PatchHunk with defaults."""
+ return PatchHunk(
+ source_start=1,
+ source_length=10,
+ target_start=1,
+ target_length=10,
+ section_header=section_header,
+ removed_lines=removed or [],
+ added_lines=added or [],
+ )
+
+
+def _make_patch_file(
+ target_path: str,
+ hunks: list[PatchHunk],
+ is_new_file: bool = False,
+ is_deleted_file: bool = False,
+) -> PatchFile:
+ """Helper to create a PatchFile with defaults."""
+ return PatchFile(
+ source_path="a/" + target_path if not is_new_file else "/dev/null",
+ target_path="b/" + target_path,
+ hunks=hunks,
+ is_new_file=is_new_file,
+ is_deleted_file=is_deleted_file,
+ )
+
+
+def _make_parsed_patch(files: list[PatchFile]) -> ParsedPatch:
+ """Helper to create a ParsedPatch."""
+ return ParsedPatch(patch_filename="test.patch", files=files)
+
+
+class TestExtractFileChangeSummariesEmpty:
+ """Tests for empty/None input cases."""
+
+ def test_none_patch_returns_empty_list(self):
+ assert extract_file_change_summaries(None) == []
+
+ def test_empty_files_returns_empty_list(self):
+ patch = _make_parsed_patch(files=[])
+ assert extract_file_change_summaries(patch) == []
+
+
+class TestExtractFileChangeSummariesBasic:
+ """Tests for basic extraction scenarios."""
+
+ def test_single_file_single_hunk(self):
+ hunk = _make_hunk(
+ removed=["old line one", "old line two"],
+ added=["new line one"],
+ section_header="void my_function()",
+ )
+ pf = _make_patch_file("src/main.c", [hunk])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert len(summaries) == 1
+ s = summaries[0]
+ assert s.file_path == "src/main.c"
+ assert s.removed_lines_count == 2
+ assert s.added_lines_count == 1
+ assert s.hunks_count == 1
+ assert s.functions_touched == ["void my_function()"]
+
+ def test_multiple_hunks_aggregate_lines(self):
+ hunk1 = _make_hunk(removed=["a", "b"], added=["c"])
+ hunk2 = _make_hunk(removed=["d"], added=["e", "f"])
+ pf = _make_patch_file("file.py", [hunk1, hunk2])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert len(summaries) == 1
+ s = summaries[0]
+ assert s.removed_lines_count == 3
+ assert s.added_lines_count == 3
+ assert s.hunks_count == 2
+
+ def test_multiple_files(self):
+ pf1 = _make_patch_file("a.c", [_make_hunk(removed=["x"])])
+ pf2 = _make_patch_file("b.c", [_make_hunk(added=["y"])])
+ patch = _make_parsed_patch([pf1, pf2])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert len(summaries) == 2
+ assert summaries[0].file_path == "a.c"
+ assert summaries[1].file_path == "b.c"
+
+
+class TestFunctionsTouchedFromSectionHeader:
+ """Tests for extracting functions_touched from hunk section headers."""
+
+ def test_extracts_function_names(self):
+ hunk1 = _make_hunk(section_header="int foo(int x)")
+ hunk2 = _make_hunk(section_header="void bar()")
+ pf = _make_patch_file("funcs.c", [hunk1, hunk2])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert summaries[0].functions_touched == ["int foo(int x)", "void bar()"]
+
+ def test_ignores_empty_section_headers(self):
+ hunk1 = _make_hunk(section_header="")
+ hunk2 = _make_hunk(section_header=" ")
+ hunk3 = _make_hunk(section_header="real_func()")
+ pf = _make_patch_file("mixed.c", [hunk1, hunk2, hunk3])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert summaries[0].functions_touched == ["real_func()"]
+
+ def test_strips_whitespace_from_headers(self):
+ hunk = _make_hunk(section_header=" padded_func() ")
+ pf = _make_patch_file("pad.c", [hunk])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert summaries[0].functions_touched == ["padded_func()"]
+
+
+class TestKeyPatterns:
+ """Tests for key_removed_patterns and key_added_patterns extraction."""
+
+ def test_filters_short_patterns(self):
+ hunk = _make_hunk(
+ removed=["x", "short", "this is a longer line that passes"],
+ added=["y", "also short", "another sufficiently long line here"],
+ )
+ pf = _make_patch_file("patterns.c", [hunk])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ s = summaries[0]
+ assert "this is a longer line that passes" in s.key_removed_patterns
+ assert "another sufficiently long line here" in s.key_added_patterns
+ assert "x" not in s.key_removed_patterns
+ assert "short" not in s.key_removed_patterns
+
+ def test_respects_max_patterns(self):
+ long_lines = [f"this is long line number {i}" for i in range(10)]
+ hunk = _make_hunk(removed=long_lines)
+ pf = _make_patch_file("many.c", [hunk])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch, max_patterns=3)
+
+ assert len(summaries[0].key_removed_patterns) == 3
+
+ def test_custom_min_pattern_length(self):
+ hunk = _make_hunk(removed=["12345", "123456"])
+ pf = _make_patch_file("short.c", [hunk])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch, min_pattern_length=6)
+
+ assert summaries[0].key_removed_patterns == ["123456"]
+
+
+class TestNewAndDeletedFiles:
+ """Tests for is_new_file and is_deleted_file flags."""
+
+ def test_new_file_flag(self):
+ pf = _make_patch_file("new.c", [_make_hunk()], is_new_file=True)
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert summaries[0].is_new_file is True
+ assert summaries[0].is_deleted_file is False
+
+ def test_deleted_file_flag(self):
+ pf = _make_patch_file("old.c", [_make_hunk()], is_deleted_file=True)
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ assert summaries[0].is_deleted_file is True
+ assert summaries[0].is_new_file is False
+
+
+class TestEstimatePatchTokens:
+ """Tests for estimate_patch_tokens helper."""
+
+ def test_none_patch_returns_zero(self):
+ assert estimate_patch_tokens(None) == 0
+
+ def test_empty_patch_returns_zero(self):
+ patch = _make_parsed_patch([])
+ assert estimate_patch_tokens(patch) == 0
+
+ def test_sums_all_file_tokens(self):
+ content1 = "a" * 40
+ content2 = "b" * 80
+ hunk1 = _make_hunk(removed=[content1])
+ hunk2 = _make_hunk(added=[content2])
+ pf1 = _make_patch_file("f1.c", [hunk1])
+ pf2 = _make_patch_file("f2.c", [hunk2])
+ patch = _make_parsed_patch([pf1, pf2])
+
+ total = estimate_patch_tokens(patch)
+
+ expected = count_tokens(content1) + count_tokens(content2)
+ assert total == expected
+
+
+class TestTokenEstimation:
+ """Tests for estimated_tokens field."""
+
+ def test_estimates_tokens_from_content(self):
+ content = "x" * 100
+ hunk = _make_hunk(removed=[content])
+ pf = _make_patch_file("big.c", [hunk])
+ patch = _make_parsed_patch([pf])
+
+ summaries = extract_file_change_summaries(patch)
+
+ expected = count_tokens(content)
+ assert summaries[0].estimated_tokens == expected
+
+
+class TestFormatFileChangesForThought:
+ """Tests for format_file_changes_for_thought (thought-prompt FILE_CHANGES)."""
+
+ def test_none_or_empty_returns_empty_string(self):
+ assert format_file_changes_for_thought(None) == ""
+ assert format_file_changes_for_thought([]) == ""
+
+ def test_preserves_per_file_delete_vs_rename_locality(self):
+ """disk-io pure deletion vs extent_io rename must stay separated."""
+ summaries = [
+ FileChangeSummary(
+ file_path="fs/btrfs/disk-io.c",
+ removed_lines_count=22,
+ added_lines_count=0,
+ key_removed_patterns=[
+ "static int btree_writepages(struct address_space *mapping,",
+ "if (wbc->sync_mode == WB_SYNC_NONE) {",
+ ],
+ key_added_patterns=[],
+ ),
+ FileChangeSummary(
+ file_path="fs/btrfs/extent_io.c",
+ removed_lines_count=2,
+ added_lines_count=1,
+ key_removed_patterns=[
+ "int btree_write_cache_pages(struct address_space *mapping,",
+ ],
+ key_added_patterns=[
+ "int btree_writepages(struct address_space *mapping, struct writeback_control *wbc)",
+ ],
+ ),
+ ]
+
+ text = format_file_changes_for_thought(summaries)
+
+ assert text.startswith(FILE_CHANGES_THOUGHT_HEADER)
+ assert "fs/btrfs/disk-io.c: -22 +0" in text
+ assert "fs/btrfs/extent_io.c: -2 +1" in text
+ disk_idx = text.index("fs/btrfs/disk-io.c")
+ extent_idx = text.index("fs/btrfs/extent_io.c")
+ assert disk_idx < extent_idx
+ disk_block = text[disk_idx:extent_idx]
+ assert "btree_writepages" in disk_block
+ assert "added: (none)" in disk_block
+ assert "btree_write_cache_pages" not in disk_block
+ extent_block = text[extent_idx:]
+ assert "btree_write_cache_pages" in extent_block
+ assert "btree_writepages" in extent_block
+ assert "added: (none)" not in extent_block
+
+ def test_truncates_long_patterns(self):
+ long_pattern = "a" * (FILE_CHANGE_PATTERN_MAX_CHARS + 40)
+ summaries = [
+ FileChangeSummary(
+ file_path="long.c",
+ removed_lines_count=1,
+ added_lines_count=0,
+ key_removed_patterns=[long_pattern],
+ )
+ ]
+
+ text = format_file_changes_for_thought(summaries)
+
+ assert long_pattern not in text
+ assert ("a" * FILE_CHANGE_PATTERN_MAX_CHARS) in text
+ assert ("a" * (FILE_CHANGE_PATTERN_MAX_CHARS + 1)) not in text
+
+ def test_sorts_by_change_size_high_removal_first(self):
+ summaries = [
+ FileChangeSummary(
+ file_path="fs/btrfs/extent_io.c",
+ removed_lines_count=2,
+ added_lines_count=1,
+ key_removed_patterns=["btree_write_cache_pages"],
+ key_added_patterns=["btree_writepages"],
+ ),
+ FileChangeSummary(
+ file_path="fs/btrfs/disk-io.c",
+ removed_lines_count=22,
+ added_lines_count=0,
+ key_removed_patterns=["btree_writepages"],
+ key_added_patterns=[],
+ ),
+ ]
+
+ text = format_file_changes_for_thought(summaries, max_files=None)
+
+ assert text.index("fs/btrfs/disk-io.c") < text.index("fs/btrfs/extent_io.c")
+
+ def test_soft_caps_to_max_files_with_omit_footer(self):
+ summaries = [
+ FileChangeSummary(
+ file_path=f"file_{i}.c",
+ removed_lines_count=i,
+ added_lines_count=0,
+ key_removed_patterns=[f"pat_{i}"],
+ )
+ for i in range(1, 6)
+ ]
+
+ text = format_file_changes_for_thought(summaries, max_files=2)
+
+ assert "file_5.c" in text
+ assert "file_4.c" in text
+ assert "file_3.c" not in text
+ assert "... +3 more FILE_CHANGES omitted (cap=2)" in text
diff --git a/src/vuln_analysis/functions/tests/test_focused_observations.py b/src/vuln_analysis/functions/tests/test_focused_observations.py
new file mode 100644
index 000000000..b072c8195
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_focused_observations.py
@@ -0,0 +1,110 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for Issue 08 per-file focused observations helpers."""
+
+from vuln_analysis.functions.code_agent_graph_defs import (
+ IntelGatheringResult,
+ PerFileIntel,
+)
+from vuln_analysis.functions.cve_package_code_agent import (
+ SOURCE_GREP_TOOL_NAME,
+ _extract_file_from_grep_query,
+ _format_tool_output_with_focused_context,
+ _resolve_focused_intel_context,
+)
+
+
+class TestExtractFileFromGrepQuery:
+ """Tests for _extract_file_from_grep_query."""
+
+ def test_extracts_file_when_filter_present(self):
+ assert _extract_file_from_grep_query("malloc,alloc.c") == "alloc.c"
+ assert _extract_file_from_grep_query("pattern, src/foo.c") == "src/foo.c"
+
+ def test_returns_none_when_no_file_filter(self):
+ assert _extract_file_from_grep_query("malloc") is None
+ assert _extract_file_from_grep_query("") is None
+
+ def test_uses_last_comma_segment(self):
+ assert _extract_file_from_grep_query("pat1,pat2,file.c") == "file.c"
+
+
+class TestResolveFocusedIntelContext:
+ """Tests for _resolve_focused_intel_context."""
+
+ def _intel_with_file(self) -> IntelGatheringResult:
+ return IntelGatheringResult(
+ per_file_intel={
+ "src/alloc.c": PerFileIntel(
+ file_path="src/alloc.c",
+ vulnerable_functions=["malloc_wrapper"],
+ vulnerable_patterns=["if (size > MAX)"],
+ )
+ }
+ )
+
+ def test_returns_context_for_source_grep_with_matching_file(self):
+ context = _resolve_focused_intel_context(
+ self._intel_with_file(),
+ SOURCE_GREP_TOOL_NAME,
+ "malloc,alloc.c",
+ )
+ assert "malloc_wrapper" in context
+ assert "size > MAX" in context
+
+ def test_returns_empty_when_no_intel(self):
+ assert _resolve_focused_intel_context(None, SOURCE_GREP_TOOL_NAME, "malloc,alloc.c") == ""
+
+ def test_returns_empty_when_not_source_grep(self):
+ assert _resolve_focused_intel_context(
+ self._intel_with_file(),
+ "Lexical Search",
+ "malloc,alloc.c",
+ ) == ""
+
+ def test_returns_empty_when_query_has_no_file_filter(self):
+ assert _resolve_focused_intel_context(
+ self._intel_with_file(),
+ SOURCE_GREP_TOOL_NAME,
+ "malloc",
+ ) == ""
+
+
+class TestFormatToolOutputWithFocusedContext:
+ """Tests for _format_tool_output_with_focused_context."""
+
+ def test_passthrough_without_focused_context(self):
+ tool_output = "match at line 10"
+ assert _format_tool_output_with_focused_context(tool_output, "") == tool_output
+
+ def test_does_not_mix_focused_context_into_tool_output(self):
+ focused = "**Patch context for `alloc.c`:**\n- Vulnerable functions: `malloc_wrapper`"
+ tool_output = "match at line 10"
+ combined = _format_tool_output_with_focused_context(tool_output, focused)
+
+ assert combined == tool_output
+ assert focused not in combined
+
+
+class TestFocusedContextKeptSeparateFromToolOutput:
+ """Focused intel is resolved for patch_context_reference, not mixed into NEW OUTPUT."""
+
+ def test_focused_context_not_embedded_in_tool_output(self):
+ intel_result = IntelGatheringResult(
+ per_file_intel={
+ "src/alloc.c": PerFileIntel(
+ file_path="src/alloc.c",
+ vulnerable_functions=["malloc_wrapper"],
+ vulnerable_patterns=["if (size > MAX)"],
+ )
+ }
+ )
+ context = intel_result.format_focused_context("alloc.c")
+ tool_output = "grep hit: unrelated_api"
+ observation_text = _format_tool_output_with_focused_context(tool_output, context)
+
+ assert observation_text == tool_output
+ assert "malloc_wrapper" in context
+ assert "size > MAX" in context
+ assert "malloc_wrapper" not in observation_text
diff --git a/src/vuln_analysis/functions/tests/test_forced_finish_identify_context.py b/src/vuln_analysis/functions/tests/test_forced_finish_identify_context.py
new file mode 100644
index 000000000..c6a15edae
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_forced_finish_identify_context.py
@@ -0,0 +1,166 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from exploit_iq_commons.data_models.checker_status import EnumIdentifyResult, PackageIdentifyResult
+
+from vuln_analysis.functions.cve_package_code_agent import (
+ FORCED_FINISH_IDENTIFY_CONTEXT,
+ FORCED_FINISH_RESPONSE_MARKER,
+ FORCED_FINISH_VULNERABLE_OVERRIDE,
+ _build_forced_finish_human_prompt,
+ _knowledge_entries_for_thought,
+ _maybe_override_uncertain_forced_finish,
+)
+from vuln_analysis.functions.react_internals import FORCED_FINISH_PROMPT
+
+
+class TestBuildForcedFinishHumanPrompt:
+ """Forced-finish human message re-surfaces identify signals next to the finish order."""
+
+ def test_none_identify_returns_base_prompt(self):
+ assert _build_forced_finish_human_prompt(None) == FORCED_FINISH_PROMPT
+
+ def test_unknown_without_note_returns_base_prompt(self):
+ identify = PackageIdentifyResult(
+ is_target_package_affected=EnumIdentifyResult.UNKNOWN,
+ is_target_package_fixed=EnumIdentifyResult.UNKNOWN,
+ conclusion_reason="",
+ )
+ assert _build_forced_finish_human_prompt(identify) == FORCED_FINISH_PROMPT
+
+ def test_fixed_yes_injects_generic_context_and_runtime_note(self):
+ note = (
+ "Identify phase: target NVR is at or above the fix NVR from RHSA "
+ "affected_release (target: pkg-1.0-1.el9, fix: pkg-1.0-1.el9)."
+ )
+ identify = PackageIdentifyResult(
+ is_target_package_affected=EnumIdentifyResult.YES,
+ is_target_package_fixed=EnumIdentifyResult.YES,
+ conclusion_reason=note,
+ )
+ prompt = _build_forced_finish_human_prompt(identify)
+
+ assert prompt != FORCED_FINISH_PROMPT
+ assert "Maximum steps reached" in prompt
+ assert FORCED_FINISH_RESPONSE_MARKER in prompt
+ assert prompt.index("IDENTIFY_PHASE_CONTEXT") < prompt.index(FORCED_FINISH_RESPONSE_MARKER)
+ assert "- affected: yes" in prompt
+ assert "- fixed: yes" in prompt
+ assert f"- note: {note}" in prompt
+ assert "PATCHED via rebase" in prompt
+ assert "Incomplete AFFECTED_FILES coverage does NOT force UNCERTAIN" in prompt
+ assert "Do NOT use version-range membership to force VULNERABLE when fixed=yes" in prompt
+ # Instructions stay generic; only the note carries package-specific text.
+ assert "webkit2gtk3" not in FORCED_FINISH_IDENTIFY_CONTEXT
+
+ def test_fixed_no_still_injects_context(self):
+ identify = PackageIdentifyResult(
+ is_target_package_affected=EnumIdentifyResult.YES,
+ is_target_package_fixed=EnumIdentifyResult.NO,
+ conclusion_reason="Target NVR is below the fix NVR.",
+ )
+ prompt = _build_forced_finish_human_prompt(identify)
+
+ assert "- affected: yes" in prompt
+ assert "- fixed: no" in prompt
+ assert "Target NVR is below the fix NVR." in prompt
+ assert "MAX-STEPS DECISION ORDER" in prompt
+ assert "IDENTIFY-NOTE OVERRIDE AT MAX-STEPS" in prompt
+ assert "Strong VULNERABLE signal" in prompt
+ assert "Do NOT let stray FIX_ONLY_FOUND noise" in prompt
+ assert "FIX_CODE_ABSENT for a primary FILE_CHANGES.added marker is sufficient" in prompt
+ assert "NOT_IN_DIFF / AMBIGUOUS_PATTERN entries never justify UNCERTAIN" in prompt
+ assert "Do NOT use EXAMPLE_FINISH_EMPTY_VULN_PATTERNS_UNCERTAIN" in prompt
+ assert "Never prefer UNCERTAIN when primary FIX_CODE_ABSENT" in prompt
+
+
+class TestMaybeOverrideUncertainForcedFinish:
+ """Deterministic UNCERTAIN → VULNERABLE override for strong rule-B evidence."""
+
+ def _identify_fixed_no(self) -> PackageIdentifyResult:
+ return PackageIdentifyResult(
+ is_target_package_affected=EnumIdentifyResult.YES,
+ is_target_package_fixed=EnumIdentifyResult.NO,
+ conclusion_reason="below fix NVR",
+ )
+
+ def test_overrides_uncertain_when_fix_absent_and_fixed_no(self):
+ memory = [
+ "VULNERABLE_CODE_ABSENT: nameSep fragment",
+ "FIX_CODE_ABSENT: typedef struct MALLOC_TRACKER { (FILE_CHANGES.added)",
+ "NOT_IN_DIFF: XML_ParserFree at xmlparse.c:123",
+ ]
+ answer = (
+ "UNCERTAIN - vulnerable patterns were unavailable and fix call-site "
+ "evidence is insufficient. Manual review required."
+ )
+ result = _maybe_override_uncertain_forced_finish(
+ answer, memory, self._identify_fixed_no(),
+ )
+ assert result == FORCED_FINISH_VULNERABLE_OVERRIDE
+
+ def test_keeps_uncertain_when_no_fix_absent(self):
+ memory = ["NOT_IN_DIFF: XML_ParserFree at xmlparse.c:123"]
+ answer = "UNCERTAIN - insufficient evidence."
+ assert (
+ _maybe_override_uncertain_forced_finish(
+ answer, memory, self._identify_fixed_no(),
+ )
+ == answer
+ )
+
+ def test_keeps_uncertain_when_fix_applied(self):
+ memory = [
+ "FIX_CODE_ABSENT: MALLOC_TRACKER",
+ "FIX_APPLIED_AT_CALL_SITE: some_fix at file.c:1",
+ ]
+ answer = "UNCERTAIN - conflicting."
+ assert (
+ _maybe_override_uncertain_forced_finish(
+ answer, memory, self._identify_fixed_no(),
+ )
+ == answer
+ )
+
+ def test_keeps_vulnerable_unchanged(self):
+ memory = ["FIX_CODE_ABSENT: MALLOC_TRACKER"]
+ answer = "VULNERABLE. Fix absent."
+ assert (
+ _maybe_override_uncertain_forced_finish(
+ answer, memory, self._identify_fixed_no(),
+ )
+ == answer
+ )
+
+ def test_keeps_uncertain_when_fixed_yes(self):
+ identify = PackageIdentifyResult(
+ is_target_package_affected=EnumIdentifyResult.YES,
+ is_target_package_fixed=EnumIdentifyResult.YES,
+ conclusion_reason="at fix NVR",
+ )
+ memory = ["FIX_CODE_ABSENT: MALLOC_TRACKER"]
+ answer = "UNCERTAIN - weak fix evidence."
+ assert _maybe_override_uncertain_forced_finish(answer, memory, identify) == answer
+
+
+class TestKnowledgeEntriesForThought:
+ """NOT_IN_DIFF must not pollute thought/finish KNOWLEDGE prompts."""
+
+ def test_drops_not_in_diff_keeps_signal_and_tool_records(self):
+ entries = [
+ "FIX_CODE_ABSENT: typedef struct MALLOC_TRACKER {",
+ "NOT_IN_DIFF: XML_ParserFree at expat/lib/xmlparse.c:123",
+ "TOOL_CALL_RECORD: Source Grep [XML_ParserFree] -> found",
+ "VULNERABLE_CODE_ABSENT: nameSep fragment",
+ "NOT_IN_DIFF: XML_ParserFree at expat/lib/xmlparse.c:456",
+ ]
+ filtered = _knowledge_entries_for_thought(entries)
+ assert filtered == [
+ "FIX_CODE_ABSENT: typedef struct MALLOC_TRACKER {",
+ "TOOL_CALL_RECORD: Source Grep [XML_ParserFree] -> found",
+ "VULNERABLE_CODE_ABSENT: nameSep fragment",
+ ]
+
+ def test_empty_or_none(self):
+ assert _knowledge_entries_for_thought(None) == []
+ assert _knowledge_entries_for_thought([]) == []
diff --git a/src/vuln_analysis/functions/tests/test_intel_gathering_result.py b/src/vuln_analysis/functions/tests/test_intel_gathering_result.py
new file mode 100644
index 000000000..c0db43bf0
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_intel_gathering_result.py
@@ -0,0 +1,305 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for IntelGatheringResult model and related classes."""
+
+import pytest
+
+from vuln_analysis.functions.code_agent_graph_defs import (
+ CVEUnderstanding,
+ PerFileIntel,
+ IntelGatheringResult,
+ FileChangeSummary,
+)
+
+
+class TestCVEUnderstanding:
+ """Tests for CVEUnderstanding model."""
+
+ def test_default_values(self):
+ cve = CVEUnderstanding()
+ assert cve.vulnerability_type == ""
+ assert cve.affected_component == ""
+ assert cve.attack_vector == "unknown"
+ assert cve.cve_keywords == []
+ assert cve.root_cause == ""
+ assert cve.component_names == []
+ assert cve.affected_bitness == "both"
+ assert cve.affected_architectures is None
+
+ def test_with_values(self):
+ cve = CVEUnderstanding(
+ vulnerability_type="buffer_overflow",
+ affected_component="memory allocator",
+ attack_vector="network",
+ cve_keywords=["malloc", "free"],
+ root_cause="Missing bounds check",
+ component_names=["mod_http2"],
+ affected_bitness="32-bit",
+ affected_architectures=["x86"],
+ )
+ assert cve.vulnerability_type == "buffer_overflow"
+ assert cve.attack_vector == "network"
+ assert cve.component_names == ["mod_http2"]
+ assert cve.affected_bitness == "32-bit"
+ assert cve.affected_architectures == ["x86"]
+
+
+class TestPerFileIntel:
+ """Tests for PerFileIntel model."""
+
+ def test_default_values(self):
+ intel = PerFileIntel(file_path="test.c")
+ assert intel.file_path == "test.c"
+ assert intel.vulnerable_functions == []
+ assert intel.vulnerable_patterns == []
+ assert intel.fix_patterns == []
+ assert intel.search_keywords == []
+
+ def test_with_values(self):
+ intel = PerFileIntel(
+ file_path="src/alloc.c",
+ vulnerable_functions=["malloc_wrapper"],
+ vulnerable_patterns=["if (size > MAX)"],
+ fix_patterns=["if (size <= MAX)"],
+ search_keywords=["MAX_SIZE"],
+ )
+ assert intel.vulnerable_functions == ["malloc_wrapper"]
+ assert len(intel.vulnerable_patterns) == 1
+
+
+class TestIntelGatheringResultGetIntelForFile:
+ """Tests for get_intel_for_file method."""
+
+ def test_exact_match(self):
+ result = IntelGatheringResult(
+ per_file_intel={"src/foo.c": PerFileIntel(file_path="src/foo.c")}
+ )
+ assert result.get_intel_for_file("src/foo.c") is not None
+
+ def test_basename_match(self):
+ result = IntelGatheringResult(
+ per_file_intel={"src/lib/foo.c": PerFileIntel(file_path="src/lib/foo.c")}
+ )
+ intel = result.get_intel_for_file("foo.c")
+ assert intel is not None
+ assert intel.file_path == "src/lib/foo.c"
+
+ def test_partial_path_match(self):
+ result = IntelGatheringResult(
+ per_file_intel={"lib/parser/xml.c": PerFileIntel(file_path="lib/parser/xml.c")}
+ )
+ intel = result.get_intel_for_file("parser/xml.c")
+ assert intel is not None
+
+ def test_no_match_returns_none(self):
+ result = IntelGatheringResult(
+ per_file_intel={"src/foo.c": PerFileIntel(file_path="src/foo.c")}
+ )
+ assert result.get_intel_for_file("bar.c") is None
+
+ def test_empty_path_returns_none(self):
+ result = IntelGatheringResult(
+ per_file_intel={"src/foo.c": PerFileIntel(file_path="src/foo.c")}
+ )
+ assert result.get_intel_for_file("") is None
+
+ def test_case_insensitive_basename(self):
+ result = IntelGatheringResult(
+ per_file_intel={"src/Foo.C": PerFileIntel(file_path="src/Foo.C")}
+ )
+ intel = result.get_intel_for_file("foo.c")
+ assert intel is not None
+
+ def test_does_not_match_prefix_basename_substring(self):
+ """Regression: hint file 'rsync' must not match grep target 'rsync.c'."""
+ result = IntelGatheringResult(
+ per_file_intel={"rsync": PerFileIntel(file_path="rsync")}
+ )
+ assert result.get_intel_for_file("rsync.c") is None
+ assert result.format_focused_context("rsync.c") == ""
+
+
+class TestIntelGatheringResultGetFunctionsForFile:
+ """Tests for get_functions_for_file method."""
+
+ def test_combines_hunk_headers_and_llm(self):
+ result = IntelGatheringResult(
+ file_summaries=[
+ FileChangeSummary(file_path="src/foo.c", functions_touched=["func_a", "func_b"])
+ ],
+ per_file_intel={
+ "src/foo.c": PerFileIntel(
+ file_path="src/foo.c",
+ vulnerable_functions=["func_c", "func_a"],
+ )
+ },
+ )
+ functions = result.get_functions_for_file("foo.c")
+ assert "func_a" in functions
+ assert "func_b" in functions
+ assert "func_c" in functions
+ assert functions.count("func_a") == 1
+
+ def test_only_hunk_headers(self):
+ result = IntelGatheringResult(
+ file_summaries=[
+ FileChangeSummary(file_path="src/bar.c", functions_touched=["parse_data"])
+ ],
+ )
+ functions = result.get_functions_for_file("bar.c")
+ assert functions == ["parse_data"]
+
+ def test_only_llm_functions(self):
+ result = IntelGatheringResult(
+ per_file_intel={
+ "src/baz.c": PerFileIntel(
+ file_path="src/baz.c",
+ vulnerable_functions=["validate_input"],
+ )
+ },
+ )
+ functions = result.get_functions_for_file("baz.c")
+ assert functions == ["validate_input"]
+
+
+class TestIntelGatheringResultToVulnerabilityIntel:
+ """Tests for to_vulnerability_intel method."""
+
+ def test_aggregates_all_files(self):
+ result = IntelGatheringResult(
+ cve_understanding=CVEUnderstanding(
+ vulnerability_type="buffer_overflow",
+ root_cause="Missing bounds check",
+ component_names=["mod_http2"],
+ affected_bitness="32-bit",
+ affected_architectures=["x86"],
+ ),
+ per_file_intel={
+ "a.c": PerFileIntel(
+ file_path="a.c",
+ vulnerable_functions=["foo"],
+ vulnerable_patterns=["if (len > MAX)"],
+ ),
+ "b.c": PerFileIntel(
+ file_path="b.c",
+ vulnerable_functions=["bar"],
+ fix_patterns=["if (len <= MAX)"],
+ ),
+ },
+ )
+ intel = result.to_vulnerability_intel()
+
+ assert intel.vulnerability_type == "buffer_overflow"
+ assert intel.root_cause == "Missing bounds check"
+ assert "foo" in intel.vulnerable_functions
+ assert "bar" in intel.vulnerable_functions
+ assert "a.c" in intel.affected_files
+ assert "b.c" in intel.affected_files
+ assert intel.component_names == ["mod_http2"]
+ assert intel.affected_bitness == "32-bit"
+ assert intel.affected_architectures == ["x86"]
+
+ def test_maps_arch_gate_defaults_when_cve_understanding_absent(self):
+ result = IntelGatheringResult()
+ intel = result.to_vulnerability_intel()
+ assert intel.affected_bitness == "both"
+ assert intel.affected_architectures is None
+ assert intel.component_names == []
+
+ def test_deduplicates_functions(self):
+ result = IntelGatheringResult(
+ per_file_intel={
+ "a.c": PerFileIntel(file_path="a.c", vulnerable_functions=["shared_func", "a_only"]),
+ "b.c": PerFileIntel(file_path="b.c", vulnerable_functions=["shared_func", "b_only"]),
+ },
+ )
+ intel = result.to_vulnerability_intel()
+
+ assert intel.vulnerable_functions.count("shared_func") == 1
+ assert "a_only" in intel.vulnerable_functions
+ assert "b_only" in intel.vulnerable_functions
+
+ def test_caps_patterns_at_10(self):
+ result = IntelGatheringResult(
+ per_file_intel={
+ "a.c": PerFileIntel(
+ file_path="a.c",
+ vulnerable_patterns=[f"pattern_{i}" for i in range(15)],
+ ),
+ },
+ )
+ intel = result.to_vulnerability_intel()
+
+ assert len(intel.vulnerable_patterns) == 10
+
+ def test_empty_result(self):
+ result = IntelGatheringResult()
+ intel = result.to_vulnerability_intel()
+
+ assert intel.vulnerability_type == ""
+ assert intel.affected_files == []
+ assert intel.vulnerable_functions == []
+
+ def test_no_cve_understanding(self):
+ result = IntelGatheringResult(
+ per_file_intel={
+ "a.c": PerFileIntel(file_path="a.c", vulnerable_functions=["func"]),
+ },
+ )
+ intel = result.to_vulnerability_intel()
+
+ assert intel.vulnerability_type == ""
+ assert intel.root_cause == ""
+ assert "func" in intel.vulnerable_functions
+
+
+class TestIntelGatheringResultFormatFocusedContext:
+ """Tests for format_focused_context method."""
+
+ def test_formats_all_fields(self):
+ result = IntelGatheringResult(
+ file_summaries=[
+ FileChangeSummary(file_path="src/foo.c", functions_touched=["hunk_func"])
+ ],
+ per_file_intel={
+ "src/foo.c": PerFileIntel(
+ file_path="src/foo.c",
+ vulnerable_functions=["parse_data"],
+ vulnerable_patterns=["if (len > MAX)"],
+ fix_patterns=["if (len <= MAX)"],
+ )
+ },
+ )
+ context = result.format_focused_context("foo.c")
+
+ assert "src/foo.c" in context
+ assert "hunk_func" in context or "parse_data" in context
+ assert "if (len > MAX)" in context
+ assert "if (len <= MAX)" in context
+
+ def test_returns_empty_for_no_match(self):
+ result = IntelGatheringResult(
+ per_file_intel={
+ "src/foo.c": PerFileIntel(file_path="src/foo.c"),
+ },
+ )
+ context = result.format_focused_context("bar.c")
+
+ assert context == ""
+
+ def test_truncates_long_patterns(self):
+ long_pattern = "x" * 100
+ result = IntelGatheringResult(
+ per_file_intel={
+ "src/foo.c": PerFileIntel(
+ file_path="src/foo.c",
+ vulnerable_patterns=[long_pattern],
+ )
+ },
+ )
+ context = result.format_focused_context("foo.c")
+
+ assert len(long_pattern) > 80
+ assert "x" * 80 in context
+ assert "x" * 81 not in context
diff --git a/src/vuln_analysis/functions/tests/test_internal_memory_aggregation.py b/src/vuln_analysis/functions/tests/test_internal_memory_aggregation.py
new file mode 100644
index 000000000..b4ab6e9a5
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_internal_memory_aggregation.py
@@ -0,0 +1,55 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""InternalMemory must surface multi-hit findings in KNOWLEDGE (not drop sibling lines)."""
+
+from vuln_analysis.functions.react_internals import InternalMemory, parse_to_memory_item
+
+
+class TestInternalMemoryMultiHitAggregation:
+ """Regression: same pattern@file at different lines must aggregate into content."""
+
+ def test_aggregates_line_numbers_into_knowledge_content(self):
+ findings = [
+ "VULNERABLE_CODE_FOUND: computeRowPitch at ./path/formatutils.cpp:1761",
+ "VULNERABLE_CODE_FOUND: computeRowPitch at ./path/formatutils.cpp:1792",
+ "AMBIGUOUS_PATTERN: computeRowPitch - exists in both versions, not evidence",
+ "VULNERABLE_CODE_FOUND: computeRowPitch at ./path/formatutils.cpp:1863",
+ "AMBIGUOUS_PATTERN: computeRowPitch - exists in both versions, not evidence",
+ ]
+ memory = InternalMemory()
+ for finding in findings:
+ memory.add(parse_to_memory_item(finding))
+
+ observation = memory.to_observation()
+ knowledge = observation.memory
+
+ vuln_entries = [m for m in knowledge if m.startswith("VULNERABLE_CODE_FOUND:")]
+ ambiguous_entries = [m for m in knowledge if m.startswith("AMBIGUOUS_PATTERN:")]
+
+ assert len(vuln_entries) == 1
+ assert "1761,1792,1863" in vuln_entries[0]
+ assert "(x3)" in vuln_entries[0]
+
+ assert len(ambiguous_entries) == 1
+ assert "(x2)" in ambiguous_entries[0]
+
+ def test_aggregates_fix_only_hits_across_lines(self):
+ findings = [
+ "FIX_ONLY_FOUND: computeRowDepthSkipBytes at ./path/formatutils.cpp:1752",
+ "FIX_ONLY_FOUND: computeRowDepthSkipBytes at ./path/formatutils.cpp:2000",
+ "FIX_ONLY_FOUND: skipBytes at ./path/formatutils.cpp:1999",
+ ]
+ memory = InternalMemory()
+ for finding in findings:
+ memory.add(parse_to_memory_item(finding))
+
+ knowledge = memory.to_observation().memory
+ depth_entries = [m for m in knowledge if "computeRowDepthSkipBytes" in m]
+ skip_entries = [m for m in knowledge if m.startswith("FIX_ONLY_FOUND: skipBytes")]
+
+ assert len(depth_entries) == 1
+ assert "1752,2000" in depth_entries[0]
+ assert "(x2)" in depth_entries[0]
+ assert len(skip_entries) == 1
+ assert ":1999" in skip_entries[0]
diff --git a/src/vuln_analysis/functions/tests/test_new_intel_flow.py b/src/vuln_analysis/functions/tests/test_new_intel_flow.py
new file mode 100644
index 000000000..a4e2400ac
--- /dev/null
+++ b/src/vuln_analysis/functions/tests/test_new_intel_flow.py
@@ -0,0 +1,231 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for Issue 06: New Intel Flow helpers."""
+
+import pytest
+
+from vuln_analysis.functions.code_agent_graph_defs import FileChangeSummary
+from vuln_analysis.functions.cve_package_code_agent import (
+ _filter_code_files_for_analysis,
+ _is_code_file_for_analysis,
+ _prioritize_files_for_analysis,
+ _split_files_by_budget,
+)
+
+
+class TestFilterCodeFilesForAnalysis:
+ """Tests for non-code / test filtering before patch analysis."""
+
+ def test_keeps_source_and_headers(self):
+ summaries = [
+ FileChangeSummary(file_path="expat/lib/xmlparse.c", estimated_tokens=100),
+ FileChangeSummary(file_path="expat/lib/expat.h", estimated_tokens=50),
+ FileChangeSummary(file_path="expat/xmlwf/xmlwf_helpgen.py", estimated_tokens=40),
+ ]
+ result = _filter_code_files_for_analysis(summaries)
+ assert [fs.file_path for fs in result] == [
+ "expat/lib/xmlparse.c",
+ "expat/lib/expat.h",
+ "expat/xmlwf/xmlwf_helpgen.py",
+ ]
+
+ def test_drops_docs_changelog_and_unknown_extensions(self):
+ summaries = [
+ FileChangeSummary(file_path="expat/lib/xmlparse.c", estimated_tokens=100),
+ FileChangeSummary(file_path="expat/doc/reference.html", estimated_tokens=80),
+ FileChangeSummary(file_path="expat/Changes", estimated_tokens=20),
+ FileChangeSummary(file_path="expat/doc/xmlwf.xml", estimated_tokens=30),
+ FileChangeSummary(file_path="expat/lib/libexpat.def.cmake", estimated_tokens=10),
+ FileChangeSummary(
+ file_path=".github/workflows/data/exported-symbols.txt",
+ estimated_tokens=15,
+ ),
+ FileChangeSummary(file_path="README.md", estimated_tokens=25),
+ FileChangeSummary(file_path="config.yaml", estimated_tokens=12),
+ ]
+ result = _filter_code_files_for_analysis(summaries)
+ assert [fs.file_path for fs in result] == ["expat/lib/xmlparse.c"]
+
+ def test_drops_test_paths(self):
+ summaries = [
+ FileChangeSummary(file_path="expat/lib/xmlparse.c", estimated_tokens=100),
+ FileChangeSummary(file_path="expat/tests/basic_tests.c", estimated_tokens=60),
+ FileChangeSummary(file_path="expat/tests/nsalloc_tests.c", estimated_tokens=40),
+ ]
+ result = _filter_code_files_for_analysis(summaries)
+ assert [fs.file_path for fs in result] == ["expat/lib/xmlparse.c"]
+
+ def test_all_non_code_returns_empty(self):
+ summaries = [
+ FileChangeSummary(file_path="docs/page.md", estimated_tokens=10),
+ FileChangeSummary(file_path="Changes", estimated_tokens=5),
+ ]
+ assert _filter_code_files_for_analysis(summaries) == []
+
+ def test_is_code_file_helpers(self):
+ assert _is_code_file_for_analysis("src/main.c")
+ assert _is_code_file_for_analysis("include/api.h")
+ assert not _is_code_file_for_analysis("expat/Changes")
+ assert not _is_code_file_for_analysis("expat/doc/reference.html")
+ assert not _is_code_file_for_analysis("tests/basic_tests.c")
+
+
+class TestPrioritizeFilesForAnalysis:
+ """Tests for _prioritize_files_for_analysis()."""
+
+ def test_c_files_prioritized_over_headers(self):
+ """Implementation files (.c) should come before headers (.h)."""
+ summaries = [
+ FileChangeSummary(file_path="include/api.h", estimated_tokens=200),
+ FileChangeSummary(file_path="src/main.c", estimated_tokens=500),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ assert result[0].file_path == "src/main.c"
+ assert result[1].file_path == "include/api.h"
+
+ def test_headers_prioritized_over_other(self):
+ """Headers (.h) should come before non-code files."""
+ summaries = [
+ FileChangeSummary(file_path="README.md", estimated_tokens=100),
+ FileChangeSummary(file_path="include/api.h", estimated_tokens=200),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ assert result[0].file_path == "include/api.h"
+ assert result[1].file_path == "README.md"
+
+ def test_python_files_treated_as_impl(self):
+ """Python files should be in implementation tier."""
+ summaries = [
+ FileChangeSummary(file_path="config.yaml", estimated_tokens=100),
+ FileChangeSummary(file_path="src/parser.py", estimated_tokens=300),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ assert result[0].file_path == "src/parser.py"
+
+ def test_java_files_treated_as_impl(self):
+ """Java files should be in implementation tier."""
+ summaries = [
+ FileChangeSummary(file_path="pom.xml", estimated_tokens=100),
+ FileChangeSummary(file_path="src/Main.java", estimated_tokens=400),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ assert result[0].file_path == "src/Main.java"
+
+ def test_larger_files_first_within_tier(self):
+ """Within same tier, larger files (more tokens) come first."""
+ summaries = [
+ FileChangeSummary(file_path="small.c", estimated_tokens=100),
+ FileChangeSummary(file_path="large.c", estimated_tokens=500),
+ FileChangeSummary(file_path="medium.c", estimated_tokens=300),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ assert result[0].file_path == "large.c"
+ assert result[1].file_path == "medium.c"
+ assert result[2].file_path == "small.c"
+
+ def test_mixed_extensions_full_sort(self):
+ """Full sort with mixed file types."""
+ summaries = [
+ FileChangeSummary(file_path="readme.md", estimated_tokens=100),
+ FileChangeSummary(file_path="src/main.c", estimated_tokens=500),
+ FileChangeSummary(file_path="include/api.h", estimated_tokens=200),
+ FileChangeSummary(file_path="lib/parser.cpp", estimated_tokens=300),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ # Impl files first (by size): main.c (500), parser.cpp (300)
+ assert result[0].file_path == "src/main.c"
+ assert result[1].file_path == "lib/parser.cpp"
+ # Headers second
+ assert result[2].file_path == "include/api.h"
+ # Other last
+ assert result[3].file_path == "readme.md"
+
+ def test_empty_list(self):
+ """Empty input returns empty output."""
+ result = _prioritize_files_for_analysis([])
+ assert result == []
+
+ def test_case_insensitive_extension(self):
+ """File extension matching should be case-insensitive."""
+ summaries = [
+ FileChangeSummary(file_path="Other.txt", estimated_tokens=100),
+ FileChangeSummary(file_path="Main.C", estimated_tokens=500),
+ ]
+ result = _prioritize_files_for_analysis(summaries)
+ assert result[0].file_path == "Main.C"
+
+
+class TestSplitFilesByBudget:
+ """Tests for _split_files_by_budget()."""
+
+ def test_all_files_fit_in_budget(self):
+ """When all files fit, batch2 should be empty."""
+ summaries = [
+ FileChangeSummary(file_path="a.c", estimated_tokens=100),
+ FileChangeSummary(file_path="b.c", estimated_tokens=200),
+ ]
+ batch1, batch2 = _split_files_by_budget(summaries, max_tokens=500)
+ assert len(batch1) == 2
+ assert len(batch2) == 0
+
+ def test_split_at_budget_boundary(self):
+ """Files that exceed budget go to batch2."""
+ summaries = [
+ FileChangeSummary(file_path="a.c", estimated_tokens=3000),
+ FileChangeSummary(file_path="b.c", estimated_tokens=3000),
+ FileChangeSummary(file_path="c.c", estimated_tokens=3000),
+ ]
+ batch1, batch2 = _split_files_by_budget(summaries, max_tokens=6000)
+ assert len(batch1) == 2
+ assert len(batch2) == 1
+ assert batch2[0].file_path == "c.c"
+
+ def test_exact_budget_fit(self):
+ """File that exactly fills budget should be included."""
+ summaries = [
+ FileChangeSummary(file_path="a.c", estimated_tokens=500),
+ FileChangeSummary(file_path="b.c", estimated_tokens=500),
+ ]
+ batch1, batch2 = _split_files_by_budget(summaries, max_tokens=1000)
+ assert len(batch1) == 2
+ assert len(batch2) == 0
+
+ def test_greedy_fill_skips_large_includes_small(self):
+ """Greedy algorithm: if large file doesn't fit, smaller ones after may fit."""
+ summaries = [
+ FileChangeSummary(file_path="large.c", estimated_tokens=400),
+ FileChangeSummary(file_path="huge.c", estimated_tokens=800),
+ FileChangeSummary(file_path="small.c", estimated_tokens=100),
+ ]
+ batch1, batch2 = _split_files_by_budget(summaries, max_tokens=600)
+ # large.c (400) fits, huge.c (800) doesn't, small.c (100) fits
+ assert [f.file_path for f in batch1] == ["large.c", "small.c"]
+ assert [f.file_path for f in batch2] == ["huge.c"]
+
+ def test_empty_input(self):
+ """Empty input returns two empty lists."""
+ batch1, batch2 = _split_files_by_budget([], max_tokens=1000)
+ assert batch1 == []
+ assert batch2 == []
+
+ def test_single_file_exceeds_budget(self):
+ """Single file larger than budget goes to batch2."""
+ summaries = [
+ FileChangeSummary(file_path="huge.c", estimated_tokens=10000),
+ ]
+ batch1, batch2 = _split_files_by_budget(summaries, max_tokens=5000)
+ assert len(batch1) == 0
+ assert len(batch2) == 1
+
+ def test_preserves_order_within_batches(self):
+ """Order within batches should match input order."""
+ summaries = [
+ FileChangeSummary(file_path="first.c", estimated_tokens=100),
+ FileChangeSummary(file_path="second.c", estimated_tokens=100),
+ FileChangeSummary(file_path="third.c", estimated_tokens=100),
+ FileChangeSummary(file_path="fourth.c", estimated_tokens=100),
+ ]
+ batch1, batch2 = _split_files_by_budget(summaries, max_tokens=250)
+ assert [f.file_path for f in batch1] == ["first.c", "second.c"]
+ assert [f.file_path for f in batch2] == ["third.c", "fourth.c"]
diff --git a/src/vuln_analysis/tools/source_grep.py b/src/vuln_analysis/tools/source_grep.py
index 9220937e0..e5b6eb841 100644
--- a/src/vuln_analysis/tools/source_grep.py
+++ b/src/vuln_analysis/tools/source_grep.py
@@ -175,16 +175,22 @@ async def _arun(query: str) -> str:
- Multiple patterns: use ';' separator ONLY with a specific file
- Compatibility: "pattern1,pattern2,file.c" is accepted and normalized
+ IMPORTANT: Comma is the delimiter between pattern and file_glob.
+ Patterns must NOT contain commas. For function calls like 'foo(a, b)',
+ use only the function name 'foo' or target a specific file: 'foo,file.c'.
+
Examples:
- 'archive_read_open' - search source files
- 'archive_read_open,*.c' - search only .c source files
- 'archive_read_open -w' - search for whole word only
- 'unsigned int cursor;unsigned int nodes,archive_read.c' - multiple patterns in one file
- - 'sum2,s2length,match.c' - compatibility syntax for multiple patterns in one file
- 'logs:undefined reference' - search build logs for link errors
- 'logs:error:' - search build logs for error messages
- 'patch:CVE-2026-5121' - find patch for specific CVE
- 'patch:archive_read,*.patch' - search in patch files
+
+ WRONG: 'computeRowPitch(type, width)' - commas break parsing
+ RIGHT: 'computeRowPitch' or 'computeRowPitch,formatutils.cpp'
"""
workflow_state = ctx_state.get()
@@ -252,6 +258,10 @@ async def _arun(query: str) -> str:
"Add ' -w' suffix for whole-word matching. "
"Multiple patterns: use ';' separator ONLY with a specific file, e.g., "
"'pattern1;pattern2,filename.c' searches for both patterns in that file. "
+ "IMPORTANT: Comma is the delimiter - patterns must NOT contain commas. "
+ "For function calls like 'foo(a, b)', use only the function name 'foo' or target a file: 'foo,file.c'. "
+ "WRONG: 'computeRowPitch(type, width)' - commas break parsing. "
+ "RIGHT: 'computeRowPitch' or 'computeRowPitch,formatutils.cpp'. "
"Examples: 'archive_read_open' searches source, "
"'archive_read_open,*.c' searches only C source files, "
"'archive_read_open -w' searches for whole word only, "
diff --git a/src/vuln_analysis/tools/source_inspector.py b/src/vuln_analysis/tools/source_inspector.py
index 34ed3d39d..d88327c39 100644
--- a/src/vuln_analysis/tools/source_inspector.py
+++ b/src/vuln_analysis/tools/source_inspector.py
@@ -30,6 +30,11 @@
from nat.builder.context import Context
+# When file_glob is a path (e.g. patch-relative "expat/lib/xmlparse.c"), keep only
+# the trailing path components for post-filtering so RPM extract prefixes like
+# "expat-2.7/expat-2.7.1/" still match.
+PATH_FILTER_SUFFIX_PARTS = 2
+
@dataclass
class GrepMatch:
@@ -38,6 +43,25 @@ class GrepMatch:
line_content: str
+def _path_filter_from_file_glob(file_glob: str) -> tuple[str | None, str]:
+ """Split a path-like file_glob into (path_filter, basename for --include).
+
+ path_filter is the last PATH_FILTER_SUFFIX_PARTS components (e.g.
+ ``lib/xmlparse.c``), not the full directory prefix (``expat/``), so
+ on-disk trees that add version prefixes still pass the filter.
+ """
+ if "/" not in file_glob:
+ return None, file_glob
+
+ parts = [p for p in file_glob.split("/") if p]
+ if not parts:
+ return None, file_glob
+
+ basename = parts[-1]
+ suffix_parts = parts[-PATH_FILTER_SUFFIX_PARTS:]
+ return "/".join(suffix_parts), basename
+
+
class SourceInspector:
"""Filesystem inspector scoped to a root directory.
@@ -177,13 +201,12 @@ async def grep_native(
else:
cmd.extend(["-C", str(context_lines)])
- # If file_glob contains a path (e.g., "lib/connect.c"), split into:
- # - path_filter: directory portion for post-filtering results (e.g., "lib/")
- # - file_glob: filename only for --include (e.g., "connect.c")
+ # If file_glob contains a path (e.g., "expat/lib/xmlparse.c"), split into:
+ # - path_filter: trailing path suffix for post-filtering (e.g., "lib/xmlparse.c")
+ # - file_glob: filename only for --include (e.g., "xmlparse.c")
path_filter = None
if file_glob and "/" in file_glob:
- path_filter = file_glob.rsplit("/", 1)[0] + "/"
- file_glob = file_glob.rsplit("/", 1)[1]
+ path_filter, file_glob = _path_filter_from_file_glob(file_glob)
if file_glob:
cmd.extend(["--include", file_glob])
diff --git a/src/vuln_analysis/tools/tests/test_source_inspector.py b/src/vuln_analysis/tools/tests/test_source_inspector.py
new file mode 100644
index 000000000..f53043a79
--- /dev/null
+++ b/src/vuln_analysis/tools/tests/test_source_inspector.py
@@ -0,0 +1,79 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for SourceInspector path filtering and grep_native scoping."""
+
+from pathlib import Path
+
+import pytest
+
+from vuln_analysis.tools.source_inspector import (
+ PATH_FILTER_SUFFIX_PARTS,
+ SourceInspector,
+ _path_filter_from_file_glob,
+)
+
+
+class TestPathFilterFromFileGlob:
+ """Patch-relative paths must filter by trailing suffix, not leading dir prefix."""
+
+ def test_basename_only_has_no_path_filter(self):
+ path_filter, include = _path_filter_from_file_glob("xmlparse.c")
+ assert path_filter is None
+ assert include == "xmlparse.c"
+
+ def test_expat_style_keeps_lib_and_basename(self):
+ path_filter, include = _path_filter_from_file_glob("expat/lib/xmlparse.c")
+ assert path_filter == "lib/xmlparse.c"
+ assert include == "xmlparse.c"
+ assert path_filter != "expat/"
+
+ def test_two_component_path_uses_full_relative(self):
+ path_filter, include = _path_filter_from_file_glob("lib/connect.c")
+ assert path_filter == "lib/connect.c"
+ assert include == "connect.c"
+
+ def test_deeper_path_uses_last_n_components(self):
+ path_filter, include = _path_filter_from_file_glob(
+ "src/libANGLE/renderer/gl/TextureGL.cpp"
+ )
+ parts = path_filter.split("/")
+ assert len(parts) == PATH_FILTER_SUFFIX_PARTS
+ assert path_filter == "gl/TextureGL.cpp"
+ assert include == "TextureGL.cpp"
+
+ def test_suffix_matches_versioned_extract_layout(self):
+ """RPM trees nest under version dirs; leading expat/ must not be required."""
+ path_filter, _ = _path_filter_from_file_glob("expat/lib/xmlparse.c")
+ on_disk = "./expat-2.7/expat-2.7.1/lib/xmlparse.c:786:XML_ParserCreate("
+ assert path_filter in on_disk
+ assert "expat/" not in path_filter
+
+
+class TestGrepNativePathFilter:
+ """grep_native post-filter keeps version-prefixed paths for patch-relative globs."""
+
+ @pytest.mark.asyncio
+ async def test_patch_relative_glob_finds_nested_file(self, tmp_path: Path):
+ nested = tmp_path / "expat-2.7" / "expat-2.7.1" / "lib"
+ nested.mkdir(parents=True)
+ target = nested / "xmlparse.c"
+ target.write_text("XML_ParserCreate(const XML_Char *encodingName) {\n")
+
+ # Decoy with same basename under a different parent (should be filtered out)
+ other = tmp_path / "other" / "xmlparse.c"
+ other.parent.mkdir(parents=True)
+ other.write_text("XML_ParserCreate_DECOY(\n")
+
+ inspector = SourceInspector(tmp_path)
+ output = await inspector.grep_native(
+ "XML_ParserCreate",
+ file_glob="expat/lib/xmlparse.c",
+ max_results=20,
+ default_extensions=["*.c"],
+ )
+
+ assert output
+ assert "expat-2.7.1/lib/xmlparse.c" in output
+ assert "XML_ParserCreate(" in output
+ assert "DECOY" not in output
diff --git a/src/vuln_analysis/utils/intel_utils.py b/src/vuln_analysis/utils/intel_utils.py
index fae9bde9f..dd3a06812 100644
--- a/src/vuln_analysis/utils/intel_utils.py
+++ b/src/vuln_analysis/utils/intel_utils.py
@@ -712,6 +712,11 @@ def _strip_rejected_package_token(entry: str, rn: str) -> str:
# Priority 3-4: Vendor advisories with fix details
# Priority 5+: Generic advisories, less likely to have commit hints
ADVISORY_SOURCE_PATTERNS: dict[str, tuple[str, int]] = {
+ # Highest priority - security research with detailed root cause analysis
+ "github.com/google/security-research": ("security_research", 1),
+ "googleprojectzero.blogspot": ("security_research", 1),
+ "kb.cert.org": ("cert_cc", 1),
+ "github.com/advisories/GHSA": ("github_advisory", 2),
# High priority - detailed technical info, often has commit refs
"openwall.com/lists/oss-security": ("openwall", 1),
"seclists.org": ("mailing_list", 2),
@@ -825,7 +830,7 @@ def _classify_advisory_url(url: str) -> tuple[str, int]:
def extract_advisory_urls(intel: CveIntel) -> list[tuple[str, str, int]]:
"""Extract non-commit reference URLs from intel for advisory mining.
- Scans GHSA, NVD, RHSA, and Ubuntu references for URLs that are NOT
+ Scans GHSA, NVD, RHSA, Ubuntu, and OSIDB references for URLs that are NOT
direct commit/patch links but may contain advisory content with hints
about fix commits, vulnerable functions, or affected files.
@@ -843,18 +848,26 @@ def extract_advisory_urls(intel: CveIntel) -> list[tuple[str, str, int]]:
seen_urls: set[str] = set()
results: list[tuple[str, str, int]] = []
+ def _normalize_url(url: str) -> str:
+ """Normalize URL for deduplication (remove www prefix and trailing slash)."""
+ normalized = url.lower().replace("://www.", "://").rstrip("/")
+ return normalized
+
def _process_refs(refs: list | None) -> None:
if not refs:
return
for ref in refs:
url = ref if isinstance(ref, str) else (ref.get("url", "") if isinstance(ref, dict) else "")
- if not url or url in seen_urls:
+ if not url:
+ continue
+ normalized = _normalize_url(url)
+ if normalized in seen_urls:
continue
if not _is_safe_url(url):
continue
if not is_advisory_url(url):
continue
- seen_urls.add(url)
+ seen_urls.add(normalized)
source_type, priority = _classify_advisory_url(url)
results.append((url, source_type, priority))
@@ -884,6 +897,11 @@ def _process_refs(refs: list | None) -> None:
ubuntu_refs = getattr(intel.ubuntu, "references", None)
_process_refs(ubuntu_refs)
+ # OSIDB references
+ if intel.osidb:
+ osidb_refs = getattr(intel.osidb, "references", None)
+ _process_refs(osidb_refs)
+
# Sort by priority (ascending - lower is better)
results.sort(key=lambda x: x[2])
return results
diff --git a/src/vuln_analysis/utils/package_identifier.py b/src/vuln_analysis/utils/package_identifier.py
index e3f69836b..3d800fea8 100644
--- a/src/vuln_analysis/utils/package_identifier.py
+++ b/src/vuln_analysis/utils/package_identifier.py
@@ -57,8 +57,10 @@ def _extract_dist_tag(release: str) -> str | None:
_AFFECTED_FIX_STATES = frozenset({
"affected", "fix deferred", "under investigation",
})
+# Shared continue-to-verify sentinel for RHSA fix_state and OSIDB NOTAFFECTED.
+_VENDOR_NOT_AFFECTED_SENTINEL = "not affected"
_NOT_AFFECTED_FIX_STATES = frozenset({
- "not affected",
+ _VENDOR_NOT_AFFECTED_SENTINEL,
"will not fix",
"out of support scope",
})
@@ -124,6 +126,84 @@ def _interpret_fix_state(fix_state: str | None) -> EnumIdentifyResult | None:
return None # Unknown state, fall through to other checks
+def _osidb_stream_matches_rhel_major(
+ ps_module: str | None,
+ ps_update_stream: str | None,
+ rhel_major: str,
+) -> bool:
+ """True when OSIDB module/stream refers to the given RHEL major (e.g. 7 -> rhel-7).
+
+ Matches exact ``rhel-{major}`` or values like ``rhel-7-els`` / ``rhel-7.9``,
+ but not ``rhel-10`` when major is ``1``.
+ """
+ prefix = f"rhel-{rhel_major}"
+ for value in (ps_module, ps_update_stream):
+ if not value:
+ continue
+ if value == prefix or value.startswith(prefix + "-") or value.startswith(prefix + "."):
+ return True
+ return False
+
+
+def _osidb_affectedness_for_target(
+ affects: list,
+ target_name: str,
+ target_distro: str | None,
+) -> tuple[EnumIdentifyResult, str | None]:
+ """Map OSIDB affects to Identify affected posture for the target package/distro.
+
+ When OSIDB affects are present (VPN/internal), this is the sole source for
+ ``is_target_package_affected`` — callers must not fall through to RHSA/NVD.
+
+ Returns:
+ (result, conclusion_reason). conclusion_reason is set for YES/NO;
+ UNKNOWN returns (UNKNOWN, None).
+ """
+ rhel_major = _extract_rhel_version(target_distro)
+ if not rhel_major:
+ return EnumIdentifyResult.UNKNOWN, None
+
+ preferred: list = []
+ candidates: list = []
+ for affect in affects:
+ ps_component = getattr(affect, "ps_component", None)
+ if not ps_component or not package_names_match(target_name, ps_component):
+ continue
+ ps_module = getattr(affect, "ps_module", None)
+ ps_update_stream = getattr(affect, "ps_update_stream", None)
+ if not _osidb_stream_matches_rhel_major(ps_module, ps_update_stream, rhel_major):
+ continue
+ if ps_module == f"rhel-{rhel_major}":
+ preferred.append(affect)
+ else:
+ candidates.append(affect)
+
+ matched = preferred or candidates
+ if not matched:
+ return EnumIdentifyResult.UNKNOWN, None
+
+ affect = matched[0]
+ affectedness = (getattr(affect, "affectedness", None) or "").strip().upper()
+ ps_module = getattr(affect, "ps_module", None) or "unknown"
+ ps_update_stream = getattr(affect, "ps_update_stream", None) or "unknown"
+
+ if affectedness == "AFFECTED":
+ reason = (
+ f"OSIDB affect indicates package is affected. "
+ f"Package: {target_name}, Module: {ps_module}, "
+ f"Stream: {ps_update_stream}, affectedness: '{getattr(affect, 'affectedness', None)}'"
+ )
+ return EnumIdentifyResult.YES, reason
+ if affectedness == "NOTAFFECTED":
+ reason = (
+ f"OSIDB affect indicates package is not affected. "
+ f"Package: {target_name}, Module: {ps_module}, "
+ f"Stream: {ps_update_stream}, affectedness: '{getattr(affect, 'affectedness', None)}'"
+ )
+ return EnumIdentifyResult.NO, reason
+ return EnumIdentifyResult.UNKNOWN, None
+
+
def extract_nvd_version_info(intel: CveIntel, target_name: str) -> tuple[str, str]:
"""Extract version range and fixed version from NVD configurations.
@@ -186,9 +266,10 @@ def identify(self, intel: CveIntel | None) -> tuple[PackageCheckerStatus, Packag
``affected_release`` when Red Hat published either list; else
``PKG_IDENT_CVE_MISMATCH``.
Step 2 — Vulnerability posture: ``PKG_IDENT_NOT_VUL`` when RHSA/NVD
- shows the target is not affected, or when fix_state is "will not fix"
- or "out of support scope". When fix_state is "not affected", investigation
- continues (status=OK) to verify the RHSA assessment and catch potential errors.
+ shows the target is not vulnerable for analysis, or when fix_state is
+ "will not fix" or "out of support scope". When RHSA fix_state or OSIDB
+ affectedness is "not affected" / NOTAFFECTED, investigation continues
+ (status=OK) to verify the vendor assessment and catch potential errors.
Note: ``is_target_package_fixed`` (NVR-vs-fix-NVR comparison) is
advisory only and never gates ``status`` here. It's populated on
@@ -216,11 +297,11 @@ def identify(self, intel: CveIntel | None) -> tuple[PackageCheckerStatus, Packag
# Determine if we should early-exit with PKG_IDENT_NOT_VUL
if package_identify.is_target_package_affected == EnumIdentifyResult.NO:
- # RHSA says not affected - check if we should continue investigation
- # "not affected" continues to verify; other states (will not fix, out of support) early-exit
- if package_identify.rhsa_fix_state != "not affected":
+ # Vendor "not affected" (RHSA or OSIDB) continues so we can verify;
+ # other NO states (will not fix, out of support, NVD out-of-range) early-exit.
+ if package_identify.rhsa_fix_state != _VENDOR_NOT_AFFECTED_SENTINEL:
status = PackageCheckerStatus.PKG_IDENT_NOT_VUL
- # else: status stays OK, continue investigation to verify RHSA assessment
+ # else: status stays OK, continue investigation to verify vendor assessment
return status, package_identify
@@ -303,20 +384,41 @@ def _is_target_package_affected(
) -> EnumIdentifyResult:
"""Determine whether the target package is affected by this CVE.
+ When OSIDB affects are present (VPN/internal), OSIDB is the sole source
+ for affected posture — RHSA and NVD are not consulted.
+
+ Without OSIDB:
Priority 1: Check RHSA fix_state (distro-specific vendor assessment).
Priority 2: Fall back to NVD version range check.
Only returns NO with definitive proof; defaults to UNKNOWN otherwise.
"""
- rpm_names = self._find_and_locate_rpm(intel)
- if not rpm_names:
- return EnumIdentifyResult.UNKNOWN
- package_identify.affected_rpm_list = rpm_names
-
target_name = self._target_package.name
target_version = self._target_package.version
target_release = self._target_package.release
target_distro = _extract_dist_tag(target_release) if target_release else None
+ # OSIDB-only path (available for VPN/internal users)
+ if intel.osidb and intel.osidb.affects:
+ result, reason = _osidb_affectedness_for_target(
+ intel.osidb.affects, target_name, target_distro
+ )
+ if reason:
+ package_identify.conclusion_reason = reason
+ # NOTAFFECTED maps to NO; reuse RHSA sentinel so Identify continues
+ # and downstream conflict detection can fire.
+ if result == EnumIdentifyResult.NO:
+ package_identify.rhsa_fix_state = _VENDOR_NOT_AFFECTED_SENTINEL
+ logger.debug(
+ "OSIDB affectedness for %s on %s -> %s",
+ target_name, target_distro, result.value,
+ )
+ return result
+
+ rpm_names = self._find_and_locate_rpm(intel)
+ if not rpm_names:
+ return EnumIdentifyResult.UNKNOWN
+ package_identify.affected_rpm_list = rpm_names
+
# Priority 1: Check RHSA fix_state (distro-specific vendor assessment)
if intel.rhsa and intel.rhsa.package_state:
matched_ps = _match_package_state_for_distro(
diff --git a/src/vuln_analysis/utils/rpm_checker_prompts.py b/src/vuln_analysis/utils/rpm_checker_prompts.py
index e32e08f6c..0dabbc086 100644
--- a/src/vuln_analysis/utils/rpm_checker_prompts.py
+++ b/src/vuln_analysis/utils/rpm_checker_prompts.py
@@ -20,6 +20,13 @@
L2 prompts handle build-time configuration and hardening flag verification.
"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from vuln_analysis.functions.code_agent_graph_defs import FileChangeSummary
+
# ===========================================================================
# L1 CODE AGENT PROMPTS
# ===========================================================================
@@ -128,6 +135,189 @@
"""
+# ---------------------------------------------------------------------------
+# L1 CVE Understanding (Prompt 1 of 3 - No patch required)
+# ---------------------------------------------------------------------------
+
+CVE_UNDERSTANDING_PROMPT = """\
+Analyze this CVE to understand the vulnerability. Your output will guide source code searches.
+
+
+CVE ID: {vuln_id}
+Package: {target_package}
+CVE Description: {cve_description}
+
+
+
+{vendor_advisory}
+
+
+
+1. vulnerability_type: Classify as one of: buffer_overflow, integer_overflow, use_after_free,
+ null_deref, format_string, race_condition, path_traversal, injection,
+ uninitialized_memory, type_confusion, double_free, other
+
+2. affected_component: High-level component affected (e.g., "memory allocator", "TLS handshake", "XML parser")
+ - Extract from CVE description or advisory
+ - Be specific but not overly granular
+
+3. attack_vector: How the vulnerability is triggered
+ - "network": Remote attacker can exploit
+ - "local": Requires local access
+ - "physical": Requires physical access
+ - "adjacent": Requires adjacent network access
+ - "unknown": Cannot determine from available info
+
+4. cve_keywords: 3-5 grep-able terms for source search, STRICTLY ordered by
+ specificity (most specific FIRST, package name LAST or excluded).
+
+ **ORDERING IS CRITICAL: The first keyword will be searched first.**
+
+ PRIORITY 1 (MUST come first if present):
+ - Variable/buffer names explicitly named in CVE (e.g., "s2length", "sum2")
+ - Field names or struct members mentioned
+ - These directly identify the vulnerable code location
+
+ PRIORITY 2:
+ - Function names that appear VERBATIM in CVE description
+ - API calls mentioned as vulnerable
+
+ PRIORITY 3:
+ - Module or subsystem component names (e.g., "checksum", "parser")
+
+ PRIORITY 4 (LAST - only if nothing better exists):
+ - Package name - use ONLY as last resort when no other identifiers exist
+
+ EXCLUDE (never include):
+ - Security jargon (e.g., "remote code execution", "denial of service")
+ - Generic words (e.g., "memory", "buffer", "length", "attacker")
+ - Build flags, version strings
+
+ **EXAMPLE - CVE says: "manipulate the checksum length (s2length) to cause
+ a comparison between a checksum and uninitialized memory in rsync"**
+ - CORRECT: ["s2length", "checksum"] (specific identifier first)
+ - WRONG: ["rsync", "s2length"] (package name should not be first)
+ - WRONG: ["rsync", "memory", "checksum"] (generic + package name first)
+
+ ANTI-HALLUCINATION: Only include terms that appear VERBATIM in the CVE
+ description or vendor advisory. Do not invent identifiers.
+
+5. root_cause: 1-2 sentence explanation of WHY the code is vulnerable
+ - Focus on the technical flaw (missing bounds check, incorrect type cast, etc.)
+
+6. component_names: Module or component names ONLY if explicitly mentioned in the
+ CVE description or vendor advisory.
+ - ONLY include names that appear verbatim (e.g., "mod_http2", "mod_proxy_http2",
+ "nf_tables", "bpf_verifier")
+ - Do NOT infer from context (e.g., do NOT guess "mod_ssl" just because CVE mentions TLS)
+ - Do NOT include the package name itself (e.g., do NOT add "httpd" for an httpd package)
+ - If only a broad component is named (like "kernel" or "glibc"), leave empty
+ - Empty list when nothing explicit is named
+
+7. affected_bitness: Which bitness is affected:
+ - "32-bit": Look for "32-bit systems", "i386", "i686", "on 32-bit", "32-bit only"
+ - "64-bit": Look for "64-bit only", "x86_64 only" (rare)
+ - "both": DEFAULT when not explicitly stated
+ NOTE: Do NOT assume bitness from vulnerability type (e.g. integer overflow ≠ 32-bit).
+ Default to "both" unless the text explicitly scopes bitness.
+
+8. affected_architectures: CPU families affected, or null for all:
+ - "x86", "Intel", "AMD" -> ["x86"]
+ - "ARM", "aarch64", "arm64" -> ["arm"]
+ - "PowerPC", "POWER", "ppc64" -> ["ppc"]
+ - "s390", "z/Architecture", "IBM Z" -> ["s390"]
+ - If none mentioned or "all architectures" -> null
+ NOTE: Most CVEs affect all architectures. Only set specific families if explicitly stated.
+
+
+
+- This extraction runs WITHOUT patch data — use only the CVE description and
+ vendor advisory.
+- Keywords must be useful for grepping source code: prefer concrete identifiers
+ over narrative or impact language.
+- When the text names both a trigger parameter and an affected buffer/variable,
+ include both if both appear as identifiers.
+- Leave fields empty rather than guessing.
+- Bitness/arch gates depend on rules 7–8: wrong "both"/null disables early
+ not-vulnerable exits for arch-scoped CVEs.
+
+"""
+
+# ---------------------------------------------------------------------------
+# L1 Patch Analysis (Prompt 2/3 of 3 - Per-file extraction)
+# ---------------------------------------------------------------------------
+
+PATCH_ANALYSIS_PROMPT = """\
+Analyze this patch to extract security-relevant patterns for source code searching.
+
+
+CVE ID: {vuln_id}
+Vulnerability Type: {vulnerability_type}
+Affected Component: {affected_component}
+Root Cause: {root_cause}
+
+
+
+These functions were modified (from @@ hunk headers - confirmed):
+{functions_touched_summary}
+
+
+
+{patch_content}
+
+
+
+For EACH file in the patch, extract:
+
+1. file_path: The path to the file being modified (from patch header, strip a/ b/ prefixes)
+
+2. vulnerable_functions: Function names from the patch that are security-relevant
+ - Include functions from hunk headers (already listed above as confirmed)
+ - ADD any functions CALLED within the removed lines (- lines) that are security-critical
+ ANTI-HALLUCINATION: Only extract names that appear literally in the patch
+
+3. vulnerable_patterns: Distinctive code snippets from REMOVED lines (- lines)
+ - These indicate vulnerable code to search for
+ - Include enough context to be grep-able (10+ chars)
+ - Max 3 patterns per file
+
+4. fix_patterns: Distinctive code snippets from ADDED lines (+ lines)
+ - These indicate the fix is present
+ - Must be actual code, not comments or whitespace
+ - Max 3 patterns per file
+
+5. search_keywords: Additional grep patterns for this file
+ - Variable names, constants, or unique identifiers
+ - Ordered by specificity (most specific first)
+
+
+
+- Focus on patterns that can be grepped in source files
+- Patterns should be grep-friendly (avoid regex special chars unless escaped)
+- The hunk headers provide CONFIRMED function names - include them in vulnerable_functions
+
+"""
+
+
+def format_functions_touched_summary(file_summaries: list[FileChangeSummary]) -> str:
+ """Format functions_touched from FileChangeSummary list for prompt.
+
+ Args:
+ file_summaries: List of FileChangeSummary from patch parsing.
+
+ Returns:
+ Formatted string listing files and their modified functions.
+ """
+ lines = []
+ for fs in file_summaries:
+ if fs.functions_touched:
+ funcs = ", ".join(fs.functions_touched[:5])
+ lines.append(f"- {fs.file_path}: {funcs}")
+ else:
+ lines.append(f"- {fs.file_path}: (no function context in hunk headers)")
+ return "\n".join(lines) if lines else "No function context available from hunk headers."
+
+
# ---------------------------------------------------------------------------
# L1 System Prompts
# ---------------------------------------------------------------------------
@@ -353,9 +543,10 @@
2. Output valid JSON only. thought < 100 words. final_answer < 150 words.
3. mode="act" REQUIRES actions. mode="finish" REQUIRES final_answer.
4. If DOWNSTREAM_PATCH_STATUS is APPLIED, do max 2 searches then conclude PATCHED.
-5. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
-6. When patch is APPLIED: finding vulnerable code = GOOD, not finding fix = GOOD (expected).
-7. If a pattern contains special regex characters, escape them or use literal substrings.
+5. Source Grep uses comma as delimiter. If pattern contains commas (e.g., function calls), use ONLY the function name or target a specific file: 'funcName,file.c'. WRONG: 'foo(a, b)'. RIGHT: 'foo' or 'foo,file.c'.
+6. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
+7. When patch is APPLIED: finding vulnerable code = GOOD, not finding fix = GOOD (expected).
+8. If a pattern contains special regex characters, escape them or use literal substrings.
@@ -401,6 +592,14 @@
- VULNERABLE_PATTERNS: Code patterns indicating vulnerability
- FIX_PATTERNS: Code patterns indicating the fix
- SEARCH_KEYWORDS: Terms to grep for
+ - FILE_CHANGES (when present): per-file -N/+M and removed/added patterns from the fixed patch.
+ On UNPATCHED target, expect removed patterns present and added patterns absent.
+ When targeting a file from FILE_CHANGES, use THAT file's removed/added symbols only —
+ do NOT reuse a fix/vuln API learned from another file (cross-file symbol bleed).
+ STEP 1 (mandatory when FILE_CHANGES present): first Source Grep MUST use a removed
+ pattern from the FILE_CHANGES file with highest -N (prefer +0 added), scoped to that
+ file: 'removed_pattern,that_file.c'. Do NOT start in a file where that symbol appears
+ only under added (fix rewrite / rename destination).
PHASE 2 - SOURCE CODE INSPECTION (YOUR TASK):
CASE A - VULNERABLE_PATTERNS is NOT empty:
@@ -436,14 +635,19 @@
2. Output valid JSON only. thought < 100 words. final_answer < 150 words.
3. mode="act" REQUIRES actions. mode="finish" REQUIRES final_answer.
4. Source Grep: use query field with pattern from VULNERABILITY_INTEL (function name, variable, or code snippet).
-5. Code Keyword Search: use query field for broader searches.
-6. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
-7. IF VULNERABLE_PATTERNS is non-empty: search VULNERABLE code first.
-8. IF VULNERABLE_PATTERNS is empty: search FIX_PATTERNS first in AFFECTED_FILES.
-9. Do NOT classify as vulnerable solely because VULNERABLE_PATTERNS is empty.
-10. If a pattern contains special regex characters, escape them or use literal substrings.
-11. Before PATCHED: verify FIX_APPLIED_AT_CALL_SITE in ALL AFFECTED_FILES. FIX_DEFINITION_FOUND alone is insufficient.
-12. Fix must be CALLED and result USED (assigned/in condition). Called but unused = not applied.
+5. Source Grep uses comma as delimiter. If pattern contains commas (e.g., function calls), use ONLY the function name or target a specific file: 'funcName,file.c'. WRONG: 'foo(a, b)'. RIGHT: 'foo' or 'foo,file.c'.
+6. Code Keyword Search: use query field for broader searches.
+7. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
+8. IF VULNERABLE_PATTERNS is non-empty: search VULNERABLE code first.
+9. IF VULNERABLE_PATTERNS is empty: search FIX_PATTERNS first in AFFECTED_FILES.
+10. Do NOT classify as vulnerable solely because VULNERABLE_PATTERNS is empty.
+11. If a pattern contains special regex characters, escape them or use literal substrings.
+12. Before PATCHED: verify FIX_APPLIED_AT_CALL_SITE in ALL AFFECTED_FILES. FIX_DEFINITION_FOUND alone is insufficient.
+13. Fix must be CALLED and result USED (assigned/in condition). Called but unused = not applied.
+14. When grepping a FILE_CHANGES file, pick the pattern from that file's removed/added list — never from a different file's entry.
+15. When FILE_CHANGES is present and KNOWLEDGE has no prior searches: FIRST query MUST be
+ ',' from that file's removed list (prefer +0 added).
+ Never first-search a rename/fix destination file for a symbol that is only in its added list.
@@ -456,12 +660,13 @@
- Search for file paths from VULNERABILITY_INTEL AFFECTED_FILES
If KNOWLEDGE shows partial evidence:
- Investigate other files mentioned in VULNERABILITY_INTEL AFFECTED_FILES
-- Search for key variables from the fix pattern
+- When moving to another FILE_CHANGES file, switch to THAT file's removed/added symbols (do not carry the previous file's fix API)
+- Search for key variables from the fix pattern for the current target file
If FIX_DEFINITION_FOUND: search AFFECTED_FILES for actual usage before concluding PATCHED.
-{{"thought": "No prior searches in KNOWLEDGE. Search for the vulnerable code pattern from the patch", "mode": "act", "actions": {{"tool": "Source Grep", "query": "", "reason": "Locate vulnerable code that should exist in unpatched target"}}, "final_answer": null}}
+{{"thought": "FILE_CHANGES shows disk-io.c -22 +0 (highest removals). First search a removed pattern in that file, not the rewrite target.", "mode": "act", "actions": {{"tool": "Source Grep", "query": "btree_writepages,fs/btrfs/disk-io.c", "reason": "STEP 1: locate pre-move vulnerable code in highest-removal FILE_CHANGES file"}}, "final_answer": null}}
{{"thought": "VULNERABLE_PATTERNS is empty. Start with FIX_PATTERNS in affected files.", "mode": "act", "actions": {{"tool": "Source Grep", "query": ",", "reason": "CASE B: verify additive fix markers are present at call site"}}, "final_answer": null}}
@@ -506,6 +711,13 @@
- AFFECTED_FILES: Files to verify
- FIX_PATTERNS: Code patterns indicating the fix
- SEARCH_KEYWORDS: Terms to grep for
+ - FILE_CHANGES (when present): per-file -N/+M and removed/added patterns from the fixed patch.
+ On UNPATCHED target, expect removed patterns present and added patterns absent.
+ When targeting a file from FILE_CHANGES, use THAT file's removed/added symbols only —
+ do NOT reuse a fix/vuln API learned from another file (cross-file symbol bleed).
+ When FILE_CHANGES is present, scope each grep to that file's own removed/added symbols.
+ Prefer starting in the highest -N (prefer +0 added) file when verifying absence of fix
+ markers vs presence of pre-fix code.
PHASE 2 - SOURCE CODE INSPECTION (CASE B ONLY):
VULNERABLE_PATTERNS is empty:
@@ -546,13 +758,15 @@
2. Output valid JSON only. thought < 100 words. final_answer < 150 words.
3. mode="act" REQUIRES actions. mode="finish" REQUIRES final_answer.
4. Source Grep: use query field with pattern from VULNERABILITY_INTEL (function name, variable, or code snippet).
-5. Code Keyword Search: use query field for broader searches.
-6. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
-7. FIRST action in this mode MUST search FIX_PATTERNS in AFFECTED_FILES.
-8. Do NOT perform vulnerable-pattern-first search in this mode.
-9. If a pattern contains special regex characters, escape them or use literal substrings.
-10. Before PATCHED: verify FIX_APPLIED_AT_CALL_SITE in ALL AFFECTED_FILES. FIX_DEFINITION_FOUND alone is insufficient.
-11. Fix must be CALLED and result USED (assigned/in condition). Called but unused = not applied.
+5. Source Grep uses comma as delimiter. If pattern contains commas (e.g., function calls), use ONLY the function name or target a specific file: 'funcName,file.c'. WRONG: 'foo(a, b)'. RIGHT: 'foo' or 'foo,file.c'.
+6. Code Keyword Search: use query field for broader searches.
+7. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
+8. FIRST action in this mode MUST search FIX_PATTERNS in AFFECTED_FILES.
+9. Do NOT perform vulnerable-pattern-first search in this mode.
+10. If a pattern contains special regex characters, escape them or use literal substrings.
+11. Before PATCHED: verify FIX_APPLIED_AT_CALL_SITE in ALL AFFECTED_FILES. FIX_DEFINITION_FOUND alone is insufficient.
+12. Fix must be CALLED and result USED (assigned/in condition). Called but unused = not applied.
+13. When grepping a FILE_CHANGES file, pick the pattern from that file's removed/added list — never from a different file's entry.
@@ -565,7 +779,8 @@
- Search for file paths from VULNERABILITY_INTEL AFFECTED_FILES
If KNOWLEDGE shows partial evidence:
- Investigate other files mentioned in VULNERABILITY_INTEL AFFECTED_FILES
-- Search for key variables from the fix pattern
+- When moving to another FILE_CHANGES file, switch to THAT file's removed/added symbols (do not carry the previous file's fix API)
+- Search for key variables from the fix pattern for the current target file
If FIX_DEFINITION_FOUND: search AFFECTED_FILES for actual usage before concluding PATCHED.
@@ -624,13 +839,14 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
2. Output valid JSON only. thought < 100 words. final_answer < 150 words.
3. mode="act" REQUIRES actions. mode="finish" REQUIRES final_answer.
4. Source Grep: use query field with pattern from VULNERABILITY_INTEL (function name, variable, or code snippet).
-5. Code Keyword Search: use query field for broader searches.
-6. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
-7. FIRST search for FIX code - it SHOULD exist in rebased target.
-8. THEN verify VULNERABLE code is ABSENT from target.
-9. If a pattern contains special regex characters, escape them or use literal substrings.
-10. Before PATCHED: verify FIX_APPLIED_AT_CALL_SITE in ALL AFFECTED_FILES. FIX_DEFINITION_FOUND alone is insufficient.
-11. Fix must be CALLED and result USED (assigned/in condition). Called but unused = not applied.
+5. Source Grep uses comma as delimiter. If pattern contains commas (e.g., function calls), use ONLY the function name or target a specific file: 'funcName,file.c'. WRONG: 'foo(a, b)'. RIGHT: 'foo' or 'foo,file.c'.
+6. Code Keyword Search: use query field for broader searches.
+7. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
+8. FIRST search for FIX code - it SHOULD exist in rebased target.
+9. THEN verify VULNERABLE code is ABSENT from target.
+10. If a pattern contains special regex characters, escape them or use literal substrings.
+11. Before PATCHED: verify FIX_APPLIED_AT_CALL_SITE in ALL AFFECTED_FILES. FIX_DEFINITION_FOUND alone is insufficient.
+12. Fix must be CALLED and result USED (assigned/in condition). Called but unused = not applied.
@@ -669,7 +885,16 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
{{"thought": "FIX_DEFINITION_FOUND but no call site evidence", "mode": "finish", "actions": null, "final_answer": "UNCERTAIN - fix function exists at [file:line] but usage in AFFECTED_FILES unverified. Manual review required."}}
"""
-L1_AGENT_THOUGHT_CVE_DESC_INSTRUCTIONS = """
+L1_AGENT_THOUGHT_CVE_DESC_INSTRUCTIONS = """
+**IMPORTANT: No patch file is available for this CVE.**
+You must rely on the CVE description keywords to locate and verify vulnerability-related code.
+The search strategy differs from patch-based verification:
+- Search DIRECTLY for CVE-specific keywords (variable names, function names from CVE description)
+- Prioritize MOST SPECIFIC keywords first (e.g., "s2length" over "rsync")
+- Version evidence is a PRIMARY signal, not just a fallback
+
+
+
You will receive KNOWLEDGE (cumulative findings) and LATEST FINDINGS (most recent tool results).
BEFORE ACTING, you MUST:
1. Review KNOWLEDGE to see what tools were already called (TOOL_CALL_RECORD entries)
@@ -679,50 +904,56 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
-PHASE 1 - INTELLIGENCE (PRE-COMPLETED):
- Review VULNERABILITY_INTEL above. It contains:
- - COMPONENT_NAMES: Module/component names to use for file discovery (PRIORITY - use these FIRST)
- - AFFECTED_FILES: Files to verify (may be empty in no-patch mode)
- - VULNERABLE_FUNCTIONS: Functions to search for
- - VULNERABLE_PATTERNS: Code patterns indicating vulnerability
- - FIX_PATTERNS: Code patterns indicating the fix
- - SEARCH_KEYWORDS: Terms to grep for (includes COMPONENT_NAMES at start)
- - ROOT_CAUSE: Description of the vulnerability mechanism
-
-PHASE 2 - SOURCE CODE INSPECTION (YOUR TASK):
- **STEP 1 - FILE DISCOVERY (DO THIS FIRST):**
- If COMPONENT_NAMES is available (e.g., "mod_http2", "nf_tables"):
- - Search for files containing the component name: "mod_http2" or "mod_http2.c"
- - This locates the relevant source files for the CVE
- - Record discovered file paths for STEP 2
+PHASE 1 - REVIEW INTEL:
+ Review VULNERABILITY_INTEL above for search patterns:
+ - SEARCH_KEYWORDS: Grep patterns ordered by specificity (MOST SPECIFIC FIRST - use these!)
+ The first keywords are CVE-specific identifiers (variable names, buffer names) from the CVE description
+ - VULNERABLE_FUNCTIONS: Function names mentioned in CVE
+ - ROOT_CAUSE: The vulnerability mechanism (guides what defensive code looks like)
+ - TARGET_IN_VULNERABLE_RANGE: VERSION EVIDENCE (critical for no-patch mode)
+ - FIXED_VERSION: The first fixed upstream version
+ - TARGET_PACKAGE_VERSION: The version being analyzed
+
+PHASE 2 - KEYWORD-FIRST SEARCH (YOUR TASK):
+ **PRIORITY: Search for CVE-specific identifiers FIRST, not file names.**
- If COMPONENT_NAMES is empty or search returns no results:
- - Use SEARCH_KEYWORDS to find related files
- - Try function names from VULNERABLE_FUNCTIONS
- - Try combinations like ".c" or header file names
+ STEP 1 - Search CVE-specific keywords directly:
+ - Start with the MOST SPECIFIC keyword from CVE_KEYWORDS or SEARCH_KEYWORDS
+ - Example: For "s2length buffer overflow" CVE, search "s2length" directly, NOT "rsync"
+ - These are usually variable names, buffer names, or specific function names
+ - This both locates the file AND confirms the vulnerable code exists
- **STEP 2 - CODE ANALYSIS (AFTER finding files):**
- Once you have found relevant files from STEP 1:
- - Search WITHIN those files for VULNERABLE_PATTERNS
- - Search WITHIN those files for FIX_PATTERNS or defensive code
- - Use file-scoped searches: "pattern,discovered_file.c"
+ STEP 2 - If specific keyword search returns results:
+ - Note the file paths where matches were found
+ - Search for additional CVE indicators within those files
+ - Look for defensive patterns (bounds checks, validation) that would indicate a fix
- IMPORTANT: Do NOT skip STEP 1. File discovery FIRST, then code analysis.
+ STEP 3 - If specific keyword search returns NO results:
+ - Try the next keyword from CVE_KEYWORDS/SEARCH_KEYWORDS
+ - Try Code Keyword Search with semantic terms from ROOT_CAUSE
+ - If multiple searches fail, this may mean the code is structured differently
PHASE 3 - VERDICT:
- Only conclude when:
- - Relevant files have been discovered (STEP 1 complete)
- - Vulnerable/fix patterns searched within those files (STEP 2 complete)
- - Evidence is sufficient for confident verdict
-
- **VERSION-BASED FALLBACK (when code search is inconclusive):**
- If your code searches found NO conclusive evidence (no vulnerable pattern, no fix pattern):
- - Check TARGET_IN_VULNERABLE_RANGE in VULNERABILITY_INTEL
- - If TARGET_IN_VULNERABLE_RANGE: YES and no fix was verified in code:
- → Conclude VULNERABLE based on version evidence
- → Reason: "Target version is within affected range, and no fix was verified in code."
- - The absence of a verified fix, combined with version range membership,
- is strong evidence of vulnerability.
+ **FOR NO-PATCH MODE, use this decision tree:**
+
+ 1. VULNERABLE: Choose this if ANY of these are true:
+ a) Found CVE-specific vulnerable code patterns AND no defensive/fix code
+ b) TARGET_IN_VULNERABLE_RANGE = YES AND no fix code was found
+ (Version evidence + absence of fix = vulnerable)
+ c) TARGET_VERSION < FIXED_VERSION AND no fix code was found
+
+ 2. LIKELY PATCHED: Choose this if:
+ - Found defensive code patterns consistent with fixing the ROOT_CAUSE
+ - OR target version >= fixed version
+
+ 3. INCONCLUSIVE: Choose this ONLY if:
+ - TARGET_IN_VULNERABLE_RANGE = NO or UNKNOWN
+ - AND could not locate any CVE-related code
+ - AND version comparison is unclear
+
+ **CRITICAL: Version evidence is strong in no-patch mode.**
+ If target is in vulnerable range and you found NO evidence of a fix,
+ the verdict should be VULNERABLE, not INCONCLUSIVE.
RESPONSE FORMAT (JSON):
@@ -736,63 +967,79 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
1. Do NOT call the same tool with the same input twice - CHECK KNOWLEDGE for prior calls.
2. If KNOWLEDGE shows a search was done, your next action must be DIFFERENT.
3. Output valid JSON only. thought < 100 words.
+4. ALWAYS use the most specific CVE keyword first, NOT the package name.
+5. Version evidence (TARGET_IN_VULNERABLE_RANGE) is a PRIMARY signal in no-patch mode.
+
+**SEARCH_KEYWORDS are already ordered by specificity. Use them in order!**
+The first keywords in SEARCH_KEYWORDS are the most specific (CVE-specific identifiers).
+Do NOT skip to generic terms. Search the FIRST keyword first.
+
+Example: If SEARCH_KEYWORDS shows "s2length, sum2, checksum, rsync"
+- Search "s2length" FIRST (most specific, CVE identifier)
+- Then "sum2" if needed
+- Only use "rsync" as last resort
+
+WRONG: Skipping to "rsync" because it's the package name
+RIGHT: Starting with the first keyword "s2length"
+
+
-**STEP 1 - FILE DISCOVERY:**
-If COMPONENT_NAMES available (e.g., "mod_http2"):
-- First search: "mod_http2" to find files containing this component
-- This returns file paths like modules/http2/mod_http2.c
-If COMPONENT_NAMES empty or no results:
-- Try SEARCH_KEYWORDS from VULNERABILITY_INTEL
-- Try function names with .c suffix: "function_name.c"
-
-**STEP 2 - CODE ANALYSIS (after files found):**
-Once you have file paths from STEP 1:
-- Search WITHIN those files: "vulnerable_pattern,mod_http2.c"
-- Search for fix patterns: "fix_pattern,mod_http2.c"
-
-**MANDATORY FALLBACK - If Source Grep returns NO MATCHES:**
-When Source Grep returns empty for a function name or pattern search:
-1. MUST try Code Keyword Search with semantic terms from ROOT_CAUSE or CVE description
- - Example: CVE describes "XSS in HTML directory listing" → try "HTML directory" or "directory listing"
-2. MUST try simpler substrings within the discovered file
- - Example: instead of "mod_proxy_ftp_html_dir_list", try just "directory" or "html" in mod_proxy_ftp.c
-3. Do NOT give up after 2-3 empty Source Grep searches - switch tools first!
+**STEP 1 - Search most specific CVE keyword:**
+Search directly for the vulnerability indicator from CVE description.
+Example: CVE says "manipulate the checksum length (s2length)" → search "s2length"
+This finds both the file AND confirms vulnerable code exists.
+
+**STEP 2 - Analyze found code:**
+If search found results:
+- Note file paths (e.g., checksum.c)
+- Look at surrounding code for defensive patterns
+- Search for fix indicators if ROOT_CAUSE suggests what fix looks like
+
+**STEP 3 - Fallback if no results:**
+If most specific keyword returns empty:
+- Try next keyword from CVE_KEYWORDS
+- Try Code Keyword Search with ROOT_CAUSE terms
+- Try broader component terms
-
-{{"thought": "COMPONENT_NAMES shows mod_http2. Start with file discovery - search for files containing this module", "mode": "act", "actions": {{"tool": "Source Grep", "query": "mod_http2", "reason": "FILE DISCOVERY: Locate source files for the mod_http2 component"}}, "final_answer": null}}
-
-
-{{"thought": "KNOWLEDGE shows mod_http2.c found at modules/http2/. Now search within that file for vulnerable pattern", "mode": "act", "actions": {{"tool": "Source Grep", "query": "h2_stream_reset,mod_http2.c", "reason": "CODE ANALYSIS: Search for vulnerable function within discovered file"}}, "final_answer": null}}
-
-
-{{"thought": "COMPONENT_NAMES is empty. Use SEARCH_KEYWORDS for file discovery", "mode": "act", "actions": {{"tool": "Source Grep", "query": "", "reason": "FILE DISCOVERY: Locate relevant files using CVE keywords"}}, "final_answer": null}}
-
-
-{{"thought": "Source Grep for function name returned empty. MANDATORY FALLBACK: Try Code Keyword Search with semantic terms from CVE description (XSS in HTML directory listing)", "mode": "act", "actions": {{"tool": "Code Keyword Search", "query": "HTML directory listing", "reason": "FALLBACK: Source Grep failed, using semantic search for vulnerability-related code"}}, "final_answer": null}}
-
-
-{{"thought": "Source Grep for specific function returned empty. Try simpler pattern within discovered file", "mode": "act", "actions": {{"tool": "Source Grep", "query": "directory,mod_proxy_ftp.c", "reason": "FALLBACK: Broader search within known-relevant file"}}, "final_answer": null}}
-
+
+{{"thought": "SEARCH_KEYWORDS first entry is 's2length'. Search for this CVE-specific identifier first", "mode": "act", "actions": {{"tool": "Source Grep", "query": "s2length", "reason": "KEYWORD SEARCH: First SEARCH_KEYWORDS entry - CVE-specific identifier"}}, "final_answer": null}}
+
+
+{{"thought": "s2length search found matches in checksum.c. Try next SEARCH_KEYWORDS entry 'sum2' in same file", "mode": "act", "actions": {{"tool": "Source Grep", "query": "sum2,checksum.c", "reason": "CODE ANALYSIS: Next SEARCH_KEYWORDS entry in discovered file"}}, "final_answer": null}}
+
+
+{{"thought": "First SEARCH_KEYWORDS entries not found. Try broader keyword from end of list", "mode": "act", "actions": {{"tool": "Source Grep", "query": "checksum", "reason": "FALLBACK: Broader keyword from SEARCH_KEYWORDS"}}, "final_answer": null}}
+
+
+{{"thought": "Source Grep failed. Try Code Keyword Search with ROOT_CAUSE terms", "mode": "act", "actions": {{"tool": "Code Keyword Search", "query": "uninitialized memory checksum", "reason": "SEMANTIC SEARCH: Find code matching ROOT_CAUSE vulnerability mechanism"}}, "final_answer": null}}
+
+
-When concluding, follow this STRICT priority order:
-1. If fix code found in target → LIKELY PATCHED
-2. If vulnerable code found and no fix → VULNERABLE
-3. If code search inconclusive AND TARGET_IN_VULNERABLE_RANGE=YES → VULNERABLE (version-based)
-4. ONLY use INCONCLUSIVE when TARGET_IN_VULNERABLE_RANGE=NO or UNKNOWN
-IMPORTANT: You MUST check TARGET_IN_VULNERABLE_RANGE before concluding INCONCLUSIVE.
+**NO-PATCH MODE VERDICT RULES (in order):**
+1. Found fix/defensive code → LIKELY PATCHED
+2. Found vulnerable code AND no fix AND TARGET_IN_VULNERABLE_RANGE=YES → VULNERABLE
+3. No code found BUT TARGET_IN_VULNERABLE_RANGE=YES → VULNERABLE (version-based)
+4. Target version < fixed version AND no fix found → VULNERABLE
+5. TARGET_IN_VULNERABLE_RANGE=NO → NOT VULNERABLE or INCONCLUSIVE
+6. Cannot determine version range → INCONCLUSIVE
+
+**IMPORTANT:** In no-patch mode, absence of verified fix + version in range = VULNERABLE
-
-{{"thought": "KNOWLEDGE shows defensive code found in discovered file, no vulnerability indicators", "mode": "finish", "actions": null, "final_answer": "The package is LIKELY PATCHED. Found defensive code at [file:line]: [quote code]. The fix behavior described in the CVE appears to be present."}}
-
+
+{{"thought": "Found s2length variable in checksum.c matching CVE description. No defensive bounds checking found. TARGET_IN_VULNERABLE_RANGE is YES.", "mode": "finish", "actions": null, "final_answer": "VULNERABLE. Found CVE-specific vulnerable code pattern (s2length) at checksum.c. No fix or defensive code detected. Target version 3.0.9 is within the affected range (fixed in 3.3.0)."}}
+
-{{"thought": "Code searches found no vulnerable or fix patterns - affected module not located in source. However, TARGET_IN_VULNERABLE_RANGE is YES. No fix was verified in code. Version evidence indicates vulnerability.", "mode": "finish", "actions": null, "final_answer": "VULNERABLE (version-based). Target version is within the affected range per VULNERABILITY_INTEL. Code search could not locate the affected module or verify a fix. Based on version evidence and absence of verified fix, the package is vulnerable."}}
+{{"thought": "Code searches could not locate specific CVE patterns. However, TARGET_IN_VULNERABLE_RANGE is YES and no fix was verified.", "mode": "finish", "actions": null, "final_answer": "VULNERABLE (version-based). Target version is within the affected range per NVD. Code search could not definitively locate vulnerability patterns, but no fix was verified. Based on version evidence, the package is vulnerable."}}
+
+{{"thought": "Found defensive bounds checking on s2length in checksum.c that matches fix behavior described in CVE", "mode": "finish", "actions": null, "final_answer": "LIKELY PATCHED. Found defensive code at checksum.c:123 that validates s2length bounds. This matches the fix behavior for the vulnerability."}}
+
-{{"thought": "KNOWLEDGE shows insufficient evidence AND TARGET_IN_VULNERABLE_RANGE is NO/UNKNOWN - cannot determine patch status from version alone", "mode": "finish", "actions": null, "final_answer": "INCONCLUSIVE. Could not find definitive evidence of fix or vulnerability, and version range is unknown. Manual review recommended."}}
+{{"thought": "Could not locate CVE-related code AND TARGET_IN_VULNERABLE_RANGE is UNKNOWN - insufficient evidence", "mode": "finish", "actions": null, "final_answer": "INCONCLUSIVE. Could not locate CVE-specific code patterns, and version range information is unavailable. Manual review recommended."}}
"""
# ---------------------------------------------------------------------------
@@ -873,12 +1120,16 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
1. Check if the matched pattern appears ONLY in "-" lines → VULNERABLE_ONLY pattern
2. Check if the matched pattern appears ONLY in "+" lines → FIX_ONLY pattern
3. Check if the matched pattern appears in BOTH "-" and "+" lines → AMBIGUOUS pattern (not a unique indicator)
+4. Check if the matched pattern appears in NEITHER "-" nor "+" lines → NOT_IN_DIFF
+ (unrelated to this patch; not vuln/fix evidence)
DECISION RULES:
- Finding a VULNERABLE_ONLY pattern in source = code is VULNERABLE (fix removed this, but it still exists)
- Finding a FIX_ONLY pattern in source = fix IS APPLIED (fix added this, and it exists)
- Finding an AMBIGUOUS pattern = INCONCLUSIVE (exists in both versions, proves nothing)
-- CRITICAL: If you find a VULNERABLE_ONLY pattern, the code is VULNERABLE even if you also see AMBIGUOUS patterns nearby
+- Finding a NOT_IN_DIFF pattern = IGNORE for verdict (present in source but not part of this CVE patch)
+- CRITICAL: If you find a VULNERABLE_ONLY pattern, the code is VULNERABLE even if you also see AMBIGUOUS or NOT_IN_DIFF patterns nearby
+- NEVER label a NOT_IN_DIFF match as FIX_ONLY or VULNERABLE_ONLY
WORKED EXAMPLE - Follow this exact reasoning:
Given RAW_PATCH_DIFF:
@@ -903,12 +1154,26 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
→ This would be FIX_ONLY (only in "+" lines)
→ Its absence confirms fix is NOT applied
+Step 4: Grep finds 'unrelated_preexisting_api()' in source
+ → Check RAW_PATCH_DIFF: In "-" lines? NO. In "+" lines? NO.
+ → Classification: NOT_IN_DIFF (absent from this patch)
+ → Correct finding: "NOT_IN_DIFF: unrelated_preexisting_api() at file.c:123 — absent from patch diff, not evidence"
+ → Meaning: Do NOT treat as FIX_ONLY or VULNERABLE_ONLY
+
REMEMBER: Lines with "-" are REMOVED by fix. If you find them, the vulnerable code still exists!
If RAW_PATCH_DIFF is empty:
- Fall back to using VULNERABLE_PATTERNS and FIX_PATTERNS from VULNERABILITY_INTEL
+
+{patch_context_reference}
+
+REFERENCE ONLY: Use this to orient which symbols belong to the patch for this file.
+Do NOT emit findings for symbols that appear only here and not in NEW OUTPUT (grep result).
+Do NOT treat listed "fix patterns" / "vulnerable patterns" as grepped hits.
+
+
TOOL USED: {tool_used}
TOOL INPUT: {tool_input}
THOUGHT: {last_thought}
@@ -916,30 +1181,43 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
{tool_output}
**ANTI-HALLUCINATION RULES:**
-1. You can ONLY report findings based on text that ACTUALLY APPEARS in NEW OUTPUT above.
+1. You can ONLY report findings based on text that ACTUALLY APPEARS in NEW OUTPUT above (the grep/tool result).
2. Every finding claiming code was "found" MUST include a direct quote from NEW OUTPUT.
3. If NEW OUTPUT is empty, you CANNOT claim any code was found - report FAILED.
4. The tool_outcome MUST accurately reflect what NEW OUTPUT shows, not what you expect.
+5. PATCH_CONTEXT_REFERENCE is not evidence. Never invent FIX_ONLY_FOUND / VULNERABLE_ONLY_FOUND for symbols listed only there.
CODE ANALYSIS RULES (only if NEW OUTPUT has content):
1. MANDATORY CLASSIFICATION - For EACH pattern found in NEW OUTPUT, classify against RAW_PATCH_DIFF:
a. Pattern appears ONLY in "-" lines → VULNERABLE_ONLY (fix removed this)
b. Pattern appears ONLY in "+" lines → FIX_ONLY (fix added this)
c. Pattern appears in BOTH "-" and "+" lines → AMBIGUOUS (exists in both versions)
+ d. Pattern appears in NEITHER "-" nor "+" lines → NOT_IN_DIFF (unrelated; not evidence)
2. LABELING RULES (strict):
- VULNERABLE_ONLY → report as "VULNERABLE_ONLY_FOUND: [pattern] at [file:line]"
- FIX_ONLY → report as "FIX_ONLY_FOUND: [pattern] at [file:line]"
- AMBIGUOUS → report as "AMBIGUOUS_PATTERN: [pattern] - exists in both versions, not evidence"
- - NEVER label an AMBIGUOUS pattern as "fix pattern found" or "vulnerable pattern found"
+ - NOT_IN_DIFF → report as "NOT_IN_DIFF: [pattern] at [file:line] — absent from patch diff, not evidence"
+ - NEVER label an AMBIGUOUS or NOT_IN_DIFF pattern as "fix pattern found" or "vulnerable pattern found"
3. DECISION PRIORITY:
- If ANY VULNERABLE_ONLY pattern is found → code is VULNERABLE (definitive)
- - AMBIGUOUS patterns do NOT contradict a VULNERABLE_ONLY finding
+ - AMBIGUOUS and NOT_IN_DIFF patterns do NOT contradict a VULNERABLE_ONLY finding
+ - NOT_IN_DIFF never counts as fix-applied evidence
- Only FIX_ONLY patterns (without any VULNERABLE_ONLY) indicate fix is applied
4. Quote actual lines from NEW OUTPUT and record file:line for all matches.
-5. FIX LOCATION: Tag FIX_DEFINITION_FOUND if found outside AFFECTED_FILES. Tag FIX_APPLIED_AT_CALL_SITE only when fix is CALLED and result USED in an AFFECTED_FILE.
+5. FIX LOCATION (two gates — BOTH required):
+ - Gate A (symbol class): pattern MUST classify as FIX_ONLY (only in "+" lines / FILE_CHANGES.added).
+ - Gate B (usage shape): that same FIX_ONLY pattern is CALLED and its result USED in an AFFECTED_FILE.
+ - FIX_DEFINITION_FOUND: FIX_ONLY pattern exists but only at a definition/declaration site.
+ - FIX_APPLIED_AT_CALL_SITE: Gate A AND Gate B both true.
+ - NEVER use FIX_APPLIED_AT_CALL_SITE / FIX_DEFINITION_FOUND / FIX_ONLY_FOUND for VULNERABLE_ONLY, AMBIGUOUS, or NOT_IN_DIFF patterns.
+ - Call-site shape alone is NOT enough: `if (!fn(...))` proves usage shape, not that `fn` is a fix symbol.
+ - BAD: FIX_APPLIED_AT_CALL_SITE: computeRowPitch (old/vuln API; shape-only)
+ - GOOD: VULNERABLE_ONLY_FOUND / AMBIGUOUS_PATTERN: computeRowPitch
+ - GOOD: FIX_APPLIED_AT_CALL_SITE: computeRowSkipBytes (FIX_ONLY and called+used)
Based on VULNERABILITY_INTEL above, assess investigation completeness:
@@ -1005,14 +1283,16 @@ def select_upstream_prompt_and_instructions(vulnerable_patterns: list[str]) -> t
REFINEMENT RULES:
1. Validate that each finding uses the correct label for the evidence type:
- FIX_ONLY_FOUND: fix pattern found — use when it is unclear whether definition or call site
- - FIX_DEFINITION_FOUND: fix exists but only at a definition site (header, not called in affected file)
- - FIX_APPLIED_AT_CALL_SITE: fix is CALLED and its result USED inside an AFFECTED_FILE
+ - FIX_DEFINITION_FOUND: FIX_ONLY pattern exists but only at a definition site (header, not called in affected file)
+ - FIX_APPLIED_AT_CALL_SITE: FIX_ONLY pattern is CALLED and its result USED inside an AFFECTED_FILE
- FIX_CODE_ABSENT: fix pattern not found in any searched file
- VULNERABLE_CODE_FOUND: vulnerable pattern present at a call site
- VULNERABLE_CODE_ABSENT: vulnerable pattern not found
- AMBIGUOUS_PATTERN: pattern exists in both vulnerable and fixed versions
-2. Preserve file:line references verbatim — do NOT strip or approximate locations.
-3. Preserve FAILED: entries verbatim — do NOT convert a failed search to a positive finding.
+ - NOT_IN_DIFF: pattern present in source but absent from RAW_PATCH_DIFF — not vuln/fix evidence
+2. DOWNGRADE PASS (mandatory): If a finding uses FIX_APPLIED_AT_CALL_SITE, FIX_DEFINITION_FOUND, or FIX_ONLY_FOUND but the pattern is VULNERABLE_ONLY, AMBIGUOUS, or NOT_IN_DIFF (not FIX_ONLY / not in "+" / FILE_CHANGES.added), rewrite to VULNERABLE_CODE_FOUND, AMBIGUOUS_PATTERN, or NOT_IN_DIFF. Do NOT invent FIX_APPLIED from call-site shape alone (`if (!fn())` is insufficient). If the pattern does not appear in RAW_PATCH_DIFF at all, rewrite to NOT_IN_DIFF.
+3. Preserve file:line references verbatim — do NOT strip or approximate locations.
+4. Preserve FAILED: entries verbatim — do NOT convert a failed search to a positive finding.
OUTPUT:
- results: refined findings list using only the labels above
diff --git a/src/vuln_analysis/utils/tests/test_cve_understanding_prompt.py b/src/vuln_analysis/utils/tests/test_cve_understanding_prompt.py
new file mode 100644
index 000000000..b859162fa
--- /dev/null
+++ b/src/vuln_analysis/utils/tests/test_cve_understanding_prompt.py
@@ -0,0 +1,160 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for CVE_UNDERSTANDING_PROMPT."""
+
+import pytest
+
+from vuln_analysis.utils.rpm_checker_prompts import CVE_UNDERSTANDING_PROMPT
+
+
+class TestCVEUnderstandingPromptFormatting:
+ """Tests for prompt formatting with various inputs."""
+
+ def test_formats_with_all_placeholders(self):
+ """Test prompt formats correctly with all placeholders."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="CVE-2024-1234",
+ target_package="test-package",
+ cve_description="Buffer overflow in parse_data() function.",
+ vendor_advisory="Mitigation: upgrade to version 2.0",
+ )
+ assert "CVE-2024-1234" in prompt
+ assert "test-package" in prompt
+ assert "Buffer overflow" in prompt
+ assert "parse_data()" in prompt
+ assert "upgrade to version 2.0" in prompt
+
+ def test_formats_without_advisory(self):
+ """Test prompt works when no advisory available."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="CVE-2024-1234",
+ target_package="test-package",
+ cve_description="Integer overflow vulnerability.",
+ vendor_advisory="No vendor advisory available.",
+ )
+ assert "No vendor advisory" in prompt
+ assert "CVE-2024-1234" in prompt
+
+ def test_formats_with_empty_advisory(self):
+ """Test prompt handles empty advisory string."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="CVE-2024-5678",
+ target_package="openssl",
+ cve_description="Memory corruption in TLS handshake.",
+ vendor_advisory="",
+ )
+ assert "CVE-2024-5678" in prompt
+ assert "openssl" in prompt
+ assert "" in prompt
+
+ def test_formats_with_multiline_description(self):
+ """Test prompt handles multiline CVE description."""
+ description = """A race condition vulnerability exists in the signal handler.
+ This can lead to remote code execution when processing SSH connections.
+ The vulnerability affects sshd versions before 8.5p1."""
+
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="CVE-2024-6387",
+ target_package="openssh",
+ cve_description=description,
+ vendor_advisory="Apply the patch from upstream.",
+ )
+ assert "race condition" in prompt
+ assert "signal handler" in prompt
+ assert "sshd" in prompt
+
+
+class TestCVEUnderstandingPromptStructure:
+ """Tests for prompt structural elements."""
+
+ def test_contains_extraction_rules(self):
+ """Test prompt contains all extraction rules."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert "vulnerability_type" in prompt
+ assert "affected_component" in prompt
+ assert "attack_vector" in prompt
+ assert "cve_keywords" in prompt
+ assert "root_cause" in prompt
+ assert "component_names" in prompt
+ assert "affected_bitness" in prompt
+ assert "affected_architectures" in prompt
+
+ def test_contains_important_section(self):
+ """Test prompt contains IMPORTANT guidance section."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert "" in prompt
+ assert "WITHOUT patch data" in prompt
+
+ def test_contains_vulnerability_type_options(self):
+ """Test prompt lists vulnerability type options."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert "buffer_overflow" in prompt
+ assert "use_after_free" in prompt
+ assert "race_condition" in prompt
+
+ def test_contains_attack_vector_options(self):
+ """Test prompt lists attack vector options."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert "network" in prompt
+ assert "local" in prompt
+ assert "physical" in prompt
+ assert "adjacent" in prompt
+
+ def test_contains_anti_hallucination_warning(self):
+ """Test prompt contains anti-hallucination guidance."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert "ANTI-HALLUCINATION" in prompt
+ assert "VERBATIM" in prompt
+
+ def test_cve_keywords_prefer_identifiers_over_concepts(self):
+ """Test cve_keywords rules prioritize source identifiers over jargon."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert "ORDERING IS CRITICAL" in prompt
+ assert "PRIORITY 1 (MUST come first if present)" in prompt
+ assert "EXCLUDE (never include)" in prompt
+ assert "prefer concrete identifiers" in prompt
+ assert "Package name - use ONLY as last resort" in prompt
+
+ def test_contains_bitness_and_component_gate_rules(self):
+ """Arch/bitness + verbatim component_names must be extracted for early gates."""
+ prompt = CVE_UNDERSTANDING_PROMPT.format(
+ vuln_id="X",
+ target_package="X",
+ cve_description="X",
+ vendor_advisory="X",
+ )
+ assert '"32-bit"' in prompt or "32-bit" in prompt
+ assert "Default to \"both\"" in prompt or "DEFAULT when not explicitly stated" in prompt
+ assert "mod_http2" in prompt
+ assert "Do NOT assume bitness from vulnerability type" in prompt
diff --git a/src/vuln_analysis/utils/tests/test_patch_analysis_prompt.py b/src/vuln_analysis/utils/tests/test_patch_analysis_prompt.py
new file mode 100644
index 000000000..d003044a3
--- /dev/null
+++ b/src/vuln_analysis/utils/tests/test_patch_analysis_prompt.py
@@ -0,0 +1,171 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for PATCH_ANALYSIS_PROMPT and format_functions_touched_summary."""
+
+import pytest
+
+from vuln_analysis.utils.rpm_checker_prompts import (
+ PATCH_ANALYSIS_PROMPT,
+ format_functions_touched_summary,
+)
+from vuln_analysis.functions.code_agent_graph_defs import FileChangeSummary
+
+
+class TestPatchAnalysisPromptFormatting:
+ """Tests for prompt formatting with various inputs."""
+
+ def test_formats_with_all_placeholders(self):
+ """Test prompt formats correctly with all placeholders."""
+ prompt = PATCH_ANALYSIS_PROMPT.format(
+ vuln_id="CVE-2024-1234",
+ vulnerability_type="buffer_overflow",
+ affected_component="memory allocator",
+ root_cause="Missing bounds check on user input",
+ functions_touched_summary="- src/alloc.c: malloc_wrapper, free_wrapper",
+ patch_content="--- a/src/alloc.c\n+++ b/src/alloc.c\n- old_code\n+ new_code",
+ )
+ assert "CVE-2024-1234" in prompt
+ assert "buffer_overflow" in prompt
+ assert "memory allocator" in prompt
+ assert "Missing bounds check" in prompt
+ assert "malloc_wrapper" in prompt
+ assert "old_code" in prompt
+ assert "new_code" in prompt
+
+ def test_formats_with_empty_context(self):
+ """Test prompt handles empty CVE context fields."""
+ prompt = PATCH_ANALYSIS_PROMPT.format(
+ vuln_id="CVE-2024-5678",
+ vulnerability_type="",
+ affected_component="",
+ root_cause="",
+ functions_touched_summary="No function context available from hunk headers.",
+ patch_content="minimal patch",
+ )
+ assert "CVE-2024-5678" in prompt
+ assert "minimal patch" in prompt
+
+ def test_formats_with_multiline_patch(self):
+ """Test prompt handles multiline patch content."""
+ patch = """--- a/src/parser.c
++++ b/src/parser.c
+@@ -100,7 +100,9 @@ void parse_input(char *buf)
+- memcpy(dst, src, len);
++ if (len <= MAX_SIZE) {
++ memcpy(dst, src, len);
++ }
+"""
+ prompt = PATCH_ANALYSIS_PROMPT.format(
+ vuln_id="CVE-2024-9999",
+ vulnerability_type="buffer_overflow",
+ affected_component="input parser",
+ root_cause="Unchecked memcpy length",
+ functions_touched_summary="- src/parser.c: parse_input",
+ patch_content=patch,
+ )
+ assert "parse_input" in prompt
+ assert "memcpy" in prompt
+ assert "MAX_SIZE" in prompt
+
+
+class TestPatchAnalysisPromptStructure:
+ """Tests for prompt structural elements."""
+
+ def test_contains_extraction_rules(self):
+ """Test prompt contains all extraction rules."""
+ prompt = PATCH_ANALYSIS_PROMPT.format(
+ vuln_id="X",
+ vulnerability_type="X",
+ affected_component="X",
+ root_cause="X",
+ functions_touched_summary="X",
+ patch_content="X",
+ )
+ assert "vulnerable_functions" in prompt
+ assert "vulnerable_patterns" in prompt
+ assert "fix_patterns" in prompt
+ assert "search_keywords" in prompt
+
+ def test_contains_important_section(self):
+ """Test prompt contains IMPORTANT guidance section."""
+ prompt = PATCH_ANALYSIS_PROMPT.format(
+ vuln_id="X",
+ vulnerability_type="X",
+ affected_component="X",
+ root_cause="X",
+ functions_touched_summary="X",
+ patch_content="X",
+ )
+ assert "" in prompt
+ assert "grep-friendly" in prompt
+
+ def test_contains_anti_hallucination_warning(self):
+ """Test prompt contains anti-hallucination guidance."""
+ prompt = PATCH_ANALYSIS_PROMPT.format(
+ vuln_id="X",
+ vulnerability_type="X",
+ affected_component="X",
+ root_cause="X",
+ functions_touched_summary="X",
+ patch_content="X",
+ )
+ assert "ANTI-HALLUCINATION" in prompt
+
+
+class TestFormatFunctionsTouchedSummary:
+ """Tests for format_functions_touched_summary helper."""
+
+ def test_formats_files_with_functions(self):
+ """Test formatting files that have functions_touched."""
+ summaries = [
+ FileChangeSummary(file_path="src/alloc.c", functions_touched=["malloc_wrapper", "free_wrapper"]),
+ FileChangeSummary(file_path="src/util.c", functions_touched=["helper_func"]),
+ ]
+ result = format_functions_touched_summary(summaries)
+
+ assert "src/alloc.c: malloc_wrapper, free_wrapper" in result
+ assert "src/util.c: helper_func" in result
+
+ def test_formats_files_without_functions(self):
+ """Test formatting files with no function context."""
+ summaries = [
+ FileChangeSummary(file_path="include/header.h", functions_touched=[]),
+ ]
+ result = format_functions_touched_summary(summaries)
+
+ assert "include/header.h: (no function context in hunk headers)" in result
+
+ def test_formats_mixed_files(self):
+ """Test formatting mix of files with and without functions."""
+ summaries = [
+ FileChangeSummary(file_path="a.c", functions_touched=["foo", "bar"]),
+ FileChangeSummary(file_path="b.h", functions_touched=[]),
+ FileChangeSummary(file_path="c.c", functions_touched=["baz"]),
+ ]
+ result = format_functions_touched_summary(summaries)
+
+ assert "a.c: foo, bar" in result
+ assert "b.h: (no function context" in result
+ assert "c.c: baz" in result
+
+ def test_empty_list_returns_fallback(self):
+ """Test empty summaries list returns fallback message."""
+ result = format_functions_touched_summary([])
+
+ assert result == "No function context available from hunk headers."
+
+ def test_truncates_long_function_lists(self):
+ """Test that long function lists are truncated to 5."""
+ summaries = [
+ FileChangeSummary(
+ file_path="big.c",
+ functions_touched=["f1", "f2", "f3", "f4", "f5", "f6", "f7"],
+ ),
+ ]
+ result = format_functions_touched_summary(summaries)
+
+ assert "f1" in result
+ assert "f5" in result
+ assert "f6" not in result
+ assert "f7" not in result
diff --git a/tests/test_git_commit_searcher.py b/tests/test_git_commit_searcher.py
index 9ad0d7848..7131d4e83 100644
--- a/tests/test_git_commit_searcher.py
+++ b/tests/test_git_commit_searcher.py
@@ -32,7 +32,6 @@
CONFIDENCE_REVISION_BRANCH,
CONFIDENCE_FUNCTION_MESSAGE,
CONFIDENCE_FUNCTION_PICKAXE,
- CONFIDENCE_THRESHOLD_MIN,
)
@@ -429,11 +428,7 @@ def test_function_message_higher_than_pickaxe(self):
"""Function message search has higher confidence than pickaxe."""
assert CONFIDENCE_FUNCTION_MESSAGE > CONFIDENCE_FUNCTION_PICKAXE
- def test_threshold_is_reasonable(self):
- """Minimum threshold is a sensible value."""
- assert CONFIDENCE_THRESHOLD_MIN >= 0.0
- assert CONFIDENCE_THRESHOLD_MIN <= 1.0
- assert CONFIDENCE_THRESHOLD_MIN == 0.50
+
# ---------------------------------------------------------------------------
diff --git a/tests/test_package_identifier.py b/tests/test_package_identifier.py
index 35c00c681..556e23aa9 100644
--- a/tests/test_package_identifier.py
+++ b/tests/test_package_identifier.py
@@ -16,13 +16,20 @@
from exploit_iq_commons.data_models.checker_status import EnumIdentifyResult, PackageCheckerStatus
from exploit_iq_commons.data_models.common import TargetPackage
-from exploit_iq_commons.data_models.cve_intel import CveIntel, CveIntelRhsa, CveIntelNvd
+from exploit_iq_commons.data_models.cve_intel import (
+ CveIntel,
+ CveIntelNvd,
+ CveIntelOsidb,
+ CveIntelRhsa,
+)
from vuln_analysis.utils.package_identifier import (
PackageIdentifier,
_extract_rhel_version,
_interpret_fix_state,
_match_package_state_for_distro,
+ _osidb_affectedness_for_target,
+ _osidb_stream_matches_rhel_major,
)
@@ -625,3 +632,254 @@ def test_not_in_either_rhsa_bucket_is_cve_mismatch(self):
assert status == PackageCheckerStatus.PKG_IDENT_CVE_MISMATCH
assert result.is_target_package_affected == EnumIdentifyResult.NO
+
+
+class TestOsidbStreamMatchesRhelMajor:
+ """Tests for _osidb_stream_matches_rhel_major helper."""
+
+ def test_exact_module_match(self):
+ assert _osidb_stream_matches_rhel_major("rhel-7", "rhel-7-els", "7") is True
+
+ def test_els_stream_prefix(self):
+ assert _osidb_stream_matches_rhel_major(None, "rhel-7-els", "7") is True
+
+ def test_does_not_match_rhel10_for_major_1(self):
+ assert _osidb_stream_matches_rhel_major("rhel-10", "rhel-10.0", "1") is False
+
+ def test_no_values_returns_false(self):
+ assert _osidb_stream_matches_rhel_major(None, None, "7") is False
+
+
+class TestOsidbAffectednessForTarget:
+ """Unit tests for _osidb_affectedness_for_target."""
+
+ def test_affected_rhel7_rsync(self):
+ affects = [
+ CveIntelOsidb.Affect(
+ ps_module="rhel-7",
+ ps_component="rsync",
+ ps_update_stream="rhel-7-els",
+ affectedness="AFFECTED",
+ resolution="DELEGATED",
+ ),
+ ]
+ result, reason = _osidb_affectedness_for_target(affects, "rsync", "el7")
+ assert result == EnumIdentifyResult.YES
+ assert reason is not None
+ assert "OSIDB" in reason
+ assert "AFFECTED" in reason
+
+ def test_notaffected_returns_no(self):
+ affects = [
+ CveIntelOsidb.Affect(
+ ps_module="rhel-7",
+ ps_component="rsync",
+ ps_update_stream="rhel-7-els",
+ affectedness="NOTAFFECTED",
+ ),
+ ]
+ result, reason = _osidb_affectedness_for_target(affects, "rsync", "el7")
+ assert result == EnumIdentifyResult.NO
+ assert reason is not None
+ assert "not affected" in reason.lower()
+
+ def test_no_matching_stream_returns_unknown(self):
+ affects = [
+ CveIntelOsidb.Affect(
+ ps_module="rhel-9",
+ ps_component="rsync",
+ ps_update_stream="rhel-9.5.z",
+ affectedness="AFFECTED",
+ ),
+ ]
+ result, reason = _osidb_affectedness_for_target(affects, "rsync", "el7")
+ assert result == EnumIdentifyResult.UNKNOWN
+ assert reason is None
+
+ def test_no_distro_returns_unknown(self):
+ affects = [
+ CveIntelOsidb.Affect(
+ ps_module="rhel-7",
+ ps_component="rsync",
+ affectedness="AFFECTED",
+ ),
+ ]
+ result, reason = _osidb_affectedness_for_target(affects, "rsync", None)
+ assert result == EnumIdentifyResult.UNKNOWN
+ assert reason is None
+
+
+class TestPackageIdentifierWithOsidb:
+ """Identify uses OSIDB exclusively when affects are present."""
+
+ def test_osidb_affected_even_when_rhsa_package_state_none(self):
+ """CVE-2024-12085-style: empty RHSA package_state, OSIDB AFFECTED -> YES."""
+ target = TargetPackage(
+ name="rsync",
+ version="3.0.9",
+ release="8.el7",
+ arch="x86_64",
+ )
+ intel = CveIntel(
+ vuln_id="CVE-2024-12085",
+ nvd=CveIntelNvd(
+ cve_id="CVE-2024-12085",
+ configurations=[
+ CveIntelNvd.Configuration(
+ package="rsync",
+ vendor="samba",
+ versionEndExcluding="3.3.0",
+ ),
+ ],
+ ),
+ rhsa=CveIntelRhsa(
+ package_state=None,
+ affected_release=[
+ {
+ "package": "rsync-0:3.1.2-12.el7_9.1",
+ "product_name": "Red Hat Enterprise Linux 7 Extended Lifecycle Support",
+ },
+ ],
+ ),
+ osidb=CveIntelOsidb(
+ cve_id="CVE-2024-12085",
+ affects=[
+ CveIntelOsidb.Affect(
+ ps_module="rhel-7",
+ ps_product="Red Hat Enterprise Linux",
+ ps_component="rsync",
+ ps_update_stream="rhel-7-els",
+ resolution="DELEGATED",
+ affectedness="AFFECTED",
+ ),
+ CveIntelOsidb.Affect(
+ ps_module="rhel-9",
+ ps_component="rsync",
+ ps_update_stream="rhel-9.5.z",
+ affectedness="AFFECTED",
+ ),
+ ],
+ ),
+ )
+ identifier = PackageIdentifier(target)
+ status, result = identifier.identify(intel)
+
+ assert status == PackageCheckerStatus.OK
+ assert result.is_target_package_affected == EnumIdentifyResult.YES
+ assert "OSIDB" in result.conclusion_reason
+
+ def test_osidb_notaffected_ignores_rhsa_affected(self):
+ """When OSIDB exists, RHSA Affected must not override NOTAFFECTED."""
+ target = TargetPackage(
+ name="rsync",
+ version="3.0.9",
+ release="8.el7",
+ arch="x86_64",
+ )
+ intel = CveIntel(
+ vuln_id="CVE-TEST-OSIDB",
+ rhsa=CveIntelRhsa(
+ package_state=[
+ CveIntelRhsa.PackageState(
+ product_name="Red Hat Enterprise Linux 7",
+ fix_state="Affected",
+ package_name="rsync",
+ cpe="cpe:/o:redhat:enterprise_linux:7",
+ ),
+ ],
+ ),
+ osidb=CveIntelOsidb(
+ affects=[
+ CveIntelOsidb.Affect(
+ ps_module="rhel-7",
+ ps_component="rsync",
+ ps_update_stream="rhel-7-els",
+ affectedness="NOTAFFECTED",
+ ),
+ ],
+ ),
+ )
+ identifier = PackageIdentifier(target)
+ status, result = identifier.identify(intel)
+
+ assert result.is_target_package_affected == EnumIdentifyResult.NO
+ assert "OSIDB" in result.conclusion_reason
+ # OSIDB NOTAFFECTED sets the shared "not affected" sentinel so Identify
+ # continues (status=OK), same as RHSA "not affected", to verify the assessment.
+ assert result.rhsa_fix_state == "not affected"
+ assert status == PackageCheckerStatus.OK
+
+ def test_osidb_present_no_matching_stream_stays_unknown(self):
+ """OSIDB present but no stream match -> UNKNOWN; do not fall through to NVD."""
+ target = TargetPackage(
+ name="rsync",
+ version="3.0.9",
+ release="8.el7",
+ arch="x86_64",
+ )
+ intel = CveIntel(
+ vuln_id="CVE-TEST-OSIDB",
+ nvd=CveIntelNvd(
+ cve_id="CVE-TEST-OSIDB",
+ configurations=[
+ CveIntelNvd.Configuration(
+ package="rsync",
+ vendor="samba",
+ versionEndExcluding="3.3.0",
+ ),
+ ],
+ ),
+ rhsa=CveIntelRhsa(
+ package_state=[
+ CveIntelRhsa.PackageState(
+ product_name="Red Hat Enterprise Linux 7",
+ fix_state="Affected",
+ package_name="rsync",
+ cpe="cpe:/o:redhat:enterprise_linux:7",
+ ),
+ ],
+ ),
+ osidb=CveIntelOsidb(
+ affects=[
+ CveIntelOsidb.Affect(
+ ps_module="rhel-9",
+ ps_component="rsync",
+ ps_update_stream="rhel-9.5.z",
+ affectedness="AFFECTED",
+ ),
+ ],
+ ),
+ )
+ identifier = PackageIdentifier(target)
+ _, result = identifier.identify(intel)
+
+ assert result.is_target_package_affected == EnumIdentifyResult.UNKNOWN
+ assert result.conclusion_reason == ""
+
+ def test_no_osidb_uses_rhsa_fix_state(self):
+ """Without OSIDB, existing RHSA fix_state path still applies."""
+ target = TargetPackage(
+ name="libarchive",
+ version="3.1.2",
+ release="14.el7_9.1",
+ arch="x86_64",
+ )
+ intel = CveIntel(
+ vuln_id="CVE-2016-8687",
+ rhsa=CveIntelRhsa(
+ package_state=[
+ CveIntelRhsa.PackageState(
+ product_name="Red Hat Enterprise Linux 7",
+ fix_state="Will not fix",
+ package_name="libarchive",
+ cpe="cpe:/o:redhat:enterprise_linux:7",
+ ),
+ ],
+ ),
+ )
+ identifier = PackageIdentifier(target)
+ status, result = identifier.identify(intel)
+
+ assert result.is_target_package_affected == EnumIdentifyResult.NO
+ assert status == PackageCheckerStatus.PKG_IDENT_NOT_VUL
+ assert "RHSA fix_state" in result.conclusion_reason
diff --git a/tests/test_vulnerability_intel_format.py b/tests/test_vulnerability_intel_format.py
index 3d8acea77..e144ec9cc 100644
--- a/tests/test_vulnerability_intel_format.py
+++ b/tests/test_vulnerability_intel_format.py
@@ -77,6 +77,29 @@ def test_fix_patterns_formatted_as_indented_list(self):
assert " - if (src >= dst)" in output
assert " - return 0;" in output
+ def test_include_pattern_lists_false_omits_fat_pattern_blocks(self):
+ """When FILE_CHANGES carries per-file patterns, thought omits flat lists."""
+ intel = VulnerabilityIntel(
+ affected_files=["formatutils.cpp"],
+ vulnerable_functions=["computeRowPitch"],
+ vulnerable_patterns=["glFormat.computeRowPitch(type, area.width, unpack.alignment)"],
+ fix_patterns=["glFormat.computeRowDepthSkipBytes(type, area.width, area.height)"],
+ search_keywords=["computeRowPitch", "ANGLE"],
+ target_version_in_vulnerable_range=True,
+ )
+ output = intel.format_for_prompt(include_pattern_lists=False)
+ assert "VULNERABLE_PATTERNS:" not in output
+ assert "FIX_PATTERNS:" not in output
+ assert "glFormat.computeRowPitch" not in output
+ assert "glFormat.computeRowDepthSkipBytes" not in output
+ assert "VULNERABLE_FUNCTIONS: computeRowPitch" in output
+ assert "AFFECTED_FILES: formatutils.cpp" in output
+ assert "SEARCH_KEYWORDS: computeRowPitch, ANGLE" in output
+ assert "TARGET_IN_VULNERABLE_RANGE: YES" in output
+ # In-memory fields unchanged for Case A/B routing
+ assert intel.vulnerable_patterns
+ assert intel.fix_patterns
+
def test_affected_bitness_both_omitted(self):
"""Default bitness 'both' should not appear in output."""
intel = VulnerabilityIntel(affected_files=["a.c"], affected_bitness="both")