Skip to main content

fleetreach_cli/
scan.rs

1//! The `scan` command: parse `ScanArgs`, run the audit pipeline (config → fleet
2//! scan → assemble → enrich → reachability → render), and return the exit code.
3//! Also hosts the `--why`/`--explain` short-circuits and `-f vex` parameter build.
4
5use std::io::IsTerminal;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use fleetreach_core::{FleetReport, Severity};
10use fleetreach_report as report;
11use fleetreach_scan::{routes_to, AdvisoryDb};
12
13use crate::assemble::{
14    assemble, combine_baseline, drop_phantom, exit_code, retain_min_epss, retain_new,
15    retain_reachable, Assembled, GateConfig, SuppressedOccurrence, Suppression,
16};
17use crate::cli::{fail, usage_fail, BuildSandbox, Format, ReachMode, SeverityArg, VexScopeArg};
18use crate::config::Config;
19use crate::db::{build_provenance, check_db_age, detect_toolchain, fetch_enrichment, load_db_from};
20use crate::enrich::{self, Enrichment};
21use crate::orchestrate::{
22    discover_lockfiles, scan_fleet, GhActionsScan, GoScan, HexScan, JuliaScan, MavenScan, NpmScan,
23    NuGetScan, PackagistScan, PyPiScan, RubyGemsScan, SwiftScan,
24};
25use crate::{npm_reach, reach, resolve, static_reach, vex};
26
27#[derive(Parser)]
28pub(crate) struct ScanArgs {
29    #[arg(short, long, default_value = "./fleet.toml")]
30    config: PathBuf,
31    #[arg(short, long, value_enum, default_value_t = Format::Table)]
32    format: Format,
33
34    // advisory DB control
35    #[arg(long, help = "use a local advisory-db clone instead of fetching")]
36    db: Option<PathBuf>,
37    #[arg(long, help = "pin advisory DB to an exact commit (requires --db)")]
38    db_rev: Option<String>,
39    #[arg(long, help = "never fetch; require cache/--db")]
40    offline: bool,
41    #[arg(long, help = "exit 2 if the usable DB is older than DUR, e.g. 7d")]
42    max_db_age: Option<String>,
43
44    // filtering & gating
45    #[arg(long, value_enum, help = "report only at/above this severity")]
46    min_severity: Option<SeverityArg>,
47    #[arg(long, value_enum, default_value_t = SeverityArg::Low, help = "fail if any vuln at/above")]
48    fail_on: SeverityArg,
49    #[arg(long, help = "also fail if any warning is present")]
50    fail_on_warnings: bool,
51
52    #[arg(
53        long,
54        help = "mark findings built/phantom via cargo tree (needs buildable source)"
55    )]
56    resolve_features: bool,
57    #[arg(
58        long,
59        help = "suppress findings on phantom (unbuilt optional) deps; implies --resolve-features"
60    )]
61    ignore_phantom: bool,
62
63    // exploit-risk enrichment (CISA KEV + FIRST EPSS)
64    #[arg(long, help = "enrich findings with CISA KEV + EPSS (network)")]
65    enrich: bool,
66    #[arg(
67        long,
68        value_name = "PATH",
69        help = "KEV catalog JSON file (offline enrich)"
70    )]
71    kev_file: Option<PathBuf>,
72    #[arg(long, value_name = "PATH", help = "EPSS CSV file (offline enrich)")]
73    epss_file: Option<PathBuf>,
74    #[arg(long, help = "fail if any finding is in the CISA KEV catalog")]
75    fail_on_kev: bool,
76    #[arg(long, value_name = "P", help = "report only findings with EPSS >= P")]
77    min_epss: Option<f32>,
78
79    #[arg(
80        long,
81        value_enum,
82        num_args = 0..=1,
83        default_missing_value = "heuristic",
84        value_name = "MODE",
85        help = "reachability: bare/`heuristic` greps your source (safe, no build); `static` is a sound call-graph analysis that COMPILES each repo — running its build scripts and proc-macros (see --allow-untrusted-builds). Needs --reach-driver."
86    )]
87    reachability: Option<ReachMode>,
88    #[arg(
89        long,
90        help = "drop findings proven/assumed unreachable; implies --reachability"
91    )]
92    reachable_only: bool,
93    #[arg(
94        long,
95        help = "npm only: under --reachability, build a module import graph and mark a vulnerable package NotReachable when node_modules is present and no import path reaches it. Best-effort sound — a dynamic require()/framework autoload it cannot see may make a NotReachable wrong (this flag is your acknowledgement). Implies --reachability."
96    )]
97    npm_prune_unreachable: bool,
98    #[arg(
99        long,
100        help = "REQUIRED to acknowledge that --reachability=static executes the scanned repos' build scripts and proc-macros (arbitrary code). Only scan repos you trust."
101    )]
102    allow_untrusted_builds: bool,
103    #[arg(
104        long,
105        value_name = "PATH",
106        help = "path to the built fleetreach-reach-driver (required for --reachability=static)"
107    )]
108    reach_driver: Option<PathBuf>,
109    #[arg(
110        long,
111        value_name = "PATH",
112        help = "path to the govulncheck binary for scanning Go repos (default: search PATH and $GOPATH/bin). Go scanning also requires --allow-untrusted-builds (govulncheck compiles the module), and the build is confined per --build-sandbox."
113    )]
114    govulncheck: Option<PathBuf>,
115    #[arg(
116        long,
117        value_name = "URL",
118        help = "vulnerability DB for govulncheck (Go), passed as `-db` (default: vuln.go.dev). A `file://<mirror>` lets a confined (network-denied) Go scan run offline; falls back to the GOVULNDB env var."
119    )]
120    go_vuln_db: Option<String>,
121    #[arg(
122        long,
123        value_name = "URL",
124        help = "OSV vulnerability DB for the toolchain-free npm matcher, as `file://<path>` to either the osv.dev npm export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. npm scanning builds nothing, so it needs no --allow-untrusted-builds; without this an npm repo is an honest gap."
125    )]
126    npm_vuln_db: Option<String>,
127    #[arg(
128        long,
129        value_name = "URL",
130        help = "OSV vulnerability DB for the toolchain-free PyPI matcher, as `file://<path>` to either the osv.dev PyPI export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. PyPI scanning reads uv.lock/poetry.lock/Pipfile.lock and builds nothing, so it needs no --allow-untrusted-builds; without this a PyPI repo is an honest gap."
131    )]
132    pypi_vuln_db: Option<String>,
133    #[arg(
134        long,
135        value_name = "URL",
136        help = "OSV vulnerability DB for the toolchain-free RubyGems matcher, as `file://<path>` to either the osv.dev RubyGems export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. RubyGems scanning reads Gemfile.lock and builds nothing, so it needs no --allow-untrusted-builds; without this a RubyGems repo is an honest gap."
137    )]
138    rubygems_vuln_db: Option<String>,
139    #[arg(
140        long,
141        value_name = "URL",
142        help = "OSV vulnerability DB for the toolchain-free Packagist (Composer/PHP) matcher, as `file://<path>` to either the osv.dev Packagist export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. Packagist scanning reads composer.lock and builds nothing, so it needs no --allow-untrusted-builds; without this a Packagist repo is an honest gap."
143    )]
144    packagist_vuln_db: Option<String>,
145    #[arg(
146        long,
147        value_name = "URL",
148        help = "OSV vulnerability DB for the toolchain-free NuGet (.NET) matcher, as `file://<path>` to either the osv.dev NuGet export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. NuGet scanning reads packages.lock.json and builds nothing, so it needs no --allow-untrusted-builds; without this a NuGet repo is an honest gap."
149    )]
150    nuget_vuln_db: Option<String>,
151    #[arg(
152        long,
153        value_name = "URL",
154        help = "OSV vulnerability DB for the toolchain-free Julia matcher, as `file://<path>` to either the osv.dev Julia export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. Julia scanning reads Manifest.toml and builds nothing, so it needs no --allow-untrusted-builds; without this a Julia repo is an honest gap."
155    )]
156    julia_vuln_db: Option<String>,
157    #[arg(
158        long,
159        value_name = "URL",
160        help = "OSV vulnerability DB for the toolchain-free Swift matcher, as `file://<path>` to either the osv.dev SwiftURL export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. Swift scanning reads Package.resolved and builds nothing, so it needs no --allow-untrusted-builds; without this a Swift repo is an honest gap."
161    )]
162    swift_vuln_db: Option<String>,
163    #[arg(
164        long,
165        value_name = "URL",
166        help = "OSV vulnerability DB for the toolchain-free Hex (Elixir) matcher, as `file://<path>` to either the osv.dev Hex export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. Hex scanning reads mix.lock and builds nothing, so it needs no --allow-untrusted-builds; without this a Hex repo is an honest gap."
167    )]
168    hex_vuln_db: Option<String>,
169    #[arg(
170        long,
171        value_name = "URL",
172        help = "OSV vulnerability DB for the toolchain-free GitHub Actions matcher, as `file://<path>` to either the osv.dev GitHub Actions export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. It reads .github/workflows/*.yml and matches version-pinned `uses:` actions, building nothing, so it needs no --allow-untrusted-builds; without this a workflow repo is an honest gap."
173    )]
174    ghactions_vuln_db: Option<String>,
175    #[arg(
176        long,
177        value_name = "URL",
178        help = "OSV vulnerability DB for the toolchain-free Maven (Java) matcher, as `file://<path>` to either the osv.dev Maven export `all.zip` (read directly, fastest) or a directory of unzipped OSV JSON records. It reads gradle.lockfile (preferred) or pom.xml and builds nothing, so it needs no --allow-untrusted-builds; without this a Maven repo is an honest gap."
179    )]
180    maven_vuln_db: Option<String>,
181    #[arg(
182        long,
183        value_enum,
184        default_value = "auto",
185        value_name = "MODE",
186        help = "confine the untrusted build (--reachability=static AND govulncheck for Go repos, both of which compile scanned code): `auto` sandboxes when a mechanism is available (sandbox-exec/bwrap/firejail) else warns; `require` fails without one; `off` runs unconfined. Confinement denies network + writes outside a scratch dir. A confined Go scan is therefore offline: `auto` falls back to an online unconfined scan unless --go-vuln-db=file://<mirror> is set, while `require` needs the mirror or fails closed."
187    )]
188    build_sandbox: BuildSandbox,
189    #[arg(
190        long,
191        value_name = "FEATURES",
192        value_delimiter = ',',
193        help = "cargo features to enable when building for --reachability=static (comma-separated or repeated). Part of the reachability cache key."
194    )]
195    features: Vec<String>,
196    #[arg(long, help = "build with --all-features for --reachability=static")]
197    all_features: bool,
198    #[arg(
199        long,
200        help = "build with --no-default-features for --reachability=static"
201    )]
202    no_default_features: bool,
203
204    // OpenVEX output (`-f vex`, §12)
205    #[arg(
206        long,
207        value_name = "S",
208        help = "OpenVEX mandatory author (overrides settings.vex.author)"
209    )]
210    vex_author: Option<String>,
211    #[arg(long, value_name = "S", help = "OpenVEX document author role")]
212    vex_role: Option<String>,
213    #[arg(
214        long,
215        value_name = "IRI",
216        help = "explicit OpenVEX document @id (default: content hash)"
217    )]
218    vex_id: Option<String>,
219    #[arg(
220        long,
221        value_name = "RFC3339",
222        help = "pin the OpenVEX timestamp (default: advisory-db commit time)"
223    )]
224    vex_timestamp: Option<String>,
225    #[arg(
226        long,
227        value_enum,
228        value_name = "SCOPE",
229        help = "OpenVEX product scope (§7 edge 4)"
230    )]
231    vex_scope: Option<VexScopeArg>,
232    #[arg(
233        long,
234        help = "omit human-asserted VEX statements, keeping only machine-sound ones"
235    )]
236    vex_only_sound: bool,
237    #[arg(
238        long,
239        help = "also emit pkg:rustbinary subcomponents for binary-scanning consumers (§4.2)"
240    )]
241    vex_alias_rustbinary: bool,
242    #[arg(
243        long,
244        help = "emit `fixed` VEX statements for already-patched occurrences"
245    )]
246    vex_include_fixed: bool,
247    #[arg(
248        long,
249        value_name = "N",
250        default_value_t = 1,
251        help = "OpenVEX document version (§9.3); bump when statements change"
252    )]
253    vex_version: u64,
254    #[arg(
255        long,
256        value_name = "IRI",
257        help = "prior OpenVEX document @id this one supersedes (§9.3)"
258    )]
259    vex_supersedes: Option<String>,
260
261    // diffing & inspection
262    #[arg(long, help = "prior JSON report; report only findings new since it")]
263    baseline: Option<PathBuf>,
264    #[arg(
265        long,
266        value_name = "ID",
267        help = "print full detail for one advisory and exit"
268    )]
269    explain: Option<String>,
270    #[arg(
271        long,
272        value_name = "PKG",
273        help = "show how a package enters the fleet's dependency trees and exit"
274    )]
275    why: Option<String>,
276
277    #[arg(short, long, help = "suppress the summary line")]
278    quiet: bool,
279    #[arg(short, long, help = "per-repo progress to stderr")]
280    verbose: bool,
281}
282
283fn load_db(args: &ScanArgs) -> Result<AdvisoryDb, String> {
284    load_db_from(args.db.as_deref(), args.db_rev.as_deref(), args.offline)
285}
286
287/// Find the govulncheck binary: an explicit `--govulncheck` path if it exists,
288/// else the first `govulncheck` on `PATH`, `$GOPATH/bin`, or `~/go/bin`.
289fn locate_govulncheck(explicit: Option<&Path>) -> Option<PathBuf> {
290    if let Some(path) = explicit {
291        return path.is_file().then(|| path.to_path_buf());
292    }
293    let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
294        .map(|p| std::env::split_paths(&p).collect())
295        .unwrap_or_default();
296    if let Some(gopath) = std::env::var_os("GOPATH") {
297        dirs.extend(std::env::split_paths(&gopath).map(|p| p.join("bin")));
298    }
299    if let Some(home) = std::env::var_os("HOME") {
300        dirs.push(PathBuf::from(home).join("go").join("bin"));
301    }
302    dirs.into_iter()
303        .map(|d| d.join("govulncheck"))
304        .find(|p| p.is_file())
305}
306
307pub(crate) fn run_scan(args: ScanArgs) -> u8 {
308    // --explain short-circuits (§10.1): load DB, print one advisory, exit.
309    // It needs no fleet.toml.
310    if let Some(id) = &args.explain {
311        let db = match load_db(&args) {
312            Ok(db) => db,
313            Err(e) => return fail(&e),
314        };
315        return match db.explain(id) {
316            Ok(Some(detail)) => {
317                // The advisory detail is untrusted DB markdown; neutralize terminal
318                // control sequences while keeping newlines so it still reads.
319                println!("{}", fleetreach_report::sanitize_text(&detail));
320                0
321            }
322            Ok(None) => fail(&format!("advisory {id} not found in the database")),
323            Err(e) => fail(&e.to_string()),
324        };
325    }
326
327    // 1. Config (§10.2): any failure is a could-not-scan -> 2.
328    let config = match Config::load(&args.config) {
329        Ok(c) => c,
330        Err(e) => return fail(&e.to_string()),
331    };
332
333    // --why short-circuits (§10.1): a dependency-tree query, no advisory DB.
334    if let Some(package) = &args.why {
335        return run_why(&config, package);
336    }
337
338    // 2. Advisory DB (§10.3): network failure / unusable -> 2, loud, never clean.
339    let db = match load_db(&args) {
340        Ok(db) => db,
341        Err(e) => return fail(&e),
342    };
343
344    // 3. Freshness gate (§3): cannot prove freshness -> 2.
345    if let Some(spec) = &args.max_db_age {
346        if let Err(e) = check_db_age(&db, spec) {
347            return fail(&e);
348        }
349    }
350
351    // 4. Scan the fleet, plus the toolchain if rustc is detectable. With
352    //    --resolve-features, also annotate findings built/phantom for the host.
353    let toolchain = detect_toolchain();
354    // --ignore-phantom needs the build set, so it implies feature resolution.
355    let host = if args.resolve_features || args.ignore_phantom {
356        let detected = resolve::host_triple();
357        if detected.is_none() {
358            eprintln!("warning: feature resolution requested but host triple undetected; skipping");
359        }
360        detected
361    } else {
362        None
363    };
364    // Go repos are scanned by govulncheck, which compiles the module — gate it on
365    // the same consent as static reachability. Absent consent, Go repos surface as
366    // an honest Errored gap (handled in orchestrate), never silently skipped.
367    let go_govulncheck = if args.allow_untrusted_builds {
368        locate_govulncheck(args.govulncheck.as_deref())
369    } else {
370        None
371    };
372    // The Go vuln-DB mirror: the explicit flag wins, else the conventional
373    // GOVULNDB env var. A `file://` value lets a confined Go scan run offline.
374    let go_vuln_db = args
375        .go_vuln_db
376        .clone()
377        .or_else(|| std::env::var("GOVULNDB").ok())
378        .filter(|s| !s.is_empty());
379    // The npm OSV mirror: explicit flag, else the NPMVULNDB env var (mirrors GOVULNDB).
380    let npm_vuln_db = args
381        .npm_vuln_db
382        .clone()
383        .or_else(|| std::env::var("NPMVULNDB").ok())
384        .filter(|s| !s.is_empty());
385    // The PyPI OSV mirror: explicit flag, else the PYPIVULNDB env var (mirrors the above).
386    let pypi_vuln_db = args
387        .pypi_vuln_db
388        .clone()
389        .or_else(|| std::env::var("PYPIVULNDB").ok())
390        .filter(|s| !s.is_empty());
391    // The RubyGems OSV mirror: explicit flag, else the RUBYGEMSVULNDB env var (mirrors the above).
392    let rubygems_vuln_db = args
393        .rubygems_vuln_db
394        .clone()
395        .or_else(|| std::env::var("RUBYGEMSVULNDB").ok())
396        .filter(|s| !s.is_empty());
397    // The Packagist OSV mirror: explicit flag, else the PACKAGISTVULNDB env var (mirrors the above).
398    let packagist_vuln_db = args
399        .packagist_vuln_db
400        .clone()
401        .or_else(|| std::env::var("PACKAGISTVULNDB").ok())
402        .filter(|s| !s.is_empty());
403    // The NuGet OSV mirror: explicit flag, else the NUGETVULNDB env var (mirrors the above).
404    let nuget_vuln_db = args
405        .nuget_vuln_db
406        .clone()
407        .or_else(|| std::env::var("NUGETVULNDB").ok())
408        .filter(|s| !s.is_empty());
409    // The Julia OSV mirror: explicit flag, else the JULIAVULNDB env var (mirrors the above).
410    let julia_vuln_db = args
411        .julia_vuln_db
412        .clone()
413        .or_else(|| std::env::var("JULIAVULNDB").ok())
414        .filter(|s| !s.is_empty());
415    // The Swift OSV mirror: explicit flag, else the SWIFTVULNDB env var (mirrors the above).
416    let swift_vuln_db = args
417        .swift_vuln_db
418        .clone()
419        .or_else(|| std::env::var("SWIFTVULNDB").ok())
420        .filter(|s| !s.is_empty());
421    // The Hex OSV mirror: explicit flag, else the HEXVULNDB env var (mirrors the above).
422    let hex_vuln_db = args
423        .hex_vuln_db
424        .clone()
425        .or_else(|| std::env::var("HEXVULNDB").ok())
426        .filter(|s| !s.is_empty());
427    // The GitHub Actions OSV mirror: explicit flag, else the GHACTIONSVULNDB env var.
428    let ghactions_vuln_db = args
429        .ghactions_vuln_db
430        .clone()
431        .or_else(|| std::env::var("GHACTIONSVULNDB").ok())
432        .filter(|s| !s.is_empty());
433    // The Maven OSV mirror: explicit flag, else the MAVENVULNDB env var.
434    let maven_vuln_db = args
435        .maven_vuln_db
436        .clone()
437        .or_else(|| std::env::var("MAVENVULNDB").ok())
438        .filter(|s| !s.is_empty());
439    let scan = scan_fleet(
440        &db,
441        &config,
442        toolchain.as_ref(),
443        host.as_deref(),
444        &GoScan {
445            govulncheck: go_govulncheck.as_deref(),
446            sandbox: args.build_sandbox.into(),
447            vuln_db: go_vuln_db.as_deref(),
448            offline: args.offline,
449        },
450        &NpmScan {
451            vuln_db: npm_vuln_db.as_deref(),
452        },
453        &PyPiScan {
454            vuln_db: pypi_vuln_db.as_deref(),
455        },
456        &RubyGemsScan {
457            vuln_db: rubygems_vuln_db.as_deref(),
458        },
459        &PackagistScan {
460            vuln_db: packagist_vuln_db.as_deref(),
461        },
462        &NuGetScan {
463            vuln_db: nuget_vuln_db.as_deref(),
464        },
465        &JuliaScan {
466            vuln_db: julia_vuln_db.as_deref(),
467        },
468        &SwiftScan {
469            vuln_db: swift_vuln_db.as_deref(),
470        },
471        &HexScan {
472            vuln_db: hex_vuln_db.as_deref(),
473        },
474        &GhActionsScan {
475            vuln_db: ghactions_vuln_db.as_deref(),
476        },
477        &MavenScan {
478            vuln_db: maven_vuln_db.as_deref(),
479        },
480    );
481
482    if args.verbose {
483        for outcome in &scan.outcomes {
484            eprintln!("  {} — {:?}", outcome.repo, outcome.status);
485        }
486    }
487
488    // Surface toolchain-free packages skipped for an unparseable version, so the skip is
489    // visible rather than silent. These are pins with no registry release (a VCS/URL/path
490    // dependency), which carry no registry advisory — benign, but worth showing so a
491    // malformed-but-real version the parser wrongly rejects is not mistaken for "clean".
492    if scan.skipped_unparseable > 0 {
493        eprintln!(
494            "note: skipped {} package(s) with an unrecognized version format \
495             (non-registry pins have no advisory to match)",
496            scan.skipped_unparseable
497        );
498    }
499
500    // 5. Assemble. Ignores + vex_assertions suppress findings; the removed
501    //    occurrences are captured for VEX promotion (used only by `-f vex`).
502    let provenance = build_provenance(&db.meta());
503    let mut suppressions: Vec<Suppression> = config
504        .ignores
505        .iter()
506        .map(Suppression::from_ignore)
507        .collect();
508    suppressions.extend(
509        config
510            .vex_assertions
511            .iter()
512            .map(Suppression::from_assertion),
513    );
514    let Assembled {
515        report: mut fleet_report,
516        suppressed,
517    } = assemble(
518        scan,
519        &suppressions,
520        args.min_severity.map(Severity::from),
521        provenance,
522    );
523
524    // 5a. Suppress phantom findings (unbuilt optional deps), if requested.
525    if args.ignore_phantom {
526        let dropped = drop_phantom(&mut fleet_report);
527        if dropped > 0 && !args.quiet {
528            eprintln!("suppressed {dropped} finding(s) on packages not in the default build");
529        }
530    }
531
532    // 5a2. Exploit-risk enrichment (KEV + EPSS), opt-in. Annotate, optionally
533    //      filter by EPSS, then re-rank by exploit risk (the action queue).
534    let enrich_requested = args.enrich
535        || args.fail_on_kev
536        || args.min_epss.is_some()
537        || args.kev_file.is_some()
538        || args.epss_file.is_some();
539    if enrich_requested {
540        let loaded = if args.kev_file.is_some() || args.epss_file.is_some() {
541            Enrichment::from_files(args.kev_file.as_deref(), args.epss_file.as_deref())
542        } else if args.offline {
543            // `--offline` must mean no network: do not silently fetch KEV/EPSS/NVD
544            // (which would also leak the fleet's CVE list to a third party).
545            Err(
546                "enrichment needs the network; with --offline supply --kev-file / \
547                 --epss-file (NVD CVSS backfill is unavailable offline)"
548                    .to_string(),
549            )
550        } else {
551            fetch_enrichment(&fleet_report)
552        };
553        match loaded {
554            Ok(enrichment) => {
555                enrichment.apply(&mut fleet_report.vulnerabilities);
556                // apply() backfills `unknown` severities from NVD CVSS, so the summary
557                // (built pre-enrichment) must be refreshed or it reports a stale
558                // max_severity (e.g. `unknown` for a fleet that is actually critical).
559                fleet_report.refresh_summary();
560                if let Some(min) = args.min_epss {
561                    let dropped = retain_min_epss(&mut fleet_report, min);
562                    if !dropped.is_empty() && !args.quiet {
563                        // A network-sourced EPSS score hides a finding, so list
564                        // exactly which advisories it suppressed (auditable).
565                        eprintln!(
566                            "filtered {} finding(s) below EPSS {min} (network-sourced scores):",
567                            dropped.len()
568                        );
569                        for (id, epss) in &dropped {
570                            eprintln!("  {id} (epss {:.0}%)", epss * 100.0);
571                        }
572                    }
573                }
574                enrich::rank(&mut fleet_report.vulnerabilities);
575            }
576            Err(e) => eprintln!("warning: enrichment failed: {e}"),
577        }
578    }
579
580    // 5a3. Reachability (opt-in). `--reachable-only` implies the heuristic when no
581    //      mode is given. The static engine sets the legacy `reachable` bool too,
582    //      so `--reachable-only` drops a sound `NotReachable` via the same path.
583    let reach_mode = args.reachability.or_else(|| {
584        (args.reachable_only || args.npm_prune_unreachable).then_some(ReachMode::Heuristic)
585    });
586    if let Some(mode) = reach_mode {
587        match mode {
588            ReachMode::Heuristic => {
589                reach::assess(&mut fleet_report, &config);
590                // npm gets the richer module-import-graph engine (transitive reachability +
591                // a witness chain; sound-positive, with an opt-in best-effort NotReachable),
592                // overriding the grep heuristic for npm findings.
593                npm_reach::assess(
594                    &mut fleet_report,
595                    &config,
596                    &npm_reach::Options {
597                        prune: args.npm_prune_unreachable,
598                    },
599                );
600            }
601            ReachMode::Static => {
602                // Static reachability COMPILES each repo, which runs its build
603                // scripts and proc-macros — arbitrary code execution. Unlike the
604                // rest of the tool (which only reads Cargo.lock), this requires
605                // explicit, informed consent and warns loudly before any build.
606                if !args.allow_untrusted_builds {
607                    return fail(
608                        "--reachability=static COMPILES each scanned repo, executing its build \
609                         scripts and proc-macros (arbitrary code). Re-run with \
610                         --allow-untrusted-builds only if you trust every repo in the fleet.",
611                    );
612                }
613                let Some(driver) = args.reach_driver.as_deref() else {
614                    return fail("--reachability=static requires --reach-driver <PATH>");
615                };
616                let sandbox = args.build_sandbox.into();
617                let confinement = match args.build_sandbox {
618                    BuildSandbox::Off => "UNCONFINED (--build-sandbox=off)",
619                    BuildSandbox::Auto => {
620                        "sandboxed if a mechanism is available (--build-sandbox=auto)"
621                    }
622                    BuildSandbox::Require => {
623                        "sandboxed, or skipped if no mechanism (--build-sandbox=require)"
624                    }
625                };
626                eprintln!(
627                    "warning: static reachability is about to BUILD {} repo(s), running their \
628                     build scripts and proc-macros: {confinement}. Only trusted repos should be \
629                     scanned this way.",
630                    config.repos.len(),
631                );
632                let features = fleetreach_reach::FeatureSelection {
633                    all_features: args.all_features,
634                    no_default_features: args.no_default_features,
635                    features: args.features.clone(),
636                };
637                static_reach::assess(
638                    &mut fleet_report,
639                    &config,
640                    &static_reach::Options {
641                        driver,
642                        features,
643                        sandbox,
644                        verbose: args.verbose,
645                    },
646                );
647            }
648        }
649        if args.reachable_only {
650            let dropped = retain_reachable(&mut fleet_report);
651            if dropped > 0 && !args.quiet {
652                let how = match mode {
653                    ReachMode::Heuristic => "not found in your source (heuristic)",
654                    ReachMode::Static => "proven unreachable (static)",
655                };
656                eprintln!("dropped {dropped} finding(s) {how}");
657            }
658        }
659    }
660
661    // 5b. Baseline diff (§10.7): keep only findings new since the prior report.
662    let mut baseline_new = false;
663    if let Some(path) = &args.baseline {
664        let json = match std::fs::read_to_string(path) {
665            Ok(json) => json,
666            Err(e) => return fail(&format!("reading baseline `{}`: {e}", path.display())),
667        };
668        let ids = match report::baseline_ids_from_json(&json) {
669            Ok(ids) => ids,
670            Err(e) => return fail(&format!("parsing baseline `{}`: {e}", path.display())),
671        };
672        retain_new(&mut fleet_report, &ids);
673        baseline_new =
674            !fleet_report.vulnerabilities.is_empty() || !fleet_report.warnings.is_empty();
675    }
676
677    // 6. Render: machine payload to stdout, human summary to stderr.
678    // Color only a TTY table — never JSON, never piped output (§7).
679    let payload = match args.format {
680        Format::Json => match report::to_json(&fleet_report) {
681            Ok(json) => json,
682            Err(e) => return fail(&format!("serializing report: {e}")),
683        },
684        Format::Sarif => {
685            // SARIF suppressions (§11): machine not_affected is computed in
686            // to_sarif; approved human assertions are injected here.
687            let product_ids = vex::resolve_product_ids(&config);
688            let assertions = vex::build_human_assertions(&suppressed, &product_ids, false);
689            match report::to_sarif(&fleet_report, &assertions) {
690                Ok(sarif) => sarif,
691                Err(e) => return fail(&format!("serializing SARIF: {e}")),
692            }
693        }
694        Format::Table => report::to_table(&fleet_report, std::io::stdout().is_terminal()),
695        Format::Impact => report::to_impact(&fleet_report, std::io::stdout().is_terminal()),
696        Format::Blast => report::to_blast(&fleet_report, std::io::stdout().is_terminal()),
697        Format::Packages => report::to_packages(&fleet_report, std::io::stdout().is_terminal()),
698        Format::PackagesJson => match report::to_packages_json(&fleet_report) {
699            Ok(json) => json,
700            Err(e) => return fail(&format!("serializing packages: {e}")),
701        },
702        Format::FixFirst => report::to_fix_first(&fleet_report, std::io::stdout().is_terminal()),
703        Format::Remediation => {
704            report::to_remediation(&fleet_report, std::io::stdout().is_terminal())
705        }
706        Format::RemediationJson => match report::to_remediation_json(&fleet_report) {
707            Ok(json) => json,
708            Err(e) => return fail(&format!("serializing remediation: {e}")),
709        },
710        Format::Vex => {
711            // Author/timestamp resolution fails closed as a usage error (§7.3 edge 8).
712            let params = match build_vex_params(&args, &config, &fleet_report, &suppressed) {
713                Ok(params) => params,
714                Err(e) => return usage_fail(&e),
715            };
716            match report::to_vex(&fleet_report, &params) {
717                Ok(doc) => doc,
718                Err(e) => return fail(&format!("serializing VEX: {e}")),
719            }
720        }
721    };
722    println!("{payload}");
723    if !args.quiet {
724        eprintln!("{}", report::summary_line(&fleet_report));
725    }
726
727    // 7. Exit per §8, plus the opt-in KEV and baseline gates. `combine_baseline`
728    //    elevates a clean/gated code to >=1 on its flag while preserving the
729    //    untrustworthy `2`; we reuse it for KEV (any actively-exploited finding).
730    let kev_hit = args.fail_on_kev && fleet_report.vulnerabilities.iter().any(|v| v.exploit.kev);
731    let code = exit_code(
732        &fleet_report,
733        &GateConfig {
734            fail_on: args.fail_on.into(),
735            fail_on_warnings: args.fail_on_warnings,
736        },
737    );
738    combine_baseline(combine_baseline(code, kev_hit), baseline_new)
739}
740
741/// Resolve `-f vex` parameters from flags + `settings.vex` + the report, failing
742/// closed when no author or timestamp resolves.
743fn build_vex_params(
744    args: &ScanArgs,
745    config: &Config,
746    fleet_report: &FleetReport,
747    suppressed: &[SuppressedOccurrence],
748) -> Result<report::VexParams, String> {
749    let author = args
750        .vex_author
751        .clone()
752        .or_else(|| config.vex.author.clone())
753        .ok_or_else(|| "no VEX author: set --vex-author or settings.vex.author".to_string())?;
754
755    // Per-statement provenance lives in `status_notes`; this is the document role.
756    let role = args
757        .vex_role
758        .clone()
759        .or_else(|| config.vex.role.clone())
760        .or_else(|| Some("Document Creator".to_string()));
761
762    let scope = args
763        .vex_scope
764        .map(Into::into)
765        .or(config.vex.scope)
766        .unwrap_or(report::VexScope::Runtime);
767
768    // Reproducible by default: the advisory-DB commit time, never wall-clock (§9.1).
769    let timestamp = args
770        .vex_timestamp
771        .clone()
772        .or_else(|| fleet_report.provenance.db_timestamp.clone())
773        .ok_or_else(|| {
774            "no advisory-db commit time for the VEX timestamp; pass --vex-timestamp <RFC3339>"
775                .to_string()
776        })?;
777
778    let base = config.vex.product_id_base.clone();
779    let product_ids = vex::resolve_product_ids(config);
780    let assertions = vex::build_human_assertions(suppressed, &product_ids, !args.vex_only_sound);
781
782    Ok(report::VexParams {
783        author,
784        role,
785        scope,
786        timestamp,
787        doc_id: args.vex_id.clone(),
788        product_id_base: base,
789        product_ids,
790        assertions,
791        only_sound: args.vex_only_sound,
792        alias_rustbinary: args.vex_alias_rustbinary,
793        include_fixed: args.vex_include_fixed,
794        version: args.vex_version,
795        supersedes: args.vex_supersedes.clone(),
796    })
797}
798
799/// `--why`: print every route by which a package enters each repo's dependency
800/// tree. Exits `0` if found anywhere, `2` if it is in no tree.
801fn run_why(config: &Config, package: &str) -> u8 {
802    let mut found = false;
803    for repo in &config.repos {
804        for lockfile in discover_lockfiles(repo).0 {
805            match routes_to(&lockfile, package) {
806                Ok(routes) => {
807                    for route in routes {
808                        found = true;
809                        let kind = if route.direct { "direct" } else { "transitive" };
810                        // package (arg), version + path segments (untrusted lockfile)
811                        // are echoed to the terminal; neutralize control sequences.
812                        use fleetreach_report::sanitize_cell;
813                        let path: Vec<String> =
814                            route.path.iter().map(|s| sanitize_cell(s)).collect();
815                        println!(
816                            "{} — {} {} ({kind}):",
817                            sanitize_cell(&repo.id.0),
818                            sanitize_cell(package),
819                            sanitize_cell(&route.version),
820                        );
821                        println!("  {}", path.join(" → "));
822                    }
823                }
824                Err(e) => eprintln!("warning: {}: {e}", repo.id),
825            }
826        }
827    }
828    if found {
829        0
830    } else {
831        eprintln!("`{package}` is not in any repo's dependency tree");
832        2
833    }
834}