reachability-analyzer

Runs dead-dependency analysis across JS, Python, and Rust projects using ecosystem-native static tools (depcheck/knip for JS, vulture for Python, cargo-machete for Rust), then cross-references the unused-dependency list against SCA findings to downrank vulns in code that is never loaded. Use when SCA output (from osv-scanner, snyk-test, or npm-pip-maven-audit) is too noisy to triage and the team needs to separate unreachable CVEs from exploitable ones before sprint planning; sibling cve-exploitability-triage ranks by EPSS/KEV exploitation signal, not code reachability.

/plugin install qa-security-scanning@testland-qa

Overview

Static vulnerability scanners report a CVE for every declared dependency that matches a vulnerable version, regardless of whether the vulnerable code is ever executed. A critical-severity finding in a test-only helper that your production build tree never reaches is a different risk level than the same CVE in a hot-path dependency. This skill operationalizes a reachability heuristic: run an ecosystem-native dead-dependency tool, produce an unused-deps.txt artifact, and feed that list into your SCA prioritization step so it can assign reachable: false and route affected findings to Fix-Backlog instead of Fix-Now.

The approach is static-analysis heuristic only. A dependency flagged unused by these tools is a strong signal to deprioritize its CVEs, not a guarantee that the vulnerable code is unreachable at runtime. Treat it accordingly.

When to use

  • After any SCA scan produces more findings than the team can triage in one sprint and you need a principled filter.
  • Before feeding findings into SCA prioritization so the reachable field is populated.
  • During dependency cleanup: the same tooling surfaces dead weight (unused packages that add attack surface and install time with no benefit).
  • When a security reviewer asks "is this CVE in something we actually use?"

How to use

  1. Run SCA first and save the findings JSON - this skill annotates existing findings, it does not replace them. See osv-scanner (or snyk-test / npm-pip-maven-audit) for full setup:

    osv-scanner scan -r . --format json --output osv.json
    
  2. Run the ecosystem-native dead-dependency tool for your stack, then extract the unused names into unused-deps.txt. Full config, flags, exit codes, and suppression per tool: references/dead-dependency-tools.md:

    # JS/TS - knip (preferred; depcheck was archived June 2025)
    npx knip --reporter json | jq -r '.dependencies[].name' > unused-deps.txt
    # Python - vulture; --min-confidence 90 targets unused imports
    vulture src/ --min-confidence 90 > vulture-report.txt
    # Rust - cargo-machete; --with-metadata catches renamed/feature-gated crates
    cargo machete 2>&1 | grep "unused dependency" | awk '{print $NF}' > unused-deps.txt
    
  3. Keep production and dev unused-dep lists separate: dev deps with CVEs can still reach production in bundled builds.

  4. Cross-reference unused-deps.txt against the SCA JSON, setting reachable: false on every finding whose package is unused (script below).

  5. Verify: assert unused-deps.txt is non-empty and every finding in the annotated JSON carries a populated reachable field before handoff. If the file is empty or a finding lacks the field, re-run the dead-dependency tool (a silent tool failure or the wrong workspace root is the usual cause) and re-annotate before proceeding.

  6. Pass the annotated JSON to your SCA prioritization step; reachable: false routes to Fix-Backlog unless the CVE is in the CISA KEV catalog.

  7. Wire steps 2-4 into CI so the annotated artifact feeds the prioritization job on every run, and still schedule each unused package for cleanup.

Cross-reference unused deps with SCA findings

Once you have an unused-deps.txt for your ecosystem, annotate the SCA JSON output before passing it to your SCA prioritization step:

import json

with open("osv.json") as f:
    findings = json.load(f)

with open("unused-deps.txt") as f:
    unused = {line.strip() for line in f}

for finding in findings.get("results", []):
    pkg = finding.get("package", {}).get("name", "")
    finding["reachable"] = pkg not in unused

with open("osv-annotated.json", "w") as f:
    json.dump(findings, f, indent=2)

Pass osv-annotated.json to your SCA prioritization step. Per the priority logic, findings with reachable: false route to Fix-Backlog regardless of severity, unless they are in the CISA KEV catalog.

CI integration

jobs:
  reachability:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: JS unused deps (knip)
        run: npx knip --reporter json | jq -r '.dependencies[].name' > unused-deps.txt
      - name: Annotate OSV output
        run: python3 ci/annotate-reachability.py osv.json unused-deps.txt
      - uses: actions/upload-artifact@v4
        with:
          name: osv-annotated
          path: osv-annotated.json

The annotated artifact is then consumed by the prioritization job.

Worked example

A team's OSV scan of a Next.js service returns 60 CVEs - too many to patch in one sprint. They run the JS dead-dependency tool (step 2), which flags 12 of the 80 declared dependencies as unused, including moment (CVE-2022-31129, CVSS 7.5). The cross-reference script sets reachable: false on the moment finding because moment appears in unused-deps.txt. Prioritization routes that CVE to Fix-Backlog instead of Fix-This-Sprint, and the team removes moment in the next dependency-cleanup PR, eliminating both the CVE and the unused weight in one change.

Anti-patterns

Anti-patternWhy it failsFix
Skip reachability; sort SCA output by CVSS onlyTeams spend sprints patching vulns in unused code while exploitable issues sit in Fix-BacklogRun the cross-reference step before triage
Treat reachable: false as safeStatic heuristic; dynamic imports and plugin systems can load code at runtimeUse as deprioritization signal only; still schedule Fix-Backlog cleanup
Run vulture at default confidence (60%)Reports unused functions/variables alongside imports; noisy for CVE mappingUse --min-confidence 90 to target imports specifically
Use depcheck on new JS projectsArchived June 2025; per github.com/depcheck/depcheck (opens in new window), maintainers recommend knipSwitch to knip for new setups
Skip --with-metadata in Rust monoreposRenamed crates not detected; false "used" verdictsAlways pass --with-metadata in CI
Ignore devDependencies entirelyDev deps with CVEs can reach production in bundled buildsSeparate prod/dev lists; confirm --omit=dev in audit scope

Limitations

  • All three tools use static analysis only. Dynamic imports (require(variable) in JS, importlib.import_module in Python) and compile-time proc macros in Rust can defeat detection, producing false "unused" verdicts.
  • Import-to-package mapping for Python requires manual maintenance when import names differ from PyPI package names.
  • Monorepo setups may require per-workspace runs; a dependency unused in one package may be used in another.
  • cargo-machete per github.com/bnjbvr/cargo-machete (opens in new window) is described as "fast yet imprecise" - the trade-off is speed over recall.
  • These tools detect unused declarations; they do not trace call graphs to the vulnerable function within a used package. For function-level reachability, runtime instrumentation (e.g. Snyk's reachability analysis, CodeTF-based call-graph tools) is required.

References

Reference

Dead-dependency tools by ecosystem

View source (opens in a new tab)

Per-ecosystem detail for producing unused-deps.txt: config files, flags, exit codes, and false-positive suppression. Run the tool for your ecosystem, then extract the unused names for the SCA cross-reference step.

JavaScript / TypeScript: knip (preferred) or depcheck

knip is the current recommended tool for unused-dependency detection in JS projects. Per github.com/webpro-nl/knip (opens in new window), it detects unused files, exports, and dependencies including dev-only packages.

npx knip --reporter json > knip-report.json
# Extract unused dependency names for the cross-reference list
npx knip --reporter json | jq -r '.dependencies[].name' > unused-deps.txt

depcheck remains usable for existing setups. Per github.com/depcheck/depcheck (opens in new window), the project was archived in June 2025; the maintainers recommend switching to knip for new setups. For teams still on depcheck:

npx depcheck --json > depcheck-report.json
# Extract unused package names
jq -r '.dependencies[],.devDependencies[]' depcheck-report.json > unused-deps.txt

Per github.com/depcheck/depcheck (opens in new window), the --json flag produces an object with dependencies (unused production deps) and devDependencies (unused dev deps) as arrays of package-name strings. Packages in devDependencies that never appear in the production dep tree are prime candidates for reachable: false annotation on any CVEs they carry.

Suppress known false positives via .depcheckrc:

# .depcheckrc
ignores: ["babel-register", "eslint-*"]
ignore-patterns: ["dist", "coverage"]

Scope narrowing: dev vs production

Dev dependencies in non-production scope are already excluded from many scanners. Per github.com/depcheck/depcheck (opens in new window), running npm audit --omit=dev skips devDependencies entirely. Always separate production and dev unused-dep lists:

# Separate production unused from dev unused
jq -r '.dependencies[]' depcheck-report.json > unused-prod.txt
jq -r '.devDependencies[]' depcheck-report.json > unused-dev.txt

Python: vulture

Per github.com/jendrikseipp/vulture (opens in new window), vulture detects unused imports, functions, classes, and variables through static analysis. For dependency reachability, unused imports are the direct signal.

pip install vulture
vulture src/ --min-confidence 90 > vulture-report.txt

Per github.com/jendrikseipp/vulture (opens in new window), --min-confidence 90 targets imports specifically (confidence 90% for unused imports). Lower values include functions and variables, which is noisier for the dep-CVE cross-reference task.

Extract the unused-import lines:

grep "unused import" vulture-report.txt | sed "s/:.*unused import '\(.*\)'.*/\1/" > unused-imports.txt

Map import names back to pip package names using pip show or a manually maintained import-to-package.txt map, since Python import names often differ from PyPI package names (e.g. import PIL comes from Pillow).

Configure via pyproject.toml per github.com/jendrikseipp/vulture (opens in new window):

[tool.vulture]
paths = ["src/"]
min_confidence = 90
exclude = ["tests/", "migrations/"]
ignore_names = ["celery_app", "urlpatterns"]

Suppress known false positives (e.g. plugin registrations, dynamic imports) by generating a whitelist:

vulture src/ --make-whitelist > whitelist.py
# Then re-run including the whitelist
vulture src/ whitelist.py --min-confidence 90

Per github.com/jendrikseipp/vulture (opens in new window), exit code 3 means dead code found; 0 means clean.

Rust: cargo-machete

Per github.com/bnjbvr/cargo-machete (opens in new window), cargo-machete detects unused [dependencies] in Cargo.toml through fast static analysis.

cargo install cargo-machete
cargo machete

Per github.com/bnjbvr/cargo-machete (opens in new window), exit code 0 means no unused dependencies found; exit code 1 means at least one unused dependency was detected; exit code 2 signals a processing error.

For more accurate detection when crates use renamed or feature-gated imports, use --with-metadata:

cargo machete --with-metadata

Per github.com/bnjbvr/cargo-machete (opens in new window), --with-metadata calls cargo metadata --all-features to resolve final dependency names, which catches renames that simple text search misses.

Suppress false positives (e.g. proc-macro crates loaded at compile time only) via Cargo.toml metadata per github.com/bnjbvr/cargo-machete (opens in new window):

[package.metadata.cargo-machete]
ignored = ["prost", "openssl"]

[package.metadata.cargo-machete.renamed]
rustls-webpki = "webpki"

Capture the unused-dep list:

cargo machete 2>&1 | grep "unused dependency" | awk '{print $NF}' > unused-deps.txt