1use serde::{Deserialize, Serialize};
4
5use crate::finding::{Category, Finding, Severity};
6
7#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Report {
105 pub version: String,
107
108 pub scanned_at: String,
110
111 pub scanned_paths: Vec<String>,
113
114 pub overall_score: u32,
116
117 pub overall_grade: Grade,
119
120 pub categories: Vec<CategoryReport>,
122
123 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 pub findings: Vec<Finding>,
132}
133
134impl Report {
135 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 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 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 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 pub fn has_findings_at(&self, min: Severity) -> bool {
211 self.findings.iter().any(|f| f.severity >= min)
212 }
213}
214
215pub 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#[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); }
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}