← All guides

Reachability: can your code actually call it

Cross-cutting
On this page

In plain terms

Your project depends on a library. The library has a known vulnerability. A scanner reads your lockfile, matches the version against an advisory database, and reports it.

What the scanner has not told you is whether your code ever calls the vulnerable part. Most libraries are large and most projects use a corner of one. The advisory is about a specific function; you may never call it, and may not call anything that calls it.

Reachability analysis answers that question by building a call graph, every function that calls every other function, starting from the entry points of the code that ships, and checking whether any path arrives at the vulnerable function.

Why it matters

It is the difference between a backlog and a work queue.

A backlog

A dependency scan on a mature project returns hundreds of findings, most of them rated high, almost all of them in transitive dependencies nobody on the team chose directly. Sorted by severity, that list is unusable, because everything is high and so nothing is first.

A work queue

Reachability splits it. The findings your code can reach are a much smaller set, and they are the ones where the severity rating means what it says. The rest are still real, since a future release might call into them and the dependency still needs upgrading, but they are not what you do this morning.

It also makes a gate practical. A pipeline that fails on every high-severity dependency finding gets switched off within a week. One that fails on the findings your code reaches, while still reporting the others, is one a team can live with.

The same advisory, two call graphs Both projects depend on the same version of the same library, so a dependency scanner reports the same finding to each. Following the calls, one project reaches the vulnerable function through its upload handler; the other only ever calls a formatting helper, and no path from its code arrives at the flaw. BOTH DEPEND ON acme-parse 1.2.0, AND THE SCANNER REPORTS BOTH Project A handleUpload() parse.Read() parse.decodeChunk() the vulnerable function Reached, so fix it. Project B renderInvoice() parse.Format() parse.decodeChunk() nothing calls it Not reached, so it waits. One advisory, one version, two answers. Nothing in the manifest tells them apart.
Reachability does not decide whether a finding is real. It decides whether your code can get to it, which is a different question and the one that orders the work.

Where it fits

Reachability is a prioritization signal, not a detection one. The scanner still finds the vulnerability; reachability decides how much of your attention it deserves. It belongs with the other signals that describe the world around a finding rather than the flaw itself:

The signalWhat it asks
EPSS and KEVwhether anyone is exploiting this, anywhere
Exposure and criticalityhow much this component matters, and who can get to it
Reachabilitywhether this code path can run here at all

These pull in different directions and that is the point. A vulnerability being exploited in the wild outranks a call graph's failure to find a path to it, because one is an observation of what is happening and the other is an inference about what could.

How it works

The analyzer loads the packages that are in the build, resolves every call, and walks outward from the entry points. When it reaches a function an advisory names, it records the path it took to get there.

The function comes from the advisory. For Go, the Go vulnerability database lists the exact symbols each vulnerability affects, which is what lets govulncheck look for them; an advisory that names only a package leaves an analyzer nothing more precise than an import to find.

That path is the evidence, and it is what separates a reachability claim worth acting on from a number in a marketing table. A verdict that says "not reachable" and cannot show its working is asking you to take its word for a decision you are accountable for.

Common pitfalls

  • Coverage varies enormously by language. A call graph needs the analyzer to understand how calls are resolved, and that is far easier in a statically typed, statically linked language than in one where any string can become a method name at runtime. Treat "we do reachability" as a question about which languages, not a yes or no.
  • "Reachability" names several different analyses. Some tools follow calls from your entry points to the vulnerable function, and some follow the data as well. Others read a framework's own conventions about which handlers are wired, or check only whether the vulnerable package is imported anywhere in the tree. All four are sold under the same word, and they differ entirely in what a negative is worth, because finding a path is a claim you can go and check while finding none is worth exactly what the analysis could not see. Ask which one produced a verdict before you let it lower anything.
  • Static analysis cannot see dynamic dispatch. Reflection, code generation, plugin loading, a method name assembled from configuration. Each of these is a call the analyzer cannot follow. Reachability is evidence about how the code is called today, not proof that a flaw cannot be triggered.
  • "No answer" is not "not reachable". This is the one that quietly does damage. An analyzer that failed to run, could not parse the project, or never covered a dependency produces the same silence as one that looked and found nothing. If a tool reports two states rather than three, you cannot tell those apart, and the failure mode is a report saying everything is fine.
  • It is true for one revision. A reachability verdict describes the code as it was when it ran. The call that makes something reachable can be written tomorrow, which is why a verdict should carry the date it was computed.
  • Reachable is not the same as exploitable. Your code calling a vulnerable function does not mean an attacker can control the input that reaches it. Reachability narrows the list; it does not finish the analysis.
How Draugr fits. Draugr runs govulncheck, the Go team's analyzer, over the Go modules in your repositories. You enable it beside the setting that ranks findings in the other direction, not in a list of scanners:

config:
  reachability:
    analyzers: [govulncheck]

That placement is deliberate. Every entry in a control's scanner block adds findings; an analyzer named here adds none and ranks findings you already have downward, which can turn a failing gate green. Enabling that should look like what it is, somewhere whoever owns the gate sees it in a diff.

Every Go dependency finding is then marked reachable, unreachable or unknown, and a reachable one carries the call path from your own code to the vulnerable function, in the report and in the SARIF.

Three verdicts, not two, for the reason in the pitfalls above: a dependency Draugr's analyzer never covered is reported as unknown and counted separately, so an analysis that did not run can never be read as one that found nothing. What lowers a band is how the analyzer decided, not that it decided. govulncheck follows the call graph, so its unreachable verdict is treated as evidence that the vulnerable code does not run. A verdict reached from a framework's routing conventions, or from whether a package is imported anywhere, is shown under the finding and moves nothing, because that call belongs to the person who has to defend the band. Draugr decides this in one place, so an analyzer added later is held to the strict reading until somebody classifies it.

A verdict ranks a finding; it never removes one. An unreachable finding a call graph produced drops one priority band and stays in the report, and the severity its scanner gave it is carried unchanged into report.json and the SARIF, because suppressing a finding in Draugr records that a person decided, in a commit that names them, and an inference is not a decision. Where KEV lists a finding, or EPSS has raised one, that wins. See reachability for Go in the docs.

The run says what the analysis decided, beside everything else that moved a ranking:

SIGNALS
  KEV           2 findings listed · 2 raised
  EPSS          3 findings raised
  malicious     nothing raised
  deprecated    12 findings raised
  floor         nothing raised
  reachability  govulncheck · 2 reachable, 3 unreachable

And a finding whose band moved accounts for itself, the same way one raised by KEV or EPSS does:

  P1        high      CVE-2022-32149         trivy     checkout/go.mod:5            golang.org/x/text v0.3.0 → 0.3.8
            reachable: main → ListenAndServe → ListenAndServe → Serve → serve → ServeHTTP → ServeHTTP → ServeHTTP → handler → preferred → ParseAcceptLanguage (govulncheck, 2026-09-12)
            golang: golang.org/x/text/language: ParseAcceptLanguage takes a long time to parse complex tags
  P2        medium    CVE-2020-14040         trivy     checkout/go.mod:5            golang.org/x/text v0.3.0 → 0.3.3
            ↓ unreachable · possibility to trigger an infinite loop in… · govulncheck, 2026-09-12

Trivy rates both of those high. The Severity column shows the rating the band was computed from, so the unreachable one reads one step lower, and what the scanner itself reported is carried unchanged into report.json and the SARIF, because that is what it claimed.

Where the analyzer decided nothing at all, because there was no Go module to load, the row says so rather than reporting a pair of zeros that reads as an analysis which ran and found nothing:

SIGNALS
  reachability  govulncheck · no go.mod found, so nothing here carries a verdict

Reachability in Draugr covers Go. For every other ecosystem the sca control reports the package-level answer, which is the honest one until an analyzer has looked.

Keep learning