Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ScansAnswersReachabilityShape
fleetreachmany repos, one passwhich fix clears the most repos, rankedsound (Rust)single binary
osv-scanner · Trivy · Grypeone project“is this project vulnerable?”experimental / nonebinary
OWASP Dependency-Tracka portfoliosimilar, but as a platformnoa 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 network for 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:

CodeMeaning
3Usage / argument error.
2Could not complete a trustworthy scan (bad config, DB unloadable, a repo errored).
1Trustworthy scan; a finding tripped the gate (--fail-on).
0Trustworthy 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=static compiles each scanned repo. Building Rust runs the repo’s (and its dependencies’) build.rs scripts and proc-macros — i.e. arbitrary code, with your full user privileges. This is unlike the rest of fleetreach, which only reads Cargo.lock. Because of that it is gated behind an explicit --allow-untrusted-builds and 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.

EcosystemLockfile(s)FlagVersion semantics
npmpackage-lock.json--npm-vuln-dbSemVer
PyPIuv.lock / poetry.lock / Pipfile.lock--pypi-vuln-dbPEP 440 (PEP 503 names)
RubyGemsGemfile.lock--rubygems-vuln-dbGem::Version
Packagistcomposer.lock--packagist-vuln-dbComposer version_compare
NuGetpackages.lock.json--nuget-vuln-dbfour-part NuGetVersion
JuliaManifest.toml--julia-vuln-dbVersionNumber
SwiftPackage.resolved--swift-vuln-dbURL-identified SemVer
Hexmix.lock--hex-vuln-dbSemVer
Mavengradle.lockfile / pom.xml--maven-vuln-dbComparableVersion
GitHub Actions.github/workflows/*.yml--ghactions-vuln-dbtag 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 (default low; 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

FieldMeaning
idStable identifier used in the report.
pathRepo root (the lockfile is located within).
globDiscover every lockfile under the tree.
glob_max_depthHow deep glob descends (default 3).
ecosystemOverride 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

FlagEffect
-f <view>Output format / view (see Report views).
--db <PATH>Use a local advisory-db clone (required without --features network).
--offlineNever touch the network.
--max-db-age 7dRefuse a DB older than this (refuses when age is unknown).
--min-severity highFilter below a severity (Unknown always survives).
--fail-on <severity>Gate threshold for a new vulnerability.
--enrichAdd CISA KEV + FIRST EPSS, re-rank by real-world risk.
--why <pkg>Show how a package gets into the tree, fleet-wide.
--resolve-featuresMark 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-on and survives --min-severity filtering — it cannot be proven below the threshold, so it is never silently dropped.
  • --max-db-age refuses when age is unknown. If the DB carries no commit timestamp, freshness cannot be verified, so the run exits 2.