1use 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 #[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 #[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 #[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 #[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 #[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
287fn 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 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 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 let config = match Config::load(&args.config) {
329 Ok(c) => c,
330 Err(e) => return fail(&e.to_string()),
331 };
332
333 if let Some(package) = &args.why {
335 return run_why(&config, package);
336 }
337
338 let db = match load_db(&args) {
340 Ok(db) => db,
341 Err(e) => return fail(&e),
342 };
343
344 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 let toolchain = detect_toolchain();
354 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 let go_govulncheck = if args.allow_untrusted_builds {
368 locate_govulncheck(args.govulncheck.as_deref())
369 } else {
370 None
371 };
372 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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_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 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 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 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 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 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, ¶ms) {
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 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
741fn 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 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 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
799fn 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 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}