A dependency scanner tells you that version 4.17.15 of a library has a prototype pollution bug. What it cannot tell you is whether your code ever calls the function the bug lives in. For most dependency findings that is the question that decides the priority, and for most teams it is answered, if at all, by an engineer reading code at the end of a long ticket queue.
In short
- Reachability asks one question per dependency finding: does this repository use the function the advisory says is vulnerable?
- Four answers, each with its evidence: reachable (the call sites, file and line), imported but not called, not imported at all, or unknown with the reason.
- Only evidence moves a score down. “Not imported” takes 2 points off, “imported, not called” 1 point; “reachable” adds half a point; “unknown” changes nothing.
- It is a static index matched by name, not a full call graph. Transitive dependencies stay unknown, and we say so on every finding.
- Covered today: JavaScript and TypeScript (npm), Python (PyPI), Java and Kotlin (Maven).
The situation
An illustrative payments service has 140 open dependency findings. Twenty-two are rated High or Critical. The AppSec lead knows from experience that many of them are in code paths the service never touches, but cannot prove which, and the developers have no time to read twenty-two advisories against the code base. So the team does what most teams do: it upgrades whatever upgrades cleanly and lets the rest age past their SLA.
data
Multiple vendor and academic studies of open-source dependency use have reported that a large share of known-vulnerable dependencies are never reached from application code. The exact proportion varies widely by ecosystem, method and sample, so treat any single headline percentage with caution; the direction is consistent.
Sources: published reachability analyses from software composition analysis vendors and academic work on dependency call graphs; figures differ by methodology.
Why scanners miss it
A software composition analysis tool reads the manifest and the lockfile. It knows which package versions are installed and which advisories name them. It does not read your source code, so it cannot tell the difference between a library that is imported on every request and one that was added three years ago for a script nobody runs. Severity, the only signal it has, is the same in both cases.
The finding says the package version is affected. Whether the vulnerable function is ever called is left to whoever picks up the ticket.
The finding says which vulnerable functions the advisory names, where the package is imported, and where (if anywhere) those functions are called.
How InsightFortify answers it
Every repository scan builds a usage index of the checkout. Nothing is executed. Python is read through its syntax tree; JavaScript and TypeScript through a tokenizer that understands strings, templates, comments and regular-expression literals, so a commented-out import does not count; Java and Kotlin through their imports and per-file calls. The index records which packages each file imports and which of their functions it calls or references.
For each npm, PyPI and Maven dependency finding, the vulnerable functions come from the advisory itself where OSV lists them, or from a curated map of about ninety well-known advisories. The finding is then classified against the index.
Each classification carries its evidence or its reason, and appears as a named score factor.
| Status | What it means | Score | Evidence shown |
|---|---|---|---|
| Reachable | A vulnerable function is called or referenced (passed as a callback, stored) | +0.5 | Up to ten file:line call sites |
| Imported, not called | The package is imported; none of its vulnerable functions is used through any import | −1.0 | The import sites |
| Not imported | A direct dependency that nothing imports and no configuration or script names | −2.0 | What was searched |
| Unknown | Anything that cannot be said with evidence | 0 | The reason, e.g. transitive dependency |
npm, PyPI or Maven"] --> B{"Advisory names
vulnerable functions?"} B -- "no" --> U["Unknown
score unchanged"] B -- "yes" --> C{"Vulnerable function
called or referenced?"} C -- "yes" --> R["Reachable
+0.5, call sites shown"] C -- "no" --> D{"Package imported
anywhere?"} D -- "yes" --> I["Imported, not called
minus 1.0"] D -- "no" --> E{"Direct dependency, evidence
complete, not loaded by name?"} E -- "yes" --> N["Not imported
minus 2.0"] E -- "no" --> U style R fill:#fee2e2,stroke:#ef4444 style N fill:#ccfbf1,stroke:#0d9488 style I fill:#ecfdf5,stroke:#10b981
The decision order. Every path that lacks complete evidence ends at unknown.
Where “unknown” is the honest answer
The design rule is that a score only goes down on complete evidence. Several situations do not give it, and the finding says which:
- Transitive dependencies. Whether the package that pulls it in calls the vulnerable code is not analysed, so the status is unknown.
- Packages loaded by name. Database drivers, servers, command-line tools, logging backends and framework families are often loaded through configuration rather than an import statement.
- The module object passed around whole, star imports, and repositories too large to index completely.
- Advisories that name no function. If neither OSV nor the curated map says what is vulnerable, there is nothing to look for.
The limits, plainly
Calls are matched by name through import bindings. A local variable that shadows an import, a function reached through another library, reflection, eval and require(variable) are not followed, and Java instance calls are matched by method name in a file that imports the class. Go, Rust, Ruby, .NET and PHP are not covered yet. This is why the negative statuses demand complete evidence and why unknown never moves a score.
What it looks like in the console
The finding page gains a panel titled “Is the vulnerable code used?” listing the vulnerable functions, where they came from, and the call or import sites. The findings list gains a reachability column and filter, so a team can ask for “P2 and reachable” or “P1 but never imported”.
The effect, measured honestly
On our mixed-language test application (fourteen dependency findings across npm, PyPI and Maven), the actionable P1 and P2 count fell from ten to eight, and three findings dropped by two points each because their packages were never imported. On deliberately vulnerable public applications with deep, old dependency trees, the effect is much smaller: most of their vulnerable packages are transitive, so they stay unknown. Reachability earns its keep on services with flatter, directly managed dependencies, which is most modern application code.
The index is built on the next full repository scan; CI uploads reuse the repository’s last index.
These are usually dead dependencies: removing them closes the finding and shrinks the attack surface.
The call sites are in the finding; the fix is usually an upgrade or replacing one call.
Spot-check a few against the code to build trust in the classification before relying on it.
Lower priority is not no priority: they still reach their SLA date.
A worked example
Take an illustrative Node.js service that depends on an old version of lodash with an advisory against its
template function. Two files use lodash:
// src/report.js const _ = require('lodash'); const render = _.template(req.body.layout); // line 12: the vulnerable function, on request input // src/util.js import { chunk, uniq } from 'lodash'; // imported, but template is never used here
Illustrative code. The index records both import styles and the call through the alias.
The finding is classified reachable with one piece of evidence:
src/report.js:12 _.template. Half a point is added to the score, and the evidence tells the
developer exactly where to look. If report.js did not exist, the same finding would be
imported, not called because util.js imports lodash but never uses
template, and the score would drop by one point. If no file imported lodash at all and it were a
direct dependency, it would be not imported, a two-point drop and usually a sign the
dependency can simply be removed.
Questions to ask any vendor that claims reachability
- Which languages, exactly? Reachability for one ecosystem is often marketed as reachability in general.
- Where do the vulnerable functions come from? Advisory data, a curated list, or a model that guesses? Each has different failure modes.
- What happens to transitive dependencies? If they are marked unreachable without analysing the package in between, risk is being hidden, not removed.
- Can I see the evidence? A reachable finding should show its call sites; an unreachable one should say what was searched.
- Does unreachable lower the score or hide the finding? Lowering keeps it on the SLA clock; hiding takes it off the books.
| Metric to track | Why it matters |
|---|---|
| Share of dependency findings with a known status | Shows how much of the estate the index can speak to; unknown is not a failure, but a large unknown share limits the benefit |
| Not-imported direct dependencies removed | Dead dependencies removed is attack surface removed, not just a lower score |
| Reachable P1/P2 median time to fix | The findings with call-site evidence should be the fastest to close |
Where the vulnerable-function data comes from
Reachability is only as good as its knowledge of what is vulnerable. Advisory quality varies: some advisories name the exact functions or modules affected, many name only the package and version range. InsightFortify uses the advisory’s own data first, as published through OSV, and keeps it on the vulnerability record so findings are re-classified automatically when an advisory is enriched later. Where advisories are silent, a curated map covers about ninety widely seen advisories.
When neither source names a function, the finding is unknown and says so. That is deliberately conservative. The alternative, guessing which functions are vulnerable from the advisory text, would produce confident classifications that are wrong in ways nobody can check.
What it means for each reader
For the CISO
A defensible reason to fix some vulnerable dependencies later: evidence that the vulnerable function is not used.
Auditors see the factor and its evidence on the finding, not a judgment call in a spreadsheet.
For the AppSec lead
Spend review time where the code actually calls the vulnerable function.
Treat unknown as unknown: the transitive majority still needs the normal upgrade cadence.
For the board
Developer hours go to the dependency risk that can actually be exercised.
The method is transparent enough to explain in one sentence.
“Is the vulnerable code even called?” deserves an answer with evidence, or an honest “unknown”.
Reachability that lowers scores without proof would trade noise for blind spots. Ours only moves a score when the evidence is complete.