fleetreach_core/finding.rs
1use std::fmt;
2
3use semver::{Version, VersionReq};
4use serde::{Deserialize, Serialize};
5
6use crate::Severity;
7
8/// The package ecosystem a finding belongs to: a crate, a Go module, an npm package, a PyPI
9/// distribution, a gem, a Composer package ([`Packagist`]), or a [`NuGet`] (.NET) package,
10/// so a mixed fleet keeps the namespaces apart (the same name can exist in several). Each is
11/// fed by its own `fleetreach-<ecosystem>` crate. Ordered so it can key a grouping map.
12///
13/// [`Cargo`]: Ecosystem::Cargo
14/// [`Go`]: Ecosystem::Go
15/// [`Npm`]: Ecosystem::Npm
16/// [`Pypi`]: Ecosystem::Pypi
17/// [`RubyGems`]: Ecosystem::RubyGems
18/// [`Packagist`]: Ecosystem::Packagist
19/// [`NuGet`]: Ecosystem::NuGet
20/// [`Julia`]: Ecosystem::Julia
21/// [`Swift`]: Ecosystem::Swift
22/// [`Hex`]: Ecosystem::Hex
23/// [`GitHubActions`]: Ecosystem::GitHubActions
24#[derive(
25 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
26)]
27#[serde(rename_all = "lowercase")]
28pub enum Ecosystem {
29 #[default]
30 Cargo,
31 Go,
32 Npm,
33 Pypi,
34 RubyGems,
35 Packagist,
36 NuGet,
37 Julia,
38 Swift,
39 Hex,
40 Maven,
41 /// GitHub Actions (`owner/repo@ref` in workflow files).
42 #[serde(rename = "github-actions")]
43 GitHubActions,
44}
45
46impl Ecosystem {
47 /// Whether this is the default crates.io ecosystem — used to omit the field
48 /// from JSON for every existing Rust finding, keeping `schema_version: 1`
49 /// output byte-identical.
50 pub fn is_cargo(&self) -> bool {
51 matches!(self, Ecosystem::Cargo)
52 }
53}
54
55/// Stable identifier for a repository, taken verbatim from `fleet.toml` — a
56/// logical id, **never** a filesystem path. Paths move; ids are the group key.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(transparent)]
59pub struct RepoId(pub String);
60
61impl fmt::Display for RepoId {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str(&self.0)
64 }
65}
66
67/// How a package enters a repo's dependency graph.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum DependencyKind {
71 Direct,
72 Transitive,
73}
74
75/// Where a vulnerable package resolves *from* — which registry, git remote, or
76/// local path. This is the identity a VEX subcomponent PURL must carry (spec
77/// §4.1): a crates.io dep is the bare `pkg:cargo/<name>@<ver>` PURL, but a git or
78/// alternate-registry dep needs a qualifier so a downstream scanner matches the
79/// exact artifact. Captured here so the PURL can be derived without re-reading the
80/// lockfile. Additive — defaults to [`CratesIo`](DepSource::CratesIo) so a report
81/// written before this field existed keeps the crates.io assumption it shipped with.
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum DepSource {
85 /// The default crates.io registry — the overwhelmingly common case.
86 #[default]
87 CratesIo,
88 /// A git dependency: the remote URL and the locked commit, when known.
89 Git {
90 /// The git remote URL (without the `git+` scheme prefix or commit fragment).
91 url: String,
92 /// The locked commit hash (`SourceId::precise`), when the lock pins one.
93 rev: Option<String>,
94 },
95 /// A local path dependency or workspace member — not a published artifact.
96 Path,
97 /// Another registry (alternate / sparse / local): its index URL.
98 OtherRegistry {
99 /// The registry index URL.
100 url: String,
101 },
102}
103
104impl DepSource {
105 /// Whether this is the default crates.io source — used to omit the field from
106 /// JSON in the common case, keeping `schema_version: 1` output byte-identical.
107 pub fn is_crates_io(&self) -> bool {
108 matches!(self, DepSource::CratesIo)
109 }
110}
111
112/// A single location/version where an advisory applies.
113///
114/// The advisory groups occurrences, but the **verdict is per-occurrence**: the
115/// same crate at different versions across repos may differ (one patched, one
116/// not). A toolchain advisory (`rustsec::Collection::Rust`) has no repo to pin,
117/// so it is a distinct variant rather than a sentinel repo.
118///
119/// Serializes internally-tagged on `kind` (`"in_repo"` / `"toolchain"`), with
120/// the variant's fields inlined alongside.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(tag = "kind", rename_all = "snake_case")]
123pub enum Occurrence {
124 InRepo {
125 /// Stable id from `fleet.toml`.
126 repo: RepoId,
127 package: String,
128 installed: Version,
129 /// Versions that fix the advisory; empty means "no fix available".
130 patched: Vec<VersionReq>,
131 dependency_kind: DependencyKind,
132 /// A shortest chain of package names from a root crate down to this
133 /// package (`["my-app", "jiff", "defmt", …]`) — the answer to "who pulls
134 /// this in". There may be other paths; this is one representative. Empty
135 /// when the dependency graph could not be computed. Additive field —
136 /// omitted from JSON when empty, so `schema_version: 1` is unaffected.
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 dependency_path: Vec<String>,
139 /// Whether this package is actually compiled in the host's default build
140 /// (feature-resolved). `None` unless `--resolve-features` ran; `Some(false)`
141 /// flags a `Cargo.lock`-only optional dep that is never built. Additive —
142 /// omitted from JSON when `None`.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 active: Option<bool>,
145 /// Where this package resolves from (registry / git / path), for the VEX
146 /// subcomponent PURL (§4.1). Additive — omitted from JSON for the common
147 /// crates.io case, so `schema_version: 1` output is unaffected.
148 #[serde(default, skip_serializing_if = "DepSource::is_crates_io")]
149 source: DepSource,
150 },
151 Toolchain {
152 /// e.g. `"stable 1.xx"` — there is no repo to pin a toolchain advisory to.
153 channel: String,
154 installed: Option<Version>,
155 patched: Vec<VersionReq>,
156 },
157}
158
159impl Occurrence {
160 /// The per-occurrence verdict: is the *installed* version actually
161 /// vulnerable? An occurrence is vulnerable when its installed version is
162 /// covered by none of the advisory's patched requirements. This is computed
163 /// per occurrence precisely because the same advisory can apply to different
164 /// versions across the fleet — one already patched, one not.
165 ///
166 /// Fail-closed: an empty patched set (no fix published) or an unknown
167 /// installed version counts as vulnerable.
168 ///
169 /// ```
170 /// use fleetreach_core::{DependencyKind, Occurrence, RepoId};
171 /// use fleetreach_core::semver::{Version, VersionReq};
172 ///
173 /// let at = |major, minor, patch| Occurrence::InRepo {
174 /// repo: RepoId("app".into()),
175 /// package: "foo".into(),
176 /// installed: Version::new(major, minor, patch),
177 /// patched: vec![VersionReq::parse(">=1.2.0").unwrap()],
178 /// dependency_kind: DependencyKind::Transitive,
179 /// dependency_path: vec![],
180 /// active: None,
181 /// source: Default::default(),
182 /// };
183 /// assert!(at(1, 1, 9).is_vulnerable()); // below the fix
184 /// assert!(!at(1, 2, 0).is_vulnerable()); // at the fix
185 /// ```
186 pub fn is_vulnerable(&self) -> bool {
187 match self {
188 Occurrence::InRepo {
189 installed, patched, ..
190 } => !patched.iter().any(|req| req.matches(installed)),
191 Occurrence::Toolchain {
192 installed, patched, ..
193 } => match installed {
194 Some(version) => !patched.iter().any(|req| req.matches(version)),
195 None => true,
196 },
197 }
198 }
199}
200
201/// Exploit-risk enrichment for an advisory, from CISA KEV + FIRST EPSS (added by
202/// `--enrich`). Additive — its fields are flattened onto the vulnerability and
203/// omitted when absent, so `schema_version: 1` consumers are unaffected.
204#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
205pub struct Exploitability {
206 /// In CISA's Known Exploited Vulnerabilities catalog — actively exploited.
207 #[serde(default, skip_serializing_if = "is_false")]
208 pub kev: bool,
209 /// EPSS probability of exploitation in the next 30 days (0.0–1.0).
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub epss: Option<f32>,
212}
213
214fn is_false(value: &bool) -> bool {
215 !*value
216}
217
218/// A vulnerability (a real CVE-class advisory), correlated across the fleet.
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct VulnFinding {
221 /// Canonical `RUSTSEC-YYYY-NNNN` — the group key.
222 pub advisory_id: String,
223 /// CVE/GHSA ids — metadata for cross-reference, **never** the key.
224 pub aliases: Vec<String>,
225 /// Which ecosystem this finding came from. Additive — omitted from JSON for the
226 /// common Cargo case, so `schema_version: 1` output is unaffected; the
227 /// `fleetreach-go` feeder sets [`Ecosystem::Go`] so a mixed fleet groups crates
228 /// and Go modules separately.
229 #[serde(default, skip_serializing_if = "Ecosystem::is_cargo")]
230 pub ecosystem: Ecosystem,
231 pub title: String,
232 pub severity: Severity,
233 /// CVSS base score (0.0–10.0) behind `severity`, when one is known — from the
234 /// advisory's own CVSS, or backfilled from NVD by `--enrich`. Additive;
235 /// omitted from JSON when absent (advisories with no CVSS at all).
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub cvss_score: Option<f32>,
238 pub url: Option<String>,
239 /// At least one; the same advisory may surface in many repos/versions.
240 pub occurrences: Vec<Occurrence>,
241 /// Canonical paths to the specific functions/types the advisory marks
242 /// vulnerable *at the installed version* (`time::Time::from_hms_nano`, …),
243 /// when the advisory scopes itself that way — so you can check whether you
244 /// call any of them. Empty when the advisory affects the whole crate.
245 /// Additive; omitted from JSON when empty.
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub affected_functions: Vec<String>,
248 /// A *heuristic* (`--reachability`): does an affected function name appear in
249 /// the affected repos' own source? `Some(true)` = yes, `Some(false)` = not
250 /// found in your source (it could still be reached through a dependency —
251 /// this only scans your code), `None` = not checked or the advisory names no
252 /// functions. Never proves absence; never auto-suppresses by default.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub reachable: Option<bool>,
255 /// *Static* reachability (`--reachability=static`): a sound call-graph verdict
256 /// over the compiled crate closure, with a witness chain when reachable. Far
257 /// stronger than `reachable` (the grep heuristic) — a `NotReachable` here is
258 /// trusted enough to suppress. Additive; absent unless the static engine ran.
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub reachability: Option<Reachability>,
261 /// Exploit-risk enrichment; default (empty) until `--enrich` runs.
262 #[serde(flatten)]
263 pub exploit: Exploitability,
264}
265
266/// A static-reachability verdict for a finding's affected functions, scoped to
267/// the toolchain + feature/target config it was computed under (spec §7).
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct Reachability {
270 /// The reachability outcome for the finding's affected functions.
271 pub verdict: ReachVerdict,
272 /// The config the verdict is scoped to (toolchain + features/target).
273 pub config: String,
274 /// The engine that produced it, e.g. `static-mir-rta@<ver>`.
275 pub engine: String,
276 /// The target triple(s) the verdict was computed for (spec §7 edge 3): a
277 /// function unreachable on one target may be reachable behind a `cfg` on
278 /// another, so a `NotReachable` names its targets. Additive; omitted when empty.
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub targets: Vec<String>,
281 /// A content-addressed witness for a `NotReachable` verdict (spec §9.2): a
282 /// hash binding the verdict to the exact inputs that produced it (lockfile,
283 /// source, toolchain, features, sinks). `fleetreach vex verify` re-derives
284 /// against current source and fails if the verdict no longer holds. Additive;
285 /// `None` for `Reachable`/`Unknown` and when caching was impossible.
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub witness: Option<String>,
288}
289
290/// The reachability outcome. The acceptable error direction is over-reporting
291/// (`Reachable`/`Unknown` when in fact dead) — `NotReachable` is sound: there is
292/// genuinely no path from a root to the sink under the analyzed config.
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(tag = "kind", rename_all = "snake_case")]
295pub enum ReachVerdict {
296 /// A concrete call chain exists; `witness` is `root -> … -> sink`.
297 Reachable { witness: Vec<String> },
298 /// Sound: no path under the analyzed config.
299 NotReachable,
300 /// Could not be decided soundly (build failed, sink unresolved, an opaque
301 /// boundary on every candidate path, …).
302 Unknown { reason: String },
303}
304
305impl Reachability {
306 /// The reachability verdict shared by every toolchain-free (Tier-C) feeder: a
307 /// package/version match with no call-graph analysis. It is **always** `Unknown`
308 /// (never `NotReachable` — Tier-C must not claim a soundness it did not compute),
309 /// with a fixed `config`/`engine` so the report renders the tier consistently
310 /// across all ecosystems. `reason` names the lower fidelity for the report.
311 ///
312 /// Centralizing this here keeps the Tier-C contract in one place instead of
313 /// copy-pasted into each feeder's `scan.rs`, where a divergence would be a
314 /// silent per-ecosystem inconsistency the compiler could not catch.
315 pub fn tier_c_unknown(reason: impl Into<String>) -> Self {
316 Reachability {
317 verdict: ReachVerdict::Unknown {
318 reason: reason.into(),
319 },
320 config: "package-level".to_string(),
321 engine: "fleetreach-tier-c".to_string(),
322 targets: Vec::new(),
323 witness: None,
324 }
325 }
326
327 /// Map onto the legacy heuristic `reachable: Option<bool>` for back-compat
328 /// (spec §7): `Reachable -> Some(true)`, `NotReachable -> Some(false)`,
329 /// `Unknown -> None`.
330 pub fn as_legacy_bool(&self) -> Option<bool> {
331 match self.verdict {
332 ReachVerdict::Reachable { .. } => Some(true),
333 ReachVerdict::NotReachable => Some(false),
334 ReachVerdict::Unknown { .. } => None,
335 }
336 }
337}
338
339/// The class of a supply-chain warning — informational, **not** a CVE. Kept in a
340/// stream separate from vulnerabilities so warnings never inflate the vuln count.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
342#[serde(rename_all = "lowercase")]
343pub enum WarnKind {
344 Unmaintained,
345 Yanked,
346 Unsound,
347 Notice,
348}
349
350/// A supply-chain warning, correlated across the fleet by `(kind, id)`.
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352pub struct WarnFinding {
353 pub kind: WarnKind,
354 /// Some warnings (e.g. a plain yank) may not carry a RUSTSEC id.
355 pub advisory_id: Option<String>,
356 pub title: String,
357 pub occurrences: Vec<Occurrence>,
358}