From 0c952352b17fe9216bc010193feab2bde3a7a59c Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Sun, 12 Jul 2026 14:52:31 -0400 Subject: [PATCH] Add dependency reporting fields --- strix/agents/factory.py | 3 +- strix/report/dedupe.py | 118 ++++++++ strix/report/state.py | 17 ++ strix/tools/finish/tool.py | 72 ++++- strix/tools/reporting/tool.py | 402 +++++++++++++++++++++++- tests/test_reporting_fields.py | 538 +++++++++++++++++++++++++++++++++ 6 files changed, 1140 insertions(+), 10 deletions(-) create mode 100644 tests/test_reporting_fields.py diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 65642675..2b7de85e 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -41,7 +41,7 @@ from strix.tools.proxy.tools import ( view_request, view_sitemap_entry, ) -from strix.tools.reporting.tool import create_vulnerability_report +from strix.tools.reporting.tool import create_dependency_report, create_vulnerability_report from strix.tools.thinking.tool import think from strix.tools.todo.tools import ( create_todo, @@ -335,6 +335,7 @@ _BASE_TOOLS: tuple[Tool, ...] = ( delete_note, web_search, create_vulnerability_report, + create_dependency_report, list_requests, view_request, repeat_request, diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index ca2b1faf..b670a410 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import re from typing import TYPE_CHECKING, Any from agents.model_settings import ModelSettings @@ -51,6 +52,11 @@ CRITICAL DEDUPLICATION RULES: - One report is more thorough than another - Minor variations in technical analysis +4. DEPENDENCY-CVE reports use package identity: + - Same CVE and same package/ecosystem is a duplicate + - Same CVE but different package/ecosystem is NOT a duplicate + - Same package/ecosystem but different CVE is NOT a duplicate + COMPARISON GUIDELINES: - Focus on the technical root cause, not surface-level similarities - Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters @@ -101,6 +107,8 @@ def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]: "poc_description", "endpoint", "method", + "cve", + "dependency_metadata", ] cleaned = {} @@ -114,6 +122,112 @@ def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]: return cleaned +def _dependency_identity(report: dict[str, Any]) -> tuple[str, str, str] | None: + metadata = report.get("dependency_metadata") + if not isinstance(metadata, dict): + return None + + raw_cve = report.get("cve") + raw_package = metadata.get("package_name") + if not raw_cve or not raw_package: + return None + + cve = str(raw_cve).strip().upper() + ecosystem = str(metadata.get("package_ecosystem") or "").strip().lower() + package_name = str(raw_package).strip().lower() + if not cve or not package_name: + return None + return cve, ecosystem, package_name + + +def _report_cve(report: dict[str, Any]) -> str: + return str(report.get("cve") or "").strip().upper() + + +def _legacy_report_mentions_package( + report: dict[str, Any], + *, + ecosystem: str, + package_name: str, +) -> bool: + fields = [ + "title", + "description", + "impact", + "target", + "technical_analysis", + "poc_description", + "evidence", + ] + haystack = " ".join(str(report.get(field) or "") for field in fields).lower() + package_pattern = rf"(? dict[str, Any] | None: + candidate_identity = _dependency_identity(candidate) + if candidate_identity is None: + return None + + cve, ecosystem, package_name = candidate_identity + found_legacy_same_cve = False + for report in existing_reports: + report_identity = _dependency_identity(report) + if report_identity is not None: + report_cve, report_ecosystem, report_package_name = report_identity + if (report_cve, report_package_name) != (cve, package_name): + continue + if report_ecosystem == ecosystem: + return { + "is_duplicate": True, + "duplicate_id": str(report.get("id") or "")[:64], + "confidence": 1.0, + "reason": "Same dependency CVE/package identity", + } + if not report_ecosystem or not ecosystem: + return { + "is_duplicate": True, + "duplicate_id": str(report.get("id") or "")[:64], + "confidence": 1.0, + "reason": "Same dependency CVE/package identity with missing ecosystem", + } + continue + + if _report_cve(report) != cve: + continue + found_legacy_same_cve = True + if _legacy_report_mentions_package( + report, + ecosystem=ecosystem, + package_name=package_name, + ): + return { + "is_duplicate": True, + "duplicate_id": str(report.get("id") or "")[:64], + "confidence": 1.0, + "reason": "Same dependency CVE/package identity in legacy report", + } + + if found_legacy_same_cve: + return None + + package_label = f"{ecosystem}/{package_name}" if ecosystem else package_name + return { + "is_duplicate": False, + "duplicate_id": "", + "confidence": 1.0, + "reason": f"No existing dependency report for {cve} in {package_label}", + } + + def _parse_dedupe_response(content: str) -> dict[str, Any]: text = content.strip() if text.startswith("```"): @@ -165,6 +279,10 @@ async def check_duplicate( "reason": "No existing reports to compare against", } + dependency_duplicate = _check_dependency_duplicate(candidate, existing_reports) + if dependency_duplicate is not None: + return dependency_duplicate + try: settings = load_settings() model_name = settings.llm.model diff --git a/strix/report/state.py b/strix/report/state.py index f7acabad..3dbc9772 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -212,6 +212,9 @@ class ReportState: poc_description: str | None = None, poc_script_code: str | None = None, remediation_steps: str | None = None, + evidence: str | None = None, + assumptions: str | None = None, + fix_effort: str | None = None, cvss: float | None = None, cvss_breakdown: dict[str, str] | None = None, endpoint: str | None = None, @@ -219,6 +222,9 @@ class ReportState: cve: str | None = None, cwe: str | None = None, code_locations: list[dict[str, Any]] | None = None, + fix_pr_body: str | None = None, + finding_class: str | None = None, + dependency_metadata: dict[str, str] | None = None, agent_id: str | None = None, agent_name: str | None = None, ) -> str: @@ -245,6 +251,12 @@ class ReportState: report["poc_script_code"] = poc_script_code.strip() if remediation_steps: report["remediation_steps"] = remediation_steps.strip() + if evidence: + report["evidence"] = evidence.strip() + if assumptions: + report["assumptions"] = assumptions.strip() + if fix_effort: + report["fix_effort"] = fix_effort.strip().lower() if cvss is not None: report["cvss"] = cvss if cvss_breakdown: @@ -259,6 +271,11 @@ class ReportState: report["cwe"] = cwe.strip() if code_locations: report["code_locations"] = code_locations + if fix_pr_body: + report["fix_pr_body"] = fix_pr_body.strip() + report["finding_class"] = (finding_class or "dynamic").strip().lower() + if dependency_metadata: + report["dependency_metadata"] = dependency_metadata if agent_id: report["agent_id"] = agent_id if agent_name: diff --git a/strix/tools/finish/tool.py b/strix/tools/finish/tool.py index c7662b2a..569de8d7 100644 --- a/strix/tools/finish/tool.py +++ b/strix/tools/finish/tool.py @@ -115,8 +115,8 @@ async def finish_scan( **Calling this multiple times overwrites the previous report.** Make the single call comprehensive. - **Customer-facing report rules** (this output is rendered into the - final PDF the client sees): + **Report output rules** (this content may be rendered into generated + reports): - Never mention internal infrastructure: no local/absolute paths (``/workspace/...``), no agent names, no sandbox/orchestrator/ @@ -140,6 +140,74 @@ async def finish_scan( (Immediate / Short-term / Medium-term), each with concrete remediation steps. End with retest/validation guidance. + - **Formatting — use markdown in every field.** These fields may be + rendered into generated reports, so structure them clearly: lead + each section with a short ``# Heading``, use ``**bold**`` for labels/emphasis, + ``inline code`` for identifiers/paths/parameters, bullet or + numbered lists for enumerations, and fenced code blocks + (```` ```language ````) for any code/payload excerpts. Never emit + one flat wall of prose or leave code unformatted. + - If **zero** vulnerabilities were found, say so plainly and + characterize the posture positively; ``technical_analysis`` should + summarize the areas tested and confirm no issues, and + ``recommendations`` should focus on general hardening. + + Example (abbreviated — mirror this structure, not the wording):: + + executive_summary: + # Executive Summary + + An external assessment of the **Acme Customer Portal** + identified multiple weaknesses that could lead to + unauthorized access to customer data. + + **Overall risk posture:** Elevated. + + **Key findings** + - Confirmed SSRF in a URL-preview feature reaching internal + network ranges. + - Broken tenant isolation enabling cross-tenant data access. + + **Business impact** + - Potential exposure of customer records across tenants. + + methodology: + # Methodology + + Conducted per the **OWASP WSTG**. + + **Engagement type:** Gray-box external test. + **Scope:** `https://app.acme.example`, `.../api/v1/`. + + **Activities:** recon, authn/session review, authorization + and tenant-isolation testing, input/SSRF testing. + + technical_analysis: + # Technical Analysis + + **Severity model** reflects exploitability x impact. + + 1. **SSRF in URL preview** (Critical) — insufficient + destination validation; reaches link-local addresses. + 2. **Broken tenant isolation** (High) — object identifiers + accepted without ownership checks. + + **Systemic themes:** authorization enforced inconsistently; + no deny-by-default egress policy. + + recommendations: + # Recommendations + + **Immediate** + 1. Remediate SSRF: enforce a destination allowlist, + deny-by-default, re-validate on every redirect hop. + + **Short-term** + 2. Centralize authorization with deny-by-default middleware. + + **Retest & validation:** re-test immediate items to confirm + SSRF and tenant-isolation controls hold. + Args: executive_summary: Business-level summary for leadership. methodology: Frameworks, scope, and approach. diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 3fdcfeca..4e2e017e 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -148,8 +148,12 @@ _REQUIRED_FIELDS = { "poc_description": "PoC description cannot be empty", "poc_script_code": "PoC script/code is REQUIRED - provide the actual exploit/payload", "remediation_steps": "Remediation steps cannot be empty", + "evidence": "Evidence cannot be empty - provide concrete proof of the finding", + "assumptions": "Assumptions cannot be empty - state exploitability prerequisites", } +_VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"}) + async def _do_create( # noqa: PLR0912 *, @@ -161,12 +165,16 @@ async def _do_create( # noqa: PLR0912 poc_description: str, poc_script_code: str, remediation_steps: str, + evidence: str, + assumptions: str, + fix_effort: str, cvss_breakdown: dict[str, str], endpoint: str | None, method: str | None, cve: str | None, cwe: str | None, code_locations: list[dict[str, Any]] | None, + fix_pr_body: str | None = None, agent_id: str | None = None, agent_name: str | None = None, ) -> dict[str, Any]: @@ -180,11 +188,20 @@ async def _do_create( # noqa: PLR0912 "poc_description": poc_description, "poc_script_code": poc_script_code, "remediation_steps": remediation_steps, + "evidence": evidence, + "assumptions": assumptions, } for name, msg in _REQUIRED_FIELDS.items(): if not str(fields.get(name) or "").strip(): errors.append(msg) + fix_effort = (fix_effort or "").strip().lower() + if fix_effort not in _VALID_FIX_EFFORT: + errors.append( + f"Invalid fix_effort: {fix_effort!r}. " + f"Must be one of: {sorted(_VALID_FIX_EFFORT)}" + ) + if not isinstance(cvss_breakdown, dict) or not cvss_breakdown: errors.append("cvss_breakdown: must be an object with the 8 CVSS metrics") cvss_breakdown = {} @@ -268,6 +285,9 @@ async def _do_create( # noqa: PLR0912 poc_description=poc_description, poc_script_code=poc_script_code, remediation_steps=remediation_steps, + evidence=evidence, + assumptions=assumptions, + fix_effort=fix_effort, cvss=cvss_score, cvss_breakdown=cvss_breakdown, endpoint=endpoint, @@ -275,6 +295,7 @@ async def _do_create( # noqa: PLR0912 cve=cve, cwe=cwe, code_locations=parsed_locations, + fix_pr_body=fix_pr_body, agent_id=agent_id if isinstance(agent_id, str) else None, agent_name=agent_name if isinstance(agent_name, str) else None, ) @@ -309,12 +330,16 @@ async def create_vulnerability_report( poc_description: str, poc_script_code: str, remediation_steps: str, + evidence: str, + assumptions: str, + fix_effort: str, cvss_breakdown: dict[str, str], endpoint: str | None = None, method: str | None = None, cve: str | None = None, cwe: str | None = None, code_locations: list[dict[str, Any]] | None = None, + fix_pr_body: str | None = None, ) -> str: """File a vulnerability report — one report per fully-verified finding. @@ -333,16 +358,24 @@ async def create_vulnerability_report( get a ``duplicate_of`` response, do NOT retry — move on to other areas. - **Customer-facing report rules** (the report is PDF-rendered for - delivery): + **Report output rules** (this content may be rendered into generated + reports): - No internal/system details: never mention paths like ``/workspace``, internal tools, agents, sandboxes, models, system prompts, internal errors / stack traces, or tester environment. + Never leak internal identifiers (proxy request IDs, internal + report IDs) into any field. - Tone: formal, objective, third-person, vendor-neutral, concise. - - Standard finding structure: Overview → Severity & CVSS → - Affected assets → Technical details → PoC (steps + code) → - Impact → Remediation → Evidence (in technical_analysis). + - **Use markdown in every text field**: ``**bold**`` for emphasis, + ``inline code`` for identifiers/values/parameters, and fenced + code blocks (```` ```language ````) for any code/payload/HTTP + excerpt. Never leave code bare/unformatted. When referencing a + file, annotate the fence, e.g. + ```` ```python title=app.py startLineNumber=42 endLineNumber=50 ````. + - Field discipline: ``poc_description`` is steps only — NO code (all + code goes in ``poc_script_code``); ``remediation_steps`` is prose + only — NO code/diffs (code fixes go in ``code_locations``). - Numbered steps allowed only in PoC and Remediation sections. - Avoid hedging language; be precise and non-vague. @@ -411,9 +444,16 @@ async def create_vulnerability_report( impact: What an attacker achieves; business risk; data at risk. target: Affected URL / domain / repository. technical_analysis: The mechanism and root cause. - poc_description: Step-by-step reproduction. + poc_description: Step-by-step reproduction (steps only, no code). poc_script_code: Working PoC (Python preferred). - remediation_steps: Specific, actionable fix. + remediation_steps: Specific, actionable fix (prose, no code). + evidence: Concrete proof the issue is real and exploitable — + request/response excerpts, observed behavior, tool output. + Use fenced code blocks; no internal identifiers/paths. + assumptions: Short note on the assumptions/prerequisites that + make this finding impactful or exploitable (e.g. "assumes an + authenticated low-privilege user"). + fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high``. cvss_breakdown: 8-metric object per the format above. endpoint: API path / Git path (e.g. ``/api/login``). method: HTTP method when relevant. @@ -482,6 +522,47 @@ async def create_vulnerability_report( - Padding ``fix_before`` with surrounding context lines that aren't part of the fix. - Duplicating the same change across multiple locations. + fix_pr_body: Optional. When source is available and you have a + concrete fix, a markdown PR-description body proposing the + fix (summary + rationale). Prose/markdown only — the code + change itself belongs in ``code_locations``. Omit for + black-box findings. + + Example (abbreviated — mirror this structure):: + + title: "Reflected XSS in /search q parameter" + description: + The **`q`** parameter of `/search` reflects user input into + the HTML response without encoding, allowing script + injection. + technical_analysis: + The handler interpolates `q` directly into the page body: + + ```python title=views.py startLineNumber=42 endLineNumber=44 + html = f"

Results for {q}

" + return HttpResponse(html) + ``` + + No output encoding is applied, so ` + ``` + evidence: + Response echoes the payload verbatim: + + ```html +

Results for

+ ``` + assumptions: + Assumes a victim can be induced to open a crafted link. + remediation_steps: + Context-encode all user input rendered into HTML; prefer the + template engine's auto-escaping over string interpolation. + fix_effort: "low" """ inner = ctx.context if isinstance(ctx.context, dict) else {} raw_agent_id = inner.get("agent_id") @@ -503,12 +584,319 @@ async def create_vulnerability_report( poc_description=poc_description, poc_script_code=poc_script_code, remediation_steps=remediation_steps, + evidence=evidence, + assumptions=assumptions, + fix_effort=fix_effort, cvss_breakdown=cvss_breakdown, endpoint=endpoint, method=method, cve=cve, cwe=cwe, code_locations=code_locations, + fix_pr_body=fix_pr_body, + agent_id=agent_id, + agent_name=agent_name, + ) + return json.dumps(result, ensure_ascii=False, default=str) + + +_DEP_SEVERITY_FROM_CVSS = { + (9.0, 10.0): "critical", + (7.0, 9.0): "high", + (4.0, 7.0): "medium", + (0.0, 4.0): "low", +} + + +def _dependency_severity(advisory_cvss: float | None) -> tuple[float, str]: + if advisory_cvss is None: + return 0.0, "medium" + score = max(0.0, min(10.0, advisory_cvss)) + for (lo, hi), label in _DEP_SEVERITY_FROM_CVSS.items(): + if lo <= score < hi or (hi == 10.0 and score == 10.0): + return score, label + return score, "none" + + +def _build_dependency_metadata( + *, + package_name: str, + installed_version: str, + package_ecosystem: str, + fixed_version: str | None, +) -> dict[str, str]: + metadata = { + "package_name": package_name.strip(), + "installed_version": installed_version.strip(), + } + if package_ecosystem and package_ecosystem.strip(): + metadata["package_ecosystem"] = package_ecosystem.strip() + if fixed_version and fixed_version.strip(): + metadata["fixed_version"] = fixed_version.strip() + return metadata + + +def _build_dependency_evidence( + *, + cve: str, + package_name: str, + installed_version: str, + fixed_version: str | None, +) -> str: + evidence = ( + f"**Advisory evidence:** `{cve}` applies to `{package_name}` " + f"at installed version `{installed_version}`." + ) + if fixed_version and fixed_version.strip(): + evidence += f" The advisory is fixed in `{fixed_version.strip()}`." + return evidence + + +async def _do_create_dependency( + *, + title: str, + description: str, + target: str, + cve: str, + package_name: str, + installed_version: str, + impact: str, + remediation_steps: str, + assumptions: str, + package_ecosystem: str | None, + fixed_version: str | None, + cwe: str | None, + advisory_cvss: float | None, + technical_analysis: str | None, + fix_effort: str, + agent_id: str | None = None, + agent_name: str | None = None, +) -> dict[str, Any]: + errors: list[str] = [] + required = { + "title": title, + "description": description, + "target": target, + "package_name": package_name, + "installed_version": installed_version, + "package_ecosystem": package_ecosystem, + "impact": impact, + "remediation_steps": remediation_steps, + "assumptions": assumptions, + } + for name, value in required.items(): + if not str(value or "").strip(): + errors.append(f"{name} cannot be empty") + + parsed_cve = _extract_cve(cve or "") + cve_err = _validate_cve(parsed_cve) + if cve_err: + errors.append(cve_err) + + if cwe: + cwe = _extract_cwe(cwe) + cwe_err = _validate_cwe(cwe) + if cwe_err: + errors.append(cwe_err) + + fix_effort = (fix_effort or "").strip().lower() + if fix_effort not in _VALID_FIX_EFFORT: + errors.append( + f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}" + ) + + if advisory_cvss is not None and not 0.0 <= advisory_cvss <= 10.0: + errors.append(f"advisory_cvss must be between 0.0 and 10.0, got {advisory_cvss}") + + if errors: + return {"success": False, "error": "Validation failed", "errors": errors} + + cvss_score, severity = _dependency_severity(advisory_cvss) + dependency_metadata = _build_dependency_metadata( + package_name=package_name, + installed_version=installed_version, + package_ecosystem=package_ecosystem, + fixed_version=fixed_version, + ) + evidence = _build_dependency_evidence( + cve=parsed_cve, + package_name=package_name.strip(), + installed_version=installed_version.strip(), + fixed_version=fixed_version, + ) + + try: + from strix.report.state import get_global_report_state + + report_state = get_global_report_state() + if report_state is None: + logger.warning("No global report state; dependency report not persisted") + return { + "success": True, + "message": f"Dependency finding '{title}' created (not persisted)", + "warning": "Report could not be persisted - report state unavailable", + } + + from strix.report.dedupe import check_duplicate + + existing = report_state.get_existing_vulnerabilities() + candidate = { + "title": title, + "description": description, + "target": target, + "cve": parsed_cve, + "dependency_metadata": dependency_metadata, + "technical_analysis": technical_analysis, + } + dedupe = await check_duplicate(candidate, existing) + if dedupe.get("is_duplicate"): + duplicate_id = dedupe.get("duplicate_id", "") + return { + "success": False, + "error": ( + f"Potential duplicate (id={duplicate_id[:8]}...) — " + "do not re-report the same dependency finding" + ), + "duplicate_of": duplicate_id, + "confidence": dedupe.get("confidence", 0.0), + "reason": dedupe.get("reason", ""), + } + + report_id = report_state.add_vulnerability_report( + title=title, + description=description, + severity=severity, + impact=impact, + target=target, + technical_analysis=technical_analysis, + remediation_steps=remediation_steps, + evidence=evidence, + assumptions=assumptions, + fix_effort=fix_effort, + cvss=cvss_score if advisory_cvss is not None else None, + cve=parsed_cve, + cwe=cwe, + finding_class="dependency_cve", + dependency_metadata=dependency_metadata, + agent_id=agent_id if isinstance(agent_id, str) else None, + agent_name=agent_name if isinstance(agent_name, str) else None, + ) + except (ImportError, AttributeError) as e: + logger.exception("create_dependency_report persistence failed") + return {"success": False, "error": f"Failed to create dependency report: {e!s}"} + else: + logger.info( + "Dependency report created: id=%s cve=%s package=%s severity=%s", + report_id, + parsed_cve, + package_name, + severity, + ) + return { + "success": True, + "message": f"Dependency finding '{title}' created successfully", + "report_id": report_id, + "severity": severity, + "cve": parsed_cve, + } + + +@function_tool(timeout=180, strict_mode=False) +async def create_dependency_report( + ctx: RunContextWrapper, + title: str, + description: str, + target: str, + cve: str, + package_name: str, + installed_version: str, + impact: str, + remediation_steps: str, + assumptions: str, + package_ecosystem: str, + fixed_version: str | None = None, + cwe: str | None = None, + advisory_cvss: float | None = None, + technical_analysis: str | None = None, + fix_effort: str = "low", +) -> str: + """File a known-CVE dependency (SCA) finding — one report per CVE x package. + + Use this instead of ``create_vulnerability_report`` when the finding + is a **known-CVE supply-chain issue**: a vulnerable third-party + package/version identified from a lockfile, manifest, or SBOM. Unlike + a dynamic finding, you do NOT need to trigger the vulnerability with a + live PoC — a verified advisory + the affected installed version is the + evidence. + + **When to file**: + + - A dependency is pinned to a version covered by a published CVE. + - You have verified the CVE ID and the installed version falls in the + affected range (use ``web_search`` if unsure). + + **When NOT to file**: + + - Dynamically-proven vulnerabilities → use + ``create_vulnerability_report`` (``finding_class`` dynamic). + - Outdated-but-not-vulnerable dependencies with no CVE. + - Re-reporting the same CVE/package already filed. + + **Reachability**: do NOT silently downgrade or suppress a finding + because the vulnerable code path may be unreachable — instead state + reachability as an ``assumptions`` / confidence factor. Report the + finding; let the reader weigh exploitability. + + **Formatting**: use markdown in text fields (``**bold**``, ``inline + code`` for package/version identifiers, fenced code blocks for + manifest excerpts). No internal paths/tooling/agent references. + + Args: + title: e.g. ``"CVE-2024-1234 in lodash 4.17.20 (prototype pollution)"``. + description: What the CVE is and why the pinned version is affected. + target: Affected repository / project / manifest. + cve: ``CVE-YYYY-NNNNN`` — required and must be verified. + package_name: Affected package name (e.g. ``lodash``). + installed_version: The version currently pinned/installed. + impact: What the CVE enables; business risk in this context. + remediation_steps: How to fix (usually upgrade to a fixed version). + assumptions: Exploitability/reachability assumptions & confidence. + package_ecosystem: e.g. ``npm`` / ``pypi`` / ``maven`` / ``go``. + fixed_version: First non-vulnerable version, if known. + cwe: ``CWE-NNN`` (most specific) if certain, else omit. + advisory_cvss: Published advisory base score (0.0-10.0) if known; + drives severity. Omit if unknown (defaults to medium). + technical_analysis: Optional deeper mechanism/root-cause detail. + fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high`` + (dependency upgrades are usually ``trivial``/``low``). + """ + inner = ctx.context if isinstance(ctx.context, dict) else {} + raw_agent_id = inner.get("agent_id") + agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None + agent_name = None + coordinator = inner.get("coordinator") + if agent_id is not None and coordinator is not None: + names = getattr(coordinator, "names", {}) + if isinstance(names, dict): + raw_agent_name = names.get(agent_id) + agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None + + result = await _do_create_dependency( + title=title, + description=description, + target=target, + cve=cve, + package_name=package_name, + installed_version=installed_version, + impact=impact, + remediation_steps=remediation_steps, + assumptions=assumptions, + package_ecosystem=package_ecosystem, + fixed_version=fixed_version, + cwe=cwe, + advisory_cvss=advisory_cvss, + technical_analysis=technical_analysis, + fix_effort=fix_effort, agent_id=agent_id, agent_name=agent_name, ) diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py new file mode 100644 index 00000000..49a63e8d --- /dev/null +++ b/tests/test_reporting_fields.py @@ -0,0 +1,538 @@ +"""Tests for restored report fields, SCA tool, and report formatting guidance.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from strix.report.dedupe import ( + _check_dependency_duplicate, + _prepare_report_for_comparison, + check_duplicate, +) +from strix.report.state import ReportState, set_global_report_state +from strix.tools.finish.tool import finish_scan +from strix.tools.reporting.tool import ( + _do_create, + _do_create_dependency, + create_dependency_report, + create_vulnerability_report, +) + + +if TYPE_CHECKING: + from pathlib import Path + + +_CVSS = { + "attack_vector": "N", + "attack_complexity": "L", + "privileges_required": "N", + "user_interaction": "N", + "scope": "U", + "confidentiality": "H", + "integrity": "H", + "availability": "H", +} + + +@pytest.fixture +def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ReportState: + monkeypatch.chdir(tmp_path) + state = ReportState(run_name="test-run") + set_global_report_state(state) + return state + + +async def test_create_report_persists_new_fields(report_state: ReportState) -> None: + result = await _do_create( + title="Reflected XSS in search", + description="q reflects unencoded input.", + impact="Session theft.", + target="https://app.example.com", + technical_analysis="Input interpolated into HTML.", + poc_description="1. open /search?q=", + poc_script_code="GET /search?q=", + remediation_steps="Context-encode output.", + evidence="Response echoes the payload verbatim.", + assumptions="Assumes a victim opens a crafted link.", + fix_effort="LOW", + cvss_breakdown=_CVSS, + endpoint="/search", + method="GET", + cve=None, + cwe="CWE-79", + code_locations=None, + fix_pr_body="## Fix\nEncode output.", + ) + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert report["evidence"] == "Response echoes the payload verbatim." + assert report["assumptions"] == "Assumes a victim opens a crafted link." + assert report["fix_effort"] == "low" + assert report["fix_pr_body"] == "## Fix\nEncode output." + assert report["finding_class"] == "dynamic" + + +async def test_create_report_requires_evidence_and_assumptions( + report_state: ReportState, +) -> None: + result = await _do_create( + title="X", + description="d", + impact="i", + target="t", + technical_analysis="ta", + poc_description="p", + poc_script_code="c", + remediation_steps="r", + evidence=" ", + assumptions="", + fix_effort="low", + cvss_breakdown=_CVSS, + endpoint=None, + method=None, + cve=None, + cwe=None, + code_locations=None, + ) + assert result["success"] is False + joined = " ".join(result["errors"]) + assert "Evidence" in joined + assert "Assumptions" in joined + assert not report_state.vulnerability_reports + + +async def test_create_report_rejects_invalid_fix_effort(report_state: ReportState) -> None: + result = await _do_create( + title="X", + description="d", + impact="i", + target="t", + technical_analysis="ta", + poc_description="p", + poc_script_code="c", + remediation_steps="r", + evidence="e", + assumptions="a", + fix_effort="enormous", + cvss_breakdown=_CVSS, + endpoint=None, + method=None, + cve=None, + cwe=None, + code_locations=None, + ) + assert result["success"] is False + assert any("fix_effort" in e for e in result["errors"]) + assert not report_state.vulnerability_reports + + +async def test_dependency_report_sets_class_and_metadata(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2021-23337 in lodash 4.17.20", + description="Command injection via template.", + target="repo/package.json", + cve="CVE-2021-23337", + package_name="lodash", + installed_version="4.17.20", + impact="Arbitrary command execution.", + remediation_steps="Upgrade to 4.17.21.", + assumptions="Assumes the template sink is reachable.", + package_ecosystem="npm", + fixed_version="4.17.21", + cwe="CWE-94", + advisory_cvss=7.2, + technical_analysis=None, + fix_effort="trivial", + ) + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert report["finding_class"] == "dependency_cve" + assert report["cve"] == "CVE-2021-23337" + assert report["severity"] == "high" + assert report["evidence"] == ( + "**Advisory evidence:** `CVE-2021-23337` applies to `lodash` " + "at installed version `4.17.20`. The advisory is fixed in `4.17.21`." + ) + assert report["dependency_metadata"] == { + "package_name": "lodash", + "installed_version": "4.17.20", + "package_ecosystem": "npm", + "fixed_version": "4.17.21", + } + + +async def test_dependency_report_with_zero_cvss_remains_low_severity( + report_state: ReportState, +) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Low-impact dependency advisory.", + remediation_steps="Upgrade to 1.0.1.", + assumptions="Assumes the package is included in deployed builds.", + package_ecosystem="npm", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=0.0, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is True + assert result["severity"] == "low" + report = report_state.vulnerability_reports[0] + assert report["severity"] == "low" + assert report["cvss"] == 0.0 + + +async def test_dependency_report_dedupe_candidate_includes_dependency_metadata( + report_state: ReportState, + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def fake_check_duplicate( + candidate: dict[str, object], + existing: list[dict[str, object]], + ) -> dict[str, object]: + captured["candidate"] = candidate + captured["existing"] = existing + return {"is_duplicate": False} + + monkeypatch.setattr("strix.report.dedupe.check_duplicate", fake_check_duplicate) + report_state.vulnerability_reports.append( + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in other 1.0.0", + "severity": "low", + "timestamp": "2026-01-01 00:00:00 UTC", + "description": "Existing dependency finding.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "other", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + }, + } + ) + + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Low-impact dependency advisory.", + remediation_steps="Upgrade to 1.0.1.", + assumptions="Assumes the package is included in deployed builds.", + package_ecosystem="npm", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=0.0, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is True + assert captured["candidate"] == { + "title": "CVE-2024-0001 in sample 1.0.0", + "description": "Published advisory affects the pinned version.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + "fixed_version": "1.0.1", + }, + "technical_analysis": None, + } + + +async def test_dependency_report_rejects_bad_cve(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="bad", + description="d", + target="t", + cve="not-a-cve", + package_name="pkg", + installed_version="1.0.0", + impact="i", + remediation_steps="r", + assumptions="a", + package_ecosystem="npm", + fixed_version=None, + cwe=None, + advisory_cvss=None, + technical_analysis=None, + fix_effort="low", + ) + assert result["success"] is False + assert not report_state.vulnerability_reports + + +async def test_dependency_report_requires_ecosystem(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Low-impact dependency advisory.", + remediation_steps="Upgrade to 1.0.1.", + assumptions="Assumes the package is included in deployed builds.", + package_ecosystem="", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=0.0, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is False + assert any("package_ecosystem" in error for error in result["errors"]) + assert not report_state.vulnerability_reports + + +def test_dedupe_comparison_preserves_cve_identity() -> None: + cleaned = _prepare_report_for_comparison( + { + "title": "CVE-2021-23337 in lodash", + "description": "Pinned vulnerable dependency.", + "target": "repo/package.json", + "cve": "CVE-2021-23337", + "dependency_metadata": {"package_name": "lodash"}, + } + ) + + assert cleaned["cve"] == "CVE-2021-23337" + assert cleaned["dependency_metadata"] == {"package_name": "lodash"} + + +async def test_dependency_dedupe_uses_cve_package_identity() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in other", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "other", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + }, + } + ] + candidate = { + "title": "CVE-2024-0001 in sample", + "description": "Similar advisory prose.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + }, + } + + result = await check_duplicate(candidate, existing) + + assert result["is_duplicate"] is False + assert result["confidence"] == 1.0 + + +async def test_dependency_dedupe_rejects_same_cve_package_identity() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in sample", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + }, + } + ] + candidate = { + "title": "CVE-2024-0001 in sample with different prose", + "description": "Different prose for the same dependency identity.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.1", + "package_ecosystem": "npm", + }, + } + + result = await check_duplicate(candidate, existing) + + assert result["is_duplicate"] is True + assert result["duplicate_id"] == "vuln-0001" + assert result["confidence"] == 1.0 + + +async def test_dependency_dedupe_detects_legacy_same_cve_package() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in npm sample package", + "description": "Legacy dependency finding without structured metadata.", + "cve": "CVE-2024-0001", + } + ] + candidate = { + "title": "CVE-2024-0001 in sample", + "description": "Different prose for the same dependency identity.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.1", + "package_ecosystem": "npm", + }, + } + + result = await check_duplicate(candidate, existing) + + assert result["is_duplicate"] is True + assert result["duplicate_id"] == "vuln-0001" + assert result["confidence"] == 1.0 + + +def test_dependency_dedupe_defers_unclear_legacy_same_cve() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 dependency finding", + "description": "Legacy dependency finding without package identity.", + "cve": "CVE-2024-0001", + } + ] + candidate = { + "title": "CVE-2024-0001 in sample", + "description": "Candidate dependency finding.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.1", + "package_ecosystem": "npm", + }, + } + + assert _check_dependency_duplicate(candidate, existing) is None + + +def test_dependency_dedupe_defers_legacy_package_substring_match() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in sample-package", + "description": "Legacy dependency finding for a different package.", + "cve": "CVE-2024-0001", + } + ] + candidate = { + "title": "CVE-2024-0001 in sample", + "description": "Candidate dependency finding.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.1", + "package_ecosystem": "npm", + }, + } + + assert _check_dependency_duplicate(candidate, existing) is None + + +def test_dependency_dedupe_defers_legacy_ecosystem_mismatch() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in npm sample", + "description": "Legacy dependency finding for a different ecosystem.", + "cve": "CVE-2024-0001", + } + ] + candidate = { + "title": "CVE-2024-0001 in sample", + "description": "Candidate dependency finding.", + "target": "repo/requirements.txt", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.1", + "package_ecosystem": "pypi", + }, + } + + assert _check_dependency_duplicate(candidate, existing) is None + + +def test_dependency_dedupe_matches_structured_missing_ecosystem() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in sample", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + }, + } + ] + candidate = { + "title": "CVE-2024-0001 in sample", + "description": "Candidate dependency finding.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.1", + "package_ecosystem": "npm", + }, + } + + result = _check_dependency_duplicate(candidate, existing) + + assert result is not None + assert result["is_duplicate"] is True + assert result["duplicate_id"] == "vuln-0001" + + +def test_tool_descriptions_include_formatting_guidance() -> None: + vuln_desc = create_vulnerability_report.description + assert "markdown" in vuln_desc.lower() + assert "fenced code" in vuln_desc.lower() + + finish_desc = finish_scan.description + assert "markdown" in finish_desc.lower() + assert "# Executive Summary" in finish_desc + + dep_desc = create_dependency_report.description + assert "cve" in dep_desc.lower() + assert "reachab" in dep_desc.lower() + + +def test_vuln_tool_exposes_new_params() -> None: + props = create_vulnerability_report.params_json_schema["properties"] + for field in ("evidence", "assumptions", "fix_effort", "fix_pr_body"): + assert field in props + + dep_props = create_dependency_report.params_json_schema["properties"] + for field in ("package_name", "installed_version", "cve", "advisory_cvss"): + assert field in dep_props + assert "package_ecosystem" in create_dependency_report.params_json_schema["required"]