zeroclawlabs 0.6.9

Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.
Documentation
//! 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"));
    }
}