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")