Skip to main content

fleetreach_cli/
vex.rs

1//! The `vex` subcommands: product/assertion resolution shared by `-f vex` and
2//! SARIF, the pure cores of `vex check` (drift) and `vex verify` (witnesses), and
3//! the `Check`/`Verify` argument structs + runners. The binary only parses and
4//! dispatches into [`run_vex_check`]/[`run_vex_verify`].
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use clap::Parser;
10use fleetreach_core::{FleetReport, Occurrence, Provenance, ReachVerdict, Severity, VulnFinding};
11use fleetreach_go::SandboxPolicy;
12use fleetreach_report as report;
13use fleetreach_scan::AdvisoryDb;
14
15use crate::assemble::{assemble, Assembled, SuppressedOccurrence, Suppression};
16use crate::cli::{fail, usage_fail, BuildSandbox};
17use crate::config::{Config, Repo};
18use crate::db::{build_provenance, load_db_from};
19use crate::orchestrate::{
20    scan_fleet, GhActionsScan, GoScan, HexScan, JuliaScan, MavenScan, NpmScan, NuGetScan,
21    PackagistScan, PyPiScan, RubyGemsScan, SwiftScan,
22};
23use crate::static_reach;
24
25/// Resolve a product `@id` (§4.3) for every repo, keyed by repo id.
26pub fn resolve_product_ids(config: &Config) -> BTreeMap<String, String> {
27    let base = config.vex.product_id_base.as_deref();
28    config
29        .repos
30        .iter()
31        .map(|repo| (repo.id.0.clone(), resolve_product_id(repo, base)))
32        .collect()
33}
34
35/// Resolve a repo's product `@id` (§4.3): explicit config, else the publishable-crate
36/// PURL, else `product_id_base` + id, else a `urn:` fallback.
37pub fn resolve_product_id(repo: &Repo, base: Option<&str>) -> String {
38    if let Some(id) = &repo.vex_product_id {
39        return id.clone();
40    }
41    if let Some(purl) = crate_purl(&repo.path) {
42        return purl;
43    }
44    match base {
45        Some(base) => format!("{base}{}", repo.id.0),
46        None => format!("urn:fleetreach:product:{}", repo.id.0),
47    }
48}
49
50/// The `pkg:cargo/<name>@<version>` PURL for a repo root that is itself a publishable
51/// crate. `None` for a virtual/workspace manifest, an inherited version, or `publish = false`.
52fn crate_purl(repo_path: &Path) -> Option<String> {
53    let text = std::fs::read_to_string(repo_path.join("Cargo.toml")).ok()?;
54    let value: toml::Value = toml::from_str(&text).ok()?;
55    let pkg = value.get("package")?.as_table()?;
56    if pkg.get("publish").and_then(toml::Value::as_bool) == Some(false) {
57        return None;
58    }
59    let name = pkg.get("name")?.as_str()?;
60    // A workspace-inherited version is a table, not a string -> fall back.
61    let version = pkg.get("version")?.as_str()?;
62    Some(format!("pkg:cargo/{name}@{version}"))
63}
64
65/// Promote each suppressed occurrence (an ignore or `vex_assertion`) into a human
66/// `not_affected` (§6), shared by the VEX and SARIF paths. `warn_free_text` nudges
67/// once per advisory toward a machine `justification` label.
68pub fn build_human_assertions(
69    suppressed: &[SuppressedOccurrence],
70    product_ids: &BTreeMap<String, String>,
71    warn_free_text: bool,
72) -> Vec<report::HumanAssertion> {
73    let mut assertions = Vec::new();
74    let mut nudged: BTreeSet<&str> = BTreeSet::new();
75    for s in suppressed {
76        let Occurrence::InRepo {
77            repo,
78            package,
79            installed,
80            ..
81        } = &s.occurrence
82        else {
83            continue;
84        };
85        let Some(product_id) = product_ids.get(&repo.0) else {
86            continue;
87        };
88        if warn_free_text && s.justification.is_none() && nudged.insert(&s.advisory_id) {
89            eprintln!(
90                "warning: vex suppression for {} uses a free-text reason; \
91                 prefer a `justification` label for machine consumers",
92                s.advisory_id
93            );
94        }
95        assertions.push(report::HumanAssertion {
96            advisory_id: s.advisory_id.clone(),
97            aliases: s.aliases.clone(),
98            product_id: product_id.clone(),
99            package: package.clone(),
100            version: installed.to_string(),
101            justification: s.justification.clone(),
102            impact_statement: s.impact_statement.clone(),
103            approved_by: s.approved_by.clone(),
104        });
105    }
106    assertions
107}
108
109/// Minimal [`report::VexParams`] for [`report::project`]; the envelope fields
110/// (author/timestamp) are unused by projection.
111pub fn projection_params(
112    product_ids: BTreeMap<String, String>,
113    assertions: Vec<report::HumanAssertion>,
114) -> report::VexParams {
115    report::VexParams {
116        author: String::new(),
117        role: None,
118        scope: report::VexScope::Runtime,
119        timestamp: String::new(),
120        doc_id: None,
121        product_id_base: None,
122        product_ids,
123        assertions,
124        only_sound: false,
125        alias_rustbinary: false,
126        include_fixed: false,
127        version: 1,
128        supersedes: None,
129    }
130}
131
132/// A plain fresh scan assembled with the config's ignores + vex_assertions, for
133/// `vex check`/`verify` to compare against a committed document.
134pub fn assemble_fresh(config: &Config, db: &AdvisoryDb, provenance: Provenance) -> Assembled {
135    // No govulncheck binary or npm/PyPI mirror here, so Go/npm/PyPI repos surface as gaps
136    // before any work — the sandbox policy + DB mirrors are moot; `None` is the neutral
137    // choice.
138    let scan = scan_fleet(
139        db,
140        config,
141        None,
142        None,
143        &GoScan {
144            govulncheck: None,
145            sandbox: SandboxPolicy::Off,
146            vuln_db: None,
147            offline: false,
148        },
149        &NpmScan { vuln_db: None },
150        &PyPiScan { vuln_db: None },
151        &RubyGemsScan { vuln_db: None },
152        &PackagistScan { vuln_db: None },
153        &NuGetScan { vuln_db: None },
154        &JuliaScan { vuln_db: None },
155        &SwiftScan { vuln_db: None },
156        &HexScan { vuln_db: None },
157        &GhActionsScan { vuln_db: None },
158        &MavenScan { vuln_db: None },
159    );
160    let mut suppressions: Vec<Suppression> = config
161        .ignores
162        .iter()
163        .map(Suppression::from_ignore)
164        .collect();
165    suppressions.extend(
166        config
167            .vex_assertions
168            .iter()
169            .map(Suppression::from_assertion),
170    );
171    assemble(scan, &suppressions, None, provenance)
172}
173
174// ---- `vex check` drift (§10) ----
175
176/// The drift between a fresh projection and a committed document (§10).
177pub struct Drift {
178    pub errors: Vec<String>,
179    pub warnings: Vec<String>,
180}
181
182/// A committed VEX statement's identity, status, and authoring role, parsed from a
183/// document for the drift gate.
184pub struct CommittedStatement {
185    pub vulnerability: String,
186    pub product: String,
187    pub subcomponent: String,
188    pub status: String,
189    /// `status_notes`, used to tell a human assertion (`role=Human Assertion`) from
190    /// machine analysis (`role=Automated Analysis`). Only a human `not_affected` is
191    /// status-checked here; a machine one can't be reproduced by a plain scan.
192    pub status_notes: String,
193}
194
195impl CommittedStatement {
196    /// Whether this is a human-authored assertion — the only `not_affected` kind a
197    /// plain scan can re-derive (the assertion lives in config), so the only kind we
198    /// status-check. A machine `not_affected` (reachability / phantom) is left to
199    /// `vex verify`, which actually reproduces it.
200    fn is_human(&self) -> bool {
201        self.status_notes.contains("Human Assertion")
202    }
203}
204
205/// Diff a fresh projection against committed statements (§10):
206/// - a current statement absent from the document is **untriaged** (error);
207/// - a committed `not_affected` whose key is gone is **stale** (error);
208/// - a committed **human** `not_affected` whose key is still present but whose
209///   current status is no longer `not_affected` is a **dropped suppression**
210///   (error) — the assertion was removed or no longer applies. Only human
211///   statements are status-checked: a plain scan can't reproduce a machine
212///   `not_affected` (no `--reachability=static`), so checking its status would
213///   false-flag every one; that case is `vex verify`'s job.
214/// - a still-present gating-severity `under_investigation` is a **warning**.
215pub fn check_drift(
216    current: &[report::StatementView],
217    committed: &[CommittedStatement],
218    severity: &BTreeMap<String, Severity>,
219) -> Drift {
220    let key = |v: &str, p: &str, s: &str| (v.to_string(), p.to_string(), s.to_string());
221    let current_keys: BTreeSet<(String, String, String)> = current
222        .iter()
223        .map(|s| key(&s.vulnerability, &s.product, &s.subcomponent))
224        .collect();
225    // The current status per key, so a committed human `not_affected` can be
226    // compared against what the fresh scan now reports for the same statement.
227    let current_status: BTreeMap<(String, String, String), &str> = current
228        .iter()
229        .map(|s| {
230            (
231                key(&s.vulnerability, &s.product, &s.subcomponent),
232                s.status.as_str(),
233            )
234        })
235        .collect();
236    let committed_keys: BTreeSet<(String, String, String)> = committed
237        .iter()
238        .map(|c| key(&c.vulnerability, &c.product, &c.subcomponent))
239        .collect();
240
241    let mut errors = Vec::new();
242    for s in current {
243        if !current_in(
244            &committed_keys,
245            &s.vulnerability,
246            &s.product,
247            &s.subcomponent,
248        ) {
249            errors.push(format!(
250                "untriaged: {} on {} has no statement in the committed document",
251                s.vulnerability, s.subcomponent
252            ));
253        }
254    }
255    for c in committed {
256        if c.status != "not_affected" {
257            continue;
258        }
259        let k = key(&c.vulnerability, &c.product, &c.subcomponent);
260        match current_status.get(&k) {
261            // Key gone: the suppression pins a version that moved.
262            None => errors.push(format!(
263                "stale not_affected: {} on {} no longer appears in the fleet \
264                 (the suppression pins a version that moved)",
265                c.vulnerability, c.subcomponent
266            )),
267            // Key present but status downgraded — only checkable for human
268            // assertions (a machine verdict isn't reproduced by a plain scan).
269            Some(&now) if now != "not_affected" && c.is_human() => errors.push(format!(
270                "dropped suppression: {} on {} is not_affected in the committed \
271                 document (human assertion) but the current scan reports {now} — \
272                 the assertion was removed or no longer applies",
273                c.vulnerability, c.subcomponent
274            )),
275            Some(_) => {}
276        }
277    }
278    let mut warnings: BTreeSet<String> = BTreeSet::new();
279    for c in committed {
280        if c.status == "under_investigation"
281            && current_in(&current_keys, &c.vulnerability, &c.product, &c.subcomponent)
282            && is_gating_severity(severity.get(&c.vulnerability).copied())
283        {
284            warnings.insert(format!(
285                "{} is still under_investigation at gating severity; \
286                 resolve with --reachability=static",
287                c.vulnerability
288            ));
289        }
290    }
291    Drift {
292        errors,
293        warnings: warnings.into_iter().collect(),
294    }
295}
296
297fn current_in(keys: &BTreeSet<(String, String, String)>, v: &str, p: &str, s: &str) -> bool {
298    keys.contains(&(v.to_string(), p.to_string(), s.to_string()))
299}
300
301/// One [`CommittedStatement`] per statement in the document; missing fields default
302/// to empty (surfaced as drift, never a panic).
303pub fn parse_committed_statements(doc: &serde_json::Value) -> Vec<CommittedStatement> {
304    let Some(stmts) = doc.get("statements").and_then(|s| s.as_array()) else {
305        return Vec::new();
306    };
307    let field = |s: &serde_json::Value, ptr: &str| {
308        s.pointer(ptr)
309            .and_then(|v| v.as_str())
310            .unwrap_or_default()
311            .to_string()
312    };
313    stmts
314        .iter()
315        .map(|s| CommittedStatement {
316            vulnerability: field(s, "/vulnerability/name"),
317            product: field(s, "/products/0/@id"),
318            subcomponent: field(s, "/subcomponents/0/@id"),
319            status: field(s, "/status"),
320            status_notes: field(s, "/status_notes"),
321        })
322        .collect()
323}
324
325/// A gating-severity advisory is one we cannot prove is low-risk: High, Critical,
326/// or Unknown (fail-closed, consistent with the scan gate).
327pub fn is_gating_severity(severity: Option<Severity>) -> bool {
328    matches!(
329        severity,
330        Some(Severity::High) | Some(Severity::Critical) | Some(Severity::Unknown) | None
331    )
332}
333
334// ---- `vex verify` witnesses (§9.2) ----
335
336/// The reachability `not_affected` statements `(vulnerability, subcomponent)` that
337/// `vex verify` re-derives. Phantom and human assertions are out of scope.
338pub fn committed_reachability_witnesses(doc: &serde_json::Value) -> Vec<(String, String)> {
339    fn str_at<'a>(s: &'a serde_json::Value, ptr: &str) -> &'a str {
340        s.pointer(ptr).and_then(|v| v.as_str()).unwrap_or_default()
341    }
342    let Some(stmts) = doc.get("statements").and_then(|s| s.as_array()) else {
343        return Vec::new();
344    };
345    stmts
346        .iter()
347        .filter(|s| {
348            str_at(s, "/status") == "not_affected"
349                && str_at(s, "/justification") == "vulnerable_code_not_in_execute_path"
350                // Only machine witnesses are re-derivable; a human assertion using the
351                // same label is trust-based.
352                && str_at(s, "/status_notes").contains("Automated Analysis")
353        })
354        .map(|s| {
355            (
356                str_at(s, "/vulnerability/name").to_string(),
357                str_at(s, "/subcomponents/0/@id").to_string(),
358            )
359        })
360        .collect()
361}
362
363/// Witnesses that no longer hold against the fresh `report`: the advisory is still
364/// present but no longer a definite `NotReachable`. A disappeared advisory holds
365/// vacuously. Pure, so unit-tested without the reach-driver.
366pub fn failed_reachability_witnesses(
367    witnesses: &[(String, String)],
368    report: &FleetReport,
369) -> Vec<(String, String)> {
370    let by_advisory: BTreeMap<&str, &VulnFinding> = report
371        .vulnerabilities
372        .iter()
373        .map(|v| (v.advisory_id.as_str(), v))
374        .collect();
375    witnesses
376        .iter()
377        .filter(|(vuln, _)| match by_advisory.get(vuln.as_str()) {
378            None => false,
379            Some(finding) => !matches!(
380                finding.reachability.as_ref().map(|r| &r.verdict),
381                Some(ReachVerdict::NotReachable)
382            ),
383        })
384        .cloned()
385        .collect()
386}
387
388// ---- `vex` subcommand arguments + runners ----
389
390#[derive(Parser)]
391pub(crate) struct VexCheckArgs {
392    #[arg(short, long, default_value = "./fleet.toml")]
393    config: PathBuf,
394    #[arg(
395        long,
396        value_name = "PATH",
397        help = "the committed OpenVEX document to check against"
398    )]
399    against: PathBuf,
400
401    // advisory DB control (mirrors `scan`)
402    #[arg(long, help = "use a local advisory-db clone instead of fetching")]
403    db: Option<PathBuf>,
404    #[arg(long, help = "pin advisory DB to an exact commit (requires --db)")]
405    db_rev: Option<String>,
406    #[arg(long, help = "never fetch; require cache/--db")]
407    offline: bool,
408
409    #[arg(short, long, help = "suppress the summary line")]
410    quiet: bool,
411}
412
413#[derive(Parser)]
414pub(crate) struct VexVerifyArgs {
415    /// The OpenVEX document whose machine witnesses to re-check.
416    #[arg(value_name = "DOCUMENT")]
417    document: PathBuf,
418    #[arg(short, long, default_value = "./fleet.toml")]
419    config: PathBuf,
420
421    // advisory DB control (mirrors `scan`)
422    #[arg(long, help = "use a local advisory-db clone instead of fetching")]
423    db: Option<PathBuf>,
424    #[arg(long, help = "pin advisory DB to an exact commit (requires --db)")]
425    db_rev: Option<String>,
426    #[arg(long, help = "never fetch; require cache/--db")]
427    offline: bool,
428
429    // static reachability (re-derivation COMPILES each repo — same gates as scan)
430    #[arg(
431        long,
432        help = "REQUIRED: re-deriving witnesses compiles each repo, running its build scripts and proc-macros (arbitrary code). Only verify repos you trust."
433    )]
434    allow_untrusted_builds: bool,
435    #[arg(
436        long,
437        value_name = "PATH",
438        help = "path to the built fleetreach-reach-driver"
439    )]
440    reach_driver: Option<PathBuf>,
441    #[arg(long, value_enum, default_value = "auto", value_name = "MODE")]
442    build_sandbox: BuildSandbox,
443    #[arg(long, value_name = "FEATURES", value_delimiter = ',')]
444    features: Vec<String>,
445    #[arg(long)]
446    all_features: bool,
447    #[arg(long)]
448    no_default_features: bool,
449
450    #[arg(short, long, help = "suppress the summary line")]
451    quiet: bool,
452    #[arg(short, long, help = "per-repo progress to stderr")]
453    verbose: bool,
454}
455
456/// `vex check` (§10): fail (exit 1) when a committed OpenVEX document has drifted
457/// from a fresh scan — a stale `not_affected` (pinned version gone) or an untriaged
458/// finding. A gating-severity `under_investigation` is a warning; exit 2 = can't-scan.
459pub(crate) fn run_vex_check(args: VexCheckArgs) -> u8 {
460    let config = match Config::load(&args.config) {
461        Ok(config) => config,
462        Err(e) => return fail(&e.to_string()),
463    };
464    let db = match load_db_from(args.db.as_deref(), args.db_rev.as_deref(), args.offline) {
465        Ok(db) => db,
466        Err(e) => return fail(&e),
467    };
468    let committed_text = match std::fs::read_to_string(&args.against) {
469        Ok(text) => text,
470        Err(e) => {
471            return fail(&format!(
472                "reading committed VEX `{}`: {e}",
473                args.against.display()
474            ))
475        }
476    };
477    let committed: serde_json::Value = match serde_json::from_str(&committed_text) {
478        Ok(value) => value,
479        Err(e) => {
480            return fail(&format!(
481                "parsing committed VEX `{}`: {e}",
482                args.against.display()
483            ))
484        }
485    };
486
487    let provenance = build_provenance(&db.meta());
488    let Assembled { report, suppressed } = assemble_fresh(&config, &db, provenance);
489    let product_ids = resolve_product_ids(&config);
490    let assertions = build_human_assertions(&suppressed, &product_ids, false);
491    let params = projection_params(product_ids, assertions);
492    let current = report::project(&report, &params);
493
494    let committed_stmts = parse_committed_statements(&committed);
495    let severity: BTreeMap<String, Severity> = report
496        .vulnerabilities
497        .iter()
498        .map(|v| (v.advisory_id.clone(), v.severity))
499        .collect();
500    let drift = check_drift(&current, &committed_stmts, &severity);
501
502    for w in &drift.warnings {
503        eprintln!("warning: {w}");
504    }
505    for e in &drift.errors {
506        eprintln!("error: {e}");
507    }
508    if !args.quiet {
509        eprintln!(
510            "vex check: {} current statement(s), {} committed; {} drift error(s), {} warning(s).",
511            current.len(),
512            committed_stmts.len(),
513            drift.errors.len(),
514            drift.warnings.len()
515        );
516    }
517    u8::from(!drift.errors.is_empty())
518}
519
520/// `vex verify` (§9.2): re-derive each reachability `not_affected` witness against
521/// current source, failing (exit 1) if any no longer holds. COMPILES each repo, so
522/// it needs the same consent + driver as `scan --reachability=static` (else exit 3).
523pub(crate) fn run_vex_verify(args: VexVerifyArgs) -> u8 {
524    if !args.allow_untrusted_builds {
525        return usage_fail(
526            "vex verify re-derives witnesses by COMPILING each repo (build scripts + \
527             proc-macros run). Re-run with --allow-untrusted-builds only if you trust every repo.",
528        );
529    }
530    let Some(driver) = args.reach_driver.as_deref() else {
531        return usage_fail("vex verify requires --reach-driver <PATH>");
532    };
533    let config = match Config::load(&args.config) {
534        Ok(config) => config,
535        Err(e) => return fail(&e.to_string()),
536    };
537    let committed_text = match std::fs::read_to_string(&args.document) {
538        Ok(text) => text,
539        Err(e) => return fail(&format!("reading `{}`: {e}", args.document.display())),
540    };
541    let committed: serde_json::Value = match serde_json::from_str(&committed_text) {
542        Ok(value) => value,
543        Err(e) => return fail(&format!("parsing `{}`: {e}", args.document.display())),
544    };
545
546    let targets = committed_reachability_witnesses(&committed);
547    if targets.is_empty() {
548        if !args.quiet {
549            eprintln!("vex verify: no reachability witnesses to re-derive.");
550        }
551        return 0;
552    }
553
554    let db = match load_db_from(args.db.as_deref(), args.db_rev.as_deref(), args.offline) {
555        Ok(db) => db,
556        Err(e) => return fail(&e),
557    };
558    let provenance = build_provenance(&db.meta());
559    let Assembled {
560        mut report,
561        suppressed: _,
562    } = assemble_fresh(&config, &db, provenance);
563
564    let features = fleetreach_reach::FeatureSelection {
565        all_features: args.all_features,
566        no_default_features: args.no_default_features,
567        features: args.features.clone(),
568    };
569    static_reach::assess(
570        &mut report,
571        &config,
572        &static_reach::Options {
573            driver,
574            features,
575            sandbox: args.build_sandbox.into(),
576            verbose: args.verbose,
577        },
578    );
579
580    let failed = failed_reachability_witnesses(&targets, &report);
581    for (vuln, sub) in &failed {
582        eprintln!("error: witness no longer holds: {vuln} on {sub} is now reachable or undecided");
583    }
584    if !args.quiet {
585        eprintln!(
586            "vex verify: {} witness(es) re-derived, {} failed.",
587            targets.len(),
588            failed.len()
589        );
590    }
591    u8::from(!failed.is_empty())
592}
593
594#[cfg(test)]
595#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
596mod tests {
597    use super::*;
598    use fleetreach_core::semver::Version;
599    use fleetreach_core::{DependencyKind, Provenance, Reachability, RepoId, Summary, VulnFinding};
600    use fleetreach_report::StatementView;
601
602    fn finding_with_reach(id: &str, reach: Option<ReachVerdict>) -> VulnFinding {
603        VulnFinding {
604            advisory_id: id.into(),
605            aliases: vec![],
606            ecosystem: Default::default(),
607            title: "t".into(),
608            severity: Severity::High,
609            cvss_score: None,
610            url: None,
611            occurrences: vec![Occurrence::InRepo {
612                repo: RepoId("app".into()),
613                package: "foo".into(),
614                installed: Version::new(1, 0, 0),
615                patched: vec![],
616                dependency_kind: DependencyKind::Direct,
617                dependency_path: vec![],
618                active: None,
619                source: Default::default(),
620            }],
621            affected_functions: vec!["foo::bad".into()],
622            reachable: None,
623            reachability: reach.map(|verdict| Reachability {
624                verdict,
625                config: "nightly".into(),
626                engine: "e".into(),
627                targets: vec!["x86_64-unknown-linux-gnu".into()],
628                witness: Some("sha256:abc".into()),
629            }),
630            exploit: Default::default(),
631        }
632    }
633
634    fn report_of(vulns: Vec<VulnFinding>) -> FleetReport {
635        FleetReport {
636            schema_version: 1,
637            provenance: Provenance {
638                tool_version: "0".into(),
639                rustsec_crate_version: "0".into(),
640                db_commit: None,
641                db_timestamp: None,
642                host_os: "linux".into(),
643                host_arch: "x86_64".into(),
644                generated_at: "t".into(),
645            },
646            summary: Summary {
647                repos_scanned: 1,
648                repos_errored: 0,
649                vuln_count: vulns.len(),
650                warn_count: 0,
651                max_severity: Severity::High,
652                stale_ignores: vec![],
653            },
654            vulnerabilities: vulns,
655            warnings: vec![],
656            outcomes: vec![],
657        }
658    }
659
660    fn view(v: &str, p: &str, s: &str, status: &str) -> StatementView {
661        StatementView {
662            vulnerability: v.into(),
663            product: p.into(),
664            subcomponent: s.into(),
665            status: status.into(),
666        }
667    }
668
669    fn committed(v: &str, p: &str, s: &str, status: &str, notes: &str) -> CommittedStatement {
670        CommittedStatement {
671            vulnerability: v.into(),
672            product: p.into(),
673            subcomponent: s.into(),
674            status: status.into(),
675            status_notes: notes.into(),
676        }
677    }
678
679    #[test]
680    fn drift_flags_untriaged_and_stale() {
681        let current = vec![view(
682            "RUSTSEC-NEW",
683            "p",
684            "pkg:cargo/foo@1",
685            "under_investigation",
686        )];
687        let committed = vec![committed(
688            "RUSTSEC-OLD",
689            "p",
690            "pkg:cargo/bar@1",
691            "not_affected",
692            "role=Automated Analysis",
693        )];
694        let drift = check_drift(&current, &committed, &BTreeMap::new());
695        assert_eq!(drift.errors.len(), 2, "untriaged + stale");
696        assert!(drift.warnings.is_empty());
697    }
698
699    #[test]
700    fn drift_is_empty_when_in_sync() {
701        let current = vec![view("RUSTSEC-A", "p", "s", "not_affected")];
702        let committed = vec![committed(
703            "RUSTSEC-A",
704            "p",
705            "s",
706            "not_affected",
707            "role=Human Assertion; approved_by=x",
708        )];
709        let drift = check_drift(&current, &committed, &BTreeMap::new());
710        assert!(drift.errors.is_empty());
711    }
712
713    /// A human `not_affected` whose assertion was removed: the finding reappears as
714    /// `under_investigation` in the plain scan (same key, new status) → drift.
715    #[test]
716    fn dropped_human_suppression_is_flagged() {
717        let current = vec![view("RUSTSEC-A", "p", "s", "under_investigation")];
718        let committed = vec![committed(
719            "RUSTSEC-A",
720            "p",
721            "s",
722            "not_affected",
723            "role=Human Assertion; approved_by=x",
724        )];
725        let drift = check_drift(&current, &committed, &BTreeMap::new());
726        assert_eq!(drift.errors.len(), 1, "dropped suppression");
727        assert!(drift.errors[0].contains("dropped suppression"));
728    }
729
730    /// A machine `not_affected` reads as `under_investigation` in the plain scan
731    /// (reachability didn't run) — status-checking it would false-flag, so it must
732    /// NOT drift. (Re-deriving it is `vex verify`'s job.)
733    #[test]
734    fn machine_not_affected_is_not_status_checked() {
735        let current = vec![view("RUSTSEC-A", "p", "s", "under_investigation")];
736        let committed = vec![committed(
737            "RUSTSEC-A",
738            "p",
739            "s",
740            "not_affected",
741            "role=Automated Analysis; static call-graph: no path",
742        )];
743        let drift = check_drift(&current, &committed, &BTreeMap::new());
744        assert!(
745            drift.errors.is_empty(),
746            "machine not_affected must not be status-checked: {:?}",
747            drift.errors
748        );
749    }
750
751    /// Only machine reachability `not_affected` are witnesses; human assertions excluded.
752    #[test]
753    fn extracts_only_machine_reachability_witnesses() {
754        let doc = serde_json::json!({ "statements": [
755            { "vulnerability": { "name": "RUSTSEC-A" },
756              "subcomponents": [{ "@id": "pkg:cargo/foo@1.0.0" }],
757              "status": "not_affected",
758              "justification": "vulnerable_code_not_in_execute_path",
759              "status_notes": "role=Automated Analysis; static call-graph: no path" },
760            { "vulnerability": { "name": "RUSTSEC-H" },
761              "subcomponents": [{ "@id": "pkg:cargo/bar@2.0.0" }],
762              "status": "not_affected",
763              "justification": "vulnerable_code_not_in_execute_path",
764              "status_notes": "role=Human Assertion; approved_by=x" },
765            { "vulnerability": { "name": "RUSTSEC-U" },
766              "subcomponents": [{ "@id": "pkg:cargo/baz@3.0.0" }],
767              "status": "under_investigation" },
768        ]});
769        let w = committed_reachability_witnesses(&doc);
770        assert_eq!(
771            w,
772            vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())]
773        );
774    }
775
776    #[test]
777    fn witness_holds_when_still_not_reachable() {
778        let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
779        let report = report_of(vec![finding_with_reach(
780            "RUSTSEC-A",
781            Some(ReachVerdict::NotReachable),
782        )]);
783        assert!(failed_reachability_witnesses(&w, &report).is_empty());
784    }
785
786    #[test]
787    fn witness_fails_when_now_reachable() {
788        let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
789        let report = report_of(vec![finding_with_reach(
790            "RUSTSEC-A",
791            Some(ReachVerdict::Reachable {
792                witness: vec!["main".into(), "foo::bad".into()],
793            }),
794        )]);
795        assert_eq!(failed_reachability_witnesses(&w, &report).len(), 1);
796    }
797
798    #[test]
799    fn witness_holds_vacuously_when_advisory_is_gone() {
800        let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
801        assert!(failed_reachability_witnesses(&w, &report_of(vec![])).is_empty());
802    }
803
804    #[test]
805    fn witness_fails_when_now_undecided() {
806        let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
807        let report = report_of(vec![finding_with_reach("RUSTSEC-A", None)]);
808        assert_eq!(failed_reachability_witnesses(&w, &report).len(), 1);
809    }
810}