1use std::collections::HashMap;
2use std::fs;
3use std::thread;
4use std::time::Duration;
5
6use serde_json::Value;
7
8use crate::config::nvd_cache_file;
9use crate::error::Result;
10use crate::models::{NVDCVEInfo, ScanRecord};
11
12const NVD_API_BASE: &str = "https://services.nvd.nist.gov/rest/json/cves/2.0";
13const NVD_RATE_LIMIT_DELAY: Duration = Duration::from_millis(600);
14
15#[derive(Debug, Clone)]
16pub struct NVDClient {
17 pub api_key: String,
18 cache: HashMap<String, serde_json::Value>,
19}
20
21#[derive(Debug, Clone, Copy, Default)]
22pub struct EnrichmentStats {
23 pub fetched: usize,
24 pub cache_hits: usize,
25 pub not_found: usize,
26}
27
28impl NVDClient {
29 pub fn new(api_key: &str) -> Self {
30 let mut client = Self {
31 api_key: api_key.to_string(),
32 cache: HashMap::new(),
33 };
34 client.load_cache();
35 client
36 }
37
38 fn load_cache(&mut self) {
39 let path = nvd_cache_file();
40 if let Ok(raw) = fs::read_to_string(path) {
41 if let Ok(cache) = serde_json::from_str::<HashMap<String, Value>>(&raw) {
42 self.cache = cache;
43 }
44 }
45 }
46
47 fn save_cache(&self) -> Result<()> {
48 let path = nvd_cache_file();
49 if let Some(parent) = path.parent() {
50 fs::create_dir_all(parent)?;
51 }
52 fs::write(path, serde_json::to_string_pretty(&self.cache)?)?;
53 Ok(())
54 }
55
56 fn get_cached(&self, cve_id: &str) -> Option<&Value> {
57 self.cache.get(cve_id)
58 }
59
60 fn cache_result(&mut self, cve_id: &str, data: Value) -> Result<()> {
61 self.cache.insert(cve_id.to_string(), data);
62 self.save_cache()
63 }
64
65 fn fetch_cve(&self, cve_id: &str, use_key: bool) -> std::result::Result<Value, String> {
66 let url = format!("{}?cveId={}", NVD_API_BASE, cve_id);
67 let client = reqwest::blocking::Client::builder()
68 .user_agent(format!("PloitMalper/{}", env!("CARGO_PKG_VERSION")))
69 .timeout(Duration::from_secs(15))
70 .build()
71 .map_err(|e| e.to_string())?;
72
73 let get = |client: &reqwest::blocking::Client, key: Option<&str>| {
74 let mut request = client.get(&url);
75 if let Some(key) = key {
76 request = request.header("apiKey", key);
77 }
78 request
79 };
80
81 let key = if use_key && !self.api_key.is_empty() {
82 Some(self.api_key.as_str())
83 } else {
84 None
85 };
86
87 let first = get(&client, key).send().map_err(|e| e.to_string())?;
88 if first.status().is_success() {
89 return first.json().map_err(|e| e.to_string());
90 }
91
92 if key.is_some() {
95 let fallback = get(&client, None).send().map_err(|e| e.to_string())?;
96 if fallback.status().is_success() {
97 return fallback.json().map_err(|e| e.to_string());
98 }
99 return Err(format!(
100 "NVD request failed with HTTP {}: {}",
101 fallback.status(),
102 fallback.text().unwrap_or_default()
103 ));
104 }
105
106 Err(format!(
107 "NVD request failed with HTTP {}: {}",
108 first.status(),
109 first.text().unwrap_or_default()
110 ))
111 }
112
113 pub fn lookup_cve(&mut self, cve_id: &str) -> Option<NVDCVEInfo> {
114 if let Some(cached) = self.get_cached(cve_id) {
115 return Some(Self::info_from_cached(cve_id, cached));
116 }
117
118 thread::sleep(NVD_RATE_LIMIT_DELAY);
119
120 let resp = match self.fetch_cve(cve_id, true) {
121 Ok(value) => value,
122 Err(_) => return None,
123 };
124
125 let vulnerabilities = resp.get("vulnerabilities")?.as_array()?;
126 let cve_data = vulnerabilities.first()?.get("cve")?.as_object()?.clone();
127
128 let info = Self::info_from_cve(cve_id, &cve_data);
129 let cache_entry = serde_json::json!({
130 "cve_id": info.cve_id,
131 "description": info.description,
132 "cvss_v3_score": info.cvss_v3_score,
133 "cvss_v3_severity": info.cvss_v3_severity,
134 "cvss_v2_score": info.cvss_v2_score,
135 "published": info.published,
136 "last_modified": info.last_modified,
137 "weaknesses": info.weaknesses,
138 "references": info.references,
139 });
140
141 let _ = self.cache_result(cve_id, cache_entry);
142 Some(info)
143 }
144
145 fn info_from_cached(cve_id: &str, cached: &Value) -> NVDCVEInfo {
146 NVDCVEInfo {
147 cve_id: cached
148 .get("cve_id")
149 .and_then(Value::as_str)
150 .unwrap_or(cve_id)
151 .to_string(),
152 description: cached
153 .get("description")
154 .and_then(Value::as_str)
155 .unwrap_or("")
156 .to_string(),
157 cvss_v3_score: cached
158 .get("cvss_v3_score")
159 .and_then(Value::as_f64)
160 .map(|v| v as f32),
161 cvss_v3_severity: cached
162 .get("cvss_v3_severity")
163 .and_then(Value::as_str)
164 .map(ToOwned::to_owned),
165 cvss_v2_score: cached
166 .get("cvss_v2_score")
167 .and_then(Value::as_f64)
168 .map(|v| v as f32),
169 published: cached
170 .get("published")
171 .and_then(Value::as_str)
172 .unwrap_or("")
173 .to_string(),
174 last_modified: cached
175 .get("last_modified")
176 .and_then(Value::as_str)
177 .unwrap_or("")
178 .to_string(),
179 weaknesses: cached
180 .get("weaknesses")
181 .and_then(Value::as_array)
182 .map(|refs| {
183 refs.iter()
184 .filter_map(Value::as_str)
185 .map(ToOwned::to_owned)
186 .collect()
187 })
188 .unwrap_or_default(),
189 references: cached
190 .get("references")
191 .and_then(Value::as_array)
192 .map(|refs| {
193 refs.iter()
194 .filter_map(Value::as_str)
195 .map(ToOwned::to_owned)
196 .collect()
197 })
198 .unwrap_or_default(),
199 }
200 }
201
202 fn info_from_cve(cve_id: &str, cve_data: &serde_json::Map<String, Value>) -> NVDCVEInfo {
203 let descriptions = cve_data
204 .get("descriptions")
205 .and_then(Value::as_array)
206 .cloned()
207 .unwrap_or_default();
208
209 let mut description = String::new();
210 for entry in &descriptions {
211 if entry.get("lang").and_then(Value::as_str) == Some("en") {
212 description = entry
213 .get("value")
214 .and_then(Value::as_str)
215 .unwrap_or("")
216 .to_string();
217 break;
218 }
219 }
220 if description.is_empty() {
221 description = descriptions
222 .first()
223 .and_then(|entry| entry.get("value"))
224 .and_then(Value::as_str)
225 .unwrap_or("")
226 .to_string();
227 }
228
229 let metrics = cve_data.get("metrics").and_then(Value::as_object);
230 let mut cvss_v3_score = None;
231 let mut cvss_v3_severity = None;
232 let mut cvss_v2_score = None;
233
234 if let Some(metrics) = metrics {
235 let metric_groups = metrics
236 .get("cvssMetricV31")
237 .or_else(|| metrics.get("cvssMetricV30"))
238 .and_then(Value::as_array)
239 .cloned()
240 .unwrap_or_default();
241 if let Some(metric_group) = metric_groups.first() {
242 if let Some(cvss_data) = metric_group.get("cvssData").and_then(Value::as_object) {
243 cvss_v3_score = cvss_data
244 .get("baseScore")
245 .and_then(Value::as_f64)
246 .map(|v| v as f32);
247 cvss_v3_severity = cvss_data
248 .get("baseSeverity")
249 .and_then(Value::as_str)
250 .map(ToOwned::to_owned);
251 }
252 }
253
254 if cvss_v3_score.is_none() {
255 if let Some(cvss_v2_metrics) = metrics.get("cvssMetricV2").and_then(Value::as_array)
256 {
257 if let Some(metric) = cvss_v2_metrics.first() {
258 cvss_v2_score = metric
259 .get("cvssData")
260 .and_then(Value::as_object)
261 .and_then(|cvss| cvss.get("baseScore"))
262 .and_then(Value::as_f64)
263 .map(|v| v as f32);
264 }
265 }
266 }
267 }
268
269 let references = cve_data
270 .get("references")
271 .and_then(Value::as_array)
272 .map(|refs| {
273 refs.iter()
274 .take(5)
275 .filter_map(|ref_value| ref_value.get("url").and_then(Value::as_str))
276 .map(ToOwned::to_owned)
277 .collect::<Vec<_>>()
278 })
279 .unwrap_or_default();
280
281 let weaknesses = cve_data
282 .get("weaknesses")
283 .and_then(Value::as_array)
284 .map(|entries| {
285 let mut out = Vec::new();
286 for entry in entries {
287 if let Some(descs) = entry.get("description").and_then(Value::as_array) {
288 for desc in descs {
289 if let Some(value) = desc.get("value").and_then(Value::as_str) {
290 if !out.contains(&value.to_string()) {
291 out.push(value.to_string());
292 }
293 }
294 }
295 }
296 }
297 out
298 })
299 .unwrap_or_default();
300
301 NVDCVEInfo {
302 cve_id: cve_id.to_string(),
303 description,
304 cvss_v3_score,
305 cvss_v3_severity,
306 cvss_v2_score,
307 published: cve_data
308 .get("published")
309 .and_then(Value::as_str)
310 .unwrap_or("")
311 .to_string(),
312 last_modified: cve_data
313 .get("lastModified")
314 .and_then(Value::as_str)
315 .unwrap_or("")
316 .to_string(),
317 weaknesses,
318 references,
319 }
320 }
321
322 pub fn enrich_records(&mut self, records: &mut [ScanRecord]) -> EnrichmentStats {
323 let mut stats = EnrichmentStats::default();
324
325 for record in records {
326 let cves = if record.cves.is_empty() {
327 record.cve.clone().into_iter().collect::<Vec<_>>()
328 } else {
329 record.cves.clone()
330 };
331
332 for cve in cves {
333 let key = cve.trim().to_uppercase();
334 if let Some(cached) = self.get_cached(&key).cloned() {
335 stats.cache_hits += 1;
336 let info = Self::info_from_cached(&key, &cached);
337 apply_nvd(record, &info);
338 continue;
339 }
340
341 if let Some(info) = self.lookup_cve(&key) {
342 stats.fetched += 1;
343 apply_nvd(record, &info);
344 } else {
345 stats.not_found += 1;
346 }
347 }
348 }
349
350 stats
351 }
352}
353
354fn apply_nvd(record: &mut ScanRecord, info: &NVDCVEInfo) {
355 record.nvd_description = Some(info.description.clone());
356 record.nvd_cvss_v3 = info.cvss_v3_score;
357 record.nvd_cvss_v3_severity = info.cvss_v3_severity.clone();
358 record.nvd_cvss_v2 = info.cvss_v2_score;
359 record.nvd_published = Some(info.published.clone());
360 record.nvd_last_modified = Some(info.last_modified.clone());
361 record.nvd_weaknesses = info.weaknesses.clone();
362 record.nvd_references = info.references.clone();
363
364 if let Some(severity) = &info.cvss_v3_severity {
365 record.severity = severity.to_lowercase();
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use serde_json::json;
373
374 #[test]
375 fn parses_cwe_weaknesses_from_nvd_response() {
376 let cve_data = json!({
377 "descriptions": [{"lang": "en", "value": "Path traversal in Apache."}],
378 "metrics": {
379 "cvssMetricV31": [{
380 "cvssData": {
381 "baseScore": 9.8,
382 "baseSeverity": "CRITICAL"
383 }
384 }]
385 },
386 "weaknesses": [{
387 "description": [{"lang": "en", "value": "CWE-22"}, {"lang": "en", "value": "CWE-200"}]
388 }],
389 "published": "2021-10-05T00:00:00.000",
390 "lastModified": "2021-10-08T00:00:00.000",
391 "references": [{"url": "https://example.com/advisory"}]
392 });
393 let info = NVDClient::info_from_cve("CVE-2021-41773", cve_data.as_object().unwrap());
394 assert_eq!(
395 info.weaknesses,
396 vec!["CWE-22".to_string(), "CWE-200".to_string()]
397 );
398 assert_eq!(info.cvss_v3_score, Some(9.8));
399 assert_eq!(info.cvss_v3_severity.as_deref(), Some("CRITICAL"));
400 assert_eq!(info.published, "2021-10-05T00:00:00.000");
401 assert_eq!(
402 info.references,
403 vec!["https://example.com/advisory".to_string()]
404 );
405 }
406
407 #[test]
408 fn enriches_every_discovered_cve_from_cache() {
409 let mut client = NVDClient {
410 api_key: String::new(),
411 cache: HashMap::new(),
412 };
413 client.cache.insert(
414 "CVE-2003-1418".into(),
415 json!({
416 "cve_id": "CVE-2003-1418",
417 "description": "Inode leak",
418 "cvss_v3_score": null,
419 "cvss_v3_severity": null,
420 "cvss_v2_score": 5.0,
421 "published": "2003-08-11T00:00:00.000",
422 "last_modified": "2017-10-11T00:00:00.000",
423 "weaknesses": ["CWE-200"],
424 "references": []
425 }),
426 );
427 client.cache.insert(
428 "CVE-2021-41773".into(),
429 json!({
430 "cve_id": "CVE-2021-41773",
431 "description": "Path traversal",
432 "cvss_v3_score": 9.8,
433 "cvss_v3_severity": "CRITICAL",
434 "cvss_v2_score": null,
435 "published": "2021-10-05T00:00:00.000",
436 "last_modified": "2021-10-08T00:00:00.000",
437 "weaknesses": ["CWE-22"],
438 "references": ["https://example.com"]
439 }),
440 );
441
442 let mut records = vec![ScanRecord {
443 cves: vec!["CVE-2003-1418".to_string(), "CVE-2021-41773".to_string()],
444 ..Default::default()
445 }];
446 let stats = client.enrich_records(&mut records);
447 assert_eq!(stats.cache_hits, 2);
448 assert_eq!(stats.fetched, 0);
449 assert_eq!(stats.not_found, 0);
450 assert_eq!(records[0].nvd_cvss_v3, Some(9.8));
451 assert_eq!(records[0].severity, "critical");
452 assert_eq!(records[0].nvd_weaknesses, vec!["CWE-22".to_string()]);
453 }
454
455 #[test]
456 fn gracefully_handles_missing_cve() {
457 let mut client = NVDClient {
460 api_key: String::new(),
461 cache: HashMap::new(),
462 };
463 client.cache.insert(
464 "CVE-2021-44228".into(),
465 json!({
466 "cve_id": "CVE-2021-44228",
467 "description": "Log4j JNDI injection",
468 "cvss_v3_score": 10.0,
469 "cvss_v3_severity": "CRITICAL",
470 "cvss_v2_score": null,
471 "published": "2021-12-09T00:00:00.000",
472 "last_modified": "2021-12-10T00:00:00.000",
473 "weaknesses": ["CWE-502"],
474 "references": []
475 }),
476 );
477 let mut records = vec![ScanRecord {
478 cves: vec!["CVE-2021-44228".to_string()],
479 ..Default::default()
480 }];
481 let stats = client.enrich_records(&mut records);
482 assert_eq!(stats.cache_hits, 1);
483 assert_eq!(records[0].nvd_cvss_v3, Some(10.0));
484 assert_eq!(records[0].nvd_cvss_v3_severity.as_deref(), Some("CRITICAL"));
485 }
486}