Reachability
Confirm whether a vulnerability is actually reachable from your code. Experimental.
This feature is experimental. The flag, output shape, and ecosystem coverage may change without notice. It is not yet ready to gate releases on.
⚠️ Experimental. Reachability is opt-in (
--analyze) and under active development. The output shape is stable, but ecosystem coverage is expanding and Tier-3 analyzers (jsreach,pyreach,jvmreach) report at package precision today — sufficient for triage, not a fix substitute. Do not use--fail-on reachableas the sole gate for security-critical decisions without understanding the limitations below.
--analyze runs code analysis on top of vulnerability matching to confirm whether a finding is actually reachable from your application code. Reachability annotations are written onto the matching sdk.Vulnerability records inside the PURL-keyed sdk.PackageRegistry. The output layer resolves them at projection time via (Finding.PackageRef, Finding.VulnerabilityID), so reachability surfaces consistently in JSON, SARIF, and the text/TUI views without needing a separate field on Finding. See docs/MODELS.md for the registry data model.
The point: stop chasing high-severity CVEs in transitive packages your code never imports.
Quick start
# Annotate vulnerabilities with reachability (no audit required).
bomly scan --enrich --analyze --json
# Fail only when a finding is BOTH low+ severity AND confirmed reachable.
bomly scan --enrich --audit --analyze \
--fail-on low --fail-on reachable --json
Analyzer per ecosystem
| Ecosystem | Analyzer | Tier | What "reachable" means |
|---|---|---|---|
| Go | govulncheck | symbol | App code calls (transitively) the vulnerable function |
| JavaScript / TypeScript | jsreach | package | App source imports the npm package (directly or via the npm dep graph) |
| Python | pyreach | package | App source imports the PyPI distribution (directly or via the Python dep graph) |
| Java / Kotlin / Scala / Groovy | jvmreach | package | App source imports a class in the Maven artifact (directly or via the JVM dep graph) |
Other ecosystems are tracked for follow-up phases. On a project with no applicable analyzer, the pipeline runs cleanly and vulnerabilities keep their default nil reachability.
"Unreachable" is not "safe" {#unreachable-is-not-safe}
This caveat applies to every Tier-3 analyzer (jsreach, pyreach, jvmreach) and is the single most important thing to understand before gating CI on --fail-on reachable.
Static analysis cannot see:
- Dynamic imports:
require(userInput),importlib.import_module(name),Class.forName(string). - Runtime plugin loaders, entry-point discovery, Django
INSTALLED_APPSstrings, Spring component scanning,ServiceLoader, OSGi, JPMS dynamic layers, annotation-processed code. - Build-time code generators or runtime shell-outs.
Tier-3 "unreachable" is a triage signal, not a fix substitute. Use it to deprioritize dev-only and transitive-but-unimported dependencies, not to dismiss vulnerabilities.
Tier-1 (symbol, Go) is much stronger: govulncheck builds the actual call graph. "Unreachable" at Tier-1 means there is no static call path. Even Tier-1 cannot see reflection, but the standard library's reflection use is well-understood and rare in app code.
How it works
The pipeline gains a new analyze stage between match and process:
detect → consolidate → match → analyze → process → audit
When --analyze is set, every applicable Analyzer runs against
the matched graph. Analyzers dispatch on (Language, Ecosystem, PackageManager) and may produce results at three levels of precision
(Tier):
| Tier | Meaning |
|---|---|
symbol | Exact call graph from app code into the vulnerable function or method. |
module | App imports the vulnerable submodule, but no symbol-level evidence. |
package | App imports (or doesn't import) the package; nothing else is known. |
none | No analysis was performed (Status=unknown always uses this tier). |
Each analyzer reports one of four statuses per vulnerability:
| Status | Meaning |
|---|---|
reachable | App code reaches the vulnerable symbol(s) at the chosen tier. |
unreachable | Analyzer ran successfully but did not find a path. At package tier this does not mean "safe". |
unknown | Analyzer was applicable but could not determine reachability. Reason carries the cause. |
not_applicable | Analyzer cannot evaluate this vulnerability (e.g. no language match, no source available). |
Analyzer failures never abort the pipeline. Missing toolchains,
broken builds, canceled contexts, and other recoverable conditions all
degrade to Status: unknown with a stable, machine-readable Reason
(e.g. missing-toolchain, build-failed, cancelled,
no-module-root-discovered).
Ecosystem support
| Ecosystem | Analyzer | Tier | Notes |
|---|---|---|---|
| Go | govulncheck | symbol | Backed by the vendored golang.org/x/vuln/scan library; runs in-process so users never need a govulncheck binary on PATH. |
| JavaScript / TypeScript | jsreach | package | Backed by the vendored github.com/evanw/esbuild/pkg/api library. Walks app source from package.json entry points (main, module, browser, exports, bin, plus implicit index.* / app.js / server.js / main.js fallbacks) and reports each npm package as reachable iff it appears in the import set. |
| Python | pyreach | package | In-process line-oriented import scanner. Walks every .py file under the project root (pyproject.toml / setup.py / requirements*.txt / Pipfile / poetry.lock / pdm.lock / uv.lock), records each top-level module from import and from … import statements, and maps module names to PyPI distribution names through a static override map (e.g. yaml → PyYAML) plus PEP 503 normalization. |
| Java / Kotlin / Scala / Groovy | jvmreach | package | In-process line-oriented import scanner for JVM ecosystems (Maven, Gradle, SBT). Walks .java / .kt / .kts / .scala / .groovy source under the project root, scans top-of-file import statements (including Java static, Kotlin aliases, Scala selectors and wildcards), and maps Java/Kotlin/Scala package prefixes to Maven coordinates (groupId:artifactId) through a curated longest-prefix map. |
Other ecosystems (Java, Rust) are tracked for follow-up phases.
When --analyze is set on a project that has no applicable
analyzer for the languages present, the pipeline still runs cleanly:
vulnerabilities just keep their default nil reachability.
A note on jsreach and Tier 3
jsreach reports at the package tier today. The analyzer
classifies a package as "reachable" when there is any path from
app source to that package through the dependency graph the npm
detector resolved from the lockfile:
- App source
require('express')→expressis reachable. expressdepends onbody-parserper the lockfile →body-parseris reachable too, even if no app source file imports it directly.jest(a devDependency) is in the lockfile but no source file imports it →jestand everything reachable only throughjeststay unreachable.
Concretely, esbuild walks the application's source tree (with
PackagesExternal, so it stops at every bare specifier without
opening node_modules), giving us the directly-imported set.
The analyzer then expands that set transitively through the npm
detector's existing dep graph (via Graph.Dependencies) before
deciding each package's status. Packages can appear in the dep
graph at multiple versions (a top-level lodash@4 and a nested
lodash@3 inside some sub-tree); we key the closure by graph IDs,
not names, so attribution stays version-correct.
Three important caveats:
- "Unreachable" is not "safe". A server runtime can
require()a package dynamically based on user input; a plugin loader can pull in a package at runtime; a build script can shell out to it. Static analysis cannot see those paths. Tier-3 unreachable is useful for triage prioritization (deprioritize dev-only and transitive-but-unimported), not as a fix substitute. - Subpath imports collapse to the package name. An import of
lodash/getandlodash/setboth attribute tolodash. If an advisory affects onlylodash/template, jsreach still reports the wholelodashpackage as reachable whenlodash/getis imported. Symbol-tier resolution for npm is tracked for a future phase and would need a curated affected-symbols database (OSV / GHSA rarely carry that level of detail for npm). - The closure is only as accurate as the lockfile. If the dep
graph is incomplete (e.g. a package was installed locally but
never recorded in the lockfile, or a package was installed via
npm link), the closure can't reach it. The npm / pnpm / yarn detectors are the source of truth for what edges exist.
A note on pyreach and Tier 3
pyreach reports at the package tier today using the same
"directly-imported set + transitive closure" approach as jsreach.
The analyzer walks every .py file under the project root, scans for
import x / from x import … statements, maps top-level module names
to PyPI distribution names, and then expands the resulting set
transitively through the Python detector's dep graph (via
Graph.Dependencies).
The module-to-distribution mapping is the part Python forces on every
static analyzer: imports use module names (import yaml,
from PIL import Image), but PyPI uses distribution names (PyYAML,
Pillow). The analyzer applies a layered mapping:
- A small static override table for the well-known mismatches
(
yaml → pyyaml,cv2 → opencv-python,sklearn → scikit-learn,bs4 → beautifulsoup4,PIL → pillow,jwt → pyjwt, …). - PEP 503 identity normalization (lowercase,
_/.→-) for everything else, which catches the bulk of PyPI (requests,flask,numpy,pandas, …). - Stdlib modules (
os,sys,json, …) are dropped from the import set so they never affect the closure.
The same three caveats from jsreach apply, with two extra Python
specifics:
- "Unreachable" is not "safe".
importlib.import_module(…)on user input, plugin discovery via entry points, Django'sINSTALLED_APPSstrings, and conditional__import__calls are all invisible to a static scanner. Tier-3 unreachable is useful for prioritizing dev-only / unused dependencies, not as a fix substitute. - Submodule imports collapse to the distribution.
from urllib3.util import retryattributes the wholeurllib3distribution as reachable, even if a CVE only affects a different submodule. Symbol-tier resolution for Python is a future phase. - A missing static-override entry produces a false negative for
direct imports. If a project does
import some_obscure_moduleand the override map doesn't know it maps tosome-other-dist, the analyzer reports the distribution as unreachable when it actually was imported. The BFS through the dep graph usually recovers the case via a transitive edge from a correctly-mapped neighbor. Adding an override is a one-line PR. - The closure is only as accurate as the lockfile. Same as
jsreach: if the dep graph is incomplete, the closure can't reach what isn't there. The pip / poetry / pipenv / uv / pdm detectors are the source of truth for what edges exist.
A note on jvmreach and Tier 3
jvmreach is the third Tier-3 analyzer and follows the same
"directly-imported set + transitive closure" shape as jsreach
and pyreach. It walks .java / .kt / .kts / .scala /
.groovy files under the project root, parses each top-of-file
import statement, and maps the FQN prefix to a Maven artifact
coordinate (groupId:artifactId) via a curated longest-prefix
map. The closure is then expanded transitively through
Graph.Dependencies.
The Java/Maven mapping problem is genuinely harder than Python's.
Python's module → distribution relationship is usually identity
modulo a small override table. The Java package → Maven artifact
relationship has no naming convention at all — org.apache.commons
covers ten different distributions, com.google.common is one
specific distribution, and there is no rule that holds in general.
Consequences:
- "Unreachable" is not "safe". All the standard caveats apply:
reflection,
ServiceLoader, Spring component scanning, OSGi bundles, JPMS layers, and class-loader gymnastics are invisible to a static scanner. Annotation processors that generate code at build time also escape detection. - The curated prefix map is small and biased toward popular
libraries. A missing prefix produces a false-negative for
direct imports. The dep-graph BFS usually catches the case via
a transitive edge from a correctly-mapped neighbor, but unlike
Python there is no identity-normalization fallback. Adding a
prefix is a one-line PR in
internal/analyzers/jvmreach/prefixmap.go. - Sub-package imports collapse to the artifact. Importing
com.fasterxml.jackson.databind.ObjectMapperflips the wholejackson-databindartifact reachable, even if the advisory affects only a different class in that artifact. - The closure is only as accurate as the build manifest. Same
as
jsreach/pyreach: if the Maven / Gradle / SBT detector doesn't see an edge, the closure can't follow it.
Composing with --fail-on
--fail-on is repeatable; constraints AND together. Two kinds are
supported today:
- Severity:
any | low | medium | high | critical - Reachability:
reachable
# All findings (legacy single-string form still works).
bomly scan --enrich --audit --fail-on any
# Equivalent — Bomly defaults to "any" when no severity is supplied.
bomly scan --enrich --audit
# Low+ severity only.
bomly scan --enrich --audit --fail-on low
# Low+ severity AND confirmed reachable. nil reachability (no analyzer
# ran on this vulnerability) does NOT match — the analyzer must have
# affirmatively proven reachability.
bomly scan --enrich --audit --analyze --fail-on low --fail-on reachable
Future constraint kinds (e.g. kev, has-fix, epss>0.5) can be
added without further flag changes.
Output shape
Reachability data appears in three places:
-
JSON output under each
vulnerabilities[].reachabilityand (when--auditis set)findings[].reachability. The reachability object carriesstatus,tier,analyzer, optionalreason, optional list of confirmedsymbols, and optionalcall_pathswith frame metadata (function,package,file,line,column). Tier-3 analyzers additionally populate:hops— dep-graph distance from a directly-imported package.0means directly imported; higher values are transitive.confidence—high(directly imported, no dynamic imports),medium(1–3 hops, no dynamic imports), orlow(4+ hops or dynamic imports detected). Lets consumers triage long transitive chains without parsinghopsthemselves.dynamic_imports_detected—truewhen the analyzer's scanner observed dynamic-import constructs in app source (e.g.require(variable),importlib.import_module(name),Class.forName(string)). Whentrue, an "unreachable" verdict is necessarily incomplete; the confidence label is forced tolowfor reachable results as a triage signal.
-
Text scan report gains a "Reachability" line in the executive summary plus a
REACHABILITYcolumn in the findings table when--analyzeis set. Each cell renders as<status> (<tier>)or—when no analyzer ran on that vulnerability. -
SARIF output populates
result.codeFlowsfromReachability.CallPaths(one threadFlow per path, one location per frame with file/line/column), and a top-levelresult.propertiesexposesreachability,reachability_tier,reachability_reason,analyzer,reachability_confidence,reachability_hops, andreachability_dynamic_imports_detectedfor SARIF consumers that don't traverse codeFlows.
Limitations
- Tier-3 "unreachable" is not "safe": Package-tier results say "the
app does not import this package", which is genuinely useful for
prioritizing dev-only or transitive-but-unused dependencies, but it
does not mean the vulnerability has been mitigated. See the
jsreachnotes above for the npm-specific shape of this caveat. govulncheckrequires a buildable Go module. When the build fails, the analyzer returnsunknownwith one of the following stablereasoncodes so consumers can branch by failure mode:missing-toolchain—gonot onPATH, govulncheck binary missing, or runner not vendored.no-go-packages— target dir has no.gofiles, build constraints exclude everything, orgo listreturns no matches.module-resolution-failed—go.modnot found, missinggo.sumentry, orgo: downloadfailed for a required module.invalid-go-mod—go.modsyntax error.build-failed— generic compile-stage failure (syntax error,undefined:,imported and not used, non-zero exit).cancelled— context canceled or deadline exceeded.runner-error— fallback when none of the above patterns match.
- Multi-module / multi-project repos:
jsreachandjvmreachautomatically derive local workspace/module closures before attributing external dependencies. Other analyzers retain best-effort first-project attribution. jsreachdoes not follow runtime / dynamic imports. Calls likerequire(somethingFromUserInput), plugin loaders, and worker threads are invisible to static analysis. The analyzer is therefore a "lower bound" on what's actually reachable.jsreachworkspace traversal is manifest-driven. It supports npm/Yarnworkspacesarrays, Yarn-styleworkspaces.packagesobjects, andpnpm-workspace.yamlpackage patterns. It follows imports between consumed workspace packages by package name without depending on installed symlinks. Dynamically computed workspace membership remains invisible.pyreachdoes not follow dynamic imports.importlib.import_module(name)on user input, plugin discovery via entry points, DjangoINSTALLED_APPSstrings, conditional__import__— none of these are visible to a static scanner. The analyzer is a lower bound on what's actually reachable.pyreach's module-to-distribution map is hand-curated. Missing an entry produces a false-negative for direct top-level imports. PRs to extendinternal/analyzers/pyreach/moduletodist.goare welcome.jvmreachdoes not follow reflection or runtime class loading. Spring component scanning,ServiceLoader, OSGi, JPMS dynamic layers, and annotation-processed code are invisible to a static scanner.jvmreach's package-prefix map is hand-curated. The Javapackage → Maven artifactrelationship has no naming convention, so missing prefixes do not have an identity fallback. PRs to extendinternal/analyzers/jvmreach/prefixmap.goare welcome.jvmreachmulti-module traversal is declarative. It follows Maven parent<modules>recursively and standard Gradleinclude(...)declarations withprojectDiroverrides. Gradle composite builds (includeBuild) and dynamically computed settings remain best-effort.
Selecting analyzers
Use the --analyzers selector to restrict or extend the default set:
bomly scan --enrich --analyze --analyzers govulncheck
bomly scan --enrich --analyze --analyzers -govulncheck # disable
bomly scan --enrich --analyze --analyzers govulncheck,jsreach
Selector syntax mirrors --detectors, --matchers, and --auditors:
bare names are an explicit include set, +name appends to defaults,
-name removes from defaults.
Build layout
Unlike Syft and Grype, analyzers do not have a builtin/external build-tag split. Both ship a single in-process implementation backed by a vendored library:
govulncheckruns in-process viagolang.org/x/vuln/scan.jsreachruns in-process viagithub.com/evanw/esbuild/pkg/api.pyreachruns in-process via an in-tree line-oriented import scanner (no external dependency).jvmreachruns in-process via an in-tree line-oriented import scanner (no external dependency).
Both libraries are small enough that vendoring them outweighs the
maintenance cost of supporting an external/lite variant. Lite builds
(make build-lite) include the analyzers as-is.