zeroclaw_runtime/security/
vulnerability.rs1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct Finding {
13 #[serde(default)]
15 pub cve_id: String,
16 pub cvss_score: f64,
18 pub severity: String,
20 pub affected_asset: String,
22 pub description: String,
24 #[serde(default)]
26 pub remediation: String,
27 #[serde(default)]
29 pub internet_facing: bool,
30 #[serde(default = "default_true")]
32 pub production: bool,
33}
34
35fn default_true() -> bool {
36 true
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct VulnerabilityReport {
42 pub scan_date: DateTime<Utc>,
44 pub scanner: String,
46 pub findings: Vec<Finding>,
48}
49
50pub fn effective_priority(finding: &Finding) -> f64 {
56 let mut score = finding.cvss_score;
57 if finding.internet_facing {
58 score += 2.0;
59 }
60 if finding.production {
61 score += 1.0;
62 }
63 score.min(10.0)
64}
65
66pub fn cvss_to_severity(cvss: f64) -> &'static str {
68 match cvss {
69 s if s >= 9.0 => "critical",
70 s if s >= 7.0 => "high",
71 s if s >= 4.0 => "medium",
72 s if s > 0.0 => "low",
73 _ => "informational",
74 }
75}
76
77pub fn parse_vulnerability_json(json_str: &str) -> anyhow::Result<VulnerabilityReport> {
84 let report: VulnerabilityReport = serde_json::from_str(json_str).map_err(|e| {
85 ::zeroclaw_log::record!(
86 WARN,
87 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
88 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
89 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
90 "vulnerability report rejected: JSON parse failed"
91 );
92 anyhow::Error::msg(format!("Failed to parse vulnerability report: {e}"))
93 })?;
94
95 for (i, finding) in report.findings.iter().enumerate() {
96 if !(0.0..=10.0).contains(&finding.cvss_score) {
97 anyhow::bail!(
98 "findings[{}].cvss_score must be between 0.0 and 10.0, got {}",
99 i,
100 finding.cvss_score
101 );
102 }
103 }
104
105 Ok(report)
106}
107
108pub fn generate_summary(report: &VulnerabilityReport) -> String {
110 if report.findings.is_empty() {
111 return format!(
112 "Vulnerability scan by {} on {}: No findings.",
113 report.scanner,
114 report.scan_date.format("%Y-%m-%d")
115 );
116 }
117
118 let total = report.findings.len();
119 let critical = report
120 .findings
121 .iter()
122 .filter(|f| f.severity.eq_ignore_ascii_case("critical"))
123 .count();
124 let high = report
125 .findings
126 .iter()
127 .filter(|f| f.severity.eq_ignore_ascii_case("high"))
128 .count();
129 let medium = report
130 .findings
131 .iter()
132 .filter(|f| f.severity.eq_ignore_ascii_case("medium"))
133 .count();
134 let low = report
135 .findings
136 .iter()
137 .filter(|f| f.severity.eq_ignore_ascii_case("low"))
138 .count();
139 let informational = report
140 .findings
141 .iter()
142 .filter(|f| f.severity.eq_ignore_ascii_case("informational"))
143 .count();
144
145 let mut sorted: Vec<&Finding> = report.findings.iter().collect();
147 sorted.sort_by(|a, b| {
148 effective_priority(b)
149 .partial_cmp(&effective_priority(a))
150 .unwrap_or(std::cmp::Ordering::Equal)
151 });
152
153 let mut summary = format!(
154 "## Vulnerability Scan Summary\n\
155 **Scanner:** {} | **Date:** {}\n\
156 **Total findings:** {} (Critical: {}, High: {}, Medium: {}, Low: {}, Informational: {})\n\n",
157 report.scanner,
158 report.scan_date.format("%Y-%m-%d"),
159 total,
160 critical,
161 high,
162 medium,
163 low,
164 informational
165 );
166
167 summary.push_str("### Top Findings by Priority\n\n");
169 for (i, finding) in sorted.iter().take(10).enumerate() {
170 let priority = effective_priority(finding);
171 let context = match (finding.internet_facing, finding.production) {
172 (true, true) => " [internet-facing, production]",
173 (true, false) => " [internet-facing]",
174 (false, true) => " [production]",
175 (false, false) => "",
176 };
177 let _ = writeln!(
178 summary,
179 "{}. **{}** (CVSS: {:.1}, Priority: {:.1}){}\n Asset: {} | {}",
180 i + 1,
181 if finding.cve_id.is_empty() {
182 "No CVE"
183 } else {
184 &finding.cve_id
185 },
186 finding.cvss_score,
187 priority,
188 context,
189 finding.affected_asset,
190 finding.description
191 );
192 if !finding.remediation.is_empty() {
193 let _ = writeln!(summary, " Remediation: {}", finding.remediation);
194 }
195 summary.push('\n');
196 }
197
198 if critical > 0 || high > 0 {
200 summary.push_str("### Remediation Recommendations\n\n");
201 if critical > 0 {
202 let _ = writeln!(
203 summary,
204 "- **URGENT:** {} critical findings require immediate remediation",
205 critical
206 );
207 }
208 if high > 0 {
209 let _ = writeln!(
210 summary,
211 "- **HIGH:** {} high-severity findings should be addressed within 7 days",
212 high
213 );
214 }
215 let internet_facing_critical = sorted
216 .iter()
217 .filter(|f| f.internet_facing && (f.severity == "critical" || f.severity == "high"))
218 .count();
219 if internet_facing_critical > 0 {
220 let _ = writeln!(
221 summary,
222 "- **PRIORITY:** {} critical/high findings on internet-facing assets",
223 internet_facing_critical
224 );
225 }
226 }
227
228 summary
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn sample_findings() -> Vec<Finding> {
236 vec![
237 Finding {
238 cve_id: "CVE-2024-0001".into(),
239 cvss_score: 9.8,
240 severity: "critical".into(),
241 affected_asset: "web-server-01".into(),
242 description: "Remote code execution in web framework".into(),
243 remediation: "Upgrade to version 2.1.0".into(),
244 internet_facing: true,
245 production: true,
246 },
247 Finding {
248 cve_id: "CVE-2024-0002".into(),
249 cvss_score: 7.5,
250 severity: "high".into(),
251 affected_asset: "db-server-01".into(),
252 description: "SQL injection in query parser".into(),
253 remediation: "Apply patch KB-12345".into(),
254 internet_facing: false,
255 production: true,
256 },
257 Finding {
258 cve_id: "CVE-2024-0003".into(),
259 cvss_score: 4.3,
260 severity: "medium".into(),
261 affected_asset: "staging-app-01".into(),
262 description: "Information disclosure via debug endpoint".into(),
263 remediation: "Disable debug endpoint in config".into(),
264 internet_facing: false,
265 production: false,
266 },
267 ]
268 }
269
270 #[test]
271 fn effective_priority_adds_context_bonuses() {
272 let mut f = Finding {
273 cve_id: String::new(),
274 cvss_score: 7.0,
275 severity: "high".into(),
276 affected_asset: "host".into(),
277 description: "test".into(),
278 remediation: String::new(),
279 internet_facing: false,
280 production: false,
281 };
282
283 assert!((effective_priority(&f) - 7.0).abs() < f64::EPSILON);
284
285 f.internet_facing = true;
286 assert!((effective_priority(&f) - 9.0).abs() < f64::EPSILON);
287
288 f.production = true;
289 assert!((effective_priority(&f) - 10.0).abs() < f64::EPSILON); f.cvss_score = 9.5;
293 assert!((effective_priority(&f) - 10.0).abs() < f64::EPSILON);
294 }
295
296 #[test]
297 fn cvss_to_severity_classification() {
298 assert_eq!(cvss_to_severity(9.8), "critical");
299 assert_eq!(cvss_to_severity(9.0), "critical");
300 assert_eq!(cvss_to_severity(8.5), "high");
301 assert_eq!(cvss_to_severity(7.0), "high");
302 assert_eq!(cvss_to_severity(5.0), "medium");
303 assert_eq!(cvss_to_severity(4.0), "medium");
304 assert_eq!(cvss_to_severity(3.9), "low");
305 assert_eq!(cvss_to_severity(0.1), "low");
306 assert_eq!(cvss_to_severity(0.0), "informational");
307 }
308
309 #[test]
310 fn parse_vulnerability_json_roundtrip() {
311 let report = VulnerabilityReport {
312 scan_date: Utc::now(),
313 scanner: "nessus".into(),
314 findings: sample_findings(),
315 };
316
317 let json = serde_json::to_string(&report).unwrap();
318 let parsed = parse_vulnerability_json(&json).unwrap();
319
320 assert_eq!(parsed.scanner, "nessus");
321 assert_eq!(parsed.findings.len(), 3);
322 assert_eq!(parsed.findings[0].cve_id, "CVE-2024-0001");
323 }
324
325 #[test]
326 fn parse_vulnerability_json_rejects_invalid() {
327 let result = parse_vulnerability_json("not json");
328 assert!(result.is_err());
329 }
330
331 #[test]
332 fn generate_summary_includes_key_sections() {
333 let report = VulnerabilityReport {
334 scan_date: Utc::now(),
335 scanner: "qualys".into(),
336 findings: sample_findings(),
337 };
338
339 let summary = generate_summary(&report);
340
341 assert!(summary.contains("qualys"));
342 assert!(summary.contains("Total findings:** 3"));
343 assert!(summary.contains("Critical: 1"));
344 assert!(summary.contains("High: 1"));
345 assert!(summary.contains("CVE-2024-0001"));
346 assert!(summary.contains("URGENT"));
347 assert!(summary.contains("internet-facing"));
348 }
349
350 #[test]
351 fn parse_vulnerability_json_rejects_out_of_range_cvss() {
352 let report = VulnerabilityReport {
353 scan_date: Utc::now(),
354 scanner: "test".into(),
355 findings: vec![Finding {
356 cve_id: "CVE-2024-9999".into(),
357 cvss_score: 11.0,
358 severity: "critical".into(),
359 affected_asset: "host".into(),
360 description: "bad score".into(),
361 remediation: String::new(),
362 internet_facing: false,
363 production: false,
364 }],
365 };
366 let json = serde_json::to_string(&report).unwrap();
367 let result = parse_vulnerability_json(&json);
368 assert!(result.is_err());
369 let err = result.unwrap_err().to_string();
370 assert!(err.contains("cvss_score must be between 0.0 and 10.0"));
371 }
372
373 #[test]
374 fn parse_vulnerability_json_rejects_negative_cvss() {
375 let report = VulnerabilityReport {
376 scan_date: Utc::now(),
377 scanner: "test".into(),
378 findings: vec![Finding {
379 cve_id: "CVE-2024-9998".into(),
380 cvss_score: -1.0,
381 severity: "low".into(),
382 affected_asset: "host".into(),
383 description: "negative score".into(),
384 remediation: String::new(),
385 internet_facing: false,
386 production: false,
387 }],
388 };
389 let json = serde_json::to_string(&report).unwrap();
390 let result = parse_vulnerability_json(&json);
391 assert!(result.is_err());
392 }
393
394 #[test]
395 fn generate_summary_empty_findings() {
396 let report = VulnerabilityReport {
397 scan_date: Utc::now(),
398 scanner: "nessus".into(),
399 findings: vec![],
400 };
401
402 let summary = generate_summary(&report);
403 assert!(summary.contains("No findings"));
404 }
405}