diff --git a/strix/interface/tui/internal/app/vuln_report.go b/strix/interface/tui/internal/app/vuln_report.go index b7749805..2f460926 100644 --- a/strix/interface/tui/internal/app/vuln_report.go +++ b/strix/interface/tui/internal/app/vuln_report.go @@ -74,6 +74,8 @@ func vulnerabilityMarkdownReport(v map[string]any) string { field("Ecosystem", render.StringValue(dep["package_ecosystem"])) field("Installed Version", render.StringValue(dep["installed_version"])) field("Fixed Version", render.StringValue(dep["fixed_version"])) + field("Introduced By", render.StringValue(dep["introduced_by"])) + field("Dependency Chain", render.StringValue(dep["dependency_path"])) } field("Endpoint", render.StringValue(v["endpoint"])) field("Method", render.StringValue(v["method"])) diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index 12b057d5..0d4f23b4 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -298,6 +298,8 @@ func vulnerabilityBody(v map[string]any) string { field("Ecosystem", render.StringValue(dep["package_ecosystem"])) field("Installed Version", render.StringValue(dep["installed_version"])) field("Fixed Version", render.StringValue(dep["fixed_version"])) + field("Introduced By", render.StringValue(dep["introduced_by"])) + field("Dependency Chain", render.StringValue(dep["dependency_path"])) } field("Endpoint", render.StringValue(v["endpoint"])) field("Method", render.StringValue(v["method"])) diff --git a/strix/report/sarif.py b/strix/report/sarif.py index 6d5db177..fc6e05db 100644 --- a/strix/report/sarif.py +++ b/strix/report/sarif.py @@ -531,6 +531,10 @@ def _result_properties( if value not in (None, ""): strix[key] = value + dependency_metadata = report.get("dependency_metadata") + if isinstance(dependency_metadata, dict) and dependency_metadata: + strix["dependency_metadata"] = dependency_metadata + # SARIF is written for external upload (code-scanning / ASPM), so it must # NOT carry the weaponized exploit payload — that stays a local run # artifact (vulnerabilities.json / the finding MD). We surface the PoC diff --git a/strix/report/writer.py b/strix/report/writer.py index 1b0a8a1d..ec592f14 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -205,6 +205,8 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL ("Ecosystem", dep_meta.get("package_ecosystem")), ("Installed Version", dep_meta.get("installed_version")), ("Fixed Version", dep_meta.get("fixed_version")), + ("Introduced By", dep_meta.get("introduced_by")), + ("Dependency Chain", dep_meta.get("dependency_path")), ("Endpoint", report.get("endpoint")), ("Method", report.get("method")), ("CVE", report.get("cve")), diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 33ecf8c3..448af0e8 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -39,9 +39,11 @@ trivy version --format json 2>/dev/null | tee "$ART/trivy-version.json" # 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 \ +# --list-all-pkgs includes the package graph (Relationship + DependsOn) needed +# to attribute transitive CVEs to the direct dependency that introduces them. +trivy fs --scanners vuln --timeout 30m --offline-scan --list-all-pkgs \ --format json --output "$ART/trivy-sca.json" . \ - || trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update \ + || trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update --list-all-pkgs \ --format json --output "$ART/trivy-sca.json" . \ || true ``` @@ -78,6 +80,36 @@ For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one `create_dependency_report` per CVE — do not batch multiple CVEs into one report. +### Attribute transitive CVEs to the direct dependency + +With `--list-all-pkgs`, each `.Results[].Packages[]` entry carries `ID` +(`name@version`), `Relationship` (`direct` / `indirect`) and `DependsOn` (the +`ID`s it resolves to). For every vulnerable package that is **indirect**, walk +the `DependsOn` graph backwards to find the `direct` package(s) whose closure +contains it, then pass to `create_dependency_report`: + +- `introduced_by` — the direct dependency as `name@version` (e.g. + `express@4.18.1`). If several direct dependencies pull it in, pick the + primary one and name the rest in `technical_analysis`. +- `dependency_path` — the shortest resolution chain from that direct + dependency to the vulnerable package, joined with ` > ` (e.g. + `express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`). +- Omit both when the vulnerable package is itself a direct dependency. + +If the ecosystem's lockfile gives trivy no graph (`DependsOn` absent), derive +the chain from the package manager instead (`npm ls `, `pnpm why `, +`yarn why `, `pipdeptree --reverse -p `, `go mod graph`, +`mvn dependency:tree`, ...) — and if that also fails, leave the fields out +rather than guessing. + +For transitive findings, `remediation_steps` must be actionable at the +**direct-dependency level**: upgrading the vulnerable package directly is +usually impossible from the app's own manifest. Say which direct dependency to +bump (a version whose closure resolves the fixed version), or how to force the +resolution (npm `overrides` / yarn `resolutions` / pnpm `pnpm.overrides` / +Maven `dependencyManagement` / Gradle resolution strategy / `go mod edit`), +not just "upgrade to ". + ### Reachability is a confidence modifier, not a gate Do NOT suppress or downgrade a known CVE just because you could not prove the diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 598def57..a1425315 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -725,6 +725,8 @@ def _build_dependency_metadata( installed_version: str, package_ecosystem: str | None, fixed_version: str | None, + introduced_by: str | None, + dependency_path: str | None, ) -> dict[str, str]: metadata = { "package_name": package_name.strip(), @@ -734,6 +736,10 @@ def _build_dependency_metadata( metadata["package_ecosystem"] = package_ecosystem.strip() if fixed_version and fixed_version.strip(): metadata["fixed_version"] = fixed_version.strip() + if introduced_by and introduced_by.strip(): + metadata["introduced_by"] = introduced_by.strip() + if dependency_path and dependency_path.strip(): + metadata["dependency_path"] = dependency_path.strip() return metadata @@ -743,6 +749,8 @@ def _build_dependency_evidence( package_name: str, installed_version: str, fixed_version: str | None, + introduced_by: str | None, + dependency_path: str | None, ) -> str: evidence = ( f"**Advisory evidence:** `{cve}` applies to `{package_name}` " @@ -750,6 +758,13 @@ def _build_dependency_evidence( ) if fixed_version and fixed_version.strip(): evidence += f" The advisory is fixed in `{fixed_version.strip()}`." + if introduced_by and introduced_by.strip(): + evidence += ( + f"\n\n**Transitive dependency:** introduced by the direct " + f"dependency `{introduced_by.strip()}`." + ) + if dependency_path and dependency_path.strip(): + evidence += f"\n\n**Dependency chain:** `{dependency_path.strip()}`" return evidence @@ -770,6 +785,8 @@ async def _do_create_dependency( # noqa: PLR0912 advisory_cvss: float | None, technical_analysis: str | None, fix_effort: str, + introduced_by: str | None = None, + dependency_path: str | None = None, agent_id: str | None = None, agent_name: str | None = None, ) -> dict[str, Any]: @@ -824,12 +841,16 @@ async def _do_create_dependency( # noqa: PLR0912 installed_version=installed_version, package_ecosystem=package_ecosystem, fixed_version=fixed_version, + introduced_by=introduced_by, + dependency_path=dependency_path, ) evidence = _build_dependency_evidence( cve=parsed_cve, package_name=package_name.strip(), installed_version=installed_version.strip(), fixed_version=fixed_version, + introduced_by=introduced_by, + dependency_path=dependency_path, ) try: @@ -926,6 +947,8 @@ async def create_dependency_report( cwe: str | None = None, technical_analysis: str | None = None, fix_effort: str = "low", + introduced_by: str | None = None, + dependency_path: str | None = None, ) -> str: """File a known-CVE dependency (SCA) finding — one report per CVE x package. @@ -978,6 +1001,15 @@ async def create_dependency_report( technical_analysis: Optional deeper mechanism/root-cause detail. fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high`` (dependency upgrades are usually ``trivial``/``low``). + introduced_by: For a **transitive** dependency, the direct + dependency (from the project's own manifest) that pulls the + vulnerable package in, as ``name@version`` (e.g. + ``express@4.18.1``). Omit when the vulnerable package is + itself a direct dependency. + dependency_path: The resolution chain from the direct dependency + to the vulnerable package, joined with `` > `` (e.g. + ``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit + for direct dependencies. """ agent_id, agent_name = _caller_identity(ctx) @@ -997,6 +1029,8 @@ async def create_dependency_report( advisory_cvss=advisory_cvss, technical_analysis=technical_analysis, fix_effort=fix_effort, + introduced_by=introduced_by, + dependency_path=dependency_path, agent_id=agent_id, agent_name=agent_name, ) diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 79fa4e9a..7bef970b 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -164,6 +164,69 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta } +async def test_dependency_report_records_transitive_chain(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2022-24999 in qs 6.10.2", + description="Prototype pollution in qs parsing.", + target="repo/package.json", + cve="CVE-2022-24999", + package_name="qs", + installed_version="6.10.2", + impact="Denial of service via crafted query strings.", + remediation_steps="Upgrade express to 4.18.2, which resolves qs 6.11.0.", + assumptions="qs parses all incoming query strings by default.", + package_ecosystem="npm", + fixed_version="6.10.3", + cwe="CWE-1321", + advisory_cvss=7.5, + technical_analysis=None, + fix_effort="trivial", + introduced_by="express@4.18.1", + dependency_path="express@4.18.1 > body-parser@1.20.0 > qs@6.10.2", + ) + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert report["dependency_metadata"]["introduced_by"] == "express@4.18.1" + assert ( + report["dependency_metadata"]["dependency_path"] + == "express@4.18.1 > body-parser@1.20.0 > qs@6.10.2" + ) + assert ( + "**Transitive dependency:** introduced by the direct dependency `express@4.18.1`." + in report["evidence"] + ) + assert ( + "**Dependency chain:** `express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`" + in report["evidence"] + ) + + +async def test_dependency_report_omits_blank_chain_fields(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="Impact.", + remediation_steps="Upgrade.", + assumptions="Assumptions.", + package_ecosystem="npm", + fixed_version=None, + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="trivial", + introduced_by=" ", + dependency_path=None, + ) + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert "introduced_by" not in report["dependency_metadata"] + assert "dependency_path" not in report["dependency_metadata"] + + async def test_dependency_report_with_zero_cvss_remains_low_severity( report_state: ReportState, ) -> None: