zeroclaw/src/security/vulnerability.rs
Argenis 28396fccac
feat(security): add MCSS security operations tool (#3657)
* feat(security): add MCSS security operations tool

Add managed cybersecurity service (MCSS) tool with alert triage,
incident response playbook execution, vulnerability scan parsing,
and security report generation. Includes SecurityOpsConfig, playbook
engine with approval gating, vulnerability scoring, and full test
coverage. Also fixes pre-existing missing approval_manager field in
ChannelRuntimeContext test constructors.

Original work by @rareba. Supersedes #3599.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add SecurityOpsConfig to re-exports, fix test constructors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:17:12 +03:00

398 lines
13 KiB
Rust

//! 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<Utc>,
/// Scanner that produced the results (e.g. "nessus", "qualys", "generic").
pub scanner: String,
/// Individual findings from the scan.
pub findings: Vec<Finding>,
}
/// 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<VulnerabilityReport> {
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<Finding> {
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"));
}
}