Skip to main content

cc_audit/
cve_db.rs

1//! CVE database for known vulnerabilities in AI coding tools.
2//!
3//! This module provides functionality to load and query a database of known CVEs
4//! affecting MCP servers, AI coding assistants, and related tools.
5
6use crate::rules::{Category, Confidence, Finding, Location, Severity};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10use thiserror::Error;
11
12/// Built-in CVE database (embedded at compile time)
13const 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/// Affected product information in a CVE entry
28#[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/// A CVE entry in the database
38#[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/// CVE database file format
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CveDatabaseFile {
57    pub version: String,
58    pub updated_at: String,
59    pub entries: Vec<CveEntry>,
60}
61
62/// CVE database for querying known vulnerabilities
63pub struct CveDatabase {
64    entries: Vec<CveEntry>,
65    version: String,
66    updated_at: String,
67}
68
69impl CveDatabase {
70    /// Load the built-in CVE database
71    pub fn builtin() -> Result<Self, CveDbError> {
72        Self::from_json(BUILTIN_DATABASE)
73    }
74
75    /// Load CVE database from a JSON file
76    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    /// Load CVE database from a JSON string
82    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    /// Get database version
92    pub fn version(&self) -> &str {
93        &self.version
94    }
95
96    /// Get last update timestamp
97    pub fn updated_at(&self) -> &str {
98        &self.updated_at
99    }
100
101    /// Get all entries
102    pub fn entries(&self) -> &[CveEntry] {
103        &self.entries
104    }
105
106    /// Get entry count
107    pub fn len(&self) -> usize {
108        self.entries.len()
109    }
110
111    /// Check if database is empty
112    pub fn is_empty(&self) -> bool {
113        self.entries.is_empty()
114    }
115
116    /// Check if a product/version combination is affected by any CVE
117    /// Returns matching CVE entries
118    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    /// Check if a version string matches a version requirement
132    /// Supports: "< X.Y.Z", "<= X.Y.Z", "= X.Y.Z", ">= X.Y.Z", "> X.Y.Z"
133    fn version_matches(requirement: &str, version: &str) -> bool {
134        let requirement = requirement.trim();
135
136        // Parse the operator and version from the requirement
137        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) // Default to exact match
149        };
150
151        // Parse versions into comparable parts
152        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    /// Parse version string into comparable parts
165    fn parse_version(version: &str) -> Vec<u32> {
166        version
167            .split(['.', '-', '_'])
168            .filter_map(|s| {
169                // Extract leading numeric part
170                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    /// Compare two parsed versions
177    /// Returns: -1 if a < b, 0 if a == b, 1 if a > b
178    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    /// Check a product/version against all CVEs, ignoring vendor.
194    ///
195    /// An npm package name uniquely identifies a product, but the same package
196    /// can be recorded under different vendor strings across databases (the
197    /// shipped DB uses `modelcontextprotocol`; a custom DB may use `anthropic`
198    /// or `geelen`). Matching on product name avoids brittle vendor coupling
199    /// that silently produced zero findings (issue #149).
200    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    /// Build a `Finding` for a matched CVE entry, deriving the vendor and fixed
213    /// version from the product entry that matched.
214    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    /// Create findings for matching CVEs (vendor + product).
255    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    /// Create findings for matching CVEs by product name, ignoring vendor.
270    ///
271    /// Preferred for npm packages, where the package name is the reliable key
272    /// and the recorded vendor string varies between databases (issue #149).
273    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        // Version should be a valid semver string (e.g., "1.0.0", "1.0.1")
312        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        // Database should have at least the initial 7 CVEs (may grow over time)
398        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        // Should be a valid ISO 8601 date string (e.g., "2025-01-26T00:00:00Z")
406        assert!(!updated.is_empty());
407        // Validate year is reasonable (2024-2030)
408        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        // First entry should have a CVE ID
421        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        // Create a temporary file with valid CVE database JSON
430        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        // Test >= operator (line 140)
452        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        // Test default exact match without operator (line 148)
462        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}