From 077ae288f0299cb83830c644ccbb7127ab95593c Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sun, 12 Jul 2026 17:31:49 -0700 Subject: [PATCH] fix(reporting): require advisory_cvss for dependency findings + add SCA TUI renderer (#753) Co-authored-by: Ahmed Allam --- strix/interface/tui/app.py | 58 ++++++ .../tui/renderers/reporting_renderer.py | 173 ++++++++++++++++++ strix/report/writer.py | 17 ++ strix/skills/README.md | 1 + .../skills/custom/dependency_cve_scanning.md | 138 ++++++++++++++ strix/skills/custom/source_aware_sast.md | 5 + strix/tools/reporting/tool.py | 23 ++- tests/test_report_writer.py | 34 ++++ tests/test_reporting_fields.py | 28 ++- 9 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 strix/skills/custom/dependency_cve_scanning.md diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index 34965168..02683205 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -371,6 +371,19 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] text.append("Target: ", style=self.FIELD_STYLE) text.append(target) + dep_meta = vuln.get("dependency_metadata") or {} + for label, key in ( + ("Package", "package_name"), + ("Ecosystem", "package_ecosystem"), + ("Installed Version", "installed_version"), + ("Fixed Version", "fixed_version"), + ): + value = dep_meta.get(key) + if value: + text.append("\n\n") + text.append(f"{label}: ", style=self.FIELD_STYLE) + text.append(str(value)) + endpoint = vuln.get("endpoint", "") if endpoint: text.append("\n\n") @@ -389,6 +402,18 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] text.append("CVE: ", style=self.FIELD_STYLE) text.append(cve) + cwe = vuln.get("cwe", "") + if cwe: + text.append("\n\n") + text.append("CWE: ", style=self.FIELD_STYLE) + text.append(cwe) + + fix_effort = vuln.get("fix_effort", "") + if fix_effort: + text.append("\n\n") + text.append("Fix Effort: ", style=self.FIELD_STYLE) + text.append(str(fix_effort).title()) + cvss_breakdown = vuln.get("cvss_breakdown", {}) if cvss_breakdown: cvss_parts = [] @@ -434,6 +459,13 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] text.append("\n") text.append(technical_analysis) + evidence = vuln.get("evidence", "") + if evidence: + text.append("\n\n") + text.append("Evidence", style=self.FIELD_STYLE) + text.append("\n") + text.append(evidence) + poc_description = vuln.get("poc_description", "") if poc_description: text.append("\n\n") @@ -455,6 +487,13 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] text.append("\n") text.append(remediation_steps) + assumptions = vuln.get("assumptions", "") + if assumptions: + text.append("\n\n") + text.append("Assumptions", style=self.FIELD_STYLE) + text.append("\n") + text.append(assumptions) + return text def _get_markdown_report(self) -> str: @@ -476,14 +515,27 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] lines.append(f"**Agent:** {vuln['agent_name']}") if vuln.get("target"): lines.append(f"**Target:** {vuln['target']}") + dep_meta = vuln.get("dependency_metadata") or {} + if dep_meta.get("package_name"): + lines.append(f"**Package:** {dep_meta['package_name']}") + if dep_meta.get("package_ecosystem"): + lines.append(f"**Ecosystem:** {dep_meta['package_ecosystem']}") + if dep_meta.get("installed_version"): + lines.append(f"**Installed Version:** {dep_meta['installed_version']}") + if dep_meta.get("fixed_version"): + lines.append(f"**Fixed Version:** {dep_meta['fixed_version']}") if vuln.get("endpoint"): lines.append(f"**Endpoint:** {vuln['endpoint']}") if vuln.get("method"): lines.append(f"**Method:** {vuln['method']}") if vuln.get("cve"): lines.append(f"**CVE:** {vuln['cve']}") + if vuln.get("cwe"): + lines.append(f"**CWE:** {vuln['cwe']}") if vuln.get("cvss") is not None: lines.append(f"**CVSS:** {vuln['cvss']}") + if vuln.get("fix_effort"): + lines.append(f"**Fix Effort:** {str(vuln['fix_effort']).title()}") cvss_breakdown = vuln.get("cvss_breakdown", {}) if cvss_breakdown: @@ -514,6 +566,9 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] if vuln.get("technical_analysis"): lines.extend(["", "## Technical Analysis", "", vuln["technical_analysis"]]) + if vuln.get("evidence"): + lines.extend(["", "## Evidence", "", vuln["evidence"]]) + if vuln.get("poc_description") or vuln.get("poc_script_code"): lines.extend(["", "## Proof of Concept", ""]) if vuln.get("poc_description"): @@ -552,6 +607,9 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] if vuln.get("remediation_steps"): lines.extend(["", "## Remediation", "", vuln["remediation_steps"]]) + if vuln.get("assumptions"): + lines.extend(["", "## Assumptions", "", vuln["assumptions"]]) + lines.append("") return "\n".join(lines) diff --git a/strix/interface/tui/renderers/reporting_renderer.py b/strix/interface/tui/renderers/reporting_renderer.py index 885aba85..d7ba6edd 100644 --- a/strix/interface/tui/renderers/reporting_renderer.py +++ b/strix/interface/tui/renderers/reporting_renderer.py @@ -256,3 +256,176 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer): css_classes = cls.get_css_classes("completed") return Static(padded, classes=css_classes) + + +@register_tool_renderer +class CreateDependencyReportRenderer(BaseToolRenderer): + tool_name: ClassVar[str] = "create_dependency_report" + css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"] + + SEVERITY_COLORS: ClassVar[dict[str, str]] = { + "critical": "#dc2626", + "high": "#ea580c", + "medium": "#d97706", + "low": "#65a30d", + "info": "#0284c7", + } + + @classmethod + def _get_cvss_color(cls, cvss_score: float) -> str: + if cvss_score >= 9.0: + return "#dc2626" + if cvss_score >= 7.0: + return "#ea580c" + if cvss_score >= 4.0: + return "#d97706" + if cvss_score >= 0.1: + return "#65a30d" + return "#6b7280" + + @classmethod + def _render_unsuccessful(cls, args: dict[str, Any], result: dict[str, Any]) -> Static: + text = Text() + text.append("📦 ") + text.append("Dependency (SCA) Report", style="bold #ea580c") + title = args.get("title", "") + if title: + text.append("\n\n") + text.append("Title: ", style=FIELD_STYLE) + text.append(title) + + warning = result.get("warning") + if result.get("success") is False: + errors = result.get("errors") + detail = ( + "; ".join(errors) if isinstance(errors, list) and errors else result.get("error") + ) + label, style = "✗ Not created: ", "bold #dc2626" + fallback = "Report was not created." + else: + detail = warning + label, style = "⚠ Not persisted: ", "bold #d97706" + fallback = "Report could not be persisted." + text.append("\n\n") + text.append(label, style=style) + text.append(str(detail or fallback)) + + padded = Text() + padded.append("\n\n") + padded.append_text(text) + padded.append("\n\n") + return Static(padded, classes=cls.get_css_classes("failed")) + + @classmethod + def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 + args = tool_data.get("args", {}) + result = tool_data.get("result", {}) + + if isinstance(result, dict) and (result.get("success") is False or result.get("warning")): + return cls._render_unsuccessful(args, result) + + title = args.get("title", "") + description = args.get("description", "") + impact = args.get("impact", "") + target = args.get("target", "") + technical_analysis = args.get("technical_analysis", "") + remediation_steps = args.get("remediation_steps", "") + assumptions = args.get("assumptions", "") + + package_name = args.get("package_name", "") + package_ecosystem = args.get("package_ecosystem", "") + installed_version = args.get("installed_version", "") + fixed_version = args.get("fixed_version", "") + cve = args.get("cve", "") + cwe = args.get("cwe", "") + advisory_cvss = args.get("advisory_cvss") + fix_effort = args.get("fix_effort", "") + + severity = "" + if isinstance(result, dict): + severity = result.get("severity", "") + + text = Text() + text.append("📦 ") + text.append("Dependency (SCA) Report", style="bold #ea580c") + + if title: + text.append("\n\n") + text.append("Title: ", style=FIELD_STYLE) + text.append(title) + + if severity: + text.append("\n\n") + text.append("Severity: ", style=FIELD_STYLE) + severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280") + text.append(severity.upper(), style=f"bold {severity_color}") + + if advisory_cvss is not None: + text.append("\n\n") + text.append("Advisory CVSS: ", style=FIELD_STYLE) + try: + score = float(advisory_cvss) + text.append(str(score), style=f"bold {cls._get_cvss_color(score)}") + except (TypeError, ValueError): + text.append(str(advisory_cvss), style=DIM_STYLE) + + if cve: + text.append("\n\n") + text.append("CVE: ", style=FIELD_STYLE) + text.append(cve) + + if cwe: + text.append("\n\n") + text.append("CWE: ", style=FIELD_STYLE) + text.append(cwe) + + if package_name: + text.append("\n\n") + text.append("Package: ", style=FIELD_STYLE) + text.append(package_name, style=FILE_STYLE) + if package_ecosystem: + text.append(f" ({package_ecosystem})", style=DIM_STYLE) + + if installed_version: + text.append("\n\n") + text.append("Installed: ", style=FIELD_STYLE) + text.append(installed_version, style=BEFORE_STYLE) + if fixed_version: + text.append(" → ", style=DIM_STYLE) + text.append("Fixed: ", style=FIELD_STYLE) + text.append(fixed_version, style=AFTER_STYLE) + + if fix_effort: + text.append("\n\n") + text.append("Fix Effort: ", style=FIELD_STYLE) + text.append(fix_effort) + + if target: + text.append("\n\n") + text.append("Target: ", style=FIELD_STYLE) + text.append(target) + + for label, value in [ + ("Description", description), + ("Impact", impact), + ("Technical Analysis", technical_analysis), + ("Assumptions", assumptions), + ("Remediation", remediation_steps), + ]: + if value: + text.append("\n\n") + text.append(label, style=FIELD_STYLE) + text.append("\n") + text.append(value) + + if not title: + text.append("\n ") + text.append("Creating dependency report...", style="dim") + + padded = Text() + padded.append("\n\n") + padded.append_text(text) + padded.append("\n\n") + + css_classes = cls.get_css_classes("completed") + return Static(padded, classes=css_classes) diff --git a/strix/report/writer.py b/strix/report/writer.py index a7db1621..6fefefdb 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -124,8 +124,13 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL f"**Found:** {report.get('timestamp', 'unknown')}", ] + dep_meta = report.get("dependency_metadata") or {} metadata: list[tuple[str, Any]] = [ ("Target", report.get("target")), + ("Package", dep_meta.get("package_name")), + ("Ecosystem", dep_meta.get("package_ecosystem")), + ("Installed Version", dep_meta.get("installed_version")), + ("Fixed Version", dep_meta.get("fixed_version")), ("Endpoint", report.get("endpoint")), ("Method", report.get("method")), ("CVE", report.get("cve")), @@ -134,6 +139,8 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL cvss = report.get("cvss") if cvss is not None: metadata.append(("CVSS", cvss)) + if report.get("fix_effort"): + metadata.append(("Fix Effort", str(report["fix_effort"]).title())) for label, value in metadata: if value: lines.append(f"**{label}:** {value}") @@ -143,6 +150,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL lines.append(report.get("description") or "No description provided.") lines.append("") + if report.get("evidence"): + lines.append("## Evidence\n") + lines.append(str(report["evidence"])) + lines.append("") + if report.get("impact"): lines.append("## Impact\n") lines.append(str(report["impact"])) @@ -194,4 +206,9 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL lines.append(str(report["remediation_steps"])) lines.append("") + if report.get("assumptions"): + lines.append("## Assumptions\n") + lines.append(str(report["assumptions"])) + lines.append("") + return "\n".join(lines) diff --git a/strix/skills/README.md b/strix/skills/README.md index 501c8b04..e8e0cb45 100644 --- a/strix/skills/README.md +++ b/strix/skills/README.md @@ -41,6 +41,7 @@ The skills are dynamically injected into the agent's system prompt, allowing it Notable source-aware skills: - `source_aware_whitebox` (coordination): white-box orchestration playbook - `source_aware_sast` (custom): semgrep/AST/secrets/supply-chain static triage workflow +- `dependency_cve_scanning` (custom): trivy-based SCA workflow for reporting known dependency CVEs via `create_dependency_report` --- diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md new file mode 100644 index 00000000..33ecf8c3 --- /dev/null +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -0,0 +1,138 @@ +--- +name: dependency-cve-scanning +description: Supply-chain / SCA playbook — scan repository lockfiles for known dependency CVEs and report them with create_dependency_report (no dynamic PoC required) +--- + +# Dependency / Supply-Chain CVE Scanning (SCA) + +Use this skill on white-box / repository scans to make sure a repository pinning a +**known-vulnerable dependency** is actually reported as a finding, instead of being +discovered and then silently dropped because it cannot be dynamically exploited. + +Known-CVE dependency findings are a first-class deliverable. Report each one with +the dedicated `create_dependency_report` tool. + +## Why this skill exists + +A vulnerable dependency pinned in a lockfile (e.g. `lodash@4.17.4` with a known +prototype-pollution CVE) usually cannot be dynamically PoC'd from the outside — +the vulnerable code path may not even be reachable from a running endpoint. The +normal "no report without a dynamic PoC" rule would suppress it. For these +findings the proof is the **lockfile entry + scanner output + published +advisory**, not an exploit script. This is the one explicit exception to the +dynamic-validation rule, and it exists only for `create_dependency_report`. + +## Scan procedure + +Run from the repo root and store output in the shared artifact directory used by +the source-aware pass: + +```bash +ART=/workspace/.strix-source-aware +mkdir -p "$ART" + +# Record the vuln DB age so a stale DB is a visible signal, not a silent clean scan. +trivy version --format json 2>/dev/null | tee "$ART/trivy-version.json" +# inspect .VulnerabilityDB.UpdatedAt / NextUpdate + +# Lockfile/manifest -> known-CVE matching. Try a best-effort DB refresh first so a +# sandbox with egress gets the freshest CVEs; if the update fails, fall back to the +# cached DB instead of failing the scan. --offline-scan keeps per-package advisory +# lookups offline. +trivy fs --scanners vuln --timeout 30m --offline-scan \ + --format json --output "$ART/trivy-sca.json" . \ + || trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update \ + --format json --output "$ART/trivy-sca.json" . \ + || true +``` + +If `.VulnerabilityDB.UpdatedAt` is more than a few weeks old (the sandbox had no +egress to refresh it), treat it as a scan limitation and note it in the +`assumptions` of dependency findings — a stale DB that still returns *some* results +will not trip the "zero results is suspicious" heuristic, so its age is the only +staleness signal. + +Trivy reads the lockfiles/manifests it finds, including: +`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `poetry.lock`, +`requirements.txt`, `Pipfile.lock`, `go.mod`/`go.sum`, `Gemfile.lock`, +`pom.xml`/`gradle.lockfile`, `Cargo.lock`, `composer.lock`, etc. + +If trivy returns zero vulnerabilities on a repo with dependencies, treat it as +suspicious: confirm the vuln DB is present (`trivy-version.json`) and that +lockfiles exist. + +## Interpreting results + +For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect: + +- `VulnerabilityID` — the CVE (or GHSA; prefer the CVE if both are present) +- `PkgName` and `InstalledVersion` — the affected package + pinned version +- `FixedVersion` — the version that resolves it +- `Target` — the lockfile path it came from +- `.Results[].Type` (e.g. `npm`, `pip`, `gomod`, `pom`, `gemspec`, `cargo`) — the + package ecosystem; normalize to the registry name lowercased (`npm`, `pypi`, + `go`, `maven`, `rubygems`, `cargo`, `composer`, `nuget`, ...) +- `CVSS` — the published advisory base score +- `PrimaryURL` / references — to verify the advisory + +Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one +`create_dependency_report` per CVE — do not batch multiple CVEs into one report. + +### Reachability is a confidence modifier, not a gate + +Do NOT suppress or downgrade a known CVE just because you could not prove the +vulnerable code path is reachable. Report it, set `advisory_cvss` from the +advisory, and use `assumptions` to note reachability (e.g. "the vulnerable +`template()` API does not appear to be imported in application code, so practical +exploitability is uncertain"). If you *can* show reachability or chain it into a +dynamic exploit, do that and report it as a normal dynamic finding with +`create_vulnerability_report` instead. + +## Reporting + +Report each confirmed known CVE with the dedicated `create_dependency_report` +tool (NOT `create_vulnerability_report` — that tool is for dynamically validated +findings and rejects empty PoC fields): + +- Set `cve` to the verified `CVE-YYYY-NNNNN` id (required). If you only have a + GHSA, look up the mapped CVE; if there is genuinely no CVE, do not report it + with this tool. +- There are no PoC fields — `create_dependency_report` does not take + `poc_description` / `poc_script_code` / `code_locations`. The proof lives in + `description` and `technical_analysis` (scanner output + advisory). +- **Always fill the structured dependency fields** (they power the dedicated + dependency-report card; do not leave them only in free-text): + - `package_name` — `PkgName` (required). + - `installed_version` — `InstalledVersion` (required). + - `package_ecosystem` — normalized ecosystem from `.Results[].Type` (lowercased, + e.g. `npm`, `pypi`, `go`, `maven`, `rubygems`, `cargo`) (required). + - `fixed_version` — `FixedVersion` (leave empty only if no fix is published). +- Reference the repo-relative `Target` lockfile path in `description` / + `technical_analysis` (no leading slash) so the finding is traceable. +- Put the concrete proof in `description` / `technical_analysis`: package name, + installed/affected version, fixed version, lockfile path, and the relevant + trivy output excerpt. +- **Always set `advisory_cvss` to the published advisory base score (0.0–10.0).** + Severity is derived *solely* from this number: read it off the advisory (`CVSS` + in trivy output, or the NVD/GHSA page) and pass the real value. The tool rejects + a call that omits it, because guessing a score both inflates low CVEs and + deflates critical ones. +- Set `cwe` to the most specific `CWE-NNN` when the advisory names one. +- Do NOT cap severity at LOW just because there is no dynamic reproduction — use + the advisory score. +- Use `assumptions` for reachability/exploitability caveats. + +Verify the CVE with `web_search` when available before reporting. Never guess or +hallucinate a CVE id. + +## Anti-patterns + +- Do not report a dependency CVE with `create_vulnerability_report`; use + `create_dependency_report`. +- Do not report a finding without a verified CVE id. +- Do not batch multiple CVEs into one report. +- Do not omit `advisory_cvss` — the tool rejects it, and it is the single input + that determines dependency severity. +- Do not silently drop a known CVE because it lacks a dynamic PoC — that is the + exact failure this skill prevents. +- Do not downgrade advisory severity for lack of dynamic reproduction. diff --git a/strix/skills/custom/source_aware_sast.md b/strix/skills/custom/source_aware_sast.md index 329aa793..0db0e553 100644 --- a/strix/skills/custom/source_aware_sast.md +++ b/strix/skills/custom/source_aware_sast.md @@ -121,6 +121,11 @@ trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \ --format json --output /workspace/.strix-source-aware/trivy-fs.json . || true ``` +Known-CVE dependency findings are the one exception to the "report only after +dynamic validation" rule below: report each one with `create_dependency_report` +(not `create_vulnerability_report`), setting `advisory_cvss` from the published +advisory. `load_skill(["dependency_cve_scanning"])` for the full SCA workflow. + ## JavaScript-Side Coverage For frontends and Node services, layer these on top of the language-agnostic diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 4e2e017e..67d3bc38 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -198,8 +198,7 @@ async def _do_create( # noqa: PLR0912 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)}" + f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}" ) if not isinstance(cvss_breakdown, dict) or not cvss_breakdown: @@ -610,7 +609,7 @@ _DEP_SEVERITY_FROM_CVSS = { def _dependency_severity(advisory_cvss: float | None) -> tuple[float, str]: if advisory_cvss is None: - return 0.0, "medium" + return 0.0, "info" 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): @@ -652,7 +651,7 @@ def _build_dependency_evidence( return evidence -async def _do_create_dependency( +async def _do_create_dependency( # noqa: PLR0912 *, title: str, description: str, @@ -705,7 +704,13 @@ async def _do_create_dependency( 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: + if advisory_cvss is None: + errors.append( + "advisory_cvss is required: read the published advisory base score " + "(0.0-10.0) off the advisory (trivy CVSS / NVD / GHSA). Severity is " + "derived solely from it — do not omit it or the finding cannot be rated." + ) + elif 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: @@ -810,13 +815,13 @@ async def create_dependency_report( cve: str, package_name: str, installed_version: str, + advisory_cvss: float, 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: @@ -864,8 +869,10 @@ async def create_dependency_report( 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). + advisory_cvss: **Required.** Published advisory base score + (0.0-10.0) — read it off the advisory (trivy CVSS / NVD / GHSA). + Severity is derived solely from this score, so it must be the + real published value; do not guess or omit it. technical_analysis: Optional deeper mechanism/root-cause detail. fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high`` (dependency upgrades are usually ``trivial``/``low``). diff --git a/tests/test_report_writer.py b/tests/test_report_writer.py index 35a2d9c1..3ef16ccf 100644 --- a/tests/test_report_writer.py +++ b/tests/test_report_writer.py @@ -79,6 +79,40 @@ def test_render_vulnerability_md_includes_core_sections() -> None: assert "**Endpoint:** /api/login" in md +def test_render_vulnerability_md_includes_dependency_fields() -> None: + md = render_vulnerability_md( + _sample_report( + title="CVE-2021-23337 in lodash 4.17.20", + severity="high", + target="repo/package.json", + endpoint=None, + method=None, + cve="CVE-2021-23337", + cwe="CWE-94", + cvss=7.2, + fix_effort="trivial", + finding_class="dependency_cve", + evidence="**Advisory evidence:** `CVE-2021-23337` applies to `lodash`.", + assumptions="Assumes lodash ships in deployed builds.", + dependency_metadata={ + "package_name": "lodash", + "package_ecosystem": "npm", + "installed_version": "4.17.20", + "fixed_version": "4.17.21", + }, + remediation_steps="Upgrade to 4.17.21.", + ), + ) + assert "**Package:** lodash" in md + assert "**Ecosystem:** npm" in md + assert "**Installed Version:** 4.17.20" in md + assert "**Fixed Version:** 4.17.21" in md + assert "**CWE:** CWE-94" in md + assert "**Fix Effort:** Trivial" in md + assert "## Evidence" in md + assert "## Assumptions" in md + + def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None: reports = [ _sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"), diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 49a63e8d..79fa4e9a 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -192,6 +192,30 @@ async def test_dependency_report_with_zero_cvss_remains_low_severity( assert report["cvss"] == 0.0 +async def test_dependency_report_requires_advisory_cvss(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="Some impact.", + remediation_steps="Upgrade to 1.0.1.", + assumptions="Assumes the package ships in deployed builds.", + package_ecosystem="npm", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=None, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is False + assert any("advisory_cvss is required" in e for e in result["errors"]) + assert not report_state.vulnerability_reports + + async def test_dependency_report_dedupe_candidate_includes_dependency_metadata( report_state: ReportState, monkeypatch: pytest.MonkeyPatch, @@ -535,4 +559,6 @@ def test_vuln_tool_exposes_new_params() -> None: 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"] + dep_required = create_dependency_report.params_json_schema["required"] + assert "package_ecosystem" in dep_required + assert "advisory_cvss" in dep_required