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}