Skip to main content

fleetreach_core/
report.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{RepoOutcome, Severity, VulnFinding, WarnFinding};
4
5/// Pinned from day one so machine consumers can branch on the wire format.
6/// v2 enrichment is additive and must not bump this.
7pub const SCHEMA_VERSION: u32 = 1;
8
9/// The complete result of one `scan`. Field order matches the JSON schema (§9).
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct FleetReport {
12    pub schema_version: u32,
13    pub provenance: Provenance,
14    pub summary: Summary,
15    /// Sorted: severity desc, then advisory id (total, stable).
16    pub vulnerabilities: Vec<VulnFinding>,
17    pub warnings: Vec<WarnFinding>,
18    pub outcomes: Vec<RepoOutcome>,
19}
20
21/// Everything needed to reproduce a stored report by re-running with the same
22/// `--db-rev`. The DB timestamp is the freshness signal (§3).
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct Provenance {
25    pub tool_version: String,
26    pub rustsec_crate_version: String,
27    /// git sha of the advisory-db commit used; `null` when the DB carries no
28    /// commit metadata (e.g. a non-git directory) — honest absence, not `""`.
29    pub db_commit: Option<String>,
30    /// RFC3339 timestamp of that commit; `null` when unknown.
31    pub db_timestamp: Option<String>,
32    /// Advisories are OS/arch-scoped, so the host matters.
33    pub host_os: String,
34    pub host_arch: String,
35    /// RFC3339.
36    pub generated_at: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Summary {
41    pub repos_scanned: usize,
42    pub repos_errored: usize,
43    pub vuln_count: usize,
44    pub warn_count: usize,
45    pub max_severity: Severity,
46    /// Configured ignores that matched nothing this run — dead suppressions
47    /// surfaced as a warning so they don't silently mask future regressions.
48    pub stale_ignores: Vec<String>,
49}
50
51/// The maximum severity across `vulns` (`Unknown` when empty). The single definition
52/// of the summary's "max severity", so the initial summary and every stage that later
53/// filters findings derive it identically.
54pub fn max_severity_of(vulns: &[VulnFinding]) -> Severity {
55    vulns
56        .iter()
57        .map(|v| v.severity)
58        .max()
59        .unwrap_or(Severity::Unknown)
60}
61
62impl FleetReport {
63    /// Recompute the summary's finding-derived fields (`vuln_count`, `warn_count`,
64    /// `max_severity`) from the current findings.
65    ///
66    /// The summary is a denormalized cache. Rather than each pipeline stage that
67    /// mutates the finding set (phantom drop, enrichment backfill, EPSS / reachability
68    /// / baseline filtering) recomputing these inline — six near-identical copies that
69    /// drift, as a missed one once reported `max_severity: unknown` for a critical
70    /// fleet — every such stage calls this. There is exactly ONE definition of these
71    /// values, so a stage cannot get them subtly wrong, and a new stage just calls
72    /// `refresh_summary()`. `repos_scanned` / `repos_errored` / `stale_ignores` are set
73    /// once at assembly and are not touched here.
74    pub fn refresh_summary(&mut self) {
75        self.summary.vuln_count = self.vulnerabilities.len();
76        self.summary.warn_count = self.warnings.len();
77        self.summary.max_severity = max_severity_of(&self.vulnerabilities);
78    }
79}