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}