ferrous_forge/
security.rs1use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::process::Command;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditReport {
14 pub vulnerabilities: Vec<Vulnerability>,
16 pub dependencies_count: usize,
18 pub passed: bool,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Vulnerability {
25 pub package: String,
27 pub version: String,
29 pub severity: String,
31 pub title: String,
33 pub description: String,
35 pub cve: Option<String>,
37 pub cvss: Option<f32>,
39}
40
41impl AuditReport {
42 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
87pub async fn run_security_audit(project_path: &Path) -> Result<AuditReport> {
89 ensure_cargo_audit_installed().await?;
91
92 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_audit_output(&output.stdout)
101}
102
103async 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
127fn parse_audit_output(output: &[u8]) -> Result<AuditReport> {
129 let output_str = String::from_utf8_lossy(output);
130
131 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&output_str) {
133 let mut vulnerabilities = Vec::new();
134
135 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 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 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
201pub async fn quick_security_check(project_path: &Path) -> Result<bool> {
203 let cargo_lock = project_path.join("Cargo.lock");
205 if !cargo_lock.exists() {
206 return Ok(true); }
208
209 match run_security_audit(project_path).await {
211 Ok(report) => Ok(report.passed),
212 Err(_) => Ok(true), }
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}