use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct EpssEntry {
pub score: f64,
pub percentile: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpssScores {
entries: HashMap<String, EpssEntry>,
pub score_date: Option<String>,
pub model_version: Option<String>,
}
impl EpssScores {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
score_date: None,
model_version: None,
}
}
#[must_use]
pub fn from_csv(body: &str) -> Self {
let mut entries = HashMap::new();
let mut score_date = None;
let mut model_version = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(meta) = line.strip_prefix('#') {
for field in meta.split(',') {
if let Some((key, value)) = field.split_once(':') {
match key.trim() {
"model_version" => model_version = Some(value.trim().to_string()),
"score_date" => score_date = Some(value.trim().to_string()),
_ => {}
}
}
}
continue;
}
if line.eq_ignore_ascii_case("cve,epss,percentile") {
continue;
}
let mut cols = line.split(',');
let (Some(cve), Some(score), Some(percentile)) =
(cols.next(), cols.next(), cols.next())
else {
continue;
};
let (Ok(score), Ok(percentile)) = (
score.trim().parse::<f64>(),
percentile.trim().parse::<f64>(),
) else {
continue;
};
entries.insert(normalize_cve_id(cve), EpssEntry { score, percentile });
}
Self {
entries,
score_date,
model_version,
}
}
#[must_use]
pub fn get(&self, cve_id: &str) -> Option<EpssEntry> {
self.entries.get(&normalize_cve_id(cve_id)).copied()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for EpssScores {
fn default() -> Self {
Self::new()
}
}
fn normalize_cve_id(cve_id: &str) -> String {
cve_id.trim().to_uppercase()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CSV: &str = "\
#model_version:v2023.03.01,score_date:2024-01-15T00:00:00+0000
cve,epss,percentile
CVE-2024-0001,0.00042,0.10024
CVE-2024-0002,0.97331,0.99988
";
#[test]
fn parses_comment_and_header() {
let scores = EpssScores::from_csv(SAMPLE_CSV);
assert_eq!(scores.len(), 2);
assert_eq!(scores.model_version.as_deref(), Some("v2023.03.01"));
assert_eq!(
scores.score_date.as_deref(),
Some("2024-01-15T00:00:00+0000")
);
}
#[test]
fn looks_up_scores_case_insensitively() {
let scores = EpssScores::from_csv(SAMPLE_CSV);
let hi = scores.get("cve-2024-0002").expect("entry present");
assert!((hi.score - 0.97331).abs() < 1e-9);
assert!((hi.percentile - 0.99988).abs() < 1e-9);
assert!(scores.get("CVE-2099-9999").is_none());
}
#[test]
fn skips_malformed_rows() {
let csv = "\
#model_version:v1,score_date:2024-01-15
cve,epss,percentile
CVE-2024-0003,not-a-number,0.5
CVE-2024-0004,0.5
CVE-2024-0005,0.12345,0.54321
";
let scores = EpssScores::from_csv(csv);
assert_eq!(scores.len(), 1);
assert!(scores.get("CVE-2024-0005").is_some());
}
}