Skip to main content

fleetreach_cli/
assemble.rs

1//! Turning raw scan output into a final [`FleetReport`] plus its exit code.
2//!
3//! Pipeline steps §10.5–8: correlate, apply ignores (recording stale ones),
4//! filter by min severity, summarize. The clock is injected via `provenance`, so
5//! report assembly is fully deterministic and testable.
6
7use std::collections::BTreeSet;
8
9use fleetreach_core::{
10    max_severity_of, FleetReport, Occurrence, Provenance, RepoId, RepoOutcome, ScanStatus,
11    Severity, Summary,
12};
13
14/// Drop occurrences known to be phantom — a `Cargo.lock`-only optional dependency
15/// that is never compiled (`active: Some(false)`, from `--resolve-features`) —
16/// and remove any finding left with no occurrences. Recomputes summary counts.
17/// Returns the number of findings removed entirely. Occurrences with unknown
18/// build status (`active: None`) are always kept (fail-closed).
19pub fn drop_phantom(report: &mut FleetReport) -> usize {
20    fn is_phantom(occurrence: &Occurrence) -> bool {
21        matches!(
22            occurrence,
23            Occurrence::InRepo {
24                active: Some(false),
25                ..
26            }
27        )
28    }
29
30    for v in &mut report.vulnerabilities {
31        v.occurrences.retain(|o| !is_phantom(o));
32    }
33    for w in &mut report.warnings {
34        w.occurrences.retain(|o| !is_phantom(o));
35    }
36
37    let before = report.vulnerabilities.len() + report.warnings.len();
38    report.vulnerabilities.retain(|v| !v.occurrences.is_empty());
39    report.warnings.retain(|w| !w.occurrences.is_empty());
40    let removed = before - (report.vulnerabilities.len() + report.warnings.len());
41
42    report.refresh_summary();
43    removed
44}
45
46/// Drop vulnerabilities the reachability heuristic marked `Some(false)` (no
47/// affected function name found in your source). `None`/`Some(true)` are kept —
48/// fail-closed, since the heuristic cannot prove unreachability. Returns the
49/// number removed.
50pub fn retain_reachable(report: &mut FleetReport) -> usize {
51    let before = report.vulnerabilities.len();
52    report
53        .vulnerabilities
54        .retain(|v| v.reachable != Some(false));
55    let removed = before - report.vulnerabilities.len();
56    report.refresh_summary();
57    removed
58}
59
60/// Keep only vulnerabilities whose EPSS is at/above `min`; unknown EPSS is kept
61/// (fail-closed). Recomputes counts. Returns the `(advisory_id, epss)` of each
62/// finding dropped — the EPSS score is network-sourced and *hides* a finding, so
63/// the caller surfaces exactly what a feed suppressed (auditable, not silent).
64pub fn retain_min_epss(report: &mut FleetReport, min: f32) -> Vec<(String, f32)> {
65    let dropped: Vec<(String, f32)> = report
66        .vulnerabilities
67        .iter()
68        .filter_map(|v| {
69            v.exploit
70                .epss
71                .filter(|&e| e < min)
72                .map(|e| (v.advisory_id.clone(), e))
73        })
74        .collect();
75    report
76        .vulnerabilities
77        .retain(|v| v.exploit.epss.is_none_or(|e| e >= min));
78    report.refresh_summary();
79    dropped
80}
81
82/// Filter a report down to findings **not** present in the baseline id set, then
83/// recompute the affected summary counts. Used by `--baseline` to surface only
84/// what is new since a prior run.
85pub fn retain_new(report: &mut FleetReport, baseline_ids: &BTreeSet<String>) {
86    report
87        .vulnerabilities
88        .retain(|v| !baseline_ids.contains(&v.advisory_id));
89    report.warnings.retain(|w| {
90        w.advisory_id
91            .as_ref()
92            .is_none_or(|id| !baseline_ids.contains(id))
93    });
94    report.refresh_summary();
95}
96
97/// Fold a `--baseline` "has new findings" signal into an exit code while
98/// preserving §8 precedence: an untrustworthy `2` always wins; otherwise a new
99/// finding raises the code to at least `1`.
100///
101/// ```
102/// use fleetreach_cli::assemble::combine_baseline;
103///
104/// assert_eq!(combine_baseline(2, true), 2);  // untrustworthy wins
105/// assert_eq!(combine_baseline(0, true), 1);  // a new finding gates
106/// assert_eq!(combine_baseline(0, false), 0); // nothing new, unchanged
107/// ```
108pub fn combine_baseline(code: u8, baseline_new: bool) -> u8 {
109    if code == 2 {
110        2
111    } else if baseline_new {
112        code.max(1)
113    } else {
114        code
115    }
116}
117use fleetreach_correlate::{correlate, Correlated};
118
119use crate::config::{Ignore, VexAssertion};
120use crate::orchestrate::ScanData;
121
122/// A human suppression applied before gating (§6): an `ignore` (fleet-wide, no
123/// approver) or a `vex_assertion` (optionally repo-scoped, approved). Matching
124/// occurrences are removed from the gated report and captured for `-f vex`.
125#[derive(Debug, Clone)]
126pub struct Suppression {
127    pub id: String,
128    /// `None` = fleet-wide; else only this repo.
129    pub repo: Option<RepoId>,
130    pub justification: Option<String>,
131    /// The OpenVEX `impact_statement` when there is no `justification`.
132    pub reason: String,
133    /// `None` for an `ignore`; `Some(approver)` for a `vex_assertion`.
134    pub approved_by: Option<String>,
135}
136
137impl Suppression {
138    pub fn from_ignore(ignore: &Ignore) -> Self {
139        Self {
140            id: ignore.id.clone(),
141            repo: None,
142            justification: None,
143            reason: ignore.reason.clone(),
144            approved_by: None,
145        }
146    }
147
148    pub fn from_assertion(assertion: &VexAssertion) -> Self {
149        Self {
150            id: assertion.id.clone(),
151            repo: assertion.repo.clone(),
152            justification: assertion.justification.clone(),
153            reason: assertion.reason.clone(),
154            approved_by: Some(assertion.approved_by.clone()),
155        }
156    }
157}
158
159/// An occurrence removed by a [`Suppression`], with the context `-f vex` needs to
160/// emit a `not_affected` statement (§6, §7.1).
161#[derive(Debug, Clone)]
162pub struct SuppressedOccurrence {
163    pub advisory_id: String,
164    pub aliases: Vec<String>,
165    pub occurrence: Occurrence,
166    pub justification: Option<String>,
167    pub impact_statement: String,
168    pub approved_by: Option<String>,
169}
170
171/// An assembled report plus the suppressed occurrences (consumed only by `-f vex`).
172pub struct Assembled {
173    pub report: FleetReport,
174    pub suppressed: Vec<SuppressedOccurrence>,
175}
176
177/// What makes a trustworthy run "fail" with exit `1` (§8).
178#[derive(Debug, Clone)]
179pub struct GateConfig {
180    pub fail_on: Severity,
181    pub fail_on_warnings: bool,
182}
183
184/// Correlate and assemble the report, capturing occurrences removed by
185/// `suppressions` for VEX promotion.
186pub fn assemble(
187    scan: ScanData,
188    suppressions: &[Suppression],
189    min_severity: Option<Severity>,
190    provenance: Provenance,
191) -> Assembled {
192    let correlated = correlate(scan.vulnerabilities, scan.warnings);
193    let (mut correlated, stale_ignores, suppressed) = apply_suppressions(correlated, suppressions);
194    if let Some(min) = min_severity {
195        correlated
196            .vulnerabilities
197            .retain(|v| passes_threshold(v.severity, min));
198    }
199    let summary = summarize(&correlated, &scan.outcomes, stale_ignores);
200    Assembled {
201        report: FleetReport {
202            schema_version: fleetreach_core::SCHEMA_VERSION,
203            provenance,
204            summary,
205            vulnerabilities: correlated.vulnerabilities,
206            warnings: correlated.warnings,
207            outcomes: scan.outcomes,
208        },
209        suppressed,
210    }
211}
212
213/// [`assemble`] for non-VEX callers: each ignore is a fleet-wide suppression and
214/// the captured occurrences are discarded.
215pub fn build_report(
216    scan: ScanData,
217    ignores: &[Ignore],
218    min_severity: Option<Severity>,
219    provenance: Provenance,
220) -> FleetReport {
221    let suppressions: Vec<Suppression> = ignores.iter().map(Suppression::from_ignore).collect();
222    assemble(scan, &suppressions, min_severity, provenance).report
223}
224
225/// The §8 exit code for an already-assembled (trustworthy) report.
226///
227/// Precedence is resolved by the caller for the "could not scan" cases (config
228/// invalid, DB unloadable/stale) → `2`. Here we resolve the rest: any errored
229/// repo or zero repos scanned is still `2` (a gap means we cannot claim clean);
230/// otherwise `1` if the gate trips, else `0`.
231pub fn exit_code(report: &FleetReport, gate: &GateConfig) -> u8 {
232    if report.summary.repos_errored > 0 || report.summary.repos_scanned == 0 {
233        return 2;
234    }
235    let vuln_hit = report
236        .vulnerabilities
237        .iter()
238        .any(|v| gates(v.severity, gate.fail_on));
239    let warn_hit = gate.fail_on_warnings && !report.warnings.is_empty();
240    u8::from(vuln_hit || warn_hit)
241}
242
243/// Apply suppressions per occurrence: each match is removed and captured, and a
244/// finding is dropped only once all its occurrences are gone. A fleet-wide
245/// suppression clears every occurrence; a repo-scoped one only that repo's.
246fn apply_suppressions(
247    mut correlated: Correlated,
248    suppressions: &[Suppression],
249) -> (Correlated, Vec<String>, Vec<SuppressedOccurrence>) {
250    let mut matched: BTreeSet<String> = BTreeSet::new();
251    let mut suppressed: Vec<SuppressedOccurrence> = Vec::new();
252
253    correlated.vulnerabilities.retain_mut(|v| {
254        let mut kept: Vec<Occurrence> = Vec::with_capacity(v.occurrences.len());
255        for occ in std::mem::take(&mut v.occurrences) {
256            match matching_suppression(suppressions, &v.advisory_id, &occ) {
257                Some(s) => {
258                    matched.insert(s.id.clone());
259                    suppressed.push(SuppressedOccurrence {
260                        advisory_id: v.advisory_id.clone(),
261                        aliases: v.aliases.clone(),
262                        occurrence: occ,
263                        justification: s.justification.clone(),
264                        impact_statement: s.reason.clone(),
265                        approved_by: s.approved_by.clone(),
266                    });
267                }
268                None => kept.push(occ),
269            }
270        }
271        v.occurrences = kept;
272        !v.occurrences.is_empty()
273    });
274
275    // Warnings have no per-occurrence subcomponent; suppressed only fleet-wide.
276    correlated.warnings.retain(|w| match &w.advisory_id {
277        Some(id) if suppressions.iter().any(|s| &s.id == id && s.repo.is_none()) => {
278            matched.insert(id.clone());
279            false
280        }
281        _ => true,
282    });
283
284    // Suppressions that matched nothing are stale (surfaced). Deduped, order kept.
285    let mut seen: BTreeSet<&str> = BTreeSet::new();
286    let stale = suppressions
287        .iter()
288        .map(|s| s.id.as_str())
289        .filter(|id| !matched.contains(*id) && seen.insert(id))
290        .map(str::to_string)
291        .collect();
292    (correlated, stale, suppressed)
293}
294
295/// The first suppression that applies to `occ` of `advisory_id`: ids must match,
296/// and a repo-scoped suppression matches only an in-repo occurrence of that repo.
297fn matching_suppression<'a>(
298    suppressions: &'a [Suppression],
299    advisory_id: &str,
300    occ: &Occurrence,
301) -> Option<&'a Suppression> {
302    let repo = match occ {
303        Occurrence::InRepo { repo, .. } => Some(repo),
304        Occurrence::Toolchain { .. } => None,
305    };
306    suppressions.iter().find(|s| {
307        s.id == advisory_id
308            && match &s.repo {
309                None => true,
310                Some(scoped) => repo == Some(scoped),
311            }
312    })
313}
314
315fn summarize(
316    correlated: &Correlated,
317    outcomes: &[RepoOutcome],
318    stale_ignores: Vec<String>,
319) -> Summary {
320    let repos_scanned = outcomes
321        .iter()
322        .filter(|o| matches!(o.status, ScanStatus::Scanned { .. }))
323        .count();
324    let repos_errored = outcomes
325        .iter()
326        .filter(|o| matches!(o.status, ScanStatus::Errored { .. }))
327        .count();
328    Summary {
329        repos_scanned,
330        repos_errored,
331        vuln_count: correlated.vulnerabilities.len(),
332        warn_count: correlated.warnings.len(),
333        max_severity: max_severity_of(&correlated.vulnerabilities),
334        stale_ignores,
335    }
336}
337
338/// Fail-closed gating: an `Unknown`-severity vulnerability always trips the gate,
339/// because we cannot prove it sits below the threshold.
340fn gates(severity: Severity, fail_on: Severity) -> bool {
341    severity == Severity::Unknown || severity >= fail_on
342}
343
344fn passes_threshold(severity: Severity, min: Severity) -> bool {
345    severity == Severity::Unknown || severity >= min
346}