feat(reporting): record transitive dependency chain on SCA findings (#971)

Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-08-04 12:34:52 -07:00
committed by GitHub
co-authored by Ahmed Allam
parent 82dcd31357
commit 657aa5cbe6
7 changed files with 141 additions and 2 deletions
@@ -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"]))
@@ -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"]))
+4
View File
@@ -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
+2
View File
@@ -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")),
+34 -2
View File
@@ -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 <pkg>`, `pnpm why <pkg>`,
`yarn why <pkg>`, `pipdeptree --reverse -p <pkg>`, `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 <vulnerable pkg> to <fixed>".
### Reachability is a confidence modifier, not a gate
Do NOT suppress or downgrade a known CVE just because you could not prove the
+34
View File
@@ -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,
)
+63
View File
@@ -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: