Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
In application security, reachability analysis determines whether a known vulnerability in your code or dependencies can actually be triggered in your application. It checks whether the vulnerable code is imported, whether it's called, and whether untrusted input can reach it. It's one of the strongest ways to cut dependency-scanner noise, because a CVE usually affects one function and your code may never call it.
It also has real limits. Static analysis can't see reflection or dynamic imports, advisories often don't name the vulnerable function, and "not reachable today" doesn't mean "safe forever." Unreached is not the same as false.
This guide covers the five levels of reachability, how the analysis works step by step, and where its verdicts break down.
Reachability analysis answers one question about a finding: can execution get from a place an attacker controls to the vulnerable code? If it can't, the vulnerability exists but can't be exploited in this application as it's built and deployed today. For the short definition, see the reachability analysis glossary entry.
It's most often used in software composition analysis (SCA), where a dependency scanner flags every package version with a known CVE. SCA reachability analysis filters that list down to the reachable vulnerabilities, the ones your code actually exercises.
A dependency with a known CVE isn't the same as a vulnerable application. A package can contain dozens of functions, and a CVE usually affects one of them.
Go's own tooling is built on this idea. The Go team's govulncheck "only surfaces vulnerabilities that actually affect you, based on which functions in your code are transitively calling vulnerable functions," not every vulnerable module in your dependency graph.
The effect on a real queue can be large. Take one Go 1.24.1 standard library finding that Nullify triaged. It bundled 32 CVEs, and its severity label was the maximum across all of them, so it paged as Critical. Per-CVE reachability split the bundle like this:
Every Critical CVE in the bundle sat on an encoding/asn1 code path the application never entered. The worst vulnerability it actually reached was a Medium-severity bug in net/http, so the finding landed at Medium. Without reachability, someone would have spent hours reaching that conclusion by hand, for a Critical that was never critical.
"Reachable" means different things in different tools. When you compare tools or read a verdict, check which level it describes.
| Level | Question | How it's answered | Cost | Typical blind spot |
|---|---|---|---|---|
| 1. Dependency | Is the package imported or loaded at all? | Parse imports and manifests | Low | Dynamic imports, plugin loaders |
| 2. Function | Is the vulnerable function called? | Call graph from your code into the dependency | Medium | Reflection, interfaces, callbacks |
| 3. Data flow | Does untrusted input reach the vulnerable call? | Taint tracking from sources to sinks | High | Custom sanitizers, framework wiring |
| 4. Deployment | Does the vulnerable artifact actually ship and run? | Match build artifacts to running workloads | Medium | Out-of-date tags, shared base images |
| 5. Exposure | Can an attacker reach the running workload? | Trace network paths, load balancers, auth | High | Internal paths that are more exposed than they look |
Levels 1 to 3 are about code. Levels 4 and 5 are about where the code runs.
A finding can be reachable in code and still unexploitable because the image never ships. It can also be unreachable at level 2 and still risky because a plugin loads it at runtime.
Most static reachability engines follow the same six steps, whatever the language.
Resolve the dependency tree from lockfiles and manifests, then extract the imports your source actually contains. This alone settles many findings. If a package isn't imported anywhere, none of its CVEs can be reached through your code.
Read the advisory to find which function, method or class is vulnerable. Good advisory databases record this directly: the Go vulnerability database reviews each entry's "package and symbol information." When the advisory doesn't name a symbol, the engine can fall back to the upstream fix commit and look at which functions it changed.
Map which functions call which, starting from your entry points: HTTP handlers, CLI commands, message consumers and scheduled jobs. Follow those calls through your code and into the dependency.
Look for any path from an entry point to the vulnerable symbol. For data-flow reachability, also check whether attacker-controlled data travels along that path and whether a sanitizer interrupts it.
Match the repository's build artifacts (container images, packages, buckets) to the workloads that run them. Then trace whether those workloads are reachable from the internet. This step separates "reachable in code" from "reachable by an attacker."
A useful verdict is one of three states: reached, unreached or unknown. Each comes with a confidence level and the evidence behind it, such as the call site, the file and line, or the missing match. A bare "reachable: false" is hard to audit and easy to misuse.
The steps above describe static reachability, which reasons about code without running it. Runtime reachability takes the opposite approach: it observes which packages and functions actually load or execute in a running workload.
Each answers a different question. Static analysis can cover every path in the code, including ones that production traffic hasn't exercised yet. Runtime observation sees reflection and dynamic loading that static analysis misses, but only for the code paths that ran while it was watching. A function that hasn't run yet is not a function that can't run.
The two work best together: runtime data to catch what the call graph can't see, static analysis to cover what hasn't happened yet.
Every reachability engine makes trade-offs. These are the cases where a verdict needs a second look.
Static call graphs can't see calls that are decided at runtime. The govulncheck documentation says it plainly: "Calls to functions made using package reflect are not visible to static analysis," and use of unsafe "may result in false negatives."
The same applies to require(variable) in JavaScript, importlib in Python, dependency injection containers and framework auto-wiring. In those cases, "not called" may only mean "not called in a way the analysis can see."
To stay safe, call graphs treat interface and function-pointer calls conservatively, so they can report paths that never happen at runtime. govulncheck notes this "may result in false positives." Reachability reduces noise. It doesn't eliminate it.
Function-level reachability needs to know which function is vulnerable. Many advisories describe the impact but not the symbol. Without it, the engine falls back to dependency-level reachability, which is much coarser.
Call graphs usually need a successful build or a resolvable dependency tree. A missing lockfile, an unsupported build system or a language-server timeout can leave the analysis incomplete. An engine that turns "couldn't analyze" into "unreached" will quietly bury real findings.
The further a vulnerable function sits from your code, the more call-graph edges have to be right for the verdict to be right. A dependency of a dependency of a dependency is a long chain of assumptions. Confidence should drop with depth.
A function can be called by your code and still never run in production. The image may never ship, the service may be internal only, or the code may run in a browser where the vulnerable server-side path doesn't apply.
The reverse also happens: an endpoint assumed to be internal can sit behind a public load balancer. Code-level reachability needs deployment context before it's a priority signal.
"Unreached" describes the code at one commit. A new caller, a refactor or a new feature can make the same vulnerability reachable next week. Recompute the verdict whenever the code or dependencies change.
A leaked credential is exploitable whether or not the code around it ever ships. Secrets, misconfigurations and authorization flaws in reachable code need their own checks. Applying a reachability gate to secrets is a category error.
These rules keep reachability working as a prioritization signal, not a suppression switch.
If you publish software, reachability is also the evidence behind a "not affected" claim. CISA's Minimum Requirements for VEX list vulnerable_code_not_in_execute_path as one of the justifications a vendor can give for marking a product not affected by a CVE.
That makes the evidence rules above more than internal hygiene. A "not affected" statement tells your customers to stop looking, so it should rest on a high-confidence, function-level verdict with the evidence attached, not on a dependency-level guess.
Nullify's triage and remediation scores every finding continuously on reachability, exploitability, business context and confidence. It keeps severity (abstract risk) separate from priority (urgency in context). Unreachable code, test fixtures and benign configurations are filtered before human review.
Findings are re-triaged automatically when code, dependencies or threat intel change, and your team's overrides persist. The rules above come from how we built it: unknowns count as reached, a "no" moves a finding only as far as its confidence allows, known-exploited vulnerabilities keep a floor, and every verdict carries its evidence.
Where a finding is real and fixable in code, Nullify opens a minimal, build-validated fix as a pull request, and a human approves every merge. Reachability reprioritizes findings. It doesn't suppress them, because unreached is not the same as false.
Reachability asks whether execution can get from attacker-controlled input to the vulnerable code. Exploitability asks whether an attacker can actually use that path to cause harm, given the controls in front of it, such as authentication or a WAF. A finding can be reachable but not exploitable. Unreachable findings are rarely exploitable.
It removes noise, but not by proving findings false. An unreachable vulnerability is usually real but can't be triggered today. Reachability should move those findings down the queue, with the evidence attached, rather than mark them false. Static analysis can still be wrong in both directions.
Dependency-level reachability is widely supported. Function-level support varies by tool and language, and usually needs a successful build. Statically typed languages with symbol-level advisory data, such as Go, tend to get the most precise results. Check each tool's documentation for its current list.
On every change to the code, the dependencies or the deployment. A verdict describes one commit. A new caller, a refactor or a newly exposed service can make an unreached vulnerability reachable, so treat stale verdicts as unknown.