vulnera_advisor/sources/
kev.rs1use 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
21pub const KEV_URL: &str =
23 "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
24
25const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
27const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29
30pub struct KevSource {
36 client: ClientWithMiddleware,
37}
38
39impl KevSource {
40 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 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 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 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 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#[derive(Debug, Clone, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct KevCatalog {
123 pub title: String,
125 pub catalog_version: String,
127 #[serde(rename = "dateReleased")]
129 pub date_released: Option<String>,
130 pub count: u32,
132 pub vulnerabilities: Vec<KevEntry>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct KevEntry {
140 pub cve_id: String,
142 pub vendor_project: String,
144 pub product: String,
146 pub vulnerability_name: String,
148 #[serde(deserialize_with = "deserialize_date_option", default)]
150 pub date_added: Option<NaiveDate>,
151 pub short_description: String,
153 pub required_action: String,
155 #[serde(deserialize_with = "deserialize_date_option", default)]
157 pub due_date: Option<NaiveDate>,
158 #[serde(default)]
160 pub known_ransomware_campaign_use: Option<String>,
161 #[serde(default)]
163 pub notes: Option<String>,
164 #[serde(default)]
166 pub cwes: Option<Vec<String>>,
167}
168
169impl KevEntry {
170 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 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 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
191fn 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}