openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Report aggregation, scoring, and grading.

use serde::{Deserialize, Serialize};

use crate::finding::{Category, Finding, Severity};

// ── Grade ─────────────────────────────────────────────────────────────────────

/// Letter grade derived from a numeric score (0–100).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Grade {
    A,
    B,
    C,
    D,
    F,
}

impl Grade {
    pub fn from_score(score: u32) -> Self {
        match score {
            90..=100 => Grade::A,
            75..=89 => Grade::B,
            60..=74 => Grade::C,
            40..=59 => Grade::D,
            _ => Grade::F,
        }
    }
}

impl std::fmt::Display for Grade {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let c = match self {
            Grade::A => 'A',
            Grade::B => 'B',
            Grade::C => 'C',
            Grade::D => 'D',
            Grade::F => 'F',
        };
        write!(f, "{}", c)
    }
}

// ── CategoryReport ────────────────────────────────────────────────────────────

/// Aggregated findings and score for a single security category.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryReport {
    pub category: Category,
    pub score: u32,
    pub grade: Grade,
    pub findings: Vec<Finding>,
    pub critical_count: usize,
    pub high_count: usize,
    pub medium_count: usize,
    pub low_count: usize,
    pub info_count: usize,
}

impl CategoryReport {
    pub fn build(category: Category, findings: Vec<Finding>) -> Self {
        let score = compute_score(&findings);
        let grade = Grade::from_score(score);

        let critical_count = findings
            .iter()
            .filter(|f| f.severity == Severity::Critical)
            .count();
        let high_count = findings
            .iter()
            .filter(|f| f.severity == Severity::High)
            .count();
        let medium_count = findings
            .iter()
            .filter(|f| f.severity == Severity::Medium)
            .count();
        let low_count = findings
            .iter()
            .filter(|f| f.severity == Severity::Low)
            .count();
        let info_count = findings
            .iter()
            .filter(|f| f.severity == Severity::Info)
            .count();

        CategoryReport {
            category,
            score,
            grade,
            findings,
            critical_count,
            high_count,
            medium_count,
            low_count,
            info_count,
        }
    }
}

// ── Report ────────────────────────────────────────────────────────────────────

/// Full security scan report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
    /// Tool version at scan time.
    pub version: String,

    /// ISO-8601 timestamp of when the scan was run.
    pub scanned_at: String,

    /// Path(s) that were scanned.
    pub scanned_paths: Vec<String>,

    /// Overall score across all categories (0–100).
    pub overall_score: u32,

    /// Letter grade.
    pub overall_grade: Grade,

    /// Per-category breakdown.
    pub categories: Vec<CategoryReport>,

    /// Total finding counts by severity.
    pub total_critical: usize,
    pub total_high: usize,
    pub total_medium: usize,
    pub total_low: usize,
    pub total_info: usize,

    /// Flat list of all findings, sorted by severity (most severe first).
    pub findings: Vec<Finding>,
}

impl Report {
    /// Build a report from a flat list of findings produced by all scanners.
    pub fn build(
        findings: Vec<Finding>,
        scanned_paths: Vec<String>,
        version: impl Into<String>,
    ) -> Self {
        let scanned_at = chrono::Utc::now().to_rfc3339();

        // Sort findings: most severe first, then by category, then by path.
        let mut sorted = findings;
        sorted.sort_by(|a, b| {
            b.severity
                .cmp(&a.severity)
                .then_with(|| a.path.cmp(&b.path))
        });

        // Build per-category reports.
        let categories: Vec<CategoryReport> = Category::all()
            .iter()
            .map(|&cat| {
                let cat_findings: Vec<Finding> = sorted
                    .iter()
                    .filter(|f| f.category == cat)
                    .cloned()
                    .collect();
                CategoryReport::build(cat, cat_findings)
            })
            .collect();

        // Overall score = average of category scores (all categories weighted equally).
        let overall_score = if categories.is_empty() {
            100
        } else {
            categories.iter().map(|c| c.score).sum::<u32>() / categories.len() as u32
        };

        let total_critical = sorted
            .iter()
            .filter(|f| f.severity == Severity::Critical)
            .count();
        let total_high = sorted
            .iter()
            .filter(|f| f.severity == Severity::High)
            .count();
        let total_medium = sorted
            .iter()
            .filter(|f| f.severity == Severity::Medium)
            .count();
        let total_low = sorted
            .iter()
            .filter(|f| f.severity == Severity::Low)
            .count();
        let total_info = sorted
            .iter()
            .filter(|f| f.severity == Severity::Info)
            .count();

        Report {
            version: version.into(),
            scanned_at,
            scanned_paths,
            overall_score,
            overall_grade: Grade::from_score(overall_score),
            categories,
            total_critical,
            total_high,
            total_medium,
            total_low,
            total_info,
            findings: sorted,
        }
    }

    /// Returns `true` if the report has at least one finding at or above the
    /// given severity threshold.
    pub fn has_findings_at(&self, min: Severity) -> bool {
        self.findings.iter().any(|f| f.severity >= min)
    }
}

// ── Scoring ───────────────────────────────────────────────────────────────────

/// Compute a 0–100 score from a slice of findings.
///
/// Each finding deducts `Severity::penalty()` points from 100.
/// The score is floored at 0.
pub fn compute_score(findings: &[Finding]) -> u32 {
    let penalty: u32 = findings
        .iter()
        .filter(|f| f.severity != Severity::Info)
        .map(|f| f.severity.penalty())
        .sum();
    100u32.saturating_sub(penalty)
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;
    use crate::finding::{Category, Finding, Severity};

    fn make_finding(severity: Severity, category: Category) -> Finding {
        Finding::new(
            severity,
            category,
            "Test",
            "Desc",
            PathBuf::from("/tmp/x"),
            "Fix",
        )
    }

    #[test]
    fn grade_boundaries() {
        assert_eq!(Grade::from_score(100), Grade::A);
        assert_eq!(Grade::from_score(90), Grade::A);
        assert_eq!(Grade::from_score(89), Grade::B);
        assert_eq!(Grade::from_score(75), Grade::B);
        assert_eq!(Grade::from_score(74), Grade::C);
        assert_eq!(Grade::from_score(60), Grade::C);
        assert_eq!(Grade::from_score(59), Grade::D);
        assert_eq!(Grade::from_score(40), Grade::D);
        assert_eq!(Grade::from_score(39), Grade::F);
        assert_eq!(Grade::from_score(0), Grade::F);
    }

    #[test]
    fn score_no_findings() {
        assert_eq!(compute_score(&[]), 100);
    }

    #[test]
    fn score_single_critical() {
        let findings = vec![make_finding(Severity::Critical, Category::SecretDetection)];
        assert_eq!(compute_score(&findings), 75); // 100 - 25
    }

    #[test]
    fn score_does_not_go_below_zero() {
        let findings: Vec<Finding> = (0..10)
            .map(|_| make_finding(Severity::Critical, Category::SecretDetection))
            .collect();
        assert_eq!(compute_score(&findings), 0);
    }

    #[test]
    fn score_info_findings_not_penalised() {
        let findings = vec![make_finding(Severity::Info, Category::DataExposure)];
        assert_eq!(compute_score(&findings), 100);
    }

    #[test]
    fn report_sorts_by_severity() {
        let findings = vec![
            make_finding(Severity::Low, Category::DataExposure),
            make_finding(Severity::Critical, Category::SecretDetection),
            make_finding(Severity::Medium, Category::NetworkSecurity),
        ];
        let report = Report::build(findings, vec![], "0.1.0");
        assert_eq!(report.findings[0].severity, Severity::Critical);
        assert_eq!(report.findings[1].severity, Severity::Medium);
        assert_eq!(report.findings[2].severity, Severity::Low);
    }

    #[test]
    fn report_counts_by_severity() {
        let findings = vec![
            make_finding(Severity::Critical, Category::SecretDetection),
            make_finding(Severity::High, Category::ConfigSecurity),
            make_finding(Severity::High, Category::FilePermissions),
            make_finding(Severity::Medium, Category::NetworkSecurity),
            make_finding(Severity::Info, Category::DataExposure),
        ];
        let report = Report::build(findings, vec![], "0.1.0");
        assert_eq!(report.total_critical, 1);
        assert_eq!(report.total_high, 2);
        assert_eq!(report.total_medium, 1);
        assert_eq!(report.total_low, 0);
        assert_eq!(report.total_info, 1);
    }

    #[test]
    fn report_has_findings_at() {
        let findings = vec![make_finding(Severity::Medium, Category::ConfigSecurity)];
        let report = Report::build(findings, vec![], "0.1.0");
        assert!(report.has_findings_at(Severity::Low));
        assert!(report.has_findings_at(Severity::Medium));
        assert!(!report.has_findings_at(Severity::High));
        assert!(!report.has_findings_at(Severity::Critical));
    }
}