Skip to main content

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