vulnera_advisor/sources/
kev.rs

1//! CISA Known Exploited Vulnerabilities (KEV) catalog source.
2//!
3//! This module fetches the KEV catalog which lists vulnerabilities that are
4//! actively being exploited in the wild. This data is critical for prioritization.
5//!
6//! # Data Source
7//!
8//! - URL: <https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json>
9//! - Updated: As new exploited vulnerabilities are discovered
10//! - License: Public domain
11
12use crate::error::{AdvisoryError, Result};
13use chrono::{DateTime, NaiveDate, Utc};
14use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
15use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::time::Duration;
19use tracing::{debug, info};
20
21/// URL for the CISA KEV JSON feed.
22pub const KEV_URL: &str =
23    "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
24
25/// Request timeout
26const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
27/// Connection timeout  
28const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29
30/// CISA KEV data source.
31///
32/// This source fetches the Known Exploited Vulnerabilities catalog and provides
33/// enrichment data for advisories. Unlike other sources, KEV doesn't create new
34/// advisories but enriches existing ones with exploitation status.
35pub struct KevSource {
36    client: ClientWithMiddleware,
37}
38
39impl KevSource {
40    /// Create a new KEV source.
41    pub fn new() -> Self {
42        let raw_client = reqwest::Client::builder()
43            .timeout(REQUEST_TIMEOUT)
44            .connect_timeout(CONNECT_TIMEOUT)
45            .build()
46            .unwrap_or_default();
47
48        // Retry policy: 3 retries with exponential backoff
49        let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
50        let client = ClientBuilder::new(raw_client)
51            .with(RetryTransientMiddleware::new_with_policy(retry_policy))
52            .build();
53
54        Self { client }
55    }
56
57    /// Fetch the entire KEV catalog.
58    ///
59    /// Returns a map of CVE ID to KEV entry for efficient lookup.
60    pub async fn fetch_catalog(&self) -> Result<HashMap<String, KevEntry>> {
61        info!("Fetching CISA KEV catalog...");
62
63        let response = self.client.get(KEV_URL).send().await?;
64
65        if !response.status().is_success() {
66            return Err(AdvisoryError::source_fetch(
67                "KEV",
68                format!("HTTP {}", response.status()),
69            ));
70        }
71
72        let catalog: KevCatalog = response.json().await?;
73
74        let entries: HashMap<String, KevEntry> = catalog
75            .vulnerabilities
76            .into_iter()
77            .map(|v| (v.cve_id.clone(), v))
78            .collect();
79
80        info!(
81            "Fetched {} KEV entries (catalog version: {})",
82            entries.len(),
83            catalog.catalog_version
84        );
85
86        Ok(entries)
87    }
88
89    /// Check if a CVE is in the KEV catalog.
90    pub async fn is_kev(&self, cve_id: &str) -> Result<Option<KevEntry>> {
91        let catalog = self.fetch_catalog().await?;
92        Ok(catalog.get(cve_id).cloned())
93    }
94
95    /// Fetch KEV entries modified since a given date.
96    ///
97    /// Note: The KEV catalog doesn't have incremental updates, so this downloads
98    /// the full catalog and filters locally.
99    pub async fn fetch_since(&self, since: DateTime<Utc>) -> Result<Vec<KevEntry>> {
100        let catalog = self.fetch_catalog().await?;
101        let since_date = since.date_naive();
102
103        let recent: Vec<KevEntry> = catalog
104            .into_values()
105            .filter(|entry| entry.date_added.map(|d| d >= since_date).unwrap_or(false))
106            .collect();
107
108        debug!("Found {} KEV entries added since {}", recent.len(), since);
109        Ok(recent)
110    }
111}
112
113impl Default for KevSource {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119/// The full KEV catalog response.
120#[derive(Debug, Clone, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct KevCatalog {
123    /// Title of the catalog.
124    pub title: String,
125    /// Version of the catalog.
126    pub catalog_version: String,
127    /// When the catalog was last updated.
128    #[serde(rename = "dateReleased")]
129    pub date_released: Option<String>,
130    /// Total number of vulnerabilities.
131    pub count: u32,
132    /// List of vulnerabilities.
133    pub vulnerabilities: Vec<KevEntry>,
134}
135
136/// A single KEV entry representing an actively exploited vulnerability.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct KevEntry {
140    /// CVE identifier (e.g., "CVE-2024-1234").
141    pub cve_id: String,
142    /// Vendor/project name.
143    pub vendor_project: String,
144    /// Product name.
145    pub product: String,
146    /// Human-readable vulnerability name.
147    pub vulnerability_name: String,
148    /// Date the CVE was added to KEV.
149    #[serde(deserialize_with = "deserialize_date_option", default)]
150    pub date_added: Option<NaiveDate>,
151    /// Brief description.
152    pub short_description: String,
153    /// Required remediation action.
154    pub required_action: String,
155    /// Due date for remediation (for federal agencies).
156    #[serde(deserialize_with = "deserialize_date_option", default)]
157    pub due_date: Option<NaiveDate>,
158    /// Whether known ransomware campaigns use this vulnerability.
159    #[serde(default)]
160    pub known_ransomware_campaign_use: Option<String>,
161    /// Additional notes.
162    #[serde(default)]
163    pub notes: Option<String>,
164    /// CWE identifiers.
165    #[serde(default)]
166    pub cwes: Option<Vec<String>>,
167}
168
169impl KevEntry {
170    /// Check if this vulnerability is used in ransomware campaigns.
171    pub fn is_ransomware_related(&self) -> bool {
172        self.known_ransomware_campaign_use
173            .as_ref()
174            .map(|s| s.eq_ignore_ascii_case("Known"))
175            .unwrap_or(false)
176    }
177
178    /// Get the due date as a UTC DateTime.
179    pub fn due_date_utc(&self) -> Option<DateTime<Utc>> {
180        self.due_date
181            .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
182    }
183
184    /// Get the date added as a UTC DateTime.
185    pub fn date_added_utc(&self) -> Option<DateTime<Utc>> {
186        self.date_added
187            .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
188    }
189}
190
191/// Deserialize optional date fields from CISA format (YYYY-MM-DD).
192fn deserialize_date_option<'de, D>(
193    deserializer: D,
194) -> std::result::Result<Option<NaiveDate>, D::Error>
195where
196    D: serde::Deserializer<'de>,
197{
198    let opt: Option<String> = Option::deserialize(deserializer)?;
199    match opt {
200        Some(s) if !s.is_empty() => NaiveDate::parse_from_str(&s, "%Y-%m-%d")
201            .map(Some)
202            .map_err(serde::de::Error::custom),
203        _ => Ok(None),
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_kev_entry_ransomware() {
213        let entry = KevEntry {
214            cve_id: "CVE-2024-1234".to_string(),
215            vendor_project: "Test".to_string(),
216            product: "Test".to_string(),
217            vulnerability_name: "Test".to_string(),
218            date_added: None,
219            short_description: "Test".to_string(),
220            required_action: "Test".to_string(),
221            due_date: None,
222            known_ransomware_campaign_use: Some("Known".to_string()),
223            notes: None,
224            cwes: None,
225        };
226
227        assert!(entry.is_ransomware_related());
228    }
229
230    #[test]
231    fn test_kev_entry_not_ransomware() {
232        let entry = KevEntry {
233            cve_id: "CVE-2024-1234".to_string(),
234            vendor_project: "Test".to_string(),
235            product: "Test".to_string(),
236            vulnerability_name: "Test".to_string(),
237            date_added: None,
238            short_description: "Test".to_string(),
239            required_action: "Test".to_string(),
240            due_date: None,
241            known_ransomware_campaign_use: Some("Unknown".to_string()),
242            notes: None,
243            cwes: None,
244        };
245
246        assert!(!entry.is_ransomware_related());
247    }
248}