Dependencies

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.

xgrep deps reachability — of the twelve packages in the lockfile, two are imported by first-party code (naming the file that imports each), and ten are pulled in transitively

What you get

Every dependency finding carries a reachability classification. Two of them are about the package:

classmeaning
importedfirst-party code imports this package
imported-reachable…and the importing code runs, reached from a real entrypoint
imported-deadthe only code importing it is unreachable
transitive-reachablepulled in through a package you do import
transitive-orphanedreachable only through dependencies nothing imports
direct-unuseddeclared in your manifest, imported by nothing
dev-dependencydevelopment/test only, not in the deployed runtime

…and one is about the function the advisory actually names:

classmeaningwhat to do
function-reachablelive code calls the vulnerable functionfix it — the call path is attached
function-unreachablea complete search found no path to itdefer, with recorded evidence
function-undeterminedit could not be established either waytriage as before
function-not-applicablethe finding is not a call-graph questionjudge 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 dependencies

If 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            # npm

Restoring 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 dependencies graph, which is what turns them from undetermined into a verdict: a transitive something imported depends on reads transitive-reachable, one nothing reaches reads transitive-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.6

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

EcosystemRanked by importsCan rule a function outWhat it wants from you
Goyesyesgo mod download
npmyesyesnpm install
Pythonyesnothing (a local .venv/ sharpens it)
Maven / Gradleyesmvn dependency:resolve / gradle build
NuGetyesdotnet restore
PHP (Composer)yesnothing
Rust (cargo)yesnothing
Ruby (RubyGems)yesnothing
C/C++yesvcpkg 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 importscan demote
Go, npm, Python, NuGet, Maven, C/C++, Cargoyesyes
Composeryesonly on manifest scope — never "declared but unimported"
RubyGemsyesno

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 ecosystem

The 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 SpecFlow and the package XMLDiffPatch, 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.txt cannot say which of its entries you asked for and which were pulled in — a pip freeze file lists both identically. A pyproject.toml, a requirements.in, or a pip-compile output 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.

On this page