1use crate::rules::{Category, Confidence, Finding, Location, Severity};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10use thiserror::Error;
11
12const BUILTIN_DATABASE: &str = include_str!("../data/cve-database.json");
14
15#[derive(Debug, Error)]
16pub enum CveDbError {
17 #[error("Failed to read CVE database file: {0}")]
18 ReadFile(#[from] std::io::Error),
19
20 #[error("Failed to parse CVE database JSON: {0}")]
21 ParseJson(#[from] serde_json::Error),
22
23 #[error("Failed to parse version requirement for {cve_id}: {version}")]
24 InvalidVersion { cve_id: String, version: String },
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AffectedProduct {
30 pub vendor: String,
31 pub product: String,
32 pub version_affected: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub version_fixed: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CveEntry {
40 pub id: String,
41 pub title: String,
42 pub description: String,
43 pub severity: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub cvss_score: Option<f32>,
46 pub affected_products: Vec<AffectedProduct>,
47 #[serde(default)]
48 pub cwe_ids: Vec<String>,
49 #[serde(default)]
50 pub references: Vec<String>,
51 pub published_at: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CveDatabaseFile {
57 pub version: String,
58 pub updated_at: String,
59 pub entries: Vec<CveEntry>,
60}
61
62pub struct CveDatabase {
64 entries: Vec<CveEntry>,
65 version: String,
66 updated_at: String,
67}
68
69impl CveDatabase {
70 pub fn builtin() -> Result<Self, CveDbError> {
72 Self::from_json(BUILTIN_DATABASE)
73 }
74
75 pub fn from_file(path: &Path) -> Result<Self, CveDbError> {
77 let content = fs::read_to_string(path)?;
78 Self::from_json(&content)
79 }
80
81 pub fn from_json(json: &str) -> Result<Self, CveDbError> {
83 let file: CveDatabaseFile = serde_json::from_str(json)?;
84 Ok(Self {
85 entries: file.entries,
86 version: file.version,
87 updated_at: file.updated_at,
88 })
89 }
90
91 pub fn version(&self) -> &str {
93 &self.version
94 }
95
96 pub fn updated_at(&self) -> &str {
98 &self.updated_at
99 }
100
101 pub fn entries(&self) -> &[CveEntry] {
103 &self.entries
104 }
105
106 pub fn len(&self) -> usize {
108 self.entries.len()
109 }
110
111 pub fn is_empty(&self) -> bool {
113 self.entries.is_empty()
114 }
115
116 pub fn check_product(&self, vendor: &str, product: &str, version: &str) -> Vec<&CveEntry> {
119 self.entries
120 .iter()
121 .filter(|entry| {
122 entry.affected_products.iter().any(|p| {
123 p.vendor.eq_ignore_ascii_case(vendor)
124 && p.product.eq_ignore_ascii_case(product)
125 && Self::version_matches(&p.version_affected, version)
126 })
127 })
128 .collect()
129 }
130
131 fn version_matches(requirement: &str, version: &str) -> bool {
134 let requirement = requirement.trim();
135
136 let (op, req_version) = if let Some(rest) = requirement.strip_prefix("<=") {
138 ("<=", rest.trim())
139 } else if let Some(rest) = requirement.strip_prefix(">=") {
140 (">=", rest.trim())
141 } else if let Some(rest) = requirement.strip_prefix('<') {
142 ("<", rest.trim())
143 } else if let Some(rest) = requirement.strip_prefix('>') {
144 (">", rest.trim())
145 } else if let Some(rest) = requirement.strip_prefix('=') {
146 ("=", rest.trim())
147 } else {
148 ("=", requirement) };
150
151 let version_parts = Self::parse_version(version);
153 let req_parts = Self::parse_version(req_version);
154
155 match op {
156 "<" => Self::compare_versions(&version_parts, &req_parts) < 0,
157 "<=" => Self::compare_versions(&version_parts, &req_parts) <= 0,
158 ">" => Self::compare_versions(&version_parts, &req_parts) > 0,
159 ">=" => Self::compare_versions(&version_parts, &req_parts) >= 0,
160 _ => Self::compare_versions(&version_parts, &req_parts) == 0,
161 }
162 }
163
164 fn parse_version(version: &str) -> Vec<u32> {
166 version
167 .split(['.', '-', '_'])
168 .filter_map(|s| {
169 let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
171 num_str.parse().ok()
172 })
173 .collect()
174 }
175
176 fn compare_versions(a: &[u32], b: &[u32]) -> i32 {
179 let max_len = a.len().max(b.len());
180 for i in 0..max_len {
181 let av = a.get(i).copied().unwrap_or(0);
182 let bv = b.get(i).copied().unwrap_or(0);
183 if av < bv {
184 return -1;
185 }
186 if av > bv {
187 return 1;
188 }
189 }
190 0
191 }
192
193 pub fn check_product_by_name(&self, product: &str, version: &str) -> Vec<&CveEntry> {
201 self.entries
202 .iter()
203 .filter(|entry| {
204 entry.affected_products.iter().any(|p| {
205 p.product.eq_ignore_ascii_case(product)
206 && Self::version_matches(&p.version_affected, version)
207 })
208 })
209 .collect()
210 }
211
212 fn finding_from_cve(
215 cve: &CveEntry,
216 product: &str,
217 version: &str,
218 file_path: &str,
219 line: usize,
220 ) -> Finding {
221 let affected = cve
222 .affected_products
223 .iter()
224 .find(|p| p.product.eq_ignore_ascii_case(product));
225 let vendor = affected.map(|p| p.vendor.as_str()).unwrap_or("");
226
227 Finding {
228 id: cve.id.clone(),
229 severity: Self::parse_severity(&cve.severity),
230 category: Category::SupplyChain,
231 confidence: Confidence::Certain,
232 name: cve.title.clone(),
233 location: Location {
234 file: file_path.to_string(),
235 line,
236 column: None,
237 },
238 code: format!("{}/{} v{}", vendor, product, version),
239 message: cve.description.clone(),
240 recommendation: if let Some(ref fixed) = affected.and_then(|p| p.version_fixed.clone())
241 {
242 format!("Update to version {} or later", fixed)
243 } else {
244 "Check for security updates from the vendor".to_string()
245 },
246 fix_hint: None,
247 cwe_ids: cve.cwe_ids.clone(),
248 rule_severity: None,
249 client: None,
250 context: None,
251 }
252 }
253
254 pub fn create_findings(
256 &self,
257 vendor: &str,
258 product: &str,
259 version: &str,
260 file_path: &str,
261 line: usize,
262 ) -> Vec<Finding> {
263 self.check_product(vendor, product, version)
264 .into_iter()
265 .map(|cve| Self::finding_from_cve(cve, product, version, file_path, line))
266 .collect()
267 }
268
269 pub fn create_findings_by_product(
274 &self,
275 product: &str,
276 version: &str,
277 file_path: &str,
278 line: usize,
279 ) -> Vec<Finding> {
280 self.check_product_by_name(product, version)
281 .into_iter()
282 .map(|cve| Self::finding_from_cve(cve, product, version, file_path, line))
283 .collect()
284 }
285
286 fn parse_severity(s: &str) -> Severity {
287 match s.to_lowercase().as_str() {
288 "critical" => Severity::Critical,
289 "high" => Severity::High,
290 "medium" => Severity::Medium,
291 "low" => Severity::Low,
292 _ => Severity::Medium,
293 }
294 }
295}
296
297impl Default for CveDatabase {
298 fn default() -> Self {
299 Self::builtin().expect("Built-in CVE database should be valid")
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_load_builtin_database() {
309 let db = CveDatabase::builtin().unwrap();
310 assert!(!db.is_empty());
311 assert!(db.version().starts_with("1."));
313 }
314
315 #[test]
316 fn test_version_comparison_less_than() {
317 assert!(CveDatabase::version_matches("< 1.5.0", "1.4.9"));
318 assert!(CveDatabase::version_matches("< 1.5.0", "1.4.0"));
319 assert!(CveDatabase::version_matches("< 1.5.0", "0.9.0"));
320 assert!(!CveDatabase::version_matches("< 1.5.0", "1.5.0"));
321 assert!(!CveDatabase::version_matches("< 1.5.0", "1.5.1"));
322 assert!(!CveDatabase::version_matches("< 1.5.0", "2.0.0"));
323 }
324
325 #[test]
326 fn test_version_comparison_less_than_or_equal() {
327 assert!(CveDatabase::version_matches("<= 1.5.0", "1.4.9"));
328 assert!(CveDatabase::version_matches("<= 1.5.0", "1.5.0"));
329 assert!(!CveDatabase::version_matches("<= 1.5.0", "1.5.1"));
330 }
331
332 #[test]
333 fn test_version_comparison_greater_than() {
334 assert!(CveDatabase::version_matches("> 1.5.0", "1.5.1"));
335 assert!(CveDatabase::version_matches("> 1.5.0", "2.0.0"));
336 assert!(!CveDatabase::version_matches("> 1.5.0", "1.5.0"));
337 assert!(!CveDatabase::version_matches("> 1.5.0", "1.4.9"));
338 }
339
340 #[test]
341 fn test_version_comparison_equal() {
342 assert!(CveDatabase::version_matches("= 1.5.0", "1.5.0"));
343 assert!(!CveDatabase::version_matches("= 1.5.0", "1.5.1"));
344 assert!(!CveDatabase::version_matches("= 1.5.0", "1.4.9"));
345 }
346
347 #[test]
348 fn test_check_product_matches() {
349 let db = CveDatabase::builtin().unwrap();
350 let matches = db.check_product("anthropic", "claude-code-vscode", "1.4.0");
351 assert!(!matches.is_empty());
352 assert!(matches.iter().any(|e| e.id == "CVE-2025-52882"));
353 }
354
355 #[test]
356 fn test_check_product_no_match_fixed_version() {
357 let db = CveDatabase::builtin().unwrap();
358 let matches = db.check_product("anthropic", "claude-code-vscode", "1.5.0");
359 assert!(matches.is_empty());
360 }
361
362 #[test]
363 fn test_check_product_case_insensitive() {
364 let db = CveDatabase::builtin().unwrap();
365 let matches = db.check_product("Anthropic", "Claude-Code-VSCode", "1.4.0");
366 assert!(!matches.is_empty());
367 }
368
369 #[test]
370 fn test_create_findings() {
371 let db = CveDatabase::builtin().unwrap();
372 let findings = db.create_findings(
373 "anthropic",
374 "claude-code-vscode",
375 "1.4.0",
376 "package.json",
377 10,
378 );
379 assert!(!findings.is_empty());
380
381 let finding = &findings[0];
382 assert_eq!(finding.id, "CVE-2025-52882");
383 assert_eq!(finding.severity, Severity::Critical);
384 assert_eq!(finding.category, Category::SupplyChain);
385 assert!(finding.recommendation.contains("1.5.0"));
386 }
387
388 #[test]
389 fn test_parse_version_with_prerelease() {
390 let parts = CveDatabase::parse_version("1.5.0-beta.1");
391 assert_eq!(parts, vec![1, 5, 0, 1]);
392 }
393
394 #[test]
395 fn test_entry_count() {
396 let db = CveDatabase::builtin().unwrap();
397 assert!(db.len() >= 7);
399 }
400
401 #[test]
402 fn test_updated_at() {
403 let db = CveDatabase::builtin().unwrap();
404 let updated = db.updated_at();
405 assert!(!updated.is_empty());
407 let year: i32 = updated[..4].parse().unwrap_or(0);
409 assert!(
410 (2024..=2030).contains(&year),
411 "Unexpected year in updated_at: {updated}"
412 );
413 }
414
415 #[test]
416 fn test_entries() {
417 let db = CveDatabase::builtin().unwrap();
418 let entries = db.entries();
419 assert!(!entries.is_empty());
420 assert!(entries[0].id.starts_with("CVE-"));
422 }
423
424 #[test]
425 fn test_from_file() {
426 use std::io::Write;
427 use tempfile::NamedTempFile;
428
429 let mut temp_file = NamedTempFile::new().unwrap();
431 let json = r#"{
432 "version": "1.0.0",
433 "updated_at": "2025-01-01",
434 "entries": []
435 }"#;
436 temp_file.write_all(json.as_bytes()).unwrap();
437
438 let db = CveDatabase::from_file(temp_file.path()).unwrap();
439 assert_eq!(db.version(), "1.0.0");
440 assert!(db.is_empty());
441 }
442
443 #[test]
444 fn test_from_file_invalid_path() {
445 let result = CveDatabase::from_file(Path::new("/nonexistent/file.json"));
446 assert!(result.is_err());
447 }
448
449 #[test]
450 fn test_version_comparison_greater_than_or_equal() {
451 assert!(CveDatabase::version_matches(">= 1.5.0", "1.5.0"));
453 assert!(CveDatabase::version_matches(">= 1.5.0", "1.5.1"));
454 assert!(CveDatabase::version_matches(">= 1.5.0", "2.0.0"));
455 assert!(!CveDatabase::version_matches(">= 1.5.0", "1.4.9"));
456 assert!(!CveDatabase::version_matches(">= 1.5.0", "1.4.0"));
457 }
458
459 #[test]
460 fn test_version_comparison_exact_match_no_operator() {
461 assert!(CveDatabase::version_matches("1.5.0", "1.5.0"));
463 assert!(!CveDatabase::version_matches("1.5.0", "1.5.1"));
464 assert!(!CveDatabase::version_matches("1.5.0", "1.4.9"));
465 }
466}