From d76d63be1d1a65d9d34eedaedaf05f574d6558df Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Tue, 4 Aug 2026 18:55:26 +0000 Subject: [PATCH] feat(reporting): record transitive dependency chain on SCA findings --- .../skills/custom/dependency_cve_scanning.md | 36 +++++++++++- strix/tools/reporting/tool.py | 23 ++++++++ tests/test_reporting_fields.py | 55 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) 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..a44d93bc 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 @@ -770,6 +776,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,6 +832,8 @@ 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, @@ -926,6 +936,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 +990,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 +1018,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..85dfb979 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -164,6 +164,61 @@ 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" + ) + + +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: