Skip to main content

ploit_malper/
nvd.rs

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        // Retry without the API key when the authenticated request was
93        // rejected (invalid/expired key), mirroring the old curl fallback.
94        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        // A CVE not in cache would hit the network; ensure the cache-hit path
458        // with a fully populated entry never panics and applies fields.
459        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}