Reachability — can the vulnerable code actually run?
xgrep ranks dependency vulnerabilities by whether your code reaches them, down to the individual vulnerable function, and shows the call path that proves it.
Reachability
A dependency CVE tells you that a package contains a vulnerable function. It does not tell you whether your application ever calls it — and usually it doesn't.
Answering that by hand means reading the advisory to find the vulnerable function, then tracing whether your code reaches it through however many layers of transitive dependency sit in between. It is slow, it has to be redone on every scan, and so in practice it doesn't happen: teams either upgrade everything above a severity threshold — churn and breakage for findings that could never execute — or they stop reading dependency findings at all.
xgrep does that tracing for you, and shows its work.

What you get
Every dependency finding carries a reachability classification. Two of them are about the package:
| class | meaning |
|---|---|
imported | first-party code imports this package |
imported-reachable | …and the importing code runs, reached from a real entrypoint |
imported-dead | the only code importing it is unreachable |
transitive-reachable | pulled in through a package you do import |
transitive-orphaned | reachable only through dependencies nothing imports |
direct-unused | declared in your manifest, imported by nothing |
dev-dependency | development/test only, not in the deployed runtime |
…and one is about the function the advisory actually names:
| class | meaning | what to do |
|---|---|---|
function-reachable | live code calls the vulnerable function | fix it — the call path is attached |
function-unreachable | a complete search found no path to it | defer, with recorded evidence |
function-undetermined | it could not be established either way | triage as before |
function-not-applicable | the finding is not a call-graph question | judge it on its own terms |
Not every dependency finding is a question about your call graph. A Go
toolchain advisory is about go build and go get themselves — code that runs
while you compile, not code your program calls. There is no call path to look
for, so xgrep says function-not-applicable rather than reporting a permanent
"could not determine" that reads like a gap in the analysis. Go's standard
library is the opposite case and is ranked normally.
A function-reachable finding carries the call path that proves it — the
entrypoint, the function in your code that makes the call, and its file and line.
That turns a triage question into a fix with a known blast radius, and it lets you
check the reasoning rather than take it on trust.
Turning it on
Reachability needs a code graph of your project, and building that graph is the dominant cost — so it is not on by default. It comes on with cross-file analysis:
xgrep scan --graph . # cross-file SAST + reachability-ranked dependenciesIf you want ranked dependency findings without cross-file SAST, ask for reachability alone:
xgrep scan --reachability .To hide what your code does not reach at all:
xgrep scan --reachable-only .--reachable-only drops only the classes xgrep is confident about
(direct-unused, dev-dependency). Anything uncertain is kept.
Function-level reachability needs advisories that name the function
The package-level classes work from your code and your manifests alone. Deciding whether the specific vulnerable function runs needs one more input: an advisory that says which function is vulnerable.
Most advisory feeds do not carry that. Go's vulnerability database is the notable exception. Point xgrep at OSV-format advisories to supply them:
xgrep scan --advisory-symbols ./advisories/ .The path can be a single file or a directory, in OSV JSON, a JSON array, or JSONL.
--advisory-symbols implies --reachability.
Without affected-symbol data, every finding is function-undetermined — which is
reported plainly rather than dressed up as a verdict. To see how much of your
advisory data names functions at all:
xgrep deps symbol-coverage .Have your dependencies on disk
Proving that a function is not reached means reading the dependency code a call could pass through. Fetch it first:
go mod download # Go
npm install # npmRestoring Maven, Gradle and NuGet dependencies sharpens the package-level ranking too — see Which languages — but ruling a function out is currently Go and npm only.
Where source is missing, xgrep says function-undetermined — never
"unreachable". A CI runner with an empty module cache produces honest uncertainty
rather than a confident wrong answer. On a real Go project the difference is
substantial: with dependencies fetched, nearly every package can be ruled in or
out; without them, almost none can.
To see whether your project can be answered about, and what is blocking it:
xgrep deps closure .It reports how many of your packages admit a complete search, and what stopped the rest — missing source, an ecosystem with no dependency graph, or dispatch through values that cannot be followed statically. It is fully offline and needs no credentials.
Give Maven and Gradle the graph they do not commit
A pom.xml states what the project asks for; the versions it resolves to,
and the transitive dependencies they pull in, are decided at build time and
written nowhere in the repository. Maven has no lockfile, and Gradle's
dependency locking is opt-in and rarely used. So on a Java project xgrep
inventories the declared set — spring-petclinic reports 31 packages against a
resolved classpath several times that — and it cannot rank a transitive it
never saw.
If your build already emits a CycloneDX or SPDX document, xgrep reads it alongside the source:
mvn cyclonedx:makeAggregateBom # writes target/bom.json
gradle cyclonedxBom # writes build/reports/bom.json
xgrep deps reachability --deps-evidence target/bom.json .A bom.json, bom.xml, sbom.json or sbom.spdx.json committed at the
project root is read with no flag at all — it is a manifest by any reasonable
reading, the same way package-lock.json is. --deps-evidence is for a file CI
produced and did not commit; naming one suppresses the automatic read, so the
document you named is the one that is used.
xgrep does not run your build to produce that file. Resolving dependencies
is not a read operation in these ecosystems — a build.gradle is a Groovy
program — so invoking the build tool would hand unvetted code execution on the
scanning host. The file has to exist before xgrep is asked to read it.
Two things the document contributes:
- The packages the manifest could not name, so a CVE in a transitive is visible at all. They are marked as coming from build evidence, so a reader can tell them from packages read out of the project's own files.
- The
dependenciesgraph, which is what turns them from undetermined into a verdict: a transitive something imported depends on readstransitive-reachable, one nothing reaches readstransitive-orphaned.
The second only happens where the document accounts for the whole ecosystem. If
your inventory holds a package the graph never mentions, xgrep leaves every
transitive in that ecosystem undetermined rather than calling it unreached —
the missing part of the graph is exactly where the route to it might have been.
A document with no dependencies section at all adds packages and demotes
nothing.
What "unreachable" does and does not mean
function-unreachable is only reported when the search read everything it
needed. If any part could not be read — a package whose source is absent, a call
whose destination cannot be determined, an ecosystem that supplies no dependency
graph — the answer falls back to function-undetermined.
This is deliberate and it is the most important property of the feature. A wrong "unreachable" hides a vulnerability that can actually execute, and it would look like an improvement on every dashboard while doing it. Uncertainty is always reported as uncertainty.
For the same reason, reachability never removes a finding unless you ask for
that with --reachable-only, and it never changes a vulnerability's severity. It
lowers confidence and sorts demoted findings below the rest, so the ones your code
can actually reach come first.
When a version is a declared minimum
Go's go.mod can state its version two ways, and they do not mean the same
thing. A toolchain go1.26.6 line pins the toolchain. A bare go 1.26.6 line is
a minimum — the build may well use a newer Go that already carries the fix.
Where the version came from a go directive, the finding is marked
version_declared_minimum. That is a note, not a downgrade: most projects do not
pin a toolchain, so treating the common case as suspect would bury exactly the
findings this feature surfaces. Pin the toolchain if you want the question
settled:
toolchain go1.26.6Which languages
Nine ecosystems are ranked today. What you get depends on what your project gives xgrep to read — and it tells you which case you are in rather than leaving an unchanged finding count to imply the answer.
| Ecosystem | Ranked by imports | Can rule a function out | What it wants from you |
|---|---|---|---|
| Go | yes | yes | go mod download |
| npm | yes | yes | npm install |
| Python | yes | — | nothing (a local .venv/ sharpens it) |
| Maven / Gradle | yes | — | mvn dependency:resolve / gradle build |
| NuGet | yes | — | dotnet restore |
| PHP (Composer) | yes | — | nothing |
| Rust (cargo) | yes | — | nothing |
| Ruby (RubyGems) | yes | — | nothing |
| C/C++ | yes | — | vcpkg install sharpens it |
Packages in any other ecosystem are reported as out of scope, not counted against you.
Resolving an ecosystem's imports is not the same as demoting on them
Ranking is only half the story. To call a package unused, "your code never mentions it" has to actually mean it never runs — and in two of those nine it doesn't:
| resolves imports | can demote | |
|---|---|---|
| Go, npm, Python, NuGet, Maven, C/C++, Cargo | yes | yes |
| Composer | yes | only on manifest scope — never "declared but unimported" |
| RubyGems | yes | no |
A PHP application wires classes through routing, DI containers and templates the
code graph never reads, so composer.lock's own dev/prod split is the only signal
about a package no use statement names.
Ruby goes further. A Rails config/application.rb calls Bundler.require, which
loads every gem in the active groups without naming one, and a Zeitwerk autoloader
can enter a gem with no require anywhere in the tree. Ruby therefore marks gems
as imported — real, resolved evidence that keeps them at full severity — and
demotes none of them. --reachable-only drops no gem, and the coverage report
says so rather than presenting an unchanged count as a clean result.
The trade is the one this feature always makes: you may keep a finding you could have dropped, but you are never told to ignore one that can run.
Why some ecosystems want a restore
Go, npm, PHP, Rust and Ruby answer from the source alone. An import names its package closely enough — directly, or through a map the project already carries — that nothing needs installing.
Java, .NET and C/C++ don't. A using names a namespace and an import names a
Java package, and neither says which artifact provides it:
com.fasterxml.jackson.databind comes from jackson-databind, whose group ID
appears nowhere in the import. Restoring your dependencies lets xgrep read the
answer instead of guessing it:
mvn dependency:resolve # Maven
gradle build # Gradle — see below; a dependency listing is not enough
dotnet restore # NuGet
vcpkg install # C/C++That also settles two things nothing else can. A NuGet package that ships no
managed assembly — a front-end asset package like bootstrap, or a Java webjar —
cannot be called from that language at all, so it can be ruled out rather than kept
for lack of evidence. And a package's namespaces are read from what it ships,
which is the only way to know that Castle.Core provides Castle.DynamicProxy.
Without a restore, xgrep matches on the package name and says so. A namespace it cannot place keeps every finding in that ecosystem at full severity — you lose precision, never a real vulnerability.
A .NET package whose namespaces xgrep could not read is never marked
unreachable. Reading them needs the restored package: either it is missing, or
it ships assemblies without the documentation file the compiler writes beside
them. Either way the package is known only by its id, and a C# extension method
is called from a file that names the namespace of the type it extends rather
than the package it came from — services.AddSwaggerGen() names Swashbuckle
nowhere. Ruling such a package out on the absence of a using would drop a
package whose code runs on every request, so xgrep reports it as undetermined
instead. A package that ships no managed assembly is the exception and is still
ruled out: it holds no types, so no using could ever name it.
For Gradle, two levels of restore buy different things. gradle dependencies
downloads the POM of every artifact it resolves and no jars. That is enough for the
dependency graph — xgrep reads those POMs out of the module cache to recover what
your declared dependencies in turn pull in. It is not enough for import
resolution: the jar is where an artifact's Java packages are named, so a project
prepared that way still has every import resolved by guesswork. gradle build
gives you both.
That matters because a build.gradle or a gradle/libs.versions.toml states less
than a pom.xml to begin with: it lists what you asked for, not what those
dependencies pull in, so an import satisfied by a transitive dependency has no
inventory entry to resolve against. Reading the cached POMs answers one question —
is this import provided by something real on disk? — which stops it counting as
unresolved. The transitive artifact does not thereby become a reported
dependency: no SBOM entry, no version, no vulnerability verdict of its own. Only
the packages your manifest declares are ever reported, and a project that wants its
transitives inventoried commits a gradle.lockfile, which xgrep reads directly.
Where the cache does not hold them — an unrestored checkout, a partially populated one — nothing is accounted for. A dependency missing from the cache is not evidence it is unused; it is evidence nothing was read. Those imports are reported as unresolved, and no package around them is demoted.
C/C++ reads #includes. After vcpkg install, the install tree lists every
header each port installed, so a header maps to its package exactly — including the
ones whose path doesn't name it, like <nlohmann/json.hpp>. Without it, xgrep
falls back to the header path (<fmt/format.h> → fmt) plus a list of known
names. Because a header is a weaker signal than an import — <zlib.h> may be your
system's zlib rather than the packaged one — an include xgrep can't place holds
back every demotion for C/C++ in that scan.
Python resolves most imports from the name; the ones that diverge (import yaml from pyyaml, from PIL import Image from pillow) come from a curated
set, or are read exactly from a project-local virtualenv when one is present.
Cargo resolves from the crate identifier, which is the package name with -
folded to _ — a normalisation crates.io itself enforces, so a use usually names
the package directly. Usually, not always: a crate's library name can differ from
its package name, and a Cargo.toml can bind a dependency under a different name
entirely (memmap = { package = "memmap2" }). Those renames are read from the
manifests, and a module-level use root that still names nothing identifiable
withholds every demotion for Cargo in that scan — the same all-or-nothing gate
C/C++ uses, for the same reason.
Composer and RubyGems resolve a use or a require through the namespace
roots the project's own composer.json states and through the load paths of the
installed gems, so both report which packages the code names. What they do not do
is conclude anything from a package the code never names — see the table above.
When reachability cannot answer
xgrep never demotes a finding on a guess. Where it cannot establish that nothing reaches a package, it keeps the finding and says why, so a full result set is never mistaken for a clean one:
reachability: 24 finding(s) in nuget kept — import resolution not supported for this ecosystemThe same information is in --json under reachability, one entry per
ecosystem, with a decidable flag and the reasons behind it — so CI can tell
"nothing was unreachable" apart from "this could not be decided", which are
otherwise the same result set. Three things prevent an answer:
-
No import resolution for the ecosystem. Nothing maps its imports to its packages yet.
-
Imports that did not resolve. A package whose import could not be named might be any of the packages about to be demoted, so no demotion in that ecosystem stands. The line names the imports it could not place, so you can see what is blocking it:
reachability: 90 package(s) in nuget kept — some imports could not be resolved to packages (TechTalk.SpecFlow, Microsoft.XmlDiffPatch)Restoring dependencies usually clears this — those two are the package
SpecFlowand the packageXMLDiffPatch, and the restored assemblies are what say so. Because the withholding covers the whole ecosystem, it lifts only when the last one is placed. -
Direct and transitive could not be told apart. A plain
requirements.txtcannot say which of its entries you asked for and which were pulled in — apip freezefile lists both identically. Apyproject.toml, arequirements.in, or apip-compileoutput resolves it. -
The ecosystem withholds demotion by design. Imports resolved, and no package was called unused anyway, because in that ecosystem an absent import does not mean an absent dependency — RubyGems today. This one is not a gap in what could be read, so restoring or locking anything will not change it; it is reported for the same reason as the others, so that "nothing was filtered" is never mistaken for "nothing was unreachable".
An ecosystem that could be decided prints nothing.
Scanning for vulnerabilities
xgrep scan combines SAST and SCA in one run — it builds an SBOM and matches your open-source dependencies against known CVEs via Mondoo Platform.
License compliance
Classify each dependency's license and check it against a deployment-aware, tri-state policy — offline, with a CI exit-code gate and reachability-based re-ranking.