ferrous_forge/
security.rs

1//! Security audit integration module
2//!
3//! This module provides integration with cargo-audit to scan for security
4//! vulnerabilities in dependencies.
5
6use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::process::Command;
10
11/// Security audit report
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditReport {
14    /// List of vulnerabilities found
15    pub vulnerabilities: Vec<Vulnerability>,
16    /// Total number of dependencies
17    pub dependencies_count: usize,
18    /// Whether the audit passed (no vulnerabilities)
19    pub passed: bool,
20}
21
22/// A single security vulnerability
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Vulnerability {
25    /// Package name
26    pub package: String,
27    /// Version with vulnerability
28    pub version: String,
29    /// Severity level
30    pub severity: String,
31    /// Title of the vulnerability
32    pub title: String,
33    /// Description of the issue
34    pub description: String,
35    /// CVE identifier if available
36    pub cve: Option<String>,
37    /// CVSS score if available
38    pub cvss: Option<f32>,
39}
40
41impl AuditReport {
42    /// Generate a human-readable report
43    pub fn report(&self) -> String {
44        let mut report = String::new();
45        
46        if self.passed {
47            report.push_str("✅ Security audit passed - No vulnerabilities found!\n");
48            report.push_str(&format!("   Scanned {} dependencies\n", self.dependencies_count));
49        } else {
50            report.push_str(&format!(
51                "🚨 Security audit failed - Found {} vulnerabilities\n\n",
52                self.vulnerabilities.len()
53            ));
54            
55            for vuln in &self.vulnerabilities {
56                let severity_emoji = match vuln.severity.to_lowercase().as_str() {
57                    "critical" => "🔴",
58                    "high" => "🟠",
59                    "medium" => "🟡",
60                    "low" => "🟢",
61                    _ => "⚪",
62                };
63                
64                report.push_str(&format!(
65                    "{} {} [{}] in {} v{}\n",
66                    severity_emoji,
67                    vuln.severity.to_uppercase(),
68                    vuln.cve.as_ref().unwrap_or(&"N/A".to_string()),
69                    vuln.package,
70                    vuln.version
71                ));
72                report.push_str(&format!("   {}\n", vuln.title));
73                report.push_str(&format!("   {}\n", vuln.description));
74                
75                if let Some(cvss) = vuln.cvss {
76                    report.push_str(&format!("   CVSS Score: {:.1}\n", cvss));
77                }
78                
79                report.push('\n');
80            }
81        }
82        
83        report
84    }
85}
86
87/// Run security audit on a project
88pub async fn run_security_audit(project_path: &Path) -> Result<AuditReport> {
89    // Ensure cargo-audit is installed
90    ensure_cargo_audit_installed().await?;
91    
92    // Run cargo audit with JSON output
93    let output = Command::new("cargo")
94        .args(&["audit", "--json"])
95        .current_dir(project_path)
96        .output()
97        .map_err(|e| Error::process(format!("Failed to run cargo audit: {}", e)))?;
98    
99    // Parse the output
100    parse_audit_output(&output.stdout)
101}
102
103/// Ensure cargo-audit is installed
104async fn ensure_cargo_audit_installed() -> Result<()> {
105    let check = Command::new("cargo")
106        .args(&["audit", "--version"])
107        .output();
108    
109    if check.as_ref().map_or(true, |output| !output.status.success()) {
110        println!("📦 Installing cargo-audit for security scanning...");
111        
112        let install = Command::new("cargo")
113            .args(&["install", "cargo-audit", "--locked"])
114            .output()
115            .map_err(|e| Error::process(format!("Failed to install cargo-audit: {}", e)))?;
116        
117        if !install.status.success() {
118            return Err(Error::process("Failed to install cargo-audit"));
119        }
120        
121        println!("✅ cargo-audit installed successfully");
122    }
123    
124    Ok(())
125}
126
127/// Parse cargo audit JSON output
128fn parse_audit_output(output: &[u8]) -> Result<AuditReport> {
129    let output_str = String::from_utf8_lossy(output);
130    
131    // Try to parse as JSON
132    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&output_str) {
133        let mut vulnerabilities = Vec::new();
134        
135        // Extract vulnerabilities from the JSON structure
136        if let Some(vulns) = json["vulnerabilities"]["list"].as_array() {
137            for vuln in vulns {
138                if let Some(advisory) = vuln["advisory"].as_object() {
139                    vulnerabilities.push(Vulnerability {
140                        package: vuln["package"]["name"]
141                            .as_str()
142                            .unwrap_or("unknown")
143                            .to_string(),
144                        version: vuln["package"]["version"]
145                            .as_str()
146                            .unwrap_or("unknown")
147                            .to_string(),
148                        severity: advisory["severity"]
149                            .as_str()
150                            .unwrap_or("unknown")
151                            .to_string(),
152                        title: advisory["title"]
153                            .as_str()
154                            .unwrap_or("Security vulnerability")
155                            .to_string(),
156                        description: advisory["description"]
157                            .as_str()
158                            .unwrap_or("No description available")
159                            .to_string(),
160                        cve: advisory["id"].as_str().map(String::from),
161                        cvss: advisory["cvss"].as_f64().map(|v| v as f32),
162                    });
163                }
164            }
165        }
166        
167        let dependencies_count = json["dependencies"]["count"]
168            .as_u64()
169            .unwrap_or(0) as usize;
170        
171        Ok(AuditReport {
172            passed: vulnerabilities.is_empty(),
173            vulnerabilities,
174            dependencies_count,
175        })
176    } else {
177        // Fallback: If JSON parsing fails, check for success/failure in text
178        if output_str.contains("0 vulnerabilities") || output_str.contains("Success") {
179            Ok(AuditReport {
180                vulnerabilities: vec![],
181                dependencies_count: 0,
182                passed: true,
183            })
184        } else {
185            // Try to extract vulnerability count from text
186            let vuln_count = if output_str.contains("vulnerability") {
187                1
188            } else {
189                0
190            };
191            
192            Ok(AuditReport {
193                vulnerabilities: vec![],
194                dependencies_count: 0,
195                passed: vuln_count == 0,
196            })
197        }
198    }
199}
200
201/// Quick security check (non-blocking)
202pub async fn quick_security_check(project_path: &Path) -> Result<bool> {
203    // Check if Cargo.lock exists
204    let cargo_lock = project_path.join("Cargo.lock");
205    if !cargo_lock.exists() {
206        return Ok(true); // No dependencies to check
207    }
208    
209    // Run quick audit check
210    match run_security_audit(project_path).await {
211        Ok(report) => Ok(report.passed),
212        Err(_) => Ok(true), // Don't block on audit failures
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    
220    #[test]
221    fn test_vulnerability_severity_classification() {
222        let vuln = Vulnerability {
223            package: "test".to_string(),
224            version: "1.0.0".to_string(),
225            severity: "critical".to_string(),
226            title: "Test vulnerability".to_string(),
227            description: "Test description".to_string(),
228            cve: Some("CVE-2024-0001".to_string()),
229            cvss: Some(9.5),
230        };
231        
232        assert_eq!(vuln.severity, "critical");
233        assert!(vuln.cvss.unwrap_or(0.0) > 9.0);
234    }
235    
236    #[test]
237    fn test_audit_report_passed() {
238        let report = AuditReport {
239            vulnerabilities: vec![],
240            dependencies_count: 10,
241            passed: true,
242        };
243        
244        assert!(report.passed);
245        assert!(report.vulnerabilities.is_empty());
246    }
247}