Skip to main content

libverify_core/
assessment.rs

1use serde::{Deserialize, Serialize};
2
3use crate::control::{Control, ControlFinding, ControlStatus, evaluate_all};
4use crate::evidence::EvidenceBundle;
5use crate::profile::{ControlProfile, ProfileOutcome, SeverityLabels, apply_profile};
6use crate::registry::ControlRegistry;
7
8/// Complete assessment result combining raw control findings with profile-mapped outcomes.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct AssessmentReport {
11    pub profile_name: String,
12    pub findings: Vec<ControlFinding>,
13    pub outcomes: Vec<ProfileOutcome>,
14    pub severity_labels: SeverityLabels,
15}
16
17/// Assessment report with optional raw evidence bundle for audit trails.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct VerificationResult {
20    #[serde(flatten)]
21    pub report: AssessmentReport,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub evidence: Option<EvidenceBundle>,
24}
25
26impl VerificationResult {
27    pub fn new(report: AssessmentReport, evidence: Option<EvidenceBundle>) -> Self {
28        Self { report, evidence }
29    }
30}
31
32/// Batch verification report for multiple change requests.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct BatchReport {
35    pub reports: Vec<BatchEntry>,
36    pub total_pass: usize,
37    pub total_review: usize,
38    pub total_fail: usize,
39    pub skipped: Vec<SkippedEntry>,
40}
41
42/// A single entry in a batch report.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct BatchEntry {
45    pub subject_id: String,
46    #[serde(flatten)]
47    pub result: VerificationResult,
48}
49
50/// A skipped entry in a batch report.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct SkippedEntry {
53    pub subject_id: String,
54    pub reason: String,
55}
56
57/// Evaluates all controls against evidence and maps findings through a profile.
58pub fn assess(
59    evidence: &EvidenceBundle,
60    controls: &[Box<dyn Control>],
61    profile: &dyn ControlProfile,
62) -> AssessmentReport {
63    let findings: Vec<ControlFinding> = evaluate_all(controls, evidence)
64        .into_iter()
65        .filter(|f| f.status != ControlStatus::NotApplicable)
66        .collect();
67    let outcomes = apply_profile(profile, &findings);
68
69    AssessmentReport {
70        profile_name: profile.name().to_string(),
71        findings,
72        outcomes,
73        severity_labels: profile.severity_labels(),
74    }
75}
76
77/// Assess using a control registry and profile.
78pub fn assess_with_registry(
79    evidence: &EvidenceBundle,
80    registry: &ControlRegistry,
81    profile: &dyn ControlProfile,
82) -> AssessmentReport {
83    assess(evidence, registry.controls(), profile)
84}
85
86// ---------------------------------------------------------------------------
87// Fleet-level aggregation
88// ---------------------------------------------------------------------------
89
90use crate::profile::GateDecision;
91use std::collections::HashMap;
92
93/// Fleet-level aggregation of verification results across multiple repositories.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct FleetReport {
96    /// Per-repo summaries, sorted by fail count descending (worst first).
97    pub repos: Vec<RepoSummary>,
98    /// Control-level statistics across the fleet.
99    pub control_stats: Vec<ControlFleetStat>,
100    /// Fleet-wide totals.
101    pub total_pass: usize,
102    pub total_review: usize,
103    pub total_fail: usize,
104}
105
106/// Summary of a single repository's verification results.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct RepoSummary {
109    pub repo_id: String,
110    pub pass: usize,
111    pub review: usize,
112    pub fail: usize,
113    /// Failing control IDs for quick triage.
114    pub failing_controls: Vec<String>,
115}
116
117/// Fleet-wide statistics for a single control.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ControlFleetStat {
120    pub control_id: String,
121    /// Number of repos where this control failed.
122    pub fail_count: usize,
123    /// Number of repos where this control was reviewed.
124    pub review_count: usize,
125    /// Number of repos where this control passed.
126    pub pass_count: usize,
127    /// SOC2 TSC criteria mapping.
128    pub tsc_criteria: Vec<String>,
129}
130
131impl FleetReport {
132    /// Build a fleet report from a set of (repo_id, assessment_report) pairs.
133    pub fn from_assessments(entries: Vec<(String, &AssessmentReport)>) -> Self {
134        let mut repos = Vec::new();
135        let mut control_map: HashMap<String, (usize, usize, usize)> = HashMap::new();
136        let mut total_pass = 0;
137        let mut total_review = 0;
138        let mut total_fail = 0;
139
140        for (repo_id, report) in &entries {
141            let mut pass = 0;
142            let mut review = 0;
143            let mut fail = 0;
144            let mut failing_controls = Vec::new();
145
146            for outcome in &report.outcomes {
147                let key = outcome.control_id.as_str().to_string();
148                let entry = control_map.entry(key.clone()).or_insert((0, 0, 0));
149
150                match outcome.decision {
151                    GateDecision::Pass => {
152                        pass += 1;
153                        entry.2 += 1;
154                    }
155                    GateDecision::Review => {
156                        review += 1;
157                        entry.1 += 1;
158                    }
159                    GateDecision::Fail => {
160                        fail += 1;
161                        entry.0 += 1;
162                        failing_controls.push(key);
163                    }
164                }
165            }
166
167            total_pass += pass;
168            total_review += review;
169            total_fail += fail;
170
171            repos.push(RepoSummary {
172                repo_id: repo_id.clone(),
173                pass,
174                review,
175                fail,
176                failing_controls,
177            });
178        }
179
180        // Sort repos by fail count descending (worst first)
181        repos.sort_by(|a, b| b.fail.cmp(&a.fail));
182
183        // Build control stats sorted by fail count descending
184        let mut control_stats: Vec<ControlFleetStat> = control_map
185            .into_iter()
186            .map(|(id, (fail, review, pass))| ControlFleetStat {
187                tsc_criteria: crate::control::builtin_tsc_mapping(&id)
188                    .iter()
189                    .map(|s| s.to_string())
190                    .collect(),
191                control_id: id,
192                fail_count: fail,
193                review_count: review,
194                pass_count: pass,
195            })
196            .collect();
197        control_stats.sort_by(|a, b| b.fail_count.cmp(&a.fail_count));
198
199        FleetReport {
200            repos,
201            control_stats,
202            total_pass,
203            total_review,
204            total_fail,
205        }
206    }
207}