use super::scores::EpssScores;
use crate::enrichment::source::{JsonCache, namespaced_cache_dir};
use crate::enrichment::stats::EnrichmentError;
use crate::model::VulnerabilityRef;
use std::path::PathBuf;
use std::time::Duration;
const EPSS_CACHE_FILE: &str = "epss_scores.json";
pub const EPSS_SCORES_URL: &str = "https://epss.empiricalsecurity.com/epss_scores-current.csv.gz";
pub const EPSS_OFFICIAL_HOST: &str = "epss.empiricalsecurity.com";
#[derive(Debug, Clone)]
pub struct EpssClientConfig {
pub cache_dir: PathBuf,
pub cache_ttl: Duration,
pub epss_url: String,
pub timeout: Duration,
pub bypass_cache: bool,
}
impl Default for EpssClientConfig {
fn default() -> Self {
Self {
cache_dir: default_cache_dir(),
cache_ttl: Duration::from_secs(24 * 3600), epss_url: EPSS_SCORES_URL.to_string(),
timeout: Duration::from_secs(30),
bypass_cache: false,
}
}
}
fn default_cache_dir() -> PathBuf {
namespaced_cache_dir("epss")
}
#[derive(Debug, Default, Clone)]
pub struct EpssEnrichmentStats {
pub vulns_checked: usize,
pub epss_matches: usize,
pub high_probability: usize,
pub cache_hit: bool,
pub score_date: Option<String>,
pub dataset_size: usize,
}
pub struct EpssClient {
config: EpssClientConfig,
scores: Option<EpssScores>,
}
impl EpssClient {
#[must_use]
pub const fn new(config: EpssClientConfig) -> Self {
Self {
config,
scores: None,
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(EpssClientConfig::default())
}
fn cache(&self) -> Result<JsonCache<EpssScores>, EnrichmentError> {
JsonCache::new(self.config.cache_dir.clone(), self.config.cache_ttl)
.map_err(|e| EnrichmentError::CacheError(e.to_string()))
}
fn is_cache_valid(&self) -> bool {
if self.config.bypass_cache {
return false;
}
self.cache()
.ok()
.is_some_and(|c| c.get_named(EPSS_CACHE_FILE).is_some())
}
fn load_from_cache(&self) -> Option<EpssScores> {
self.cache().ok()?.get_named(EPSS_CACHE_FILE)
}
fn save_to_cache(&self, scores: &EpssScores) -> Result<(), EnrichmentError> {
self.cache()?
.set_named(EPSS_CACHE_FILE, scores)
.map_err(|e| EnrichmentError::CacheError(e.to_string()))
}
#[cfg(feature = "enrichment")]
fn fetch_from_api(&self) -> Result<EpssScores, EnrichmentError> {
let client = crate::enrichment::source::http_client(self.config.timeout)
.map_err(|e| EnrichmentError::ApiError(e.to_string()))?;
let response = crate::enrichment::source::get_with_retry(&client, &self.config.epss_url, 3)
.map_err(|e| EnrichmentError::ApiError(e.to_string()))?;
if !response.status().is_success() {
return Err(EnrichmentError::ApiError(format!(
"EPSS API returned status {}",
response.status()
)));
}
let raw = crate::enrichment::source::read_bounded(response)
.map_err(|e| EnrichmentError::ApiError(e.to_string()))?;
let body = decode_maybe_gzip(&raw)?;
Ok(EpssScores::from_csv(&body))
}
#[cfg(not(feature = "enrichment"))]
fn fetch_from_api(&self) -> Result<EpssScores, EnrichmentError> {
Err(EnrichmentError::ApiError(
"Enrichment feature not enabled".to_string(),
))
}
#[cfg(test)]
#[cfg(feature = "enrichment")]
fn decode_body(raw: &[u8]) -> Result<String, EnrichmentError> {
decode_maybe_gzip(raw)
}
pub fn load_scores(&mut self) -> Result<(), EnrichmentError> {
if self.scores.is_some() {
return Ok(());
}
if self.is_cache_valid()
&& let Some(scores) = self.load_from_cache()
{
self.scores = Some(scores);
return Ok(());
}
let scores = self.fetch_from_api()?;
let _ = self.save_to_cache(&scores);
self.scores = Some(scores);
Ok(())
}
#[must_use]
pub const fn scores(&self) -> Option<&EpssScores> {
self.scores.as_ref()
}
pub fn enrich_vulnerabilities(
&mut self,
vulnerabilities: &mut [VulnerabilityRef],
) -> Result<EpssEnrichmentStats, EnrichmentError> {
let mut stats = EpssEnrichmentStats::default();
let was_cache_hit = self.is_cache_valid();
self.load_scores()?;
let scores = self
.scores
.as_ref()
.expect("scores populated by load_scores above");
stats.score_date = scores.score_date.clone();
stats.dataset_size = scores.len();
stats.cache_hit = was_cache_hit;
for vuln in vulnerabilities.iter_mut() {
stats.vulns_checked += 1;
if !vuln.id.to_uppercase().starts_with("CVE-") {
continue;
}
if let Some(entry) = scores.get(&vuln.id) {
vuln.epss_score = Some(entry.score);
vuln.epss_percentile = Some(entry.percentile);
stats.epss_matches += 1;
if entry.score >= 0.5 {
stats.high_probability += 1;
}
}
}
Ok(stats)
}
}
#[cfg(feature = "enrichment")]
fn decode_maybe_gzip(raw: &[u8]) -> Result<String, EnrichmentError> {
use std::io::Read;
if raw.starts_with(&[0x1f, 0x8b]) {
let mut decoder = flate2::read::GzDecoder::new(raw);
let mut out = String::new();
decoder
.read_to_string(&mut out)
.map_err(|e| EnrichmentError::ParseError(format!("gzip decode failed: {e}")))?;
Ok(out)
} else {
String::from_utf8(raw.to_vec())
.map_err(|e| EnrichmentError::ParseError(format!("EPSS body is not valid UTF-8: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::VulnerabilitySource;
use tempfile::TempDir;
fn test_config(temp_dir: &TempDir) -> EpssClientConfig {
EpssClientConfig {
cache_dir: temp_dir.path().to_path_buf(),
bypass_cache: true,
..Default::default()
}
}
#[test]
fn test_epss_client_creation() {
let client = EpssClient::with_defaults();
assert!(client.scores.is_none());
}
#[test]
fn default_url_uses_official_first_host() {
let cfg = EpssClientConfig::default();
let host = cfg
.epss_url
.strip_prefix("https://")
.and_then(|rest| rest.split('/').next())
.expect("default EPSS URL must be an https URL");
assert_eq!(
host, EPSS_OFFICIAL_HOST,
"default EPSS endpoint must point at the official FIRST host"
);
assert!(
EPSS_SCORES_URL.starts_with("https://"),
"default EPSS URL must use https"
);
}
#[cfg(feature = "enrichment")]
#[test]
fn decodes_gzip_and_plain_bodies() {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
let csv = "#model_version:v1,score_date:2026-06-01\n\
cve,epss,percentile\n\
CVE-2024-9999,0.87654,0.95432\n";
let plain = EpssClient::decode_body(csv.as_bytes()).unwrap();
assert_eq!(plain, csv);
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(csv.as_bytes()).unwrap();
let gz = encoder.finish().unwrap();
assert_eq!(&gz[..2], &[0x1f, 0x8b], "gzip magic must be present");
let decoded = EpssClient::decode_body(&gz).unwrap();
assert_eq!(decoded, csv);
let scores = EpssScores::from_csv(&decoded);
let entry = scores.get("CVE-2024-9999").expect("score parsed");
assert!((entry.score - 0.87654).abs() < 1e-9);
}
#[test]
fn test_cache_validity() {
let temp_dir = TempDir::new().unwrap();
let mut config = test_config(&temp_dir);
config.bypass_cache = false;
let client = EpssClient::new(config);
assert!(!client.is_cache_valid());
}
#[test]
fn test_enrich_sets_scores_on_cve() {
let temp_dir = TempDir::new().unwrap();
let config = test_config(&temp_dir);
let mut client = EpssClient::new(config);
client.scores = Some(EpssScores::from_csv(
"#model_version:v1,score_date:2024-01-15\ncve,epss,percentile\nCVE-2024-1234,0.91234,0.99\n",
));
let mut vulns = vec![
VulnerabilityRef::new("CVE-2024-1234".to_string(), VulnerabilitySource::Cve),
VulnerabilityRef::new("GHSA-aaaa-bbbb".to_string(), VulnerabilitySource::Ghsa),
];
let stats = client.enrich_vulnerabilities(&mut vulns).unwrap();
assert_eq!(stats.vulns_checked, 2);
assert_eq!(stats.epss_matches, 1);
assert_eq!(stats.high_probability, 1);
assert_eq!(vulns[0].epss_score, Some(0.91234));
assert_eq!(vulns[0].epss_percentile, Some(0.99));
assert!(vulns[1].epss_score.is_none());
}
}