Skip to main content

openclaw_scan/
report.rs

1//! Report aggregation, scoring, and grading.
2
3use serde::{Deserialize, Serialize};
4
5use crate::finding::{Category, Finding, Severity};
6
7// ── Grade ─────────────────────────────────────────────────────────────────────
8
9/// Letter grade derived from a numeric score (0–100).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum Grade {
12    A,
13    B,
14    C,
15    D,
16    F,
17}
18
19impl Grade {
20    pub fn from_score(score: u32) -> Self {
21        match score {
22            90..=100 => Grade::A,
23            75..=89 => Grade::B,
24            60..=74 => Grade::C,
25            40..=59 => Grade::D,
26            _ => Grade::F,
27        }
28    }
29}
30
31impl std::fmt::Display for Grade {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        let c = match self {
34            Grade::A => 'A',
35            Grade::B => 'B',
36            Grade::C => 'C',
37            Grade::D => 'D',
38            Grade::F => 'F',
39        };
40        write!(f, "{}", c)
41    }
42}
43
44// ── CategoryReport ────────────────────────────────────────────────────────────
45
46/// Aggregated findings and score for a single security category.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CategoryReport {
49    pub category: Category,
50    pub score: u32,
51    pub grade: Grade,
52    pub findings: Vec<Finding>,
53    pub critical_count: usize,
54    pub high_count: usize,
55    pub medium_count: usize,
56    pub low_count: usize,
57    pub info_count: usize,
58}
59
60impl CategoryReport {
61    pub fn build(category: Category, findings: Vec<Finding>) -> Self {
62        let score = compute_score(&findings);
63        let grade = Grade::from_score(score);
64
65        let critical_count = findings
66            .iter()
67            .filter(|f| f.severity == Severity::Critical)
68            .count();
69        let high_count = findings
70            .iter()
71            .filter(|f| f.severity == Severity::High)
72            .count();
73        let medium_count = findings
74            .iter()
75            .filter(|f| f.severity == Severity::Medium)
76            .count();
77        let low_count = findings
78            .iter()
79            .filter(|f| f.severity == Severity::Low)
80            .count();
81        let info_count = findings
82            .iter()
83            .filter(|f| f.severity == Severity::Info)
84            .count();
85
86        CategoryReport {
87            category,
88            score,
89            grade,
90            findings,
91            critical_count,
92            high_count,
93            medium_count,
94            low_count,
95            info_count,
96        }
97    }
98}
99
100// ── Report ────────────────────────────────────────────────────────────────────
101
102/// Full security scan report.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Report {
105    /// Tool version at scan time.
106    pub version: String,
107
108    /// ISO-8601 timestamp of when the scan was run.
109    pub scanned_at: String,
110
111    /// Path(s) that were scanned.
112    pub scanned_paths: Vec<String>,
113
114    /// Overall score across all categories (0–100).
115    pub overall_score: u32,
116
117    /// Letter grade.
118    pub overall_grade: Grade,
119
120    /// Per-category breakdown.
121    pub categories: Vec<CategoryReport>,
122
123    /// Total finding counts by severity.
124    pub total_critical: usize,
125    pub total_high: usize,
126    pub total_medium: usize,
127    pub total_low: usize,
128    pub total_info: usize,
129
130    /// Flat list of all findings, sorted by severity (most severe first).
131    pub findings: Vec<Finding>,
132}
133
134impl Report {
135    /// Build a report from a flat list of findings produced by all scanners.
136    pub fn build(
137        findings: Vec<Finding>,
138        scanned_paths: Vec<String>,
139        version: impl Into<String>,
140    ) -> Self {
141        let scanned_at = chrono::Utc::now().to_rfc3339();
142
143        // Sort findings: most severe first, then by category, then by path.
144        let mut sorted = findings;
145        sorted.sort_by(|a, b| {
146            b.severity
147                .cmp(&a.severity)
148                .then_with(|| a.path.cmp(&b.path))
149        });
150
151        // Build per-category reports.
152        let categories: Vec<CategoryReport> = Category::all()
153            .iter()
154            .map(|&cat| {
155                let cat_findings: Vec<Finding> = sorted
156                    .iter()
157                    .filter(|f| f.category == cat)
158                    .cloned()
159                    .collect();
160                CategoryReport::build(cat, cat_findings)
161            })
162            .collect();
163
164        // Overall score = average of category scores (all categories weighted equally).
165        let overall_score = if categories.is_empty() {
166            100
167        } else {
168            categories.iter().map(|c| c.score).sum::<u32>() / categories.len() as u32
169        };
170
171        let total_critical = sorted
172            .iter()
173            .filter(|f| f.severity == Severity::Critical)
174            .count();
175        let total_high = sorted
176            .iter()
177            .filter(|f| f.severity == Severity::High)
178            .count();
179        let total_medium = sorted
180            .iter()
181            .filter(|f| f.severity == Severity::Medium)
182            .count();
183        let total_low = sorted
184            .iter()
185            .filter(|f| f.severity == Severity::Low)
186            .count();
187        let total_info = sorted
188            .iter()
189            .filter(|f| f.severity == Severity::Info)
190            .count();
191
192        Report {
193            version: version.into(),
194            scanned_at,
195            scanned_paths,
196            overall_score,
197            overall_grade: Grade::from_score(overall_score),
198            categories,
199            total_critical,
200            total_high,
201            total_medium,
202            total_low,
203            total_info,
204            findings: sorted,
205        }
206    }
207
208    /// Returns `true` if the report has at least one finding at or above the
209    /// given severity threshold.
210    pub fn has_findings_at(&self, min: Severity) -> bool {
211        self.findings.iter().any(|f| f.severity >= min)
212    }
213}
214
215// ── Scoring ───────────────────────────────────────────────────────────────────
216
217/// Compute a 0–100 score from a slice of findings.
218///
219/// Each finding deducts `Severity::penalty()` points from 100.
220/// The score is floored at 0.
221pub fn compute_score(findings: &[Finding]) -> u32 {
222    let penalty: u32 = findings
223        .iter()
224        .filter(|f| f.severity != Severity::Info)
225        .map(|f| f.severity.penalty())
226        .sum();
227    100u32.saturating_sub(penalty)
228}
229
230// ── Tests ─────────────────────────────────────────────────────────────────────
231
232#[cfg(test)]
233mod tests {
234    use std::path::PathBuf;
235
236    use super::*;
237    use crate::finding::{Category, Finding, Severity};
238
239    fn make_finding(severity: Severity, category: Category) -> Finding {
240        Finding::new(
241            severity,
242            category,
243            "Test",
244            "Desc",
245            PathBuf::from("/tmp/x"),
246            "Fix",
247        )
248    }
249
250    #[test]
251    fn grade_boundaries() {
252        assert_eq!(Grade::from_score(100), Grade::A);
253        assert_eq!(Grade::from_score(90), Grade::A);
254        assert_eq!(Grade::from_score(89), Grade::B);
255        assert_eq!(Grade::from_score(75), Grade::B);
256        assert_eq!(Grade::from_score(74), Grade::C);
257        assert_eq!(Grade::from_score(60), Grade::C);
258        assert_eq!(Grade::from_score(59), Grade::D);
259        assert_eq!(Grade::from_score(40), Grade::D);
260        assert_eq!(Grade::from_score(39), Grade::F);
261        assert_eq!(Grade::from_score(0), Grade::F);
262    }
263
264    #[test]
265    fn score_no_findings() {
266        assert_eq!(compute_score(&[]), 100);
267    }
268
269    #[test]
270    fn score_single_critical() {
271        let findings = vec![make_finding(Severity::Critical, Category::SecretDetection)];
272        assert_eq!(compute_score(&findings), 75); // 100 - 25
273    }
274
275    #[test]
276    fn score_does_not_go_below_zero() {
277        let findings: Vec<Finding> = (0..10)
278            .map(|_| make_finding(Severity::Critical, Category::SecretDetection))
279            .collect();
280        assert_eq!(compute_score(&findings), 0);
281    }
282
283    #[test]
284    fn score_info_findings_not_penalised() {
285        let findings = vec![make_finding(Severity::Info, Category::DataExposure)];
286        assert_eq!(compute_score(&findings), 100);
287    }
288
289    #[test]
290    fn report_sorts_by_severity() {
291        let findings = vec![
292            make_finding(Severity::Low, Category::DataExposure),
293            make_finding(Severity::Critical, Category::SecretDetection),
294            make_finding(Severity::Medium, Category::NetworkSecurity),
295        ];
296        let report = Report::build(findings, vec![], "0.1.0");
297        assert_eq!(report.findings[0].severity, Severity::Critical);
298        assert_eq!(report.findings[1].severity, Severity::Medium);
299        assert_eq!(report.findings[2].severity, Severity::Low);
300    }
301
302    #[test]
303    fn report_counts_by_severity() {
304        let findings = vec![
305            make_finding(Severity::Critical, Category::SecretDetection),
306            make_finding(Severity::High, Category::ConfigSecurity),
307            make_finding(Severity::High, Category::FilePermissions),
308            make_finding(Severity::Medium, Category::NetworkSecurity),
309            make_finding(Severity::Info, Category::DataExposure),
310        ];
311        let report = Report::build(findings, vec![], "0.1.0");
312        assert_eq!(report.total_critical, 1);
313        assert_eq!(report.total_high, 2);
314        assert_eq!(report.total_medium, 1);
315        assert_eq!(report.total_low, 0);
316        assert_eq!(report.total_info, 1);
317    }
318
319    #[test]
320    fn report_has_findings_at() {
321        let findings = vec![make_finding(Severity::Medium, Category::ConfigSecurity)];
322        let report = Report::build(findings, vec![], "0.1.0");
323        assert!(report.has_findings_at(Severity::Low));
324        assert!(report.has_findings_at(Severity::Medium));
325        assert!(!report.has_findings_at(Severity::High));
326        assert!(!report.has_findings_at(Severity::Critical));
327    }
328}