Reachability analysis is the most effective noise reduction available in dependency scanning. It routinely removes most of a backlog, and unlike severity filtering it removes findings for a defensible reason: your code never calls the affected function.
It is also wrong in specific, knowable ways, and a team that does not know which ways will eventually dismiss something that was reachable after all. This post is the list. Written for whoever owns the triage decision, and it applies whichever tool you use, including ours.
What the analysis actually computes
Build a call graph from your entry points. Walk it. Ask whether any path arrives at the vulnerable symbol in the dependency. If no path exists, the vulnerability cannot be triggered through your code, so it is not exploitable in your application even though the vulnerable package is installed.
That reasoning is sound. Every failure below is a case where the call graph is incomplete, and an incomplete call graph produces a confident "unreachable" rather than an error. That is the important part: the failure mode is silence, not a warning.
One: dynamic dispatch the analyser cannot resolve
Reflection in Java, getattr in Python, require with a computed path in Node, dynamic proxies, dependency injection containers that wire implementations at runtime from configuration.
The call exists. It is simply not written down anywhere the static analysis can see, because the target is a string assembled at runtime. Frameworks built on this pattern, which is most enterprise Java and much of Python, hide a meaningful share of their edges from any static walker.
Practical response: treat findings in packages that are loaded reflectively, or wired by a container, as unresolved rather than unreachable. Most tools expose a confidence level. Use it, and look at anything marked low confidence in a framework-heavy service by hand.
Two: the path runs through a configuration file
A deserialisation gadget that is only reachable when a specific parser is enabled. A template engine feature switched on by a YAML key. A logging appender that becomes dangerous only with a particular layout configured.
The code path is conditional on data, and the data is not code, so the analysis never sees the condition. This is the category that produced the worst of the Log4j triage errors: the vulnerable path depended on configuration and message content, not on whether the application called a particular method.
Practical response: for any finding where the advisory text mentions a configuration option, read the configuration rather than the call graph.
Three: the vulnerability is not in a function at all
Reachability answers "do you call this symbol". Some vulnerabilities have no symbol.
A malicious package executing in a postinstall script runs at install time, before any of your code runs. A dependency confusion attack is about resolution, not invocation. A vulnerable build plugin never ships in your artifact and compromises the artifact anyway. A vulnerability in a transitively loaded native library may be triggered by data rather than by a call you make.
For these the question is meaningless and a tool that answers it is answering a different question than the one that matters. Malware findings in particular should never be reachability filtered. Presence is the finding.
Four: the entry points are wrong
The whole analysis is relative to a set of entry points. Get that set wrong and everything downstream inherits the error.
The common omissions: scheduled jobs and cron entry points, message queue consumers, serverless handlers, CLI subcommands, admin-only routes, test utilities that somehow ship in the production image, and code reachable only in a failure path. Each one is a root the walker never started from, so everything only reachable from it looks dead.
Practical response: get the list of entry points your tool used and read it. If it does not show you that list, that is a meaningful gap. Compare it against your actual deployment: every process you start, every handler you register, every schedule you run.
Five: reachable in a dependency's own graph, not yours
Your code does not call the vulnerable function. Your dependency does, in a path triggered by input you pass it.
Whether the analysis catches this depends on how deep it walks and whether it models the intermediate package's behaviour. Shallow implementations effectively check only your first-party code, which makes every transitive vulnerability look unreachable. That is the single most misleading configuration available, and it is often the default because it is fast.
Practical response: find out the analysis depth. If a tool reports 95 percent of a large dependency tree as unreachable, that is a claim about its depth more than about your code.
What reachability is still worth
All of the above, and it is still the best filter in the stack. The mistake is treating it as a verdict rather than as an ordering.
The framing that survives contact with reality:
- Reachable means work on it now. High confidence, low false positive rate.
- Unreachable means deprioritise, with the reason recorded and a review when the dependency or the entry points change.
- Never "unreachable" as a permanent closure. A refactor that adds one call makes it reachable, and nothing will tell you.
Which means reachability state has to be recomputed per build, not stamped once on a finding and forgotten. A dismissal that outlives the code it was true about is how these tools produce harm.
The concession
Everything here is an argument for reading the confidence signal, and confidence signals are themselves estimates. There is no configuration of any tool that gets you to certainty, because the underlying problem is undecidable in general. Static analysis of a dynamic language with reflection is approximation all the way down.
So the honest claim for reachability is that it reorders the queue extremely well and closes nothing on its own. Vendors who describe it as eliminating findings, including when we describe it that way, are compressing a more careful statement about ordering.
The implication
The value is real and the failure mode is quiet. Those two facts together are why this deserves a page in your triage runbook rather than a setting you enable once.
Keep the five categories above as exceptions in that runbook. When a finding falls into one, it does not get the filter applied, it gets a human. That is a small number of findings a month, and it is the difference between a filter that cuts noise and a filter that cuts findings.