Skip to main content

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}