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!(
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
90pub async fn run_security_audit(project_path: &Path) -> Result<AuditReport> {
92 ensure_cargo_audit_installed().await?;
94
95 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_audit_output(&output.stdout)
104}
105
106async 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
131fn parse_audit_output(output: &[u8]) -> Result<AuditReport> {
133 let output_str = String::from_utf8_lossy(output);
134
135 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
143fn 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
155fn 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
170fn 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
201fn 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
224pub async fn quick_security_check(project_path: &Path) -> Result<bool> {
226 let cargo_lock = project_path.join("Cargo.lock");
228 if !cargo_lock.exists() {
229 return Ok(true); }
231
232 match run_security_audit(project_path).await {
234 Ok(report) => Ok(report.passed),
235 Err(_) => Ok(true), }
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}