Back to glossary

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

Reachability analysis, also called vulnerability reachability, determines whether an application can execute the vulnerable code behind a CVE in one of its dependencies. It builds a call graph from the application's entry points through direct and transitive dependencies 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, 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 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.

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 apptemplate app
Present in lockfile555
Module loaded555
Vulnerable function reachable012
Exploitable with attacker input010

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 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 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. 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. 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, 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, 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 works this way, from the manifest alone, with no access to your source.

The shortcut has one known hole: 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:

AdvisoryCVECVSSVulnerable function
GHSA-35jh-r3h4-6jhmCVE-2021-233377.2template (the variable option)
GHSA-r5fr-rjxr-66jcCVE-2026-48008.1template (options.imports key names)
GHSA-29mw-wpgm-hmr9CVE-2020-285005.3toNumber, trim, trimEnd
GHSA-xxjr-mmjv-4gpgCVE-2025-134656.5unset, omit
GHSA-f23m-r3pf-42rhCVE-2026-29506.5unset, 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. 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:

RuntemplatetoNumbertrimtrimEndbaseUnset
node a-get.js00000
node b-chunk.js 201000
node b-chunk.js (no size)00000
node c-dynamic.js html10000
node c-dynamic.js text00000

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:

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 lengthlodash 4.17.20lodash 4.17.21
10,002 characters100 msunder 1 ms
20,002 characters372 msunder 1 ms
40,002 characters1,588 msunder 1 ms
80,002 characters6,043 msunder 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 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 lists up to 35% fewer false positives for package-level dependency reachability. Precomputed reachability typically marks about 60% of CVEs in transitive dependencies unreachable. 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, 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 exists for exactly this verdict. A VEX statement lets downstream consumers skip the same triage.
  • Rank what's left. Reachability is the first gate in 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.

ApproachWhat it provesWhat it missesNeedsVerdict
Text match on advisory function namesA file mentions the functionEvery indirect path: _.chunk to toNumber, _[name] dispatchSource onlyDon't use as a filter
Package or module import checkThe package is loadedWhich function runs. All 5 lodash CVEs stay "used" because require('lodash') loads one fileManifest plus import statementsCheap first cut, safe to automate
Static function-level call graphA call path can existReflection, string-built module names, use outside codeSpecifications, dependency code, computeUse as the gate, in CI, before deploy
Runtime observation (agents, coverage)A call path did runEvery path the observed inputs didn't take, such as c-dynamic.js textA deployed app and real trafficUse 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 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.

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, 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 execute before the application loads, which is why npm v12 turned them off by default 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.

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.