Reachability analysis in software composition analysis (SCA) is a static (and sometimes dynamic) technique that determines whether the vulnerable function inside a third-party dependency is actually invoked by your application's code, rather than merely present in your dependency tree. A vanilla SCA scanner matches package versions against a CVE database and stops there. Reachability goes one step further: it builds a call graph from your entry points down into your dependencies and asks whether execution can ever arrive at the specific function the CVE describes. If it can't, the finding is almost certainly not exploitable in your context.
That distinction matters because most flagged vulnerabilities are never reachable. Industry measurements from Datadog, Endor Labs, and academic work on Maven ecosystems consistently land in the same range: somewhere between 70% and 90% of version-matched findings involve code your application never calls.
How a scanner decides a package is "vulnerable"
Classic SCA is manifest matching. The scanner reads package-lock.json, pom.xml, go.sum, or requirements.txt, resolves the full transitive tree, and compares each coordinate-plus-version against advisories from NVD, GitHub Security Advisories, and OSV. If com.fasterxml.jackson.core:jackson-databind:2.13.2 appears anywhere in the tree and there's a CVE affecting that version range, you get an alert.
This is package-level matching, and it produces the notorious backlog. A mid-sized Java service with 250 transitive dependencies will routinely show 300–500 open findings, most of them in libraries pulled in three levels deep by a framework you never touch directly.
What reachability adds: the call graph
A reachability engine parses your source (or bytecode, for JVM languages) and constructs a directed graph: nodes are functions, edges are calls. It then does two things:
- Maps the CVE to specific vulnerable symbols. For CVE-2022-42003 in jackson-databind, the vulnerable code lives in
UNWRAP_SINGLE_VALUE_ARRAYShandling inside the deserializer paths — not in the entire 800-class library. - Runs a graph search from your application's entry points (HTTP handlers,
main, scheduled jobs, message consumers) to see whether any path terminates in those symbols.
If no path exists, the finding gets marked unreachable and drops to the bottom of the queue. If a path exists, you get the trace: OrderController.parse() → ObjectMapper.readValue() → BeanDeserializer.deserialize(). That trace is gold during triage because it tells the owning team exactly which code to look at.
The precision difference between package-level and function-level analysis is significant enough that we wrote a separate breakdown of it in function-level vs package-level reachability.
A concrete example: lodash prototype pollution
CVE-2019-10744 affected lodash versions before 4.17.12 via the defaultsDeep function. Practically every JavaScript codebase on earth had a flagged lodash somewhere in its tree at the time. But the vulnerability only fires if attacker-controlled input flows into defaultsDeep, merge, or a handful of related functions.
A reachability-aware scan asks: does your code, or any code on a path from your code, call _.defaultsDeep? For most projects the answer was no — they used _.get, _.debounce, and _.cloneDeep and nothing else. Package-level tooling said "critical, fix now." Function-level analysis said "you don't call the vulnerable API; batch this into your next routine upgrade." Both statements are true; only one is useful for scheduling work.
Where reachability analysis breaks down
Nobody sells this honestly often enough, so here it is:
| Challenge | Why it's hard | Typical handling |
|---|---|---|
| Reflection and dynamic dispatch | Class.forName(userInput) defeats static call graphs | Over-approximate: assume reachable |
| Dynamic languages | Python's getattr, JS's computed property access | Heuristic edges, lower confidence scores |
| Deserialization gadget chains | The "call" happens inside the deserializer, driven by data | Treat deserialization sinks as entry points |
| Missing vulnerable-symbol data | Many CVEs never specify affected functions | Fall back to package-level (reachable by default) |
| Framework magic | Spring proxies, dependency injection, annotations | Framework-specific modeling, imperfect |
The correct posture is that unreachable means "strong evidence of non-exploitability," not proof. Serious tools express this as a confidence score and never silently delete findings — they reorder them. When a vendor claims 95% noise reduction with no caveats about reflection or gadget chains, ask harder questions.
Putting it into a pipeline
Reachability is most useful at two points:
- PR-time gating. Fail the build only on reachable critical/high findings. This keeps the gate strict without training developers to ignore it. A gate that blocks on unreachable transitive findings gets an exemption process within a month, and then the exemption process becomes the real policy.
- Backlog triage. Re-rank the existing pile. Teams that adopt function-level analysis typically watch their "must fix this sprint" list shrink from hundreds to a few dozen, which is small enough to actually finish.
Most commercial SCA products now offer some form of this — Safeguard's SCA does function-level reachability across JVM, Node, Python, and Go, and it's one of the main axes in our comparison against Snyk. Whatever tool you use, insist on seeing the call trace for a "reachable" verdict. A boolean with no evidence is just a different flavor of noise.
One operational note: reachability results are per-application, not per-package. The same jackson-databind version can be reachable in your API service and unreachable in your batch worker. Store verdicts against the deploying artifact, not the library.
Frequently asked questions
Does reachability analysis replace fixing vulnerabilities?
No. It changes the order and urgency, not the destination. Unreachable findings still get fixed through routine dependency upgrades; they just stop interrupting sprints. New code can make a previously unreachable function reachable, so verdicts must be recomputed on every build.
Is reachability analysis static or dynamic?
Usually static: the call graph is computed from source or bytecode without running the app. Some tools add runtime instrumentation (sometimes marketed as "runtime SCA") that observes which classes actually load in production. Runtime data is higher-confidence but only covers code paths that executed during the observation window.
How accurate is it for JavaScript and Python?
Meaningfully less precise than for Java or Go, because dynamic dispatch and monkey-patching create call edges a static analyzer can't see. Good tools compensate by over-approximating — when in doubt, mark it reachable — so the errors skew toward false positives rather than missed exploitable paths.
Does an unreachable finding still matter for compliance?
Often yes. Frameworks like PCI DSS and customer security questionnaires generally ask about known vulnerabilities in shipped software, not reachable ones. The clean way to handle this is documenting unreachable findings as assessed-and-deprioritized with the call-graph evidence attached, rather than pretending they don't exist.