use std::collections::BTreeSet;
use fleetreach_core::{
max_severity_of, FleetReport, Occurrence, Provenance, RepoId, RepoOutcome, ScanStatus,
Severity, Summary,
};
pub fn drop_phantom(report: &mut FleetReport) -> usize {
fn is_phantom(occurrence: &Occurrence) -> bool {
matches!(
occurrence,
Occurrence::InRepo {
active: Some(false),
..
}
)
}
for v in &mut report.vulnerabilities {
v.occurrences.retain(|o| !is_phantom(o));
}
for w in &mut report.warnings {
w.occurrences.retain(|o| !is_phantom(o));
}
let before = report.vulnerabilities.len() + report.warnings.len();
report.vulnerabilities.retain(|v| !v.occurrences.is_empty());
report.warnings.retain(|w| !w.occurrences.is_empty());
let removed = before - (report.vulnerabilities.len() + report.warnings.len());
report.refresh_summary();
removed
}
pub fn retain_reachable(report: &mut FleetReport) -> usize {
let before = report.vulnerabilities.len();
report
.vulnerabilities
.retain(|v| v.reachable != Some(false));
let removed = before - report.vulnerabilities.len();
report.refresh_summary();
removed
}
pub fn retain_min_epss(report: &mut FleetReport, min: f32) -> Vec<(String, f32)> {
let dropped: Vec<(String, f32)> = report
.vulnerabilities
.iter()
.filter_map(|v| {
v.exploit
.epss
.filter(|&e| e < min)
.map(|e| (v.advisory_id.clone(), e))
})
.collect();
report
.vulnerabilities
.retain(|v| v.exploit.epss.is_none_or(|e| e >= min));
report.refresh_summary();
dropped
}
pub fn retain_new(report: &mut FleetReport, baseline_ids: &BTreeSet<String>) {
report
.vulnerabilities
.retain(|v| !baseline_ids.contains(&v.advisory_id));
report.warnings.retain(|w| {
w.advisory_id
.as_ref()
.is_none_or(|id| !baseline_ids.contains(id))
});
report.refresh_summary();
}
pub fn combine_baseline(code: u8, baseline_new: bool) -> u8 {
if code == 2 {
2
} else if baseline_new {
code.max(1)
} else {
code
}
}
use fleetreach_correlate::{correlate, Correlated};
use crate::config::{Ignore, VexAssertion};
use crate::orchestrate::ScanData;
#[derive(Debug, Clone)]
pub struct Suppression {
pub id: String,
pub repo: Option<RepoId>,
pub justification: Option<String>,
pub reason: String,
pub approved_by: Option<String>,
}
impl Suppression {
pub fn from_ignore(ignore: &Ignore) -> Self {
Self {
id: ignore.id.clone(),
repo: None,
justification: None,
reason: ignore.reason.clone(),
approved_by: None,
}
}
pub fn from_assertion(assertion: &VexAssertion) -> Self {
Self {
id: assertion.id.clone(),
repo: assertion.repo.clone(),
justification: assertion.justification.clone(),
reason: assertion.reason.clone(),
approved_by: Some(assertion.approved_by.clone()),
}
}
}
#[derive(Debug, Clone)]
pub struct SuppressedOccurrence {
pub advisory_id: String,
pub aliases: Vec<String>,
pub occurrence: Occurrence,
pub justification: Option<String>,
pub impact_statement: String,
pub approved_by: Option<String>,
}
pub struct Assembled {
pub report: FleetReport,
pub suppressed: Vec<SuppressedOccurrence>,
}
#[derive(Debug, Clone)]
pub struct GateConfig {
pub fail_on: Severity,
pub fail_on_warnings: bool,
}
pub fn assemble(
scan: ScanData,
suppressions: &[Suppression],
min_severity: Option<Severity>,
provenance: Provenance,
) -> Assembled {
let correlated = correlate(scan.vulnerabilities, scan.warnings);
let (mut correlated, stale_ignores, suppressed) = apply_suppressions(correlated, suppressions);
if let Some(min) = min_severity {
correlated
.vulnerabilities
.retain(|v| passes_threshold(v.severity, min));
}
let summary = summarize(&correlated, &scan.outcomes, stale_ignores);
Assembled {
report: FleetReport {
schema_version: fleetreach_core::SCHEMA_VERSION,
provenance,
summary,
vulnerabilities: correlated.vulnerabilities,
warnings: correlated.warnings,
outcomes: scan.outcomes,
},
suppressed,
}
}
pub fn build_report(
scan: ScanData,
ignores: &[Ignore],
min_severity: Option<Severity>,
provenance: Provenance,
) -> FleetReport {
let suppressions: Vec<Suppression> = ignores.iter().map(Suppression::from_ignore).collect();
assemble(scan, &suppressions, min_severity, provenance).report
}
pub fn exit_code(report: &FleetReport, gate: &GateConfig) -> u8 {
if report.summary.repos_errored > 0 || report.summary.repos_scanned == 0 {
return 2;
}
let vuln_hit = report
.vulnerabilities
.iter()
.any(|v| gates(v.severity, gate.fail_on));
let warn_hit = gate.fail_on_warnings && !report.warnings.is_empty();
u8::from(vuln_hit || warn_hit)
}
fn apply_suppressions(
mut correlated: Correlated,
suppressions: &[Suppression],
) -> (Correlated, Vec<String>, Vec<SuppressedOccurrence>) {
let mut matched: BTreeSet<String> = BTreeSet::new();
let mut suppressed: Vec<SuppressedOccurrence> = Vec::new();
correlated.vulnerabilities.retain_mut(|v| {
let mut kept: Vec<Occurrence> = Vec::with_capacity(v.occurrences.len());
for occ in std::mem::take(&mut v.occurrences) {
match matching_suppression(suppressions, &v.advisory_id, &occ) {
Some(s) => {
matched.insert(s.id.clone());
suppressed.push(SuppressedOccurrence {
advisory_id: v.advisory_id.clone(),
aliases: v.aliases.clone(),
occurrence: occ,
justification: s.justification.clone(),
impact_statement: s.reason.clone(),
approved_by: s.approved_by.clone(),
});
}
None => kept.push(occ),
}
}
v.occurrences = kept;
!v.occurrences.is_empty()
});
correlated.warnings.retain(|w| match &w.advisory_id {
Some(id) if suppressions.iter().any(|s| &s.id == id && s.repo.is_none()) => {
matched.insert(id.clone());
false
}
_ => true,
});
let mut seen: BTreeSet<&str> = BTreeSet::new();
let stale = suppressions
.iter()
.map(|s| s.id.as_str())
.filter(|id| !matched.contains(*id) && seen.insert(id))
.map(str::to_string)
.collect();
(correlated, stale, suppressed)
}
fn matching_suppression<'a>(
suppressions: &'a [Suppression],
advisory_id: &str,
occ: &Occurrence,
) -> Option<&'a Suppression> {
let repo = match occ {
Occurrence::InRepo { repo, .. } => Some(repo),
Occurrence::Toolchain { .. } => None,
};
suppressions.iter().find(|s| {
s.id == advisory_id
&& match &s.repo {
None => true,
Some(scoped) => repo == Some(scoped),
}
})
}
fn summarize(
correlated: &Correlated,
outcomes: &[RepoOutcome],
stale_ignores: Vec<String>,
) -> Summary {
let repos_scanned = outcomes
.iter()
.filter(|o| matches!(o.status, ScanStatus::Scanned { .. }))
.count();
let repos_errored = outcomes
.iter()
.filter(|o| matches!(o.status, ScanStatus::Errored { .. }))
.count();
Summary {
repos_scanned,
repos_errored,
vuln_count: correlated.vulnerabilities.len(),
warn_count: correlated.warnings.len(),
max_severity: max_severity_of(&correlated.vulnerabilities),
stale_ignores,
}
}
fn gates(severity: Severity, fail_on: Severity) -> bool {
severity == Severity::Unknown || severity >= fail_on
}
fn passes_threshold(severity: Severity, min: Severity) -> bool {
severity == Severity::Unknown || severity >= min
}