//! Vulnerability scan result parsing and management. //! //! Parses vulnerability scan outputs from common scanners (Nessus, Qualys, generic //! CVSS JSON) and provides priority scoring with business context adjustments. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt::Write; /// A single vulnerability finding. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Finding { /// CVE identifier (e.g. "CVE-2024-1234"). May be empty for non-CVE findings. #[serde(default)] pub cve_id: String, /// CVSS base score (0.0 - 10.0). pub cvss_score: f64, /// Severity label: "low", "medium", "high", "critical". pub severity: String, /// Affected asset identifier (hostname, IP, or service name). pub affected_asset: String, /// Description of the vulnerability. pub description: String, /// Recommended remediation steps. #[serde(default)] pub remediation: String, /// Whether the asset is internet-facing (increases effective priority). #[serde(default)] pub internet_facing: bool, /// Whether the asset is in a production environment. #[serde(default = "default_true")] pub production: bool, } fn default_true() -> bool { true } /// A parsed vulnerability scan report. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VulnerabilityReport { /// When the scan was performed. pub scan_date: DateTime, /// Scanner that produced the results (e.g. "nessus", "qualys", "generic"). pub scanner: String, /// Individual findings from the scan. pub findings: Vec, } /// Compute effective priority score for a finding. /// /// Base: CVSS score (0-10). Adjustments: /// - Internet-facing: +2.0 (capped at 10.0) /// - Production: +1.0 (capped at 10.0) pub fn effective_priority(finding: &Finding) -> f64 { let mut score = finding.cvss_score; if finding.internet_facing { score += 2.0; } if finding.production { score += 1.0; } score.min(10.0) } /// Classify CVSS score into severity label. pub fn cvss_to_severity(cvss: f64) -> &'static str { match cvss { s if s >= 9.0 => "critical", s if s >= 7.0 => "high", s if s >= 4.0 => "medium", s if s > 0.0 => "low", _ => "informational", } } /// Parse a generic CVSS JSON vulnerability report. /// /// Expects a JSON object with: /// - `scan_date`: ISO 8601 date string /// - `scanner`: string /// - `findings`: array of Finding objects pub fn parse_vulnerability_json(json_str: &str) -> anyhow::Result { let report: VulnerabilityReport = serde_json::from_str(json_str) .map_err(|e| anyhow::anyhow!("Failed to parse vulnerability report: {e}"))?; for (i, finding) in report.findings.iter().enumerate() { if !(0.0..=10.0).contains(&finding.cvss_score) { anyhow::bail!( "findings[{}].cvss_score must be between 0.0 and 10.0, got {}", i, finding.cvss_score ); } } Ok(report) } /// Generate a summary of the vulnerability report. pub fn generate_summary(report: &VulnerabilityReport) -> String { if report.findings.is_empty() { return format!( "Vulnerability scan by {} on {}: No findings.", report.scanner, report.scan_date.format("%Y-%m-%d") ); } let total = report.findings.len(); let critical = report .findings .iter() .filter(|f| f.severity.eq_ignore_ascii_case("critical")) .count(); let high = report .findings .iter() .filter(|f| f.severity.eq_ignore_ascii_case("high")) .count(); let medium = report .findings .iter() .filter(|f| f.severity.eq_ignore_ascii_case("medium")) .count(); let low = report .findings .iter() .filter(|f| f.severity.eq_ignore_ascii_case("low")) .count(); let informational = report .findings .iter() .filter(|f| f.severity.eq_ignore_ascii_case("informational")) .count(); // Sort by effective priority descending let mut sorted: Vec<&Finding> = report.findings.iter().collect(); sorted.sort_by(|a, b| { effective_priority(b) .partial_cmp(&effective_priority(a)) .unwrap_or(std::cmp::Ordering::Equal) }); let mut summary = format!( "## Vulnerability Scan Summary\n\ **Scanner:** {} | **Date:** {}\n\ **Total findings:** {} (Critical: {}, High: {}, Medium: {}, Low: {}, Informational: {})\n\n", report.scanner, report.scan_date.format("%Y-%m-%d"), total, critical, high, medium, low, informational ); // Top 10 by effective priority summary.push_str("### Top Findings by Priority\n\n"); for (i, finding) in sorted.iter().take(10).enumerate() { let priority = effective_priority(finding); let context = match (finding.internet_facing, finding.production) { (true, true) => " [internet-facing, production]", (true, false) => " [internet-facing]", (false, true) => " [production]", (false, false) => "", }; let _ = writeln!( summary, "{}. **{}** (CVSS: {:.1}, Priority: {:.1}){}\n Asset: {} | {}", i + 1, if finding.cve_id.is_empty() { "No CVE" } else { &finding.cve_id }, finding.cvss_score, priority, context, finding.affected_asset, finding.description ); if !finding.remediation.is_empty() { let _ = writeln!(summary, " Remediation: {}", finding.remediation); } summary.push('\n'); } // Remediation recommendations if critical > 0 || high > 0 { summary.push_str("### Remediation Recommendations\n\n"); if critical > 0 { let _ = writeln!( summary, "- **URGENT:** {} critical findings require immediate remediation", critical ); } if high > 0 { let _ = writeln!( summary, "- **HIGH:** {} high-severity findings should be addressed within 7 days", high ); } let internet_facing_critical = sorted .iter() .filter(|f| f.internet_facing && (f.severity == "critical" || f.severity == "high")) .count(); if internet_facing_critical > 0 { let _ = writeln!( summary, "- **PRIORITY:** {} critical/high findings on internet-facing assets", internet_facing_critical ); } } summary } #[cfg(test)] mod tests { use super::*; fn sample_findings() -> Vec { vec![ Finding { cve_id: "CVE-2024-0001".into(), cvss_score: 9.8, severity: "critical".into(), affected_asset: "web-server-01".into(), description: "Remote code execution in web framework".into(), remediation: "Upgrade to version 2.1.0".into(), internet_facing: true, production: true, }, Finding { cve_id: "CVE-2024-0002".into(), cvss_score: 7.5, severity: "high".into(), affected_asset: "db-server-01".into(), description: "SQL injection in query parser".into(), remediation: "Apply patch KB-12345".into(), internet_facing: false, production: true, }, Finding { cve_id: "CVE-2024-0003".into(), cvss_score: 4.3, severity: "medium".into(), affected_asset: "staging-app-01".into(), description: "Information disclosure via debug endpoint".into(), remediation: "Disable debug endpoint in config".into(), internet_facing: false, production: false, }, ] } #[test] fn effective_priority_adds_context_bonuses() { let mut f = Finding { cve_id: String::new(), cvss_score: 7.0, severity: "high".into(), affected_asset: "host".into(), description: "test".into(), remediation: String::new(), internet_facing: false, production: false, }; assert!((effective_priority(&f) - 7.0).abs() < f64::EPSILON); f.internet_facing = true; assert!((effective_priority(&f) - 9.0).abs() < f64::EPSILON); f.production = true; assert!((effective_priority(&f) - 10.0).abs() < f64::EPSILON); // capped // High CVSS + both bonuses still caps at 10.0 f.cvss_score = 9.5; assert!((effective_priority(&f) - 10.0).abs() < f64::EPSILON); } #[test] fn cvss_to_severity_classification() { assert_eq!(cvss_to_severity(9.8), "critical"); assert_eq!(cvss_to_severity(9.0), "critical"); assert_eq!(cvss_to_severity(8.5), "high"); assert_eq!(cvss_to_severity(7.0), "high"); assert_eq!(cvss_to_severity(5.0), "medium"); assert_eq!(cvss_to_severity(4.0), "medium"); assert_eq!(cvss_to_severity(3.9), "low"); assert_eq!(cvss_to_severity(0.1), "low"); assert_eq!(cvss_to_severity(0.0), "informational"); } #[test] fn parse_vulnerability_json_roundtrip() { let report = VulnerabilityReport { scan_date: Utc::now(), scanner: "nessus".into(), findings: sample_findings(), }; let json = serde_json::to_string(&report).unwrap(); let parsed = parse_vulnerability_json(&json).unwrap(); assert_eq!(parsed.scanner, "nessus"); assert_eq!(parsed.findings.len(), 3); assert_eq!(parsed.findings[0].cve_id, "CVE-2024-0001"); } #[test] fn parse_vulnerability_json_rejects_invalid() { let result = parse_vulnerability_json("not json"); assert!(result.is_err()); } #[test] fn generate_summary_includes_key_sections() { let report = VulnerabilityReport { scan_date: Utc::now(), scanner: "qualys".into(), findings: sample_findings(), }; let summary = generate_summary(&report); assert!(summary.contains("qualys")); assert!(summary.contains("Total findings:** 3")); assert!(summary.contains("Critical: 1")); assert!(summary.contains("High: 1")); assert!(summary.contains("CVE-2024-0001")); assert!(summary.contains("URGENT")); assert!(summary.contains("internet-facing")); } #[test] fn parse_vulnerability_json_rejects_out_of_range_cvss() { let report = VulnerabilityReport { scan_date: Utc::now(), scanner: "test".into(), findings: vec![Finding { cve_id: "CVE-2024-9999".into(), cvss_score: 11.0, severity: "critical".into(), affected_asset: "host".into(), description: "bad score".into(), remediation: String::new(), internet_facing: false, production: false, }], }; let json = serde_json::to_string(&report).unwrap(); let result = parse_vulnerability_json(&json); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("cvss_score must be between 0.0 and 10.0")); } #[test] fn parse_vulnerability_json_rejects_negative_cvss() { let report = VulnerabilityReport { scan_date: Utc::now(), scanner: "test".into(), findings: vec![Finding { cve_id: "CVE-2024-9998".into(), cvss_score: -1.0, severity: "low".into(), affected_asset: "host".into(), description: "negative score".into(), remediation: String::new(), internet_facing: false, production: false, }], }; let json = serde_json::to_string(&report).unwrap(); let result = parse_vulnerability_json(&json); assert!(result.is_err()); } #[test] fn generate_summary_empty_findings() { let report = VulnerabilityReport { scan_date: Utc::now(), scanner: "nessus".into(), findings: vec![], }; let summary = generate_summary(&report); assert!(summary.contains("No findings")); } }