use crate::config::{EpssMode, ScoreSection, Thresholds};
use crate::model::Tier;
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreRequest {
pub package_name: String,
pub kev: bool,
pub epss: Option<EpssSample>,
pub cvss_base: Option<f64>,
pub direct: Option<bool>,
pub fix_available: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EpssSample {
pub score: f64,
pub percentile: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreBreakdown {
pub kev: f64,
pub epss: f64,
pub cvss: f64,
pub direct: f64,
pub fix_available: f64,
pub total_weight: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScoredFinding {
pub package_name: String,
pub kev: bool,
pub score: u32,
pub tier: Tier,
pub breakdown: ScoreBreakdown,
}
pub fn score_request(request: &ScoreRequest, score: &ScoreSection) -> (ScoredFinding, Vec<String>) {
let (mut findings, warnings) = score_findings(std::slice::from_ref(request), score);
let finding = findings
.pop()
.expect("INVARIANT: one request produces one scored row");
(finding, warnings)
}
pub fn score_findings(
requests: &[ScoreRequest],
score: &ScoreSection,
) -> (Vec<ScoredFinding>, Vec<String>) {
let total = weight_total(score);
let mut warnings = Vec::new();
if (total - 100.0).abs() > 1e-9 {
warnings.push(format!("score weights total {total}, normalized to 0-100"));
}
let mut ranked: Vec<(usize, ScoredFinding)> = requests
.iter()
.enumerate()
.map(|(index, request)| (index, score_one(request, score, total)))
.collect();
ranked.sort_by(|left, right| {
right
.1
.score
.cmp(&left.1.score)
.then_with(|| right.1.kev.cmp(&left.1.kev))
.then_with(|| left.1.package_name.cmp(&right.1.package_name))
.then_with(|| left.0.cmp(&right.0))
});
let findings = ranked.into_iter().map(|(_, finding)| finding).collect();
(findings, warnings)
}
pub fn finalize(raw: f64, total_weight: f64, thresholds: &Thresholds) -> (u32, Tier) {
let scaled = if total_weight.abs() < 1e-12 {
0.0
} else {
raw / total_weight * 100.0
};
let clamped = scaled.clamp(0.0, 100.0);
let score = clamped.round() as u32;
(score, tier_for(score, thresholds))
}
fn score_one(request: &ScoreRequest, score: &ScoreSection, total: f64) -> ScoredFinding {
let weights = &score.weights;
let kev = if request.kev { weights.kev } else { 0.0 };
let epss = weights.epss * epss_factor(request.epss, score);
let cvss = match request.cvss_base {
Some(base) => weights.cvss * (base / 10.0),
None => 0.0,
};
let direct_fraction = match request.direct {
Some(true) => 1.0,
Some(false) => 0.0,
None => score.unknown_direct.weight_fraction,
};
let direct = weights.direct * direct_fraction;
let fix_available = if request.fix_available {
weights.fix_available
} else {
0.0
};
let raw = kev + epss + cvss + direct + fix_available;
let (rounded, tier) = finalize(raw, total, &score.thresholds);
ScoredFinding {
package_name: request.package_name.clone(),
kev: request.kev,
score: rounded,
tier,
breakdown: ScoreBreakdown {
kev,
epss,
cvss,
direct,
fix_available,
total_weight: total,
},
}
}
fn epss_factor(sample: Option<EpssSample>, score: &ScoreSection) -> f64 {
let Some(sample) = sample else {
return 0.0;
};
match score.epss.mode {
EpssMode::Probability => {
let saturation = score.epss.saturation;
if saturation <= 0.0 {
0.0
} else {
(sample.score / saturation).clamp(0.0, 1.0)
}
}
EpssMode::Percentile => sample.percentile.clamp(0.0, 1.0),
}
}
fn weight_total(score: &ScoreSection) -> f64 {
let weights = &score.weights;
weights.kev + weights.epss + weights.cvss + weights.direct + weights.fix_available
}
fn tier_for(score: u32, thresholds: &Thresholds) -> Tier {
let score = f64::from(score);
if score >= thresholds.critical {
Tier::Critical
} else if score >= thresholds.review {
Tier::Review
} else {
Tier::Defer
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{EpssMode, ScoreSection};
fn req(name: &str) -> ScoreRequest {
ScoreRequest {
package_name: name.to_string(),
kev: false,
epss: None,
cvss_base: None,
direct: Some(true),
fix_available: true,
}
}
fn one(request: ScoreRequest, score: &ScoreSection) -> ScoredFinding {
let (mut findings, _) = score_findings(&[request], score);
findings.pop().expect("one")
}
#[test]
fn prd_cases_a_through_e() {
let score = ScoreSection::default();
let mut exploited = req("a");
exploited.kev = true;
exploited.epss = Some(EpssSample {
score: 0.94,
percentile: 0.99,
});
exploited.cvss_base = Some(9.8);
let a = one(exploited, &score);
assert_eq!(a.score, 100, "A breakdown {:?}", a.breakdown);
assert_eq!(a.tier, Tier::Critical);
let mut rare = req("b");
rare.epss = Some(EpssSample {
score: 0.0123,
percentile: 0.5,
});
rare.cvss_base = Some(7.2);
let b = one(rare, &score);
assert_eq!(b.score, 37, "B breakdown {:?}", b.breakdown);
assert_eq!(b.tier, Tier::Defer);
assert!(
(b.breakdown.cvss - 14.4).abs() < 1e-9,
"{}",
b.breakdown.cvss
);
assert!(
(b.breakdown.epss - 3.075).abs() < 1e-6,
"{}",
b.breakdown.epss
);
let mut transitive = req("c");
transitive.kev = true;
transitive.epss = Some(EpssSample {
score: 0.05,
percentile: 0.2,
});
transitive.cvss_base = Some(8.1);
transitive.direct = Some(false);
transitive.fix_available = false;
let c = one(transitive, &score);
assert_eq!(c.score, 64, "C breakdown {:?}", c.breakdown);
assert_eq!(c.tier, Tier::Review);
let mut no_cve = req("d");
no_cve.cvss_base = Some(5.0);
let d = one(no_cve, &score);
assert_eq!(d.score, 30, "D breakdown {:?}", d.breakdown);
assert_eq!(d.tier, Tier::Defer);
assert_eq!(d.breakdown.epss, 0.0);
let mut edge = req("e");
edge.kev = true;
edge.epss = Some(EpssSample {
score: 0.02,
percentile: 0.1,
});
edge.cvss_base = Some(10.0);
edge.fix_available = false;
let e = one(edge, &score);
assert_eq!(e.score, 70, "E breakdown {:?}", e.breakdown);
assert_eq!(e.tier, Tier::Critical);
}
#[test]
fn rounding_boundaries_use_half_away_from_zero() {
let thresholds = Thresholds::default();
let cases = [
(69.5, 70, Tier::Critical),
(70.0, 70, Tier::Critical),
(69.4, 69, Tier::Review),
(39.9, 40, Tier::Review),
(40.0, 40, Tier::Review),
(39.4, 39, Tier::Defer),
];
for (raw, score, tier) in cases {
assert_eq!(finalize(raw, 100.0, &thresholds), (score, tier), "{raw}");
}
}
#[test]
fn non_100_weights_are_normalized_with_a_warning() {
let mut score = ScoreSection::default();
score.weights.kev = 10.0;
score.weights.epss = 0.0;
score.weights.cvss = 0.0;
score.weights.direct = 0.0;
score.weights.fix_available = 0.0;
let mut request = req("only-kev");
request.kev = true;
request.direct = Some(false);
request.fix_available = false;
let (findings, warnings) = score_findings(&[request], &score);
assert_eq!(findings[0].score, 100);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("normalized"), "{}", warnings[0]);
}
#[test]
fn unknown_direct_uses_the_configured_fraction() {
let mut score = ScoreSection::default();
score.unknown_direct.weight_fraction = 0.5;
let mut request = req("unknown");
request.direct = None;
request.fix_available = false;
request.cvss_base = None;
let scored = one(request, &score);
assert!((scored.breakdown.direct - 5.0).abs() < 1e-9);
}
#[test]
fn percentile_mode_uses_the_percentile() {
let mut score = ScoreSection::default();
score.epss.mode = EpssMode::Percentile;
let mut request = req("pct");
request.epss = Some(EpssSample {
score: 0.01,
percentile: 0.40,
});
request.direct = Some(false);
request.fix_available = false;
let scored = one(request, &score);
assert!((scored.breakdown.epss - 10.0).abs() < 1e-9);
}
#[test]
fn sort_is_score_then_kev_then_name() {
let score = ScoreSection::default();
let mut high = req("z");
high.kev = false;
high.cvss_base = Some(10.0);
high.epss = Some(EpssSample {
score: 0.94,
percentile: 1.0,
});
let mut kev_late = req("c");
kev_late.kev = true;
kev_late.cvss_base = Some(5.0);
kev_late.fix_available = false;
let mut kev_early = req("a");
kev_early.kev = true;
kev_early.cvss_base = Some(5.0);
kev_early.fix_available = false;
let mut plain = req("b");
plain.cvss_base = Some(5.0);
plain.fix_available = false;
let (findings, warnings) = score_findings(&[plain, kev_late, high, kev_early], &score);
assert!(warnings.is_empty());
let names: Vec<_> = findings
.iter()
.map(|finding| finding.package_name.as_str())
.collect();
assert_eq!(names, vec!["z", "a", "c", "b"]);
assert!(findings[1].kev && findings[2].kev);
assert!(!findings[3].kev);
assert_eq!(findings[1].score, findings[2].score);
}
}