use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KevCatalogResponse {
pub title: String,
#[serde(rename = "catalogVersion")]
pub catalog_version: String,
#[serde(rename = "dateReleased")]
pub date_released: String,
pub count: usize,
pub vulnerabilities: Vec<KevVulnerability>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KevVulnerability {
#[serde(rename = "cveID")]
pub cve_id: String,
#[serde(rename = "vendorProject")]
pub vendor_project: String,
pub product: String,
#[serde(rename = "vulnerabilityName")]
pub vulnerability_name: String,
#[serde(rename = "dateAdded")]
pub date_added: String,
#[serde(rename = "shortDescription")]
pub short_description: String,
#[serde(rename = "requiredAction")]
pub required_action: String,
#[serde(rename = "dueDate")]
pub due_date: String,
#[serde(rename = "knownRansomwareCampaignUse")]
pub known_ransomware_campaign_use: String,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KevEntry {
pub cve_id: String,
pub vendor_project: String,
pub product: String,
pub vulnerability_name: String,
pub date_added: DateTime<Utc>,
pub description: String,
pub required_action: String,
pub due_date: DateTime<Utc>,
pub known_ransomware_use: bool,
pub notes: Option<String>,
}
impl KevEntry {
#[must_use]
pub fn from_raw(raw: &KevVulnerability) -> Option<Self> {
let date_added = parse_kev_date(&raw.date_added)?;
let due_date = parse_kev_date(&raw.due_date)?;
let known_ransomware_use = raw.known_ransomware_campaign_use.to_lowercase() == "known";
Some(Self {
cve_id: raw.cve_id.clone(),
vendor_project: raw.vendor_project.clone(),
product: raw.product.clone(),
vulnerability_name: raw.vulnerability_name.clone(),
date_added,
description: raw.short_description.clone(),
required_action: raw.required_action.clone(),
due_date,
known_ransomware_use,
notes: raw.notes.clone(),
})
}
#[must_use]
pub fn is_overdue(&self) -> bool {
Utc::now() > self.due_date
}
#[must_use]
pub fn days_until_due(&self) -> i64 {
(self.due_date - Utc::now()).num_days()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KevCatalog {
entries: HashMap<String, KevEntry>,
pub version: String,
pub last_updated: DateTime<Utc>,
pub count: usize,
}
impl KevCatalog {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
version: String::new(),
last_updated: Utc::now(),
count: 0,
}
}
#[must_use]
pub fn from_response(response: KevCatalogResponse) -> Self {
let mut entries = HashMap::new();
for vuln in &response.vulnerabilities {
if let Some(entry) = KevEntry::from_raw(vuln) {
entries.insert(entry.cve_id.clone(), entry);
}
}
let count = entries.len();
Self {
entries,
version: response.catalog_version,
last_updated: Utc::now(),
count,
}
}
#[must_use]
pub fn contains(&self, cve_id: &str) -> bool {
let normalized = normalize_cve_id(cve_id);
self.entries.contains_key(&normalized)
}
#[must_use]
pub fn get(&self, cve_id: &str) -> Option<&KevEntry> {
let normalized = normalize_cve_id(cve_id);
self.entries.get(&normalized)
}
#[must_use]
pub fn is_ransomware_related(&self, cve_id: &str) -> bool {
self.get(cve_id).is_some_and(|e| e.known_ransomware_use)
}
#[must_use]
pub fn ransomware_cves(&self) -> Vec<&KevEntry> {
self.entries
.values()
.filter(|e| e.known_ransomware_use)
.collect()
}
#[must_use]
pub fn overdue_cves(&self) -> Vec<&KevEntry> {
self.entries.values().filter(|e| e.is_overdue()).collect()
}
pub fn all_entries(&self) -> impl Iterator<Item = &KevEntry> {
self.entries.values()
}
#[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 KevCatalog {
fn default() -> Self {
Self::new()
}
}
fn parse_kev_date(date_str: &str) -> Option<DateTime<Utc>> {
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc))
}
fn normalize_cve_id(cve_id: &str) -> String {
cve_id.to_uppercase().trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_kev_date() {
let date = parse_kev_date("2024-01-15").unwrap();
assert_eq!(date.format("%Y-%m-%d").to_string(), "2024-01-15");
}
#[test]
fn test_normalize_cve_id() {
assert_eq!(normalize_cve_id("cve-2024-1234"), "CVE-2024-1234");
assert_eq!(normalize_cve_id(" CVE-2024-1234 "), "CVE-2024-1234");
}
#[test]
fn test_kev_entry_from_raw() {
let raw = KevVulnerability {
cve_id: "CVE-2024-1234".to_string(),
vendor_project: "Test Vendor".to_string(),
product: "Test Product".to_string(),
vulnerability_name: "Test Vuln".to_string(),
date_added: "2024-01-01".to_string(),
short_description: "Test description".to_string(),
required_action: "Apply patch".to_string(),
due_date: "2024-02-01".to_string(),
known_ransomware_campaign_use: "Known".to_string(),
notes: None,
};
let entry = KevEntry::from_raw(&raw).unwrap();
assert_eq!(entry.cve_id, "CVE-2024-1234");
assert!(entry.known_ransomware_use);
}
#[test]
fn test_catalog_contains() {
let mut catalog = KevCatalog::new();
catalog.entries.insert(
"CVE-2024-1234".to_string(),
KevEntry {
cve_id: "CVE-2024-1234".to_string(),
vendor_project: "Test".to_string(),
product: "Test".to_string(),
vulnerability_name: "Test".to_string(),
date_added: Utc::now(),
description: "Test".to_string(),
required_action: "Test".to_string(),
due_date: Utc::now(),
known_ransomware_use: false,
notes: None,
},
);
assert!(catalog.contains("CVE-2024-1234"));
assert!(catalog.contains("cve-2024-1234")); assert!(!catalog.contains("CVE-2024-5678"));
}
}