Skip to main content

zeroclaw_runtime/security/
vulnerability.rs

1//! Vulnerability scan result parsing and management.
2//!
3//! Parses vulnerability scan outputs from common scanners (Nessus, Qualys, generic
4//! CVSS JSON) and provides priority scoring with business context adjustments.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10/// A single vulnerability finding.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct Finding {
13    /// CVE identifier (e.g. "CVE-2024-1234"). May be empty for non-CVE findings.
14    #[serde(default)]
15    pub cve_id: String,
16    /// CVSS base score (0.0 - 10.0).
17    pub cvss_score: f64,
18    /// Severity label: "low", "medium", "high", "critical".
19    pub severity: String,
20    /// Affected asset identifier (hostname, IP, or service name).
21    pub affected_asset: String,
22    /// Description of the vulnerability.
23    pub description: String,
24    /// Recommended remediation steps.
25    #[serde(default)]
26    pub remediation: String,
27    /// Whether the asset is internet-facing (increases effective priority).
28    #[serde(default)]
29    pub internet_facing: bool,
30    /// Whether the asset is in a production environment.
31    #[serde(default = "default_true")]
32    pub production: bool,
33}
34
35fn default_true() -> bool {
36    true
37}
38
39/// A parsed vulnerability scan report.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct VulnerabilityReport {
42    /// When the scan was performed.
43    pub scan_date: DateTime<Utc>,
44    /// Scanner that produced the results (e.g. "nessus", "qualys", "generic").
45    pub scanner: String,
46    /// Individual findings from the scan.
47    pub findings: Vec<Finding>,
48}
49
50/// Compute effective priority score for a finding.
51///
52/// Base: CVSS score (0-10). Adjustments:
53/// - Internet-facing: +2.0 (capped at 10.0)
54/// - Production: +1.0 (capped at 10.0)
55pub 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
66/// Classify CVSS score into severity label.
67pub 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
77/// Parse a generic CVSS JSON vulnerability report.
78///
79/// Expects a JSON object with:
80/// - `scan_date`: ISO 8601 date string
81/// - `scanner`: string
82/// - `findings`: array of Finding objects
83pub 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
108/// Generate a summary of the vulnerability report.
109pub 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    // Sort by effective priority descending
146    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    // Top 10 by effective priority
168    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    // Remediation recommendations
199    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); // capped
290
291        // High CVSS + both bonuses still caps at 10.0
292        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}