tty/0·uid=audit gid=audit
blog.reachability_analysis()

Reachability analysis: why most of your dependency CVEs don't matter

July 28, 2026 · 9 min read

run any dependency scanner against a real-world repo and you'll get a wall of CVEs — often hundreds on a mature codebase. teams learn to skim it, then ignore it, then get paged when one of those hundreds turns out to matter. the scanner wasn't wrong about any individual finding. it was wrong about what a lockfile entry means.

## a lockfile is not an attack surface

traditional dependency scanning works off the manifest: parse the lockfile, look up every package@version against a CVE database (nvd, osv, ghsa), report matches. it's fast and it's complete — every known vulnerability in every resolved package gets flagged.

the problem is that resolved and used are different things. a typical node.js project pulls in hundreds of transitive dependencies for a handful of direct ones. python projects carry dev-only extras. a CVE in a package deep in your dependency tree that nothing in your code ever imports cannot be exploited through your application — there's no code path that reaches it. it's still a real CVE. it's just not your problem this week, and a report that can't tell you which is which trains people to stop reading it.

## what reachability analysis actually checks

reachability analysis answers one narrow, useful question: does any code in this repository import the vulnerable package at all? it's static analysis, not dynamic — no code runs. the mechanics vary by ecosystem but the shape is consistent:

  • +python — parse import/from statements across the source tree, resolve them against the dependency graph from requirements.txt, pyproject.toml, or poetry/pipenv lockfiles
  • +javascript/typescript — walk import/require statements (including re-exports) against package.json and the lockfile's resolved tree
  • +go — cross-reference go.mod requires against actual package imports, since go's module system already prunes a lot of this at build time
  • +ruby — match require/require_relative calls against Gemfile.lock
  • +java/kotlin — resolve import statements against maven/gradle dependency trees, which is the noisiest case given how deep enterprise dependency trees typically run

a package with zero matching imports anywhere in the source tree is unreachable. that doesn't delete the finding — it demotes it. severity drops a rung, it stops occupying a slot in the top-of-queue list, and it stays fully visible and reversible on demand. the goal is triage, not suppression.

## package-level vs. function-level reachability

it's worth being honest about the limits, because overclaiming here is how security tooling loses trust. what the previous section describes is package-level reachability — it answers "is this package imported at all." it does not answer the stronger question, function-level reachability: does an actual call path reach the specific vulnerable function, with attacker-influenced data flowing into it? that requires a call graph and often taint tracking, and it runs into real limits fast:

  • +dynamic imports
    importlib.import_module(name)
    -style loading where the module name is computed at runtime is invisible to static import parsing
  • +reflection and eval — java reflection, ruby's
    send
    , javascript's
    eval
    — all can reach code no static graph sees
  • +plugin/middleware architectures — frameworks that load handlers by convention (file path, decorator registry) rather than explicit import obscure the real call graph
  • +partial reachability — a package can be imported for one harmless function while the vulnerable function in the same package is never called; package-level analysis can't see that far

the honest framing: package-level reachability is a high-confidence noise filter, not a soundness proof. it will essentially never mark something reachable when it isn't (false negatives on the safe side), but it can occasionally miss a genuinely exploitable path reached only through dynamic loading. that asymmetry is exactly why the output should be a demotion with a visible toggle, not a silent deletion — the deeper call-path analysis that would close this gap is a meaningfully larger project (real call graphs, taint tracking across ecosystem-specific frameworks) and most teams get more value from a fast, conservative filter today than a slow, complete one eventually.

## what to do with the "unreachable" pile

don't delete it, and don't ignore it forever either. a sane workflow:

  • +let reachable CVEs drive this week's fix queue — that's the actionable set
  • +batch unreachable CVEs into a periodic dependency-hygiene pass (monthly is usually enough) rather than a per-scan fire drill
  • +if a package shows up unreachable but you know it's loaded dynamically somewhere (plugin registries, dependency-injection containers), flag that pattern once — it's a systemic blind spot worth a manual note, not a per-CVE re-review
  • +re-check reachability whenever you actually start using a previously-unused package — the demotion is a snapshot of current usage, not a permanent verdict

## how virgil applies this

virgil runs reachability analysis as one stage in its audit pipeline — after normalizing trivy's dependency findings, before ranking. unreachable CVEs drop a severity rung and collapse out of the default view, with a one-click toggle to see them and a note on why each was demoted. it's combined with finding clustering (grouping duplicate findings by shared root cause) so the queue you actually read is short enough to act on. the full pipeline is on the reachability docs page.

or just run it: paste a repo url, get a ranked triage in about three minutes, 3 free audits on a new account.