Nullify

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.

Reachability Analysis Explained: How It Works and Where It Falls Short

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.

What reachability analysis checks

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.

Why reachability analysis matters

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:

  • Reached, high confidence: 3 CVEs. Severity unchanged.
  • Unreached, high confidence: 21 CVEs. Symbol-level evidence of no path.
  • Unreached, medium confidence: 6 CVEs. Moved down one band only.
  • Unknown: 2 CVEs. Counted as reached.

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.

The five levels of reachability

"Reachable" means different things in different tools. When you compare tools or read a verdict, check which level it describes.

LevelQuestionHow it's answeredCostTypical blind spot
1. DependencyIs the package imported or loaded at all?Parse imports and manifestsLowDynamic imports, plugin loaders
2. FunctionIs the vulnerable function called?Call graph from your code into the dependencyMediumReflection, interfaces, callbacks
3. Data flowDoes untrusted input reach the vulnerable call?Taint tracking from sources to sinksHighCustom sanitizers, framework wiring
4. DeploymentDoes the vulnerable artifact actually ship and run?Match build artifacts to running workloadsMediumOut-of-date tags, shared base images
5. ExposureCan an attacker reach the running workload?Trace network paths, load balancers, authHighInternal 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.

How reachability analysis works

Most static reachability engines follow the same six steps, whatever the language.

1. Build an inventory of what's used

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.

2. Identify the vulnerable symbol

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.

3. Build a call graph

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.

4. Search for a path

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.

5. Add deployment and exposure context

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."

6. Emit a verdict with confidence and evidence

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.

Static vs. runtime reachability

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.

Where reachability analysis falls short

Every reachability engine makes trade-offs. These are the cases where a verdict needs a second look.

Dynamic dispatch, reflection and runtime loading

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."

Call graphs also over-report paths

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.

Advisories that don't name the vulnerable function

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.

Builds that fail or can't be analyzed

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.

Transitive depth

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.

Reachable in code is not reachable in production

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.

Code changes

"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.

Not every finding is a reachability question

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.

How to use reachability analysis safely

These rules keep reachability working as a prioritization signal, not a suppression switch.

  • Treat unknown as reachable. If the analysis couldn't decide, keep the full severity.
  • Reprioritize, don't delete. An unreached finding moves down the queue and stays on record with its evidence.
  • Let confidence set how far a finding moves. A high-confidence, function-level "no" can move a lot. A medium-confidence "no" should move one band. A low-confidence "no" shouldn't move it at all.
  • Keep a floor for known exploitation. A CVE in the CISA Known Exploited Vulnerabilities catalog shouldn't drop to informational on static reachability alone.
  • Prefer evidence you can see. Ask for the call site, or the reason no path was found.
  • Re-run on every change. Recompute verdicts when code, dependencies or deployment change, not on a quarterly scan.
  • Combine levels. Use code reachability with deployment and exposure context, and with threat data such as EPSS and KEV.

How reachability feeds VEX statements

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.

How Nullify uses reachability analysis

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.

Frequently asked questions

What is the difference between reachability and exploitability?

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.

Does reachability analysis remove false positives?

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.

Which languages support reachability analysis?

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.

How often should reachability be recomputed?

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.