use serde::{Deserialize, Serialize};
use crate::finding::{Category, Finding, Severity};
#[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)
}
}
#[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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub version: String,
pub scanned_at: String,
pub scanned_paths: Vec<String>,
pub overall_score: u32,
pub overall_grade: Grade,
pub categories: Vec<CategoryReport>,
pub total_critical: usize,
pub total_high: usize,
pub total_medium: usize,
pub total_low: usize,
pub total_info: usize,
pub findings: Vec<Finding>,
}
impl Report {
pub fn build(
findings: Vec<Finding>,
scanned_paths: Vec<String>,
version: impl Into<String>,
) -> Self {
let scanned_at = chrono::Utc::now().to_rfc3339();
let mut sorted = findings;
sorted.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.path.cmp(&b.path))
});
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();
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,
}
}
pub fn has_findings_at(&self, min: Severity) -> bool {
self.findings.iter().any(|f| f.severity >= min)
}
}
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)
}
#[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); }
#[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));
}
}