1use std::collections::BTreeSet;
8
9use fleetreach_core::{
10 max_severity_of, FleetReport, Occurrence, Provenance, RepoId, RepoOutcome, ScanStatus,
11 Severity, Summary,
12};
13
14pub 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
46pub 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
60pub 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
82pub 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
97pub 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#[derive(Debug, Clone)]
126pub struct Suppression {
127 pub id: String,
128 pub repo: Option<RepoId>,
130 pub justification: Option<String>,
131 pub reason: String,
133 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#[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
171pub struct Assembled {
173 pub report: FleetReport,
174 pub suppressed: Vec<SuppressedOccurrence>,
175}
176
177#[derive(Debug, Clone)]
179pub struct GateConfig {
180 pub fail_on: Severity,
181 pub fail_on_warnings: bool,
182}
183
184pub 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
213pub 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
225pub 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
243fn 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 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 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
295fn 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
338fn 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}