deprot_core/score.rs
1//! Aggregation: turn a set of [`Signal`]s into a final [`Score`] — a 0–100 value, a letter
2//! [`Grade`], and a [`Tier`] verdict.
3//!
4//! The base value is a weight-normalized average of whatever signals were computable. On top of
5//! that, a few conditions **force** the verdict to `Risky` regardless of the average, because they
6//! represent facts a good average should never wash out: an unfixed high/critical vulnerability, a
7//! registry deprecation, or an archived upstream.
8
9use crate::facts::{Facts, Severity};
10use crate::signals::{self, Signal};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14/// Letter grade derived from the 0–100 score.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum Grade {
17 A,
18 B,
19 C,
20 D,
21 F,
22}
23
24impl Grade {
25 fn from_value(v: u8) -> Grade {
26 match v {
27 90..=100 => Grade::A,
28 75..=89 => Grade::B,
29 60..=74 => Grade::C,
30 40..=59 => Grade::D,
31 _ => Grade::F,
32 }
33 }
34
35 /// Single-character label.
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Grade::A => "A",
39 Grade::B => "B",
40 Grade::C => "C",
41 Grade::D => "D",
42 Grade::F => "F",
43 }
44 }
45}
46
47/// The headline verdict tier. This is what `--fail-on` gates against and what the summary banner
48/// reports.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum Tier {
52 Ok,
53 Caution,
54 Risky,
55}
56
57impl Tier {
58 /// Lowercase label.
59 pub fn as_str(self) -> &'static str {
60 match self {
61 Tier::Ok => "ok",
62 Tier::Caution => "caution",
63 Tier::Risky => "risky",
64 }
65 }
66
67 /// Parse from a CLI string (`ok` / `caution` / `risky`).
68 pub fn parse(s: &str) -> Option<Tier> {
69 match s.trim().to_lowercase().as_str() {
70 "ok" => Some(Tier::Ok),
71 "caution" => Some(Tier::Caution),
72 "risky" => Some(Tier::Risky),
73 _ => None,
74 }
75 }
76}
77
78/// The full result of scoring one dependency.
79#[derive(Debug, Clone, PartialEq)]
80pub struct Score {
81 /// 0–100 health value.
82 pub value: u8,
83 /// Letter grade derived from `value`.
84 pub grade: Grade,
85 /// Headline verdict (may be forced worse than `value` alone implies).
86 pub tier: Tier,
87 /// The signals that produced this score, in display order.
88 pub signals: Vec<Signal>,
89 /// Human-readable reasons the tier was *forced* (empty when the tier follows the value).
90 pub forced_reasons: Vec<String>,
91}
92
93/// Score value thresholds for the (unforced) tier mapping.
94const CAUTION_BELOW: u8 = 80;
95const RISKY_BELOW: u8 = 55;
96
97/// Score floor for a mature, popular, otherwise-clean package (the maturity dampener). Lands at a
98/// low `C` / `CAUTION` — "stable but stale", never a false `OK`, never a rotting `F`.
99const MATURE_FLOOR: u8 = 70;
100
101/// Score one dependency from its [`Facts`] at reference time `now`.
102///
103/// Pure and deterministic: no I/O, no ambient clock. Pass the same inputs, get the same output —
104/// which is exactly what the engine's unit tests and replay fixtures rely on.
105pub fn score(facts: &Facts, now: DateTime<Utc>) -> Score {
106 let signals = signals::all(facts, now);
107
108 let total_weight: f64 = signals.iter().map(|s| s.weight).sum();
109 let mut value = if total_weight <= 0.0 {
110 50 // no signals at all: neutral, not a false "perfect"
111 } else {
112 let weighted: f64 = signals.iter().map(|s| s.score * s.weight).sum();
113 (weighted / total_weight * 100.0).round() as u8
114 };
115
116 // Maturity dampener. A widely-used, complete library that simply hasn't shipped a release
117 // lately is *stable*, not *rotting* — yet staleness + a missing OpenSSF Scorecard alone can drag
118 // such a package to a D/F. When nothing is actively wrong (no advisories, not deprecated, not
119 // archived, and the package resolved), a mature/popular package earns a score floor so those
120 // two soft signals can't, by themselves, read as failing. It only ever lifts a score.
121 let clean =
122 !facts.is_unresolved() && !facts.deprecated && !facts.archived && facts.vulns.is_empty();
123 let mature = facts.total_versions.unwrap_or(0) >= 10 || facts.stars.unwrap_or(0) >= 1_000;
124 if clean && mature {
125 value = value.max(MATURE_FLOOR);
126 }
127
128 // Base tier from the value.
129 let mut tier = if value < RISKY_BELOW {
130 Tier::Risky
131 } else if value < CAUTION_BELOW {
132 Tier::Caution
133 } else {
134 Tier::Ok
135 };
136
137 // Forced escalations — facts that must not be averaged away.
138 let mut forced_reasons = Vec::new();
139 // A package we could not resolve at all (unknown/typosquatted name, a registry 404, or a
140 // failed lookup) must never be presented as healthy: absence of data is not absence of risk.
141 // Force RISKY with an explicit reason so a CI gate (`--fail-on risky`) surfaces it instead of
142 // the remaining default signals averaging into a reassuring grade.
143 if facts.is_unresolved() {
144 forced_reasons.push(
145 "no registry data — package unknown or lookup failed; risk not assessable".to_string(),
146 );
147 }
148 if facts.deprecated {
149 forced_reasons.push("package is deprecated".to_string());
150 }
151 if facts.archived {
152 forced_reasons.push("source repository is archived".to_string());
153 }
154 if let Some(v) = facts.vulns.iter().find(|v| v.severity() >= Severity::High) {
155 forced_reasons.push(format!("unresolved high/critical advisory {}", v.id));
156 }
157 if !forced_reasons.is_empty() {
158 tier = Tier::Risky;
159 }
160
161 Score {
162 value,
163 grade: Grade::from_value(value),
164 tier,
165 signals,
166 forced_reasons,
167 }
168}