fleetreach
See how far every vulnerability reaches across your fleet.
fleetreach audits many repositories in one pass and produces a single
deduplicated, ranked, CI-pipeable view of which dependencies carry known
advisories, plus supply-chain warnings (unmaintained, unsound, notice) and
advisories against the Rust toolchain itself. One binary, no server, no SBOM
pipeline.
It covers 12 ecosystems (Rust, Go, npm, PyPI, RubyGems, Packagist, NuGet, Julia, Swift, Hex, Maven, GitHub Actions), and for Rust it adds a sound MIR-based reachability analysis that proves whether a vulnerable function is actually callable.
The fleet question
Single-project scanners answer “is this repo vulnerable?”. fleetreach answers the question a fleet actually has: which one fix clears the most repos, and in what order do I work?
That ranking falls out of correlating advisories across repos. One bump to a shared dependency can clear the same advisory in many repos at once; a transitive-only exposure needs an upstream bump rather than a manifest edit. The report surfaces both.
What it is, and isn’t
fleetreach is not a scanner or an advisory database. It is an orchestration
and correlation layer over audited data sources: the
rustsec engine for Rust (the same library
cargo-audit is built on) and the OSV database for every
other ecosystem. The trust boundary is “structured advisory data plus your own
config”, never raw HTML.
It fails closed: a gap it cannot scan is never reported clean. A
falsely-clean report is the worst possible output for a security tool, so the
tool never exits 0 unless it completed a scan it can stand behind.
How it compares
| Scans | Answers | Reachability | Shape | |
|---|---|---|---|---|
| fleetreach | many repos, one pass | which fix clears the most repos, ranked | sound (Rust) | single binary |
| osv-scanner · Trivy · Grype | one project | “is this project vulnerable?” | experimental / none | binary |
| OWASP Dependency-Track | a portfolio | similar, but as a platform | no | a server you run |
If you want container scanning, a hosted dashboard, or single-repo CI checks, those tools fit better. If you want one command that answers “what is my fleet’s dependency risk, and what do I fix first”, that is what this is for.
Next: install it, then run your first scan.
Installation
cargo install fleetreach-cli --features network
This installs the fleetreach binary with network support (advisory-DB fetch
plus KEV/EPSS/NVD enrichment).
The network feature
The default build is pure-Rust — no vendored-C TLS stack. It has no network
support and expects a local advisory-db clone passed with --db <PATH>. The
opt-in network feature adds advisory-DB fetch and KEV/EPSS/NVD enrichment
(pulling a rustls TLS stack).
- Install with
--features networkfor the usual fetch-on-run behavior. - Omit it for a minimal, dependency-light, offline (
--db) build.
From source
git clone https://github.com/tess-fun/fleetreach
cd fleetreach
cargo install --path crates/cli --features network
Minimum supported Rust version
The MSRV is 1.89, driven by the whole dependency closure (not just
rustsec) and verified in CI.
Static reachability (optional)
The --reachability=static mode needs a separately built nightly driver
(fleetreach-reach-driver). It links rustc_private, so it is not published to
crates.io — build it from the repository when you want sound static
reachability. See Reachability.
Quickstart
1. Describe your fleet
Create a fleet.toml listing the repositories to scan:
[[repo]]
id = "core-lib"
path = "../core-lib" # repo root; Cargo.lock located within
[[repo]]
id = "services"
path = "../services"
glob = true # discover **/Cargo.lock under the tree
[[repo]]
id = "web-frontend"
path = "../web-frontend" # a package-lock.json repo
ecosystem = "npm" # optional; auto-detected from the manifests
The ecosystem is auto-detected from each repo’s manifests, so ecosystem = … is
usually optional. See the full configuration reference.
2. Scan
fleetreach scan -c fleet.toml # human table (default)
fleetreach scan -c fleet.toml -f json # machine payload, clean for | jq
fleetreach scan -c fleet.toml -f sarif # SARIF 2.1.0 for code scanning
A mixed-ecosystem fleet (Rust, Go, npm, and any of the toolchain-free feeders) folds into one unified, blast-radius-ranked report.
3. Read the result
The default table lists each advisory with its severity, the repos it hits, and a fix hint. From there, the report views reshape the same findings to answer specific questions:
-f impact— which fix clears the most repos?-f blast— split each advisory’s reach into direct vs transitive.-f packages— which dependency is my biggest fleet liability?-f remediation— the batched fix queue: what to bump, in what order.
Exit codes (the CI contract)
Evaluated top-down, first match wins:
| Code | Meaning |
|---|---|
3 | Usage / argument error. |
2 | Could not complete a trustworthy scan (bad config, DB unloadable, a repo errored). |
1 | Trustworthy scan; a finding tripped the gate (--fail-on). |
0 | Trustworthy scan; nothing met the failure threshold. |
The tool never exits 0 unless it completed a scan it can stand behind.
Report views
The same findings can be reshaped with -f <view> to answer different
questions. The fleet-scale views are what single-repo tooling cannot give you.
impact — which fix clears the most repos?
Ranks advisories by how many of your crates they hit.
Repos Severity Advisory Affected Title
2 medium 6.2 RUSTSEC-2020-0071 payments-api, scheduler Potential segfault in time
1 critical 9.8 RUSTSEC-2021-0003 ingest-worker SmallVec::insert_many overflow
1 critical 9.8 RUSTSEC-2021-0097 ls-replacement SM2 decryption buffer overflow
The lead row is a medium, not a critical: the time segfault is the one
advisory present in two repos, so a single bump clears both. That ordering is
the question single-repo tooling cannot answer.
blast — direct vs transitive
Keeps the impact ranking but splits each advisory’s reach into direct vs transitive repos and adds a fix-path hint, because how you fix it depends on the split.
Repos Direct Transitive Fix Severity Advisory Title
2 1 1 mixed unknown RUSTSEC-2025-0004 ssl select_next_proto UAF
1 1 0 manifest critical 9.8 RUSTSEC-2021-0003 SmallVec::insert_many overflow
1 0 1 upstream medium 6.2 RUSTSEC-2020-0071 Potential segfault in time
An advisory hitting its repos transitively can’t be fixed by editing those
repos’ manifests — you need an upstream bump or a dependency override
(upstream); a direct one can (manifest). A corpus study of the Go ecosystem
found ~3 in 4 vulnerable-dependency exposures are transitive, so a plain
affected-repo count hides the fix strategy.
packages — your biggest fleet liability
Rolls the rows up to the dependency. One package often carries many advisories across many repos, and a single bump clears them all.
Repos Direct Transitive Advisories Severity Fix Package
3 0 3 2 medium upstream time
2 1 1 7 unknown mixed openssl
1 1 0 1 critical manifest smallvec
Here a single openssl bump clears seven advisories — a rollup the per-advisory
views can’t show. (packages-json emits the same data as JSON.)
fix-first — what do I patch first?
Severity-dominant: actively-exploited (KEV) findings lead, then strict severity
bands, and only within a band does blast radius break the tie. The opposite
trade-off from impact, which floats a wide-but-informational warning to the
top.
remediation — the fix queue
Where fix-first ranks which advisory, this prints what to do about it: the
concrete dependency bump. Each row is batched, so a single bump tokio 1.0 → 1.38 clears every advisory that one upgrade resolves across every repo, and
breaking (semver-major) jumps are flagged so low-churn fixes go first. Advisories
with no published fix are called out honestly (no fix: …). With static
reachability, soundly-unreachable advisories drop to an
informational tail. (remediation-json emits the queue as JSON.)
Prioritize by real-world risk
--enrich annotates each finding with CISA KEV (actively exploited) and
FIRST EPSS (exploit probability), re-ranks them into an action queue, and
adds a Risk column:
Severity Risk Advisory Fix Title
critical 9.8 epss 88% RUSTSEC-2021-0097 openssl-src → 111.16.0 SM2 decryption buffer overflow
high 7.5 epss 14% RUSTSEC-2022-0013 regex 1.5.4 → 1.5.5 regex repetition DoS
critical 9.8 epss 2% RUSTSEC-2021-0003 smallvec 1.6.0 → 0.6.14 SmallVec::insert_many overflow
The two critical 9.8s are tied by CVSS, but EPSS breaks the tie. Gate with
--fail-on-kev or --min-epss 0.5; both feeds can be supplied offline via
--kev-file / --epss-file.
Provenance: --why
Every finding shows whether the flagged package is a direct or transitive
dependency and the chain that pulls it in. --why <crate> asks that across the
whole fleet at once:
$ fleetreach scan --why serde
cli-tools — serde 1.0.228 (direct):
ripgrep → serde
file-finder — serde 1.0.228 (transitive):
fd-find → globset → bstr → serde
Tracking drift over time
fleetreach diff <baseline.json> <current.json> compares two saved reports and
splits findings into new, fixed, and still-open — the question a
single scan can’t answer: did this branch make the fleet better or worse? It
is pure (no scanning or network), so it drops into CI as a cheap gate.
Reachability
A dependency advisory tells you a crate contains a vulnerable function — but most of the time your code never calls it, so the finding is noise. Reachability asks whether that function is actually reachable from your code.
fleetreach has two tiers, both opt-in. The acceptable error direction is always over-reporting: a finding is only ever suppressed by a verdict it can stand behind.
Heuristic (--reachability)
--reachability (bare, or =heuristic) is a labelled source-presence
heuristic that greps your source and never builds anything. For Rust it greps
for the advisory’s affected function names; for the toolchain-free feeders it
greps for an import of each direct dependency.
The heuristic only ever raises a finding to reachable on a positive match — it never marks a finding unreachable, so a missed import can’t hide a vulnerability. All 12 ecosystems produce a reachability signal this way.
npm import graph
Under --reachability, npm uses a build-free module import graph instead of
the flat grep: it parses every require/import in your source (and, when
node_modules is present, in each installed package), then reports a vulnerable
package as Reachable with a witness import-chain. --npm-prune-unreachable
additionally marks a package NotReachable when no import path reaches it. That
negative is best-effort sound (a dynamic require(expr) it can’t see may make it
wrong), which is why it is a separate opt-in.
Static (--reachability=static)
--reachability=static is a sound MIR call-graph analysis that proves
whether a vulnerable function is callable, with a witness chain. A
NotReachable verdict here is trusted enough to suppress: it returns
NotReachable only when there is genuinely no path from a root to the sink in
the (over-approximating) call graph. Every uncertainty resolves to Reachable
or Unknown, never a false NotReachable.
⚠️
--reachability=staticcompiles each scanned repo. Building Rust runs the repo’s (and its dependencies’)build.rsscripts and proc-macros — i.e. arbitrary code, with your full user privileges. This is unlike the rest of fleetreach, which only readsCargo.lock. Because of that it is gated behind an explicit--allow-untrusted-buildsand prints a warning before any build. Only point it at repositories you trust. For untrusted code, run it inside a sandbox/container with no network and no secrets.
It also needs the pinned-nightly fleetreach-reach-driver built and passed via
--reach-driver. The driver links rustc_private and reads rustc’s own
monomorphization set, so the node universe is sound by codegen rather than a
hand-audited walk.
Go
A Go repo (a go.mod with no Cargo.lock) is scanned by govulncheck, which
compiles the module and confirms call sites. A confirmed call is marked
reachable (the analysis is sound-positive); present-but-uncalled stays
unknown, never a false “not reachable”. Because it compiles, Go scanning needs
--allow-untrusted-builds. A degraded toolchain-free module-level mode reads
go.mod and matches against a vuln.go.dev mirror without compiling anything.
Ecosystems
fleetreach covers 12 ecosystems. Rust uses the rustsec advisory engine; Go
uses govulncheck (plus a toolchain-free fallback); the other ten are
toolchain-free OSV feeders.
Toolchain-free OSV feeders
Every non-Rust ecosystem is scanned the same way: fleetreach reads the lockfile
(the full transitive tree, already pinned to exact versions) and matches each
package against an OSV mirror passed as --<ecosystem>-vuln-db=file://<path>
— point it at the osv.dev export all.zip (read directly, no unzip needed) or a
directory of unzipped records.
It runs no package manager and no install/build scripts, so it is safe by
construction and needs no --allow-untrusted-builds. Without a mirror the repo
is an honest errored gap, never silently skipped. Severity comes from the GHSA
band or a CVSS vector; direct vs transitive comes from the lockfile; findings are
unknown reachability unless a reachability mode runs.
| Ecosystem | Lockfile(s) | Flag | Version semantics |
|---|---|---|---|
| npm | package-lock.json | --npm-vuln-db | SemVer |
| PyPI | uv.lock / poetry.lock / Pipfile.lock | --pypi-vuln-db | PEP 440 (PEP 503 names) |
| RubyGems | Gemfile.lock | --rubygems-vuln-db | Gem::Version |
| Packagist | composer.lock | --packagist-vuln-db | Composer version_compare |
| NuGet | packages.lock.json | --nuget-vuln-db | four-part NuGetVersion |
| Julia | Manifest.toml | --julia-vuln-db | VersionNumber |
| Swift | Package.resolved | --swift-vuln-db | URL-identified SemVer |
| Hex | mix.lock | --hex-vuln-db | SemVer |
| Maven | gradle.lockfile / pom.xml | --maven-vuln-db | ComparableVersion |
| GitHub Actions | .github/workflows/*.yml | --ghactions-vuln-db | tag SemVer |
The osv.dev exports live at
https://osv-vulnerabilities.storage.googleapis.com/<Ecosystem>/all.zip.
Why bespoke comparators
Each ecosystem orders versions by its own rules, and a stock SemVer comparator
would mis-order most of them and silently false-clean. So each feeder ships a
faithful port of the ecosystem’s real comparator, validated differentially
against the upstream library where one exists — for example the Maven comparator
agrees with Apache Maven’s own ComparableVersion over 710,000+ version pairs,
and npm/PyPI/RubyGems matching was validated at 100% recall with zero
false-cleans against the OSV exports.
A few specifics: where an advisory enumerates affected versions instead of a
range (notably malware MAL- records), the matcher consults both; PyPI
normalizes names per PEP 503 so Flask and flask match; and for GitHub Actions
only version-pinned uses: references are matched (e.g. the
tj-actions/changed-files supply-chain advisory), while SHA and branch pins are
skipped as honest gaps.
CI integration
GitHub Action
The bundled composite action installs fleetreach, scans the repo, and uploads findings to the Security tab as SARIF:
name: audit
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 6 * * *" # daily, so newly-published advisories surface
permissions:
contents: read
security-events: write # required to upload SARIF
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: tess-fun/fleetreach@v1
with:
# rank by real-world exploit risk and flag never-built optional deps
args: "--enrich --resolve-features"
with.args is forwarded to fleetreach scan. By default the action generates a
fleet.toml that scans the current repo; pass config: to point at your own.
Gating
The exit code is the CI contract (see Quickstart). Tune what trips a failure:
--fail-on <severity>— floor a finding must reach to gate (defaultlow; Unknown always counts, fail-closed).--fail-on-warnings— also gate on supply-chain warnings.--fail-on-kev/--min-epss 0.5— gate on real-world exploit risk (needs--enrich).
Drift gating with diff
Save a baseline report and compare against it so only new findings fail the build:
fleetreach scan -c fleet.toml -f json > current.json
fleetreach diff baseline.json current.json --fail-on high
diff is pure (no scanning, DB, or network — just two JSON files). The exit code
mirrors scan: 1 when a new finding trips the gate. --exit-zero makes it
report-only.
SARIF and suppression
-f sarif emits SARIF 2.1.0. A machine-sound not_affected (a static
NotReachable verdict or a phantom optional dependency) carries a
suppressions[] entry so GitHub’s Security tab greys it out rather than alerting.
fleetreach can also emit and consume OpenVEX (-f vex) for cross-tool
suppression with Grype and Trivy.
Resolving the real build
With --resolve-features (needs the repo’s buildable source), each finding is
marked built vs. a phantom Cargo.lock-only optional dependency that is never
compiled; the table flags those with ⚠ not in default build. Default scans
stay lockfile-only and portable.
Configuration
fleet.toml
A list of repos to scan, plus optional settings. Pass it with -c.
[[repo]]
id = "core-lib"
path = "../core-lib" # repo root; the lockfile is located within
[[repo]]
id = "services"
path = "../services"
glob = true # discover **/Cargo.lock under the tree
glob_max_depth = 4 # bounded; default 3
[[repo]]
id = "billing-api"
path = "../billing-api" # a go.mod repo; scanned via govulncheck
ecosystem = "go" # optional; auto-detected from the manifests
[[repo]]
id = "ml-service"
path = "../ml-service" # a uv.lock/poetry.lock/Pipfile.lock repo
ecosystem = "pypi"
[[settings.ignore]]
id = "RUSTSEC-2020-0071"
reason = "dev-dependency only, not in any shipped path" # REQUIRED, non-empty
Repo fields
| Field | Meaning |
|---|---|
id | Stable identifier used in the report. |
path | Repo root (the lockfile is located within). |
glob | Discover every lockfile under the tree. |
glob_max_depth | How deep glob descends (default 3). |
ecosystem | Override auto-detection (cargo, go, npm, pypi, rubygems, packagist, nuget, julia, swift, hex, maven, githubactions). |
Ignoring an advisory
Each [[settings.ignore]] requires a non-empty reason — an ignore without a
justification is rejected. A stale ignore (one that no longer matches anything)
is surfaced, so the ignore list cannot rot silently.
Auto-detection order
When ecosystem is omitted, fleetreach is Rust-first: a Cargo.lock wins, then
go.mod, then package-lock.json, then a Python lockfile, and so on. Set
ecosystem explicitly only to override that order.
Key flags
| Flag | Effect |
|---|---|
-f <view> | Output format / view (see Report views). |
--db <PATH> | Use a local advisory-db clone (required without --features network). |
--offline | Never touch the network. |
--max-db-age 7d | Refuse a DB older than this (refuses when age is unknown). |
--min-severity high | Filter below a severity (Unknown always survives). |
--fail-on <severity> | Gate threshold for a new vulnerability. |
--enrich | Add CISA KEV + FIRST EPSS, re-rank by real-world risk. |
--why <pkg> | Show how a package gets into the tree, fleet-wide. |
--resolve-features | Mark phantom (never-built) optional dependencies. |
--reachability[=heuristic|static] | Reachability analysis (see Reachability). |
Run fleetreach scan --help for the complete list.
Design decisions (fail-closed)
fleetreach errs toward noise over silence — when it cannot prove something is safe, it surfaces it:
- Unknown-severity vulnerabilities always gate. An advisory with no CVSS
score still trips
--fail-onand survives--min-severityfiltering — it cannot be proven below the threshold, so it is never silently dropped. --max-db-agerefuses when age is unknown. If the DB carries no commit timestamp, freshness cannot be verified, so the run exits2.