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!(
49                "   Scanned {} dependencies\n",
50                self.dependencies_count
51            ));
52        } else {
53            report.push_str(&format!(
54                "🚨 Security audit failed - Found {} vulnerabilities\n\n",
55                self.vulnerabilities.len()
56            ));
57
58            for vuln in &self.vulnerabilities {
59                let severity_emoji = match vuln.severity.to_lowercase().as_str() {
60                    "critical" => "🔴",
61                    "high" => "🟠",
62                    "medium" => "🟡",
63                    "low" => "🟢",
64                    _ => "⚪",
65                };
66
67                report.push_str(&format!(
68                    "{} {} [{}] in {} v{}\n",
69                    severity_emoji,
70                    vuln.severity.to_uppercase(),
71                    vuln.cve.as_ref().unwrap_or(&"N/A".to_string()),
72                    vuln.package,
73                    vuln.version
74                ));
75                report.push_str(&format!("   {}\n", vuln.title));
76                report.push_str(&format!("   {}\n", vuln.description));
77
78                if let Some(cvss) = vuln.cvss {
79                    report.push_str(&format!("   CVSS Score: {:.1}\n", cvss));
80                }
81
82                report.push('\n');
83            }
84        }
85
86        report
87    }
88}
89
90/// Run security audit on a project
91pub async fn run_security_audit(project_path: &Path) -> Result<AuditReport> {
92    // Ensure cargo-audit is installed
93    ensure_cargo_audit_installed().await?;
94
95    // Run cargo audit with JSON output
96    let output = Command::new("cargo")
97        .args(&["audit", "--json"])
98        .current_dir(project_path)
99        .output()
100        .map_err(|e| Error::process(format!("Failed to run cargo audit: {}", e)))?;
101
102    // Parse the output
103    parse_audit_output(&output.stdout)
104}
105
106/// Ensure cargo-audit is installed
107async fn ensure_cargo_audit_installed() -> Result<()> {
108    let check = Command::new("cargo").args(&["audit", "--version"]).output();
109
110    if check
111        .as_ref()
112        .map_or(true, |output| !output.status.success())
113    {
114        println!("📦 Installing cargo-audit for security scanning...");
115
116        let install = Command::new("cargo")
117            .args(&["install", "cargo-audit", "--locked"])
118            .output()
119            .map_err(|e| Error::process(format!("Failed to install cargo-audit: {}", e)))?;
120
121        if !install.status.success() {
122            return Err(Error::process("Failed to install cargo-audit"));
123        }
124
125        println!("✅ cargo-audit installed successfully");
126    }
127
128    Ok(())
129}
130
131/// Parse cargo audit JSON output
132fn parse_audit_output(output: &[u8]) -> Result<AuditReport> {
133    let output_str = String::from_utf8_lossy(output);
134
135    // Try to parse as JSON first
136    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&output_str) {
137        parse_json_audit_output(&json)
138    } else {
139        parse_text_audit_output(&output_str)
140    }
141}
142
143/// Parse JSON-formatted audit output
144fn parse_json_audit_output(json: &serde_json::Value) -> Result<AuditReport> {
145    let vulnerabilities = extract_vulnerabilities_from_json(json);
146    let dependencies_count = json["dependencies"]["count"].as_u64().unwrap_or(0) as usize;
147
148    Ok(AuditReport {
149        passed: vulnerabilities.is_empty(),
150        vulnerabilities,
151        dependencies_count,
152    })
153}
154
155/// Extract vulnerabilities from JSON structure
156fn extract_vulnerabilities_from_json(json: &serde_json::Value) -> Vec<Vulnerability> {
157    let mut vulnerabilities = Vec::new();
158
159    if let Some(vulns) = json["vulnerabilities"]["list"].as_array() {
160        for vuln in vulns {
161            if let Some(advisory) = vuln["advisory"].as_object() {
162                vulnerabilities.push(create_vulnerability_from_json(vuln, advisory));
163            }
164        }
165    }
166
167    vulnerabilities
168}
169
170/// Create a vulnerability from JSON data
171fn create_vulnerability_from_json(
172    vuln: &serde_json::Value,
173    advisory: &serde_json::Map<String, serde_json::Value>,
174) -> Vulnerability {
175    Vulnerability {
176        package: vuln["package"]["name"]
177            .as_str()
178            .unwrap_or("unknown")
179            .to_string(),
180        version: vuln["package"]["version"]
181            .as_str()
182            .unwrap_or("unknown")
183            .to_string(),
184        severity: advisory["severity"]
185            .as_str()
186            .unwrap_or("unknown")
187            .to_string(),
188        title: advisory["title"]
189            .as_str()
190            .unwrap_or("Security vulnerability")
191            .to_string(),
192        description: advisory["description"]
193            .as_str()
194            .unwrap_or("No description available")
195            .to_string(),
196        cve: advisory["id"].as_str().map(String::from),
197        cvss: advisory["cvss"].as_f64().map(|v| v as f32),
198    }
199}
200
201/// Parse text-formatted audit output (fallback)
202fn parse_text_audit_output(output_str: &str) -> Result<AuditReport> {
203    if output_str.contains("0 vulnerabilities") || output_str.contains("Success") {
204        Ok(AuditReport {
205            vulnerabilities: vec![],
206            dependencies_count: 0,
207            passed: true,
208        })
209    } else {
210        let vuln_count = if output_str.contains("vulnerability") {
211            1
212        } else {
213            0
214        };
215
216        Ok(AuditReport {
217            vulnerabilities: vec![],
218            dependencies_count: 0,
219            passed: vuln_count == 0,
220        })
221    }
222}
223
224/// Quick security check (non-blocking)
225pub async fn quick_security_check(project_path: &Path) -> Result<bool> {
226    // Check if Cargo.lock exists
227    let cargo_lock = project_path.join("Cargo.lock");
228    if !cargo_lock.exists() {
229        return Ok(true); // No dependencies to check
230    }
231
232    // Run quick audit check
233    match run_security_audit(project_path).await {
234        Ok(report) => Ok(report.passed),
235        Err(_) => Ok(true), // Don't block on audit failures
236    }
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_vulnerability_severity_classification() {
246        let vuln = Vulnerability {
247            package: "test".to_string(),
248            version: "1.0.0".to_string(),
249            severity: "critical".to_string(),
250            title: "Test vulnerability".to_string(),
251            description: "Test description".to_string(),
252            cve: Some("CVE-2024-0001".to_string()),
253            cvss: Some(9.5),
254        };
255
256        assert_eq!(vuln.severity, "critical");
257        assert!(vuln.cvss.unwrap_or(0.0) > 9.0);
258    }
259
260    #[test]
261    fn test_audit_report_passed() {
262        let report = AuditReport {
263            vulnerabilities: vec![],
264            dependencies_count: 10,
265            passed: true,
266        };
267
268        assert!(report.passed);
269        assert!(report.vulnerabilities.is_empty());
270    }
271}