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