use crate::error::{AdvisoryError, Result};
use chrono::{DateTime, NaiveDate, Utc};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, info};
pub const KEV_URL: &str =
"https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub struct KevSource {
client: ClientWithMiddleware,
}
impl KevSource {
pub fn new() -> Self {
let raw_client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.unwrap_or_default();
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(raw_client)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
Self { client }
}
pub async fn fetch_catalog(&self) -> Result<HashMap<String, KevEntry>> {
info!("Fetching CISA KEV catalog...");
let response = self.client.get(KEV_URL).send().await?;
if !response.status().is_success() {
return Err(AdvisoryError::source_fetch(
"KEV",
format!("HTTP {}", response.status()),
));
}
let catalog: KevCatalog = response.json().await?;
let entries: HashMap<String, KevEntry> = catalog
.vulnerabilities
.into_iter()
.map(|v| (v.cve_id.clone(), v))
.collect();
info!(
"Fetched {} KEV entries (catalog version: {})",
entries.len(),
catalog.catalog_version
);
Ok(entries)
}
pub async fn is_kev(&self, cve_id: &str) -> Result<Option<KevEntry>> {
let catalog = self.fetch_catalog().await?;
Ok(catalog.get(cve_id).cloned())
}
pub async fn fetch_since(&self, since: DateTime<Utc>) -> Result<Vec<KevEntry>> {
let catalog = self.fetch_catalog().await?;
let since_date = since.date_naive();
let recent: Vec<KevEntry> = catalog
.into_values()
.filter(|entry| entry.date_added.map(|d| d >= since_date).unwrap_or(false))
.collect();
debug!("Found {} KEV entries added since {}", recent.len(), since);
Ok(recent)
}
}
impl Default for KevSource {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KevCatalog {
pub title: String,
pub catalog_version: String,
#[serde(rename = "dateReleased")]
pub date_released: Option<String>,
pub count: u32,
pub vulnerabilities: Vec<KevEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KevEntry {
pub cve_id: String,
pub vendor_project: String,
pub product: String,
pub vulnerability_name: String,
#[serde(deserialize_with = "deserialize_date_option", default)]
pub date_added: Option<NaiveDate>,
pub short_description: String,
pub required_action: String,
#[serde(deserialize_with = "deserialize_date_option", default)]
pub due_date: Option<NaiveDate>,
#[serde(default)]
pub known_ransomware_campaign_use: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub cwes: Option<Vec<String>>,
}
impl KevEntry {
pub fn is_ransomware_related(&self) -> bool {
self.known_ransomware_campaign_use
.as_ref()
.map(|s| s.eq_ignore_ascii_case("Known"))
.unwrap_or(false)
}
pub fn due_date_utc(&self) -> Option<DateTime<Utc>> {
self.due_date
.map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
}
pub fn date_added_utc(&self) -> Option<DateTime<Utc>> {
self.date_added
.map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
}
}
fn deserialize_date_option<'de, D>(
deserializer: D,
) -> std::result::Result<Option<NaiveDate>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
Some(s) if !s.is_empty() => NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kev_entry_ransomware() {
let entry = KevEntry {
cve_id: "CVE-2024-1234".to_string(),
vendor_project: "Test".to_string(),
product: "Test".to_string(),
vulnerability_name: "Test".to_string(),
date_added: None,
short_description: "Test".to_string(),
required_action: "Test".to_string(),
due_date: None,
known_ransomware_campaign_use: Some("Known".to_string()),
notes: None,
cwes: None,
};
assert!(entry.is_ransomware_related());
}
#[test]
fn test_kev_entry_not_ransomware() {
let entry = KevEntry {
cve_id: "CVE-2024-1234".to_string(),
vendor_project: "Test".to_string(),
product: "Test".to_string(),
vulnerability_name: "Test".to_string(),
date_added: None,
short_description: "Test".to_string(),
required_action: "Test".to_string(),
due_date: None,
known_ransomware_campaign_use: Some("Unknown".to_string()),
notes: None,
cwes: None,
};
assert!(!entry.is_ransomware_related());
}
}