Skip to main content

deprot_core/
facts.rs

1//! Input types for the scoring engine.
2//!
3//! [`Dependency`] is *what to look at* (produced by a manifest parser); [`Facts`] is *everything
4//! we learned about it* (produced by a collector). The scoring engine in [`crate::score`] reads
5//! only [`Facts`] — it never knows which ecosystem or data source produced them, which is what
6//! keeps deprot ecosystem-agnostic.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// A package ecosystem. New ecosystems are added here and wired up with a manifest + collector
12/// adapter; the scoring engine does not change.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Ecosystem {
16    Npm,
17    Cargo,
18    PyPI,
19    Go,
20    Ruby,
21    Php,
22    Maven,
23    NuGet,
24}
25
26impl Ecosystem {
27    /// Every ecosystem deprot knows about, in a stable order.
28    pub const ALL: [Ecosystem; 8] = [
29        Ecosystem::Npm,
30        Ecosystem::Cargo,
31        Ecosystem::PyPI,
32        Ecosystem::Go,
33        Ecosystem::Ruby,
34        Ecosystem::Php,
35        Ecosystem::Maven,
36        Ecosystem::NuGet,
37    ];
38
39    /// The identifier deps.dev uses for this ecosystem (their "system" path segment). deps.dev does
40    /// not cover Packagist; PHP relies on OSV for vulnerabilities and gets no deps.dev enrichment
41    /// (the lookup 404s and is ignored).
42    pub fn deps_dev_system(self) -> &'static str {
43        match self {
44            Ecosystem::Npm => "npm",
45            Ecosystem::Cargo => "cargo",
46            Ecosystem::PyPI => "pypi",
47            Ecosystem::Go => "go",
48            Ecosystem::Ruby => "rubygems",
49            Ecosystem::Php => "packagist", // unsupported upstream; kept for a stable, harmless URL
50            Ecosystem::Maven => "maven",
51            Ecosystem::NuGet => "nuget",
52        }
53    }
54
55    /// The identifier [OSV.dev](https://osv.dev) uses for this ecosystem.
56    pub fn osv_ecosystem(self) -> &'static str {
57        match self {
58            Ecosystem::Npm => "npm",
59            Ecosystem::Cargo => "crates.io",
60            Ecosystem::PyPI => "PyPI",
61            Ecosystem::Go => "Go",
62            Ecosystem::Ruby => "RubyGems",
63            Ecosystem::Php => "Packagist",
64            Ecosystem::Maven => "Maven",
65            Ecosystem::NuGet => "NuGet",
66        }
67    }
68
69    /// Human-facing label.
70    pub fn label(self) -> &'static str {
71        match self {
72            Ecosystem::Npm => "npm",
73            Ecosystem::Cargo => "crates.io",
74            Ecosystem::PyPI => "PyPI",
75            Ecosystem::Go => "Go",
76            Ecosystem::Ruby => "RubyGems",
77            Ecosystem::Php => "Packagist",
78            Ecosystem::Maven => "Maven",
79            Ecosystem::NuGet => "NuGet",
80        }
81    }
82}
83
84/// A single dependency to analyze, as extracted from a manifest.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Dependency {
87    /// Registry name of the package (e.g. `lodash`, `serde`).
88    pub name: String,
89    /// The version requirement as written in the manifest (e.g. `^4.17.0`), if any.
90    pub requested: Option<String>,
91    /// Which ecosystem this dependency belongs to.
92    pub ecosystem: Ecosystem,
93    /// Whether this is a direct dependency (vs. transitive / dev-only).
94    pub direct: bool,
95}
96
97/// A known vulnerability affecting the analyzed version.
98#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
99pub struct Vuln {
100    /// Primary advisory identifier — a CVE id when one exists, otherwise the GHSA/OSV id.
101    pub id: String,
102    /// CVSS base score (0.0–10.0), if published or computable from a CVSS vector.
103    pub cvss: Option<f64>,
104    /// Short human-readable title / summary.
105    pub title: Option<String>,
106    /// Other identifiers for the same advisory (CVE / GHSA / OSV / RUSTSEC …), for cross-reference.
107    #[serde(default)]
108    pub aliases: Vec<String>,
109    /// The first version that fixes this advisory for the analyzed package, if known — what a user
110    /// should upgrade to. `None` means no fixed version is published yet.
111    #[serde(default)]
112    pub fixed_version: Option<String>,
113    /// A primary reference URL (the advisory page), if available.
114    #[serde(default)]
115    pub reference: Option<String>,
116    /// Database severity label (e.g. `CRITICAL`, `HIGH`) used when no numeric CVSS is available.
117    #[serde(default)]
118    pub severity_label: Option<String>,
119}
120
121impl Vuln {
122    /// Severity bucket. Prefers the numeric CVSS score, falls back to a database severity label,
123    /// and treats a completely unscored-but-real advisory as `Medium` so it's never silently
124    /// ignored.
125    pub fn severity(&self) -> Severity {
126        if let Some(s) = self.cvss {
127            return if s >= 9.0 {
128                Severity::Critical
129            } else if s >= 7.0 {
130                Severity::High
131            } else if s >= 4.0 {
132                Severity::Medium
133            } else {
134                Severity::Low
135            };
136        }
137        match self.severity_label.as_deref().map(str::to_ascii_uppercase) {
138            Some(l) if l == "CRITICAL" => Severity::Critical,
139            Some(l) if l == "HIGH" => Severity::High,
140            Some(l) if l == "MODERATE" || l == "MEDIUM" => Severity::Medium,
141            Some(l) if l == "LOW" => Severity::Low,
142            _ => Severity::Medium,
143        }
144    }
145
146    /// Whether a fixed version is published (i.e. the advisory is actionable by upgrading).
147    pub fn is_fixable(&self) -> bool {
148        self.fixed_version.is_some()
149    }
150}
151
152/// Coarse severity bucket for a [`Vuln`].
153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum Severity {
156    Low,
157    Medium,
158    High,
159    Critical,
160}
161
162/// Everything deprot learned about one dependency. Populated by the collector from public data
163/// sources; all fields are optional so the engine degrades gracefully when a source is
164/// unavailable (e.g. no auth, offline, or the package has no linked repository).
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166pub struct Facts {
167    /// The concrete version these facts describe (the version deprot chose to analyze).
168    pub analyzed_version: Option<String>,
169    /// When the most recent release was published (drives the staleness signal).
170    pub latest_published: Option<DateTime<Utc>>,
171    /// Number of releases published in the trailing 12 months (drives the cadence signal).
172    pub releases_last_year: Option<u32>,
173    /// Total number of published versions ever.
174    pub total_versions: Option<u32>,
175    /// The registry has marked this version (or package) deprecated.
176    pub deprecated: bool,
177    /// Reason string attached to the deprecation, if any.
178    pub deprecated_reason: Option<String>,
179    /// The source repository is archived (read-only / abandoned upstream).
180    pub archived: bool,
181    /// SPDX-ish license identifiers reported for the analyzed version.
182    pub licenses: Vec<String>,
183    /// Known vulnerabilities affecting the analyzed version.
184    pub vulns: Vec<Vuln>,
185    /// Canonical source repository (e.g. `github.com/lodash/lodash`), if known.
186    pub repo: Option<String>,
187    /// Repository star count, if known.
188    pub stars: Option<u64>,
189    /// Open issue count, if known.
190    pub open_issues: Option<u64>,
191    /// OpenSSF Scorecard "Maintained" check (0–10), if available.
192    pub scorecard_maintained: Option<f64>,
193    /// OpenSSF Scorecard aggregate score (0–10), if available.
194    pub scorecard_overall: Option<f64>,
195    /// Fraction (0.0–1.0) of recent commits authored by the single most active contributor.
196    /// High concentration = high bus-factor / capture risk. Optional (needs a GitHub token).
197    pub top_contributor_share: Option<f64>,
198    /// Registry maintainer/owner identities (email or login). Populated only in `--deep` mode.
199    #[serde(default)]
200    pub maintainers: Vec<String>,
201    /// The package runs an install/pre/post-install script (npm) — a code-execution vector.
202    #[serde(default)]
203    pub has_install_script: bool,
204}
205
206impl Facts {
207    /// Whether the collector obtained **no** registry data for this package — an unknown or
208    /// typosquatted name, a registry 404, or a failed/offline lookup. The collector always sets
209    /// `total_versions` (and usually `latest_published`) the moment it reaches the registry, so
210    /// both being absent means we never got a usable response.
211    ///
212    /// Scoring uses this to avoid presenting an *unassessed* package as healthy: absence of data
213    /// is not absence of risk.
214    pub fn is_unresolved(&self) -> bool {
215        self.total_versions.is_none() && self.latest_published.is_none()
216    }
217}