# Reachability Analysis

> Reachability analysis checks whether an application can execute the vulnerable function behind a dependency CVE, so triage targets real exposure.

Also known as: vulnerability reachability, function-level reachability, static reachability analysis, CVE reachability, reachability-based SCA

Source: https://socket.dev/glossary/reachability-analysis

Last updated: 2026-10-29

Reachability analysis, also called vulnerability reachability, determines whether an application can execute the vulnerable code behind a [CVE](https://socket.dev/glossary/common-vulnerabilities-and-exposures-cve) in one of its dependencies. It builds a call graph from the application's entry points through direct and [transitive dependencies](https://socket.dev/glossary/transitive-dependency) and searches for a path to the affected function. Most dependency CVEs have no such path and cannot be triggered.

We tested this on a real advisory set. Installing lodash 4.17.20 gives `npm audit` five advisories. An app that calls only `_.get` reaches none of the five vulnerable functions. An app that calls `_.chunk` reaches one of them, the `toNumber` function behind [CVE-2020-28500](https://socket.dev/blog/the-coana-approach-to-reachability-analysis), without ever naming it. In that app, an 80,002-character page size blocked the Node.js event loop for 6.0 seconds.

Our position: reachability, not severity score, should be the first filter on dependency CVEs. The analysis has to work at the function level, follow calls into transitive dependency code, and answer "reachable" whenever it cannot prove otherwise. It also has a hard boundary. Reachability analysis ranks known vulnerabilities in code you call. It says nothing about a malicious package, whose install script runs whether or not anything imports it.

In formal verification, the same phrase means enumerating every state a system can reach, for example to find deadlocks in a distributed protocol. This entry covers the software supply chain meaning.

## What is reachability analysis?

Reachability analysis is the step between a scanner's match and a human's triage. A [software composition analysis](https://socket.dev/glossary/software-composition-analysis) tool joins your lockfile against advisory databases on package name and version. That join proves the vulnerable code is present. It does not prove anything runs it.

An advisory describes a flaw in specific code, and a version range is only a proxy for "contains that code." The precise question has four levels, and each one is a subset of the one before:

- **Present:** the vulnerable version is in the lockfile. `npm audit` stops here.
- **Loaded:** some code imports the package or the affected module.
- **Reachable:** a chain of calls leads from your code to the vulnerable function.
- **Exploitable:** attacker-controlled input can arrive at that function in the shape the advisory requires.

Tools are usually named for the level they reach. Package-level or dependency reachability checks imports. Function-level reachability builds a call graph. Exploitability needs data-flow analysis or a human, and almost no tool claims it.

> Five lodash 4.17.20 advisories counted at four levels for three apps. Present and loaded: 5 of 5 in every app. Reachable: 0 in the \_.get app, 1 in the \_.chunk app, 2 in the template app. Exploitable with attacker input: 0, 1, 0.

The figure counts the five lodash 4.17.20 advisories at each level for three small apps we wrote. All five are present and loaded in every app, because `require('lodash')` loads a single 17,161-line `lodash.js` file that contains every function, so a module-level check cannot separate them. At the function level, the `_.get` app reaches 0 of 5, the `_.chunk` app reaches 1, and the template app reaches 2. Only the `_.chunk` app is exploitable with ordinary attacker input, and only if the page size comes from a request.

| Level | \_.get app | \_.chunk app | template app |
| --- | --- | --- | --- |
| Present in lockfile | 5 | 5 | 5 |
| Module loaded | 5 | 5 | 5 |
| Vulnerable function reachable | 0 | 1 | 2 |
| Exploitable with attacker input | 0 | 1 | 0 |

### The reachability specification

Function-level analysis needs a map from each advisory to the functions it affects. Go's official vulnerability database is the clearest case of an ecosystem that publishes that map. This is the [Go vulnerability database](https://vuln.go.dev/ID/GO-2022-1059.json) record for CVE-2022-32149, trimmed to the relevant fields:

```json
{
  "id": "GO-2022-1059",
  "aliases": ["CVE-2022-32149", "GHSA-69ch-w2m2-3vjp"],
  "affected": [{
    "package": { "name": "golang.org/x/text", "ecosystem": "Go" },
    "ranges": [{ "type": "SEMVER",
                 "events": [{ "introduced": "0" }, { "fixed": "0.3.8" }] }],
    "ecosystem_specific": {
      "imports": [{
        "path": "golang.org/x/text/language",
        "symbols": ["MatchStrings", "ParseAcceptLanguage"]
      }]
    }
  }]
}
```

The `symbols` array is the reachability specification. Go's `govulncheck` reads it and reports a finding as affecting your code only when it finds a call stack to one of those symbols. The record uses the [OSV](https://socket.dev/glossary/osv) format. RustSec, the Rust advisory database, has an optional `[affected.functions]` table for the same purpose. npm, PyPI, and Maven advisories mostly leave function data out. GitHub's advisory API has a `vulnerable_functions` field, but as of October 2026 it is an empty array on all five packages listed in the [CVE-2020-28500 advisory](https://github.com/advisories/GHSA-29mw-wpgm-hmr9). The function names exist only in prose.

Reachability tools for those ecosystems therefore write their own specifications by reading advisories and patches. Socket keeps one per vulnerability. By default only human-verified specifications are used, and organizations can opt in to AI-generated ones for broader coverage, according to the [Socket static reachability documentation](https://docs.socket.dev/docs/static-reachability-analysis). A CVE with no specification yet gets the status "unknown" rather than a guess.

## How does reachability analysis work?

Reachability analysis works by building a call graph of the whole program, including dependency code, and checking whether any path connects an entry point to a function named in the specification. Direct calls are easy edges. The difficulty is everything else.

Consider this dispatcher:

```javascript
const _ = require('lodash');
const renderers = { html: 'template', text: 'escape' };
function render(kind, src, data) {
  const fn = _[renderers[kind]];
  return kind === 'html' ? fn(src)(data) : fn(src);
}
console.log(render(process.argv[2], '<b><%= name %></b>', { name: 'x' }));
```

Nothing in this file contains `_.template(`. To draw the edge from `render` to lodash's `template`, an analysis has to track that the string `'template'` flows from an object literal into a computed property read, and that the read returns lodash's `template` function. That is points-to analysis: computing, for every call site, which function values can arrive there. Dynamic languages make it hard, because properties, methods, and modules can all be chosen by strings at run time.

The same pattern appears in real libraries. axios defines `delete`, `get`, `head`, and `options` by looping over an array of method names and assigning each to `Axios.prototype[method]`. An analysis that cannot resolve that loop never connects an application's `axios.get()` call to the follow-redirects flaw behind [CVE-2022-0155](https://socket.dev/blog/how-to-evaluate-an-sca-with-reachability), which leaked cookies across redirects.

No analysis resolves every such call exactly. Rice's theorem rules out a perfect program analysis, so every tool picks a direction to be wrong in:

- **Over-approximate:** add an edge whenever a call might happen. The cost is false positives, which waste triage time.
- **Under-approximate:** add an edge only when a call is certain. The cost is false negatives, which hide exploitable bugs.

For a security filter, only the first direction is acceptable. Socket's engines are built to over-approximate, and the documentation says a vulnerability is marked unreachable only with "strong confidence that it truly cannot be exploited." The way to check the other direction is soundness testing: run the program, record which calls actually happen, and confirm the static graph contains every observed edge. When we [introduced reachability for PHP](https://socket.dev/blog/reachability-for-php), we measured our call graphs against dynamically observed ones. Accuracy landed above 90% on PHPUnit, WordPress, and Flysystem, and in the mid-to-high 80s on Twig and Espo. The remaining gaps sat mostly in reflection-driven dispatch and runtime-generated code.

### Why transitive CVEs can be checked without your code

Application code can reach a transitive dependency only through the packages that depend on it. If no path inside those parent packages reaches the vulnerable function, no application code can reach it either. Dependency code is public, so this half of the analysis can be computed once per package version and cached. Socket's [precomputed reachability](https://socket.dev/blog/announcing-precomputed-reachability-analysis-in-socket) works this way, from the manifest alone, with no access to your source.

The shortcut has one known hole: [phantom dependencies](https://socket.dev/glossary/phantom-dependencies). If your code imports a package it never declared, the dependency graph says the package is only reachable through its parents, and that is false. Full application analysis reads your source and catches these imports. Precomputed analysis cannot.

## Five lodash advisories, three applications

We built the example above into a reproducible test. Everything below ran on October 28, 2026, with Node.js 22.23.4 and npm 10.9.9.

```bash
$ npm init -y && npm i lodash@4.17.20 --save-exact
$ npm audit
# npm audit report

lodash  <=4.17.23
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9
lodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jc
lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit` - https://github.com/advisories/GHSA-f23m-r3pf-42rh
Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpg
fix available via `npm audit fix --force`
Will install lodash@4.18.1, which is outside the stated dependency range
node_modules/lodash

1 high severity vulnerability
```

`npm audit` counts one vulnerable package and lists five advisories. Each advisory names the functions it affects:

| Advisory | CVE | CVSS | Vulnerable function |
| --- | --- | --- | --- |
| GHSA-35jh-r3h4-6jhm | CVE-2021-23337 | 7.2 | template (the variable option) |
| GHSA-r5fr-rjxr-66jc | CVE-2026-4800 | 8.1 | template (options.imports key names) |
| GHSA-29mw-wpgm-hmr9 | CVE-2020-28500 | 5.3 | toNumber, trim, trimEnd |
| GHSA-xxjr-mmjv-4gpg | CVE-2025-13465 | 6.5 | unset, omit |
| GHSA-f23m-r3pf-42rh | CVE-2026-2950 | 6.5 | unset, omit |

The fix for CVE-2025-13465 shipped in lodash 4.17.23, the project's first security release in several years, as part of [lodash's security reset](https://socket.dev/blog/inside-lodash-security-reset). Lodash TSC member Ulises Gascón described the triage question the new committee adopted: "Is this actually exploitable in Lodash's real usage model?" Reachability analysis asks the same question of your usage.

We wrote three apps against this install: `a-get.js` reads a config value with `_.get`, `c-dynamic.js` is the dispatcher above, and `b-chunk.js` paginates a list:

```javascript
const _ = require('lodash');
// page size arrives as a query-string value, e.g. ?size=10
function paginate(items, size) {
  return _.chunk(items, size);
}
console.log(paginate([1, 2, 3, 4], process.argv[2]));
```

A text search of all three files for calls to `template`, `toNumber`, `trim`, `trimEnd`, `unset`, or `omit` finds none. To get ground truth, we ran each app under V8's built-in coverage (`NODE_V8_COVERAGE=dir node app.js`) and counted calls to the five vulnerable functions inside `lodash.js`:

| Run | template | toNumber | trim | trimEnd | baseUnset |
| --- | --- | --- | --- | --- | --- |
| node a-get.js | 0 | 0 | 0 | 0 | 0 |
| node b-chunk.js 2 | 0 | 1 | 0 | 0 | 0 |
| node b-chunk.js (no size) | 0 | 0 | 0 | 0 | 0 |
| node c-dynamic.js html | 1 | 0 | 0 | 0 | 0 |
| node c-dynamic.js text | 0 | 0 | 0 | 0 | 0 |

`baseUnset` is the internal helper that both `unset` and `omit` call.

### How `_.chunk` reaches a function the advisory names

The `_.chunk` app calls a function no advisory mentions, and still reaches `toNumber`:

> Call path from b-chunk.js to the vulnerable regex: paginate calls \_.chunk, which calls toInteger, then toFinite, then toNumber, which runs value.replace(reTrim). Your code names only \_.chunk. The advisory names only toNumber, trim, and trimEnd.

The path has five hops: `paginate` in `b-chunk.js` calls `_.chunk`, which converts the size with `toInteger`. `toInteger` calls `toFinite`, `toFinite` calls `toNumber`, and `toNumber` runs `value.replace(reTrim, '')` with `reTrim = /^\s+|\s+$/g`. The `\s+$` branch backtracks quadratically on a long run of inner whitespace. Inside lodash 4.17.20, `toNumber` has 11 direct call sites, and `toInteger` has 40 more. A filter keyed on the advisory's function names misses every one of them.

We timed the call with a size argument of `'1' + ' '.repeat(n) + '1'` on a 2-vCPU Intel Xeon at 2.10 GHz:

| Size argument length | lodash 4.17.20 | lodash 4.17.21 |
| --- | --- | --- |
| 10,002 characters | 100 ms | under 1 ms |
| 20,002 characters | 372 ms | under 1 ms |
| 40,002 characters | 1,588 ms | under 1 ms |
| 80,002 characters | 6,043 ms | under 1 ms |

Doubling the input roughly quadruples the time. Every millisecond blocks the event loop, so one request stalls every other request on the process. If `size` comes from a request, CVE-2020-28500 is reachable and exploitable here, despite its 5.3 CVSS score.

### Reachable is not exploitable

The template app is the opposite case. `template` is reachable, so a function-level analysis should report both template CVEs, and an over-approximating one will. Neither is exploitable as written. CVE-2021-23337 needs attacker control of the `variable` option, and CVE-2026-4800 needs attacker control of `options.imports` key names. `render` passes neither. The [CVE-2026-4800 advisory](https://github.com/advisories/GHSA-r5fr-rjxr-66jc) adds one more path: keys polluted onto `Object.prototype` by some other bug flow into the same `Function()` call. That is a data-flow question, and it is why "reachable" should mean "a human looks at this," not "fix tonight."

The two `c-dynamic.js` rows in the coverage table show the weakness of runtime evidence. Run with `text`, the app never calls `template`, and a tool that only observes execution would report the CVE unreachable. Run with `html`, it does. Runtime observation proves a path exists. It cannot prove one doesn't.

## How does reachability analysis reduce CVE noise?

Reachability analysis reduces CVE noise by removing findings with no path to the vulnerable function before anyone reads them. The numbers depend on the level. Socket's [reachability documentation](https://docs.socket.dev/docs/reachability-analysis) lists up to 35% fewer false positives for package-level dependency reachability. [Precomputed reachability](https://docs.socket.dev/docs/precomputed-reachability) typically marks about 60% of CVEs in transitive dependencies unreachable. [Full application reachability](https://docs.socket.dev/docs/full-application-reachability), which also analyzes your source, flags about 80%, and above 90% in some ecosystems. It runs as `socket scan create --reach` in the repository.

Go's tooling makes the same split explicit. In the [govulncheck tutorial](https://go.dev/doc/tutorial/govulncheck), GO-2022-1059, the record shown above, lands in an informational section with this text: "Found 1 vulnerability in packages that you import, but there are no call stacks leading to the use of this vulnerability." The tutorial program imports the vulnerable package but never calls `MatchStrings` or `ParseAcceptLanguage`.

What to do with an unreachable finding matters as much as the verdict:

- **Downgrade it, don't delete it.** When we shipped precomputed reachability, unreachable CVEs defaulted to Socket's "monitor" action instead of "block" or "warn." A new call site can make a CVE reachable in the next commit.
- **Record the reason.** The OpenVEX `not_affected` justification [`vulnerable_code_not_in_execute_path`](https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md) exists for exactly this verdict. A [VEX](https://socket.dev/glossary/vex) statement lets downstream consumers skip the same triage.
- **Rank what's left.** Reachability is the first gate in [vulnerability prioritization](https://socket.dev/glossary/vulnerability-prioritization). Known exploitation, exploit probability, and then CVSS order the reachable remainder.

## Static or runtime reachability: which should you use?

Use static function-level reachability as the gate, and treat runtime observation as confirmation for services already in production. The approaches answer different questions, and the lodash runs above show each one failing in a different place.

| Approach | What it proves | What it misses | Needs | Verdict |
| --- | --- | --- | --- | --- |
| Text match on advisory function names | A file mentions the function | Every indirect path: \_.chunk to toNumber, \_\[name\] dispatch | Source only | Don't use as a filter |
| Package or module import check | The package is loaded | Which function runs. All 5 lodash CVEs stay "used" because require('lodash') loads one file | Manifest plus import statements | Cheap first cut, safe to automate |
| Static function-level call graph | A call path can exist | Reflection, string-built module names, use outside code | Specifications, dependency code, compute | Use as the gate, in CI, before deploy |
| Runtime observation (agents, coverage) | A call path did run | Every path the observed inputs didn't take, such as c-dynamic.js text | A deployed app and real traffic | Use to confirm, never to close a finding |

The asymmetry decides it. A static over-approximation errs toward "reachable," which costs time. A runtime "not observed" errs toward "unreachable," which costs a breach. Production observation also arrives after deploy, when the vulnerable code has already shipped.

## Where reachability analysis gets it wrong

Reachability analysis gets it wrong wherever the call graph and the program disagree. Five gaps account for most of it.

**Reflection and dynamic loading.** The Go team's [govulncheck documentation](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) states it plainly: "Calls to functions made using package reflect are not visible to static analysis." Socket's engines model Java reflection and JavaScript dynamic property reads and writes. They treat reflectively loaded classes, such as those named in Java properties files, as potentially reachable on a best-effort basis.

**Use outside code.** Socket's documentation says the analysis "is limited to interactions that occur within code." A build tool you only invoke from a shell script has no call path from your source. Socket classifies packages used primarily as CLI tools as not determinable rather than unreachable. Tools fetched by `npx` at run time are worse: they never enter the repository being analyzed, a gap we documented in [the hidden blast radius of the axios compromise](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise).

**Missing specifications.** A CVE disclosed this morning has no function mapping yet, so it stays "unknown." Socket aims to add specifications within 24 hours of public disclosure. Until then, unknown has to be triaged as reachable.

**Reachable but not exploitable.** The template app above is the pattern. Call graphs don't track whether attacker data reaches the vulnerable argument, so reachable findings still need judgment.

**Malicious packages.** A malicious package has no CVE, no specification, and no need for a call path. In the [axios compromise](https://socket.dev/blog/axios-npm-package-compromised), `plain-crypto-js@4.2.1` ran its dropper from a postinstall hook during `npm install`. Whether any code ever imported it was irrelevant. [Install scripts](https://socket.dev/glossary/postinstall-scripts) execute before the application loads, which is why [npm v12 turned them off by default](https://socket.dev/blog/npm-12) in July 2026, following pnpm 10. Reachability analysis tells you which known bugs your code can trigger. It cannot tell you whether a dependency is hostile, and a team that uses it to deprioritize malware alerts has misread what it measures.

## The advisory is the weak link

Every function-level verdict above rests on a specification, and the largest ecosystems don't publish one. Each lodash advisory names its vulnerable functions, but only in prose. For CVE-2020-28500, GitHub's structured field for them is still empty. Each reachability vendor then reverse-engineers the same patch, and each can get the same CVE wrong in a different way.

The fix is cheap and belongs upstream. OSV already has a place for affected symbols, and Go fills it routinely. If advisory authors in npm, PyPI, and Maven named the vulnerable functions in structured form, reachability results would become reproducible across tools. The analysis is the hard part, and it has improved for a decade. The input is the easy part, and it is still missing.
