ferrous_forge/
security.rs1use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AuditReport {
13 pub vulnerabilities: Vec<Vulnerability>,
15 pub dependencies_count: usize,
17 pub passed: bool,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Vulnerability {
24 pub package: String,
26 pub version: String,
28 pub severity: String,
30 pub title: String,
32 pub description: String,
34 pub cve: Option<String>,
36 pub cvss: Option<f32>,
38}
39
40impl AuditReport {
41 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
89pub async fn run_security_audit(project_path: &Path) -> Result<AuditReport> {
96 ensure_cargo_audit_installed().await?;
98
99 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_audit_output(&output.stdout)
109}
110
111async 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
140fn parse_audit_output(output: &[u8]) -> Result<AuditReport> {
142 let output_str = String::from_utf8_lossy(output);
143
144 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
152fn 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
164fn 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
179fn 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
210fn 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
233pub async fn quick_security_check(project_path: &Path) -> Result<bool> {
240 let cargo_lock = project_path.join("Cargo.lock");
242 if !cargo_lock.exists() {
243 return Ok(true); }
245
246 match run_security_audit(project_path).await {
248 Ok(report) => Ok(report.passed),
249 Err(_) => Ok(true), }
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}