Skip to main content

forge_guard/dependencies/
mod.rs

1//! Dependency analysis — scans for known vulnerabilities in dependencies.
2//!
3//! Maintains an embedded vulnerability database of known Solidity library CVEs
4//! and supports online updates via HTTP fetch (using `curl` subprocess) with
5//! a local JSON cache that persists between runs.
6
7use crate::core::ForgeGuardError;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11/// A vulnerability found in a dependency.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DependencyVulnerability {
14    pub name: String,
15    pub package: String,
16    pub version: String,
17    pub severity: String,
18    pub description: String,
19    pub recommended_fix: String,
20}
21
22/// Scanner for dependency vulnerabilities.
23pub struct DependencyScanner {
24    #[allow(dead_code)]
25    depth: u32,
26    known_vulnerabilities: Vec<KnownVulnerability>,
27    /// Path to the local cache file for the online vulnerability feed.
28    cache_path: PathBuf,
29}
30
31/// Known vulnerability entry — uses `&'static str` so the built-in DB can be const.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct KnownVulnerability {
34    /// Remapping/package name pattern to match (e.g., "openzeppelin-contracts").
35    package_pattern: String,
36    /// Semver constraint (e.g., "<4.9.3", ">=4.0.0 <5.0.0").
37    version_constraint: String,
38    /// Severity: "critical", "high", "medium", "low", "informational"
39    severity: String,
40    /// Vulnerability identifier / CVE name.
41    name: String,
42    /// Human-readable description.
43    description: String,
44    /// Recommended fix/upgrade instruction.
45    fix: String,
46    /// CVE identifier if known.
47    #[serde(default)]
48    cve: String,
49    /// Category of the vulnerability.
50    #[serde(default)]
51    category: String,
52}
53
54/// Helper to build a `KnownVulnerability` from `&str` values.
55#[allow(clippy::too_many_arguments)]
56fn vuln(
57    package: &str,
58    constraint: &str,
59    severity: &str,
60    name: &str,
61    desc: &str,
62    fix: &str,
63    cve: &str,
64    category: &str,
65) -> KnownVulnerability {
66    KnownVulnerability {
67        package_pattern: package.to_string(),
68        version_constraint: constraint.to_string(),
69        severity: severity.to_string(),
70        name: name.to_string(),
71        description: desc.to_string(),
72        fix: fix.to_string(),
73        cve: cve.to_string(),
74        category: category.to_string(),
75    }
76}
77
78/// Build the built-in vulnerability database at runtime.
79fn builtin_db() -> Vec<KnownVulnerability> {
80    vec![
81        // ── OpenZeppelin Contracts ──────────────────────────────────
82        vuln("openzeppelin-contracts", ">=2.0.0 <4.9.3", "high",
83            "GovernorCompatibilityBravo proposal cancellation",
84            "GovernorCompatibilityBravo allows anyone to cancel a proposal when the voting period has not started",
85            "Upgrade to OpenZeppelin Contracts 4.9.3+", "CVE-2023-34459", "Access Control"),
86        vuln("openzeppelin-contracts", ">=4.0.0 <4.8.3", "high",
87            "ERC165Checker gas griefing",
88            "ERC165Checker.supportsInterface can cause gas griefing attacks with large contract addresses",
89            "Upgrade to OpenZeppelin Contracts 4.8.3+", "CVE-2023-30541", "Gas"),
90        vuln("openzeppelin-contracts", ">=4.0.0 <4.7.2", "high",
91            "ERC2771Context and Multicall interaction",
92            "ERC2771Context and Multicall interaction can lead to lost funds with trusted forwarder pattern",
93            "Upgrade to OpenZeppelin Contracts 4.7.2+", "CVE-2022-39384", "Logic"),
94        vuln("openzeppelin-contracts", ">=4.0.0 <4.7.0", "critical",
95            "ERC20/ERC721 signature replay via delegatecall",
96            "Signature-based permit functions vulnerable to replay attacks when used with delegatecall in proxies",
97            "Upgrade to OpenZeppelin Contracts 4.7.0+", "CVE-2022-35961", "Cryptography"),
98        vuln("openzeppelin-contracts", ">=4.0.0 <4.6.0", "high",
99            "ERC1155 supply minting vulnerability",
100            "ERC1155._mint does not check zero address for batch minting, enabling supply inflation",
101            "Upgrade to OpenZeppelin Contracts 4.6.0+", "CVE-2022-35915", "Access Control"),
102        vuln("openzeppelin-contracts", ">=4.0.0 <4.4.1", "critical",
103            "UUPSUpgradeable delegatecall to untrusted implementation",
104            "UUPSUpgradeable does not prevent upgradeTo on the implementation, allowing selfdestruct",
105            "Upgrade to OpenZeppelin Contracts 4.4.1+", "CVE-2021-46339", "Upgradeability"),
106        vuln("openzeppelin-contracts", ">=4.0.0 <4.3.2", "high",
107            "SignatureChecker EIP-2098 compact signature issue",
108            "SignatureChecker does not handle EIP-2098 compact signatures, allowing malleability",
109            "Upgrade to OpenZeppelin Contracts 4.3.2+", "CVE-2021-41273", "Cryptography"),
110        vuln("openzeppelin-contracts", ">=3.4.0 <4.3.1", "high",
111            "TimelockController executor rights escalation",
112            "TimelockController allows proposers to call executeBatch with arbitrary targets",
113            "Upgrade to OpenZeppelin Contracts 4.3.1+", "CVE-2021-41264", "Access Control"),
114        vuln("openzeppelin-contracts", ">=3.0.0 <4.3.0", "high",
115            "ERC20 approve frontrunning race condition",
116            "Classic approve frontrunning attack when changing from non-zero to non-zero allowance",
117            "Use increaseAllowance/decreaseAllowance or upgrade to 4.3.0+", "CVE-2021-41180", "DeFi"),
118        vuln("openzeppelin-contracts", ">=3.0.0 <4.2.0", "high",
119            "ERC721 unsafe minting",
120            "ERC721._mint does not check recipient capability, potentially locking tokens",
121            "Use _safeMint or upgrade to OpenZeppelin Contracts 4.2.0+", "CVE-2021-39143", "Logic"),
122        vuln("openzeppelin-contracts", ">=4.0.0 <4.1.0", "medium",
123            "Governor proposal threshold bypass",
124            "Governor contract does not properly validate proposal threshold changes",
125            "Upgrade to OpenZeppelin Contracts 4.1.0+", "", "Access Control"),
126        vuln("openzeppelin-contracts", ">=2.0.0 <3.4.2", "critical",
127            "ProxyAdmin unauthorized ownership transfer",
128            "ProxyAdmin does not check msg.sender in renounceOwnership, allowing anyone to lock the proxy",
129            "Upgrade to OpenZeppelin Contracts 3.4.2+", "CVE-2021-34449", "Access Control"),
130
131        // ── OpenZeppelin Contracts Upgradeable ─────────────────────
132        vuln("openzeppelin-contracts-upgradeable", ">=4.0.0 <4.7.2", "high",
133            "Upgradeable ERC2771Context with Multicall",
134            "Same as ERC2771Context vulnerability — affects upgradeable variants",
135            "Upgrade to OpenZeppelin Contracts Upgradeable 4.7.2+", "CVE-2022-39384", "Logic"),
136        vuln("openzeppelin-contracts-upgradeable", ">=4.0.0 <4.4.1", "critical",
137            "Upgradeable UUPS selfdestruct via delegatecall",
138            "Same as UUPS vulnerability — affects upgradeable implementations",
139            "Upgrade to OpenZeppelin Contracts Upgradeable 4.4.1+", "CVE-2021-46339", "Upgradeability"),
140
141        // ── Solmate ────────────────────────────────────────────────
142        vuln("solmate", "<6.6.0", "medium",
143            "ERC4626 inflation attack",
144            "Solmate ERC4626 vaults vulnerable to inflation attacks via donation front-running",
145            "Upgrade to Solmate 6.6+ or use OpenZeppelin's ERC4626", "", "DeFi"),
146        vuln("solmate", "<6.4.0", "high",
147            "Solmate ERC721 unsafe minting",
148            "Solmate ERC721 does not check recipient capability before minting",
149            "Upgrade to Solmate 6.4+", "", "Logic"),
150        vuln("solmate", "<6.2.0", "medium",
151            "Solmate ERC20 permit signature malleability",
152            "Solmate ERC20 permit does not use standard EIP-2612 nonce management",
153            "Upgrade to Solmate 6.2+", "", "Cryptography"),
154
155        // ── Solady ─────────────────────────────────────────────────
156        vuln("solady", "<0.0.124", "high",
157            "Solady ERC721 unchecked mint",
158            "Solady ERC721 mint does not check if recipient is a contract, potentially locking tokens",
159            "Upgrade to Solady 0.0.124+", "", "Logic"),
160        vuln("solady", "<0.0.96", "medium",
161            "Solady EIP-712 domain separator issue",
162            "Solady's optimized EIP-712 may produce incorrect domain separators on certain chains",
163            "Upgrade to Solady 0.0.96+", "", "Cryptography"),
164
165        // ── Uniswap Libraries ──────────────────────────────────────
166        vuln("@uniswap", "<3.0.0", "high",
167            "Uniswap V2 library unsafe cast",
168            "Uniswap V2 lib uses unsafe uint112 casting that can overflow",
169            "Upgrade to @uniswap/lib 3.0.0+ or use safe casting", "", "DeFi"),
170        vuln("uniswap", "<0.5.0", "high",
171            "Uniswap V2 oracle manipulation",
172            "Uniswap V2 TWAP oracle is vulnerable to manipulation with short observation windows",
173            "Use Uniswap V2 0.5.0+ with proper observation period", "", "DeFi"),
174
175        // ── Chainlink ──────────────────────────────────────────────
176        vuln("chainlink", "<1.10.0", "medium",
177            "Chainlink price feed stale data",
178            "Older Chainlink AggregatorV3Interface may return stale price data without freshness checks",
179            "Use Chainlink 1.10.0+ with proper staleness checks", "", "DeFi"),
180
181        // ── Wormhole ───────────────────────────────────────────────
182        vuln("wormhole", "<2.3.0", "critical",
183            "Wormhole signature verification bypass",
184            "Wormhole bridge signature verification can be bypassed with empty guardian set",
185            "Upgrade to Wormhole 2.3.0+", "CVE-2022-36119", "Cross-Chain"),
186
187        // ── LayerZero ──────────────────────────────────────────────
188        vuln("layerzero", "<1.0.0", "high",
189            "LayerZero message verification bypass",
190            "Older LayerZero endpoint allows message verification bypass via oracle spoofing",
191            "Upgrade to LayerZero 1.0.0+", "", "Cross-Chain"),
192
193        // ── Older OpenZeppelin ─────────────────────────────────────
194        vuln("openzeppelin-contracts", "<3.0.0", "critical",
195            "Initializable uninitialized implementation",
196            "Initializable before v3.0.0 does not protect implementation contracts from direct calls",
197            "Upgrade to OpenZeppelin Contracts 3.0.0+ and call _disableInitializers()", "", "Upgradeability"),
198        vuln("openzeppelin-contracts", "<2.5.0", "high",
199            "MerkleProof signature replay",
200            "MerkleProof in early versions lacks domain separator, allowing cross-contract replay",
201            "Upgrade to OpenZeppelin Contracts 2.5.0+", "", "Cryptography"),
202        vuln("openzeppelin-contracts", "<2.3.0", "critical",
203            "SafeMath overflow in ERC20",
204            "SafeMath was not used in ERC20 transfers, making them vulnerable to overflow",
205            "Upgrade to OpenZeppelin Contracts 2.3.0+", "", "Security"),
206
207        // ── OpenZeppelin Others ────────────────────────────────────
208        vuln("openzeppelin-foundry-upgrades", "<0.1.0", "medium",
209            "Foundry Upgrades unsafe validation",
210            "OpenZeppelin Foundry Upgrades may not validate storage layouts correctly",
211            "Use OpenZeppelin Foundry Upgrades 0.1.0+", "", "Upgradeability"),
212
213        // ── Forge Standard Library ─────────────────────────────────
214        vuln("forge-std", "<1.6.0", "low",
215            "Forge Std Vm unsafe cheat codes",
216            "Older forge-std includes cheat codes exploitable if not guarded with isTest()",
217            "Upgrade to forge-std 1.6.0+", "", "Best Practices"),
218        vuln("forge-std", "<1.5.0", "medium",
219            "Forge Std console.log gas leakage",
220            "forge-std console.log calls remain in deployed bytecode, wasting gas",
221            "Remove console.log imports or upgrade to forge-std 1.5.0+", "", "Gas"),
222
223        // ── PRBMath ────────────────────────────────────────────────
224        vuln("prb-math", "<4.0.0", "medium",
225            "PRBMath unsigned integer overflow",
226            "PRBMath v3 and earlier may underflow on large-number division operations",
227            "Upgrade to PRBMath 4.0.0+ or add overflow guards", "", "Security"),
228
229        // ── Solc Compiler Issues ───────────────────────────────────
230        vuln("@solc", ">=0.8.0 <0.8.22", "high",
231            "Solc optimizer storage collision",
232            "Solc 0.8.0-0.8.21 optimizer can incorrectly optimize storage reads/writes",
233            "Use solc 0.8.22+ or disable optimizer for affected contracts", "", "Architecture"),
234        vuln("@solc", ">=0.8.0 <0.8.14", "high",
235            "Solc ABI-encoding v2 memory corruption",
236            "Solc 0.8.0-0.8.13 has memory corruption in ABI encoder v2 with dynamic arrays",
237            "Use solc 0.8.14+", "CVE-2022-37721", "Architecture"),
238    ]
239}
240
241impl DependencyScanner {
242    /// Create a new dependency scanner.
243    pub fn new(depth: u32) -> Self {
244        Self {
245            depth,
246            known_vulnerabilities: builtin_db(),
247            cache_path: PathBuf::from(".forge-guard-cache/vulnerability-db.json"),
248        }
249    }
250
251    /// Update the vulnerability database from a remote source.
252    ///
253    /// Uses `curl` to fetch the latest vulnerability feed from a GitHub raw source,
254    /// then merges it with the built-in database. The fetched entries are cached
255    /// locally to enable offline use on subsequent runs.
256    pub fn update_database(&mut self) -> Result<(), ForgeGuardError> {
257        let urls = [
258            "https://raw.githubusercontent.com/codetibo/forge-guard/main/vulnerability-db.json",
259            "https://raw.githubusercontent.com/codetibo/forge-guard/main/feeds/vulnerabilities.json",
260        ];
261
262        eprintln!("   Updating vulnerability database from remote sources...");
263
264        let mut fetched = false;
265        for url in &urls {
266            match Self::fetch_url(url) {
267                Ok(body) => match serde_json::from_str::<Vec<KnownVulnerability>>(&body) {
268                    Ok(remote_entries) => {
269                        let count = remote_entries.len();
270                        let merged =
271                            Self::merge_databases(&self.known_vulnerabilities, &remote_entries);
272                        self.known_vulnerabilities = merged;
273                        if let Err(e) = self.cache_database(&remote_entries) {
274                            eprintln!("   ⚠️  Failed to cache DB: {}", e);
275                        }
276                        eprintln!(
277                            "   ✅ Downloaded {} vulnerability entries from remote",
278                            count
279                        );
280                        fetched = true;
281                        break;
282                    }
283                    Err(e) => {
284                        eprintln!("   ⚠️  Failed to parse remote DB from {}: {}", url, e);
285                    }
286                },
287                Err(e) => {
288                    eprintln!("   ⚠️  Could not fetch {}: {}", url, e);
289                }
290            }
291        }
292
293        if !fetched {
294            if self.load_cached_db() {
295                eprintln!(
296                    "   ✅ Loaded {} entries from local cache",
297                    self.known_vulnerabilities.len()
298                );
299            } else {
300                eprintln!(
301                    "   ℹ️  Using built-in database ({} entries)",
302                    self.known_vulnerabilities.len()
303                );
304                eprintln!("   ℹ️  Install `curl` to enable online updates");
305            }
306        }
307
308        Ok(())
309    }
310
311    /// Fetch a URL using `curl` subprocess.
312    fn fetch_url(url: &str) -> Result<String, ForgeGuardError> {
313        let output = std::process::Command::new("curl")
314            .args(["-sSL", "--max-time", "10", url])
315            .stdout(std::process::Stdio::piped())
316            .stderr(std::process::Stdio::null())
317            .output()
318            .map_err(|e| {
319                ForgeGuardError::Command(format!("Failed to run curl: {}. Is curl installed?", e))
320            })?;
321
322        if !output.status.success() {
323            return Err(ForgeGuardError::Command(format!(
324                "curl exited with code {}",
325                output.status.code().unwrap_or(-1)
326            )));
327        }
328
329        let body = String::from_utf8_lossy(&output.stdout).to_string();
330        if body.is_empty() {
331            return Err(ForgeGuardError::Command(
332                "Empty response from remote source".into(),
333            ));
334        }
335        Ok(body)
336    }
337
338    /// Merge built-in and remote databases, with remote entries taking precedence.
339    fn merge_databases(
340        builtin: &[KnownVulnerability],
341        remote: &[KnownVulnerability],
342    ) -> Vec<KnownVulnerability> {
343        let mut merged: Vec<KnownVulnerability> = builtin.to_vec();
344
345        'remote: for remote_entry in remote {
346            for existing in merged.iter_mut() {
347                if existing.name == remote_entry.name
348                    && existing.package_pattern == remote_entry.package_pattern
349                {
350                    *existing = remote_entry.clone();
351                    continue 'remote;
352                }
353            }
354            merged.push(remote_entry.clone());
355        }
356        merged
357    }
358
359    /// Cache the remote database to a local JSON file.
360    fn cache_database(&self, entries: &[KnownVulnerability]) -> Result<(), ForgeGuardError> {
361        if let Some(parent) = self.cache_path.parent() {
362            std::fs::create_dir_all(parent)?;
363        }
364        let json = serde_json::to_string_pretty(entries)?;
365        std::fs::write(&self.cache_path, json)?;
366        Ok(())
367    }
368
369    /// Load a previously cached database from disk.
370    fn load_cached_db(&mut self) -> bool {
371        if !self.cache_path.exists() {
372            return false;
373        }
374        match std::fs::read_to_string(&self.cache_path) {
375            Ok(content) => match serde_json::from_str::<Vec<KnownVulnerability>>(&content) {
376                Ok(entries) => {
377                    self.known_vulnerabilities = Self::merge_databases(&builtin_db(), &entries);
378                    true
379                }
380                Err(_) => false,
381            },
382            Err(_) => false,
383        }
384    }
385
386    /// Scan project dependencies for known vulnerabilities.
387    pub fn scan(&self) -> Result<Vec<DependencyVulnerability>, ForgeGuardError> {
388        let mut vulnerabilities = Vec::new();
389        let mut dep_lines = Vec::new();
390
391        // Scan remappings.txt
392        if let Ok(content) = std::fs::read_to_string("remappings.txt") {
393            for line in content.lines() {
394                let line = line.trim();
395                if !line.is_empty() && !line.starts_with('#') && !line.starts_with("//") {
396                    dep_lines.push(line.to_string());
397                }
398            }
399        }
400
401        // Scan foundry.toml for dependency references
402        if let Ok(content) = std::fs::read_to_string("foundry.toml") {
403            for line in content.lines() {
404                let line = line.trim();
405                if line.starts_with("remappings") || (line.contains('=') && line.contains('@')) {
406                    dep_lines.push(line.to_string());
407                }
408            }
409        }
410
411        // Scan lib/ directory structure
412        if let Ok(entries) = std::fs::read_dir("lib") {
413            for entry in entries.flatten() {
414                if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
415                    let name = entry.file_name().to_string_lossy().to_string();
416                    dep_lines.push(format!("{}=lib/{}", name, name));
417                }
418            }
419        }
420
421        // Match dependency lines against known vulnerabilities
422        for line in &dep_lines {
423            vulnerabilities.extend(self.check_line(line));
424        }
425
426        // Also check implicit patterns (solc version in foundry.toml, etc.)
427        vulnerabilities.extend(self.check_implicit_patterns()?);
428
429        // Deduplicate by (name, package)
430        vulnerabilities.sort_by(|a, b| a.name.cmp(&b.name).then(a.package.cmp(&b.package)));
431        vulnerabilities.dedup_by(|a, b| a.name == b.name && a.package == b.package);
432
433        Ok(vulnerabilities)
434    }
435
436    /// Check a single dependency line against all known vulnerabilities.
437    fn check_line(&self, line: &str) -> Vec<DependencyVulnerability> {
438        let mut results = Vec::new();
439        for vuln in &self.known_vulnerabilities {
440            if line.contains(&vuln.package_pattern) {
441                results.push(DependencyVulnerability {
442                    name: vuln.name.clone(),
443                    package: vuln.package_pattern.clone(),
444                    version: vuln.version_constraint.clone(),
445                    severity: vuln.severity.clone(),
446                    description: vuln.description.clone(),
447                    recommended_fix: vuln.fix.clone(),
448                });
449            }
450        }
451        results
452    }
453
454    /// Check for implicit vulnerability patterns (no explicit dep reference needed).
455    fn check_implicit_patterns(&self) -> Result<Vec<DependencyVulnerability>, ForgeGuardError> {
456        let mut results = Vec::new();
457        if let Ok(content) = std::fs::read_to_string("foundry.toml") {
458            for line in content.lines() {
459                let line = line.trim();
460                if let Some(version_str) = line
461                    .strip_prefix("solc")
462                    .or_else(|| line.strip_prefix("solc_version"))
463                    .or_else(|| line.strip_prefix("solc-version"))
464                {
465                    let version_str = version_str
466                        .trim()
467                        .trim_start_matches('=')
468                        .trim()
469                        .trim_matches('"')
470                        .trim_matches('\'');
471                    results.extend(self.check_solc_version(version_str));
472                } else if let Some(remapping_target) = line.split('=').nth(1) {
473                    let target = remapping_target.trim();
474                    for vuln in &self.known_vulnerabilities {
475                        if target.contains(&vuln.package_pattern) {
476                            results.push(DependencyVulnerability {
477                                name: vuln.name.clone(),
478                                package: vuln.package_pattern.clone(),
479                                version: vuln.version_constraint.clone(),
480                                severity: vuln.severity.clone(),
481                                description: vuln.description.clone(),
482                                recommended_fix: vuln.fix.clone(),
483                            });
484                        }
485                    }
486                }
487            }
488        }
489        results.sort_by(|a, b| a.name.cmp(&b.name));
490        results.dedup_by(|a, b| a.name == b.name);
491        Ok(results)
492    }
493
494    /// Check if a solc version is vulnerable.
495    fn check_solc_version(&self, version: &str) -> Vec<DependencyVulnerability> {
496        let mut results = Vec::new();
497        for vuln in &self.known_vulnerabilities {
498            if vuln.package_pattern == "@solc"
499                && self.matches_constraint(version, &vuln.version_constraint)
500            {
501                results.push(DependencyVulnerability {
502                    name: vuln.name.clone(),
503                    package: vuln.package_pattern.clone(),
504                    version: vuln.version_constraint.clone(),
505                    severity: vuln.severity.clone(),
506                    description: vuln.description.clone(),
507                    recommended_fix: vuln.fix.clone(),
508                });
509            }
510        }
511        results
512    }
513
514    /// Check if a version string satisfies a semver constraint.
515    fn matches_constraint(&self, version: &str, constraint: &str) -> bool {
516        let version_parts = parse_version(version);
517        if version_parts.is_empty() {
518            return false;
519        }
520        let parts: Vec<&str> = constraint.split_whitespace().collect();
521
522        if parts.len() == 1 {
523            let op = parts[0];
524            if let Some(target) = op.strip_prefix('<') {
525                let target_parts = parse_version(target);
526                if target_parts.is_empty() {
527                    return false;
528                }
529                return compare_versions(&version_parts, &target_parts) == std::cmp::Ordering::Less;
530            } else if let Some(target) = op.strip_prefix("<=") {
531                let target_parts = parse_version(target);
532                if target_parts.is_empty() {
533                    return false;
534                }
535                return compare_versions(&version_parts, &target_parts)
536                    != std::cmp::Ordering::Greater;
537            }
538            return true;
539        }
540
541        if parts.len() >= 2 {
542            let lower = parts[0];
543            let upper = parts[1];
544
545            let lower_satisfied = if let Some(target) = lower.strip_prefix(">=") {
546                let target_parts = parse_version(target);
547                if target_parts.is_empty() {
548                    true
549                } else {
550                    compare_versions(&version_parts, &target_parts) != std::cmp::Ordering::Less
551                }
552            } else if let Some(target) = lower.strip_prefix('>') {
553                let target_parts = parse_version(target);
554                if target_parts.is_empty() {
555                    true
556                } else {
557                    compare_versions(&version_parts, &target_parts) == std::cmp::Ordering::Greater
558                }
559            } else {
560                true
561            };
562
563            let upper_satisfied = if let Some(target) = upper.strip_prefix('<') {
564                let target_parts = parse_version(target);
565                if target_parts.is_empty() {
566                    true
567                } else {
568                    compare_versions(&version_parts, &target_parts) == std::cmp::Ordering::Less
569                }
570            } else if let Some(target) = upper.strip_prefix("<=") {
571                let target_parts = parse_version(target);
572                if target_parts.is_empty() {
573                    true
574                } else {
575                    compare_versions(&version_parts, &target_parts) != std::cmp::Ordering::Greater
576                }
577            } else {
578                true
579            };
580
581            return lower_satisfied && upper_satisfied;
582        }
583        true
584    }
585
586    /// Get the count of known vulnerability entries.
587    pub fn db_size(&self) -> usize {
588        self.known_vulnerabilities.len()
589    }
590}
591
592/// Parse a version string into numeric parts.
593fn parse_version(v: &str) -> Vec<u32> {
594    v.trim()
595        .split('.')
596        .filter_map(|s| s.parse::<u32>().ok())
597        .collect()
598}
599
600/// Compare two version vectors.
601fn compare_versions(a: &[u32], b: &[u32]) -> std::cmp::Ordering {
602    let max_len = a.len().max(b.len());
603    for i in 0..max_len {
604        let a_val = a.get(i).copied().unwrap_or(0);
605        let b_val = b.get(i).copied().unwrap_or(0);
606        match a_val.cmp(&b_val) {
607            std::cmp::Ordering::Equal => continue,
608            other => return other,
609        }
610    }
611    std::cmp::Ordering::Equal
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_parse_version() {
620        assert_eq!(parse_version("4.9.3"), vec![4, 9, 3]);
621        assert_eq!(parse_version("6.6"), vec![6, 6]);
622        assert_eq!(parse_version("0.8.22"), vec![0, 8, 22]);
623        assert_eq!(parse_version(""), vec![] as Vec<u32>);
624        assert_eq!(parse_version("invalid"), vec![] as Vec<u32>);
625    }
626
627    #[test]
628    fn test_compare_versions() {
629        assert_eq!(
630            compare_versions(&[4, 9, 3], &[4, 9, 3]),
631            std::cmp::Ordering::Equal
632        );
633        assert_eq!(
634            compare_versions(&[4, 9, 2], &[4, 9, 3]),
635            std::cmp::Ordering::Less
636        );
637        assert_eq!(
638            compare_versions(&[4, 10, 0], &[4, 9, 3]),
639            std::cmp::Ordering::Greater
640        );
641        assert_eq!(
642            compare_versions(&[6, 6], &[6, 5, 0]),
643            std::cmp::Ordering::Greater
644        );
645    }
646
647    #[test]
648    fn test_matches_constraint_less_than() {
649        let scanner = DependencyScanner::new(1);
650        assert!(scanner.matches_constraint("4.9.2", "<4.9.3"));
651        assert!(!scanner.matches_constraint("4.9.3", "<4.9.3"));
652        assert!(!scanner.matches_constraint("4.10.0", "<4.9.3"));
653    }
654
655    #[test]
656    fn test_matches_constraint_range() {
657        let scanner = DependencyScanner::new(1);
658        assert!(scanner.matches_constraint("4.5.0", ">=4.0.0 <5.0.0"));
659        assert!(scanner.matches_constraint("4.0.0", ">=4.0.0 <5.0.0"));
660        assert!(!scanner.matches_constraint("3.9.9", ">=4.0.0 <5.0.0"));
661        assert!(!scanner.matches_constraint("5.0.0", ">=4.0.0 <5.0.0"));
662    }
663
664    #[test]
665    fn test_scanner_creation() {
666        let scanner = DependencyScanner::new(0);
667        assert!(scanner.db_size() >= 30);
668        assert_eq!(scanner.depth, 0);
669    }
670
671    #[test]
672    fn test_builtin_db_not_empty() {
673        let db = builtin_db();
674        assert!(!db.is_empty());
675        assert!(db.len() >= 30);
676    }
677
678    #[test]
679    fn test_check_solc_version_vulnerable() {
680        let scanner = DependencyScanner::new(1);
681        let results = scanner.check_solc_version("0.8.13");
682        assert!(results.iter().any(|v| v.name.contains("ABI-encoding")));
683    }
684
685    #[test]
686    fn test_check_solc_version_safe() {
687        let scanner = DependencyScanner::new(1);
688        let results = scanner.check_solc_version("0.8.22");
689        assert!(!results.iter().any(|v| v.name.contains("ABI-encoding")));
690    }
691
692    #[test]
693    fn test_check_line_matches_openzeppelin() {
694        let scanner = DependencyScanner::new(1);
695        let line = "@openzeppelin/contracts=lib/openzeppelin-contracts";
696        let results = scanner.check_line(line);
697        assert!(!results.is_empty());
698        assert!(results.iter().any(|v| v.package.contains("openzeppelin")));
699    }
700
701    #[test]
702    fn test_check_line_solmate() {
703        let scanner = DependencyScanner::new(1);
704        let line = "solmate=lib/solmate";
705        let results = scanner.check_line(line);
706        assert!(!results.is_empty());
707        assert!(results.iter().any(|v| v.package.contains("solmate")));
708    }
709
710    #[test]
711    fn test_check_line_no_match() {
712        let scanner = DependencyScanner::new(1);
713        let line = "my-custom-lib=lib/my-custom-lib";
714        let results = scanner.check_line(line);
715        assert!(results.is_empty());
716    }
717
718    #[test]
719    fn test_merge_databases_with_override() {
720        let builtin = vec![KnownVulnerability {
721            package_pattern: "test-pkg".to_string(),
722            version_constraint: "<1.0.0".to_string(),
723            severity: "high".to_string(),
724            name: "Test Vuln".to_string(),
725            description: "old desc".to_string(),
726            fix: "upgrade".to_string(),
727            cve: String::new(),
728            category: String::new(),
729        }];
730        let remote = vec![KnownVulnerability {
731            package_pattern: "test-pkg".to_string(),
732            version_constraint: "<2.0.0".to_string(),
733            severity: "critical".to_string(),
734            name: "Test Vuln".to_string(),
735            description: "new desc".to_string(),
736            fix: "upgrade".to_string(),
737            cve: String::new(),
738            category: String::new(),
739        }];
740        let merged = DependencyScanner::merge_databases(&builtin, &remote);
741        assert_eq!(merged.len(), 1);
742        assert_eq!(merged[0].severity, "critical");
743    }
744
745    #[test]
746    fn test_cache_roundtrip() {
747        let scanner = DependencyScanner::new(1);
748        let entries = vec![KnownVulnerability {
749            package_pattern: "cache-test".to_string(),
750            version_constraint: "<1.0.0".to_string(),
751            severity: "low".to_string(),
752            name: "Cache Test".to_string(),
753            description: "Testing cache".to_string(),
754            fix: "nothing".to_string(),
755            cve: String::new(),
756            category: String::new(),
757        }];
758        assert!(scanner.cache_database(&entries).is_ok());
759
760        let mut scanner2 = DependencyScanner::new(1);
761        assert!(scanner2.load_cached_db());
762        assert!(scanner2
763            .known_vulnerabilities
764            .iter()
765            .any(|v| v.name == "Cache Test"));
766
767        let _ = std::fs::remove_file(&scanner.cache_path);
768    }
769
770    #[test]
771    fn test_solc_version_non_matching() {
772        let scanner = DependencyScanner::new(1);
773        let results = scanner.check_solc_version("1.0.0");
774        assert!(results.is_empty());
775    }
776}