Skip to main content

fleet_assessment/
fleet_assessment.rs

1//! Fleet-wide ASPM assessment simulation.
2//!
3//! Evaluates 5 synthetic repo profiles against the 4 ASPM controls
4//! (codeowners-coverage, secret-scanning, vulnerability-scanning, security-policy)
5//! through the SOC2 policy preset, then prints a prioritized fleet summary.
6//!
7//! Run: cargo run -p libverify-policy --example fleet_assessment
8
9use libverify_core::control::{Control, ControlFinding, builtin, evaluate_all};
10use libverify_core::controls::codeowners_coverage::CodeownersCoverageControl;
11use libverify_core::controls::secret_scanning::SecretScanningControl;
12use libverify_core::controls::security_policy::SecurityPolicyControl;
13use libverify_core::controls::vulnerability_scanning::VulnerabilityScanningControl;
14use libverify_core::evidence::{CodeownersEntry, EvidenceBundle, EvidenceState, RepositoryPosture};
15use libverify_core::profile::{GateDecision, apply_profile};
16use libverify_policy::OpaProfile;
17
18// ---------------------------------------------------------------------------
19// Repo profile definitions
20// ---------------------------------------------------------------------------
21
22struct RepoProfile {
23    name: &'static str,
24    description: &'static str,
25    posture: RepositoryPosture,
26}
27
28fn repo_profiles() -> Vec<RepoProfile> {
29    vec![
30        // 1. frontend-app: public, no CODEOWNERS, Dependabot only, no SECURITY.md
31        RepoProfile {
32            name: "frontend-app",
33            description: "Public SPA, Dependabot only, no ownership or policy",
34            posture: RepositoryPosture {
35                codeowners_entries: vec![],
36                secret_scanning_enabled: false,
37                secret_push_protection_enabled: false,
38                vulnerability_scanning_enabled: true, // Dependabot
39                code_scanning_enabled: false,
40                security_policy_present: false,
41                security_policy_has_disclosure: false,
42                default_branch_protected: false,
43            },
44        },
45        // 2. core-api: private, CODEOWNERS with catch-all, secret scanning + push
46        //    protection, CodeQL, SECURITY.md with disclosure
47        RepoProfile {
48            name: "core-api",
49            description: "Private API, full GHAS, CODEOWNERS, SECURITY.md",
50            posture: RepositoryPosture {
51                codeowners_entries: vec![
52                    CodeownersEntry {
53                        pattern: "*".to_string(),
54                        owners: vec!["@org/backend-team".to_string()],
55                    },
56                    CodeownersEntry {
57                        pattern: "/src/auth/".to_string(),
58                        owners: vec!["@org/security-team".to_string()],
59                    },
60                    CodeownersEntry {
61                        pattern: "/infra/".to_string(),
62                        owners: vec!["@org/platform-team".to_string()],
63                    },
64                ],
65                secret_scanning_enabled: true,
66                secret_push_protection_enabled: true,
67                vulnerability_scanning_enabled: true, // Dependabot
68                code_scanning_enabled: true,          // CodeQL
69                security_policy_present: true,
70                security_policy_has_disclosure: true,
71                default_branch_protected: true,
72            },
73        },
74        // 3. infra-terraform: private, CODEOWNERS (2 entries only), secret scanning
75        //    (no push protection), no SECURITY.md
76        RepoProfile {
77            name: "infra-terraform",
78            description: "Private IaC, partial CODEOWNERS, detection-only scanning",
79            posture: RepositoryPosture {
80                codeowners_entries: vec![
81                    CodeownersEntry {
82                        pattern: "/modules/".to_string(),
83                        owners: vec!["@org/platform-team".to_string()],
84                    },
85                    CodeownersEntry {
86                        pattern: "/environments/".to_string(),
87                        owners: vec!["@org/sre-team".to_string()],
88                    },
89                ],
90                secret_scanning_enabled: true,
91                secret_push_protection_enabled: false,
92                vulnerability_scanning_enabled: false,
93                code_scanning_enabled: false,
94                security_policy_present: false,
95                security_policy_has_disclosure: false,
96                default_branch_protected: false,
97            },
98        },
99        // 4. archived-legacy: no scanning, no CODEOWNERS, no policy
100        RepoProfile {
101            name: "archived-legacy",
102            description: "Unmaintained legacy service, no controls",
103            posture: RepositoryPosture {
104                codeowners_entries: vec![],
105                secret_scanning_enabled: false,
106                secret_push_protection_enabled: false,
107                vulnerability_scanning_enabled: false,
108                code_scanning_enabled: false,
109                security_policy_present: false,
110                security_policy_has_disclosure: false,
111                default_branch_protected: false,
112            },
113        },
114        // 5. new-microservice: Dependabot only, basic SECURITY.md, 3 CODEOWNERS entries
115        RepoProfile {
116            name: "new-microservice",
117            description: "New service, Dependabot + ownership, basic policy",
118            posture: RepositoryPosture {
119                codeowners_entries: vec![
120                    CodeownersEntry {
121                        pattern: "/src/".to_string(),
122                        owners: vec!["@org/backend-team".to_string()],
123                    },
124                    CodeownersEntry {
125                        pattern: "/deploy/".to_string(),
126                        owners: vec!["@org/platform-team".to_string()],
127                    },
128                    CodeownersEntry {
129                        pattern: "/.github/".to_string(),
130                        owners: vec!["@org/devops".to_string()],
131                    },
132                ],
133                secret_scanning_enabled: false,
134                secret_push_protection_enabled: false,
135                vulnerability_scanning_enabled: true, // Dependabot
136                code_scanning_enabled: false,
137                security_policy_present: true,
138                security_policy_has_disclosure: false, // basic, no disclosure process
139                default_branch_protected: false,
140            },
141        },
142    ]
143}
144
145// ---------------------------------------------------------------------------
146// Assessment logic
147// ---------------------------------------------------------------------------
148
149fn aspm_controls() -> Vec<Box<dyn Control>> {
150    vec![
151        Box::new(CodeownersCoverageControl),
152        Box::new(SecretScanningControl),
153        Box::new(VulnerabilityScanningControl),
154        Box::new(SecurityPolicyControl),
155    ]
156}
157
158struct RepoAssessment {
159    name: &'static str,
160    description: &'static str,
161    pass: u32,
162    review: u32,
163    fail: u32,
164    details: Vec<(String, GateDecision, String)>, // (control_id, decision, rationale)
165}
166
167fn assess_repo(profile: &RepoProfile, soc2: &OpaProfile) -> RepoAssessment {
168    let bundle = EvidenceBundle {
169        repository_posture: EvidenceState::complete(profile.posture.clone()),
170        ..Default::default()
171    };
172
173    let controls = aspm_controls();
174    let findings: Vec<ControlFinding> = evaluate_all(&controls, &bundle);
175    let outcomes = apply_profile(soc2, &findings);
176
177    let mut pass = 0u32;
178    let mut review = 0u32;
179    let mut fail = 0u32;
180    let mut details = Vec::new();
181
182    for outcome in &outcomes {
183        match outcome.decision {
184            GateDecision::Pass => pass += 1,
185            GateDecision::Review => review += 1,
186            GateDecision::Fail => fail += 1,
187        }
188        details.push((
189            outcome.control_id.to_string(),
190            outcome.decision,
191            outcome.rationale.clone(),
192        ));
193    }
194
195    RepoAssessment {
196        name: profile.name,
197        description: profile.description,
198        pass,
199        review,
200        fail,
201        details,
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Output formatting
207// ---------------------------------------------------------------------------
208
209fn risk_score(a: &RepoAssessment) -> u32 {
210    // Fail=3, Review=1, Pass=0 — higher is worse
211    a.fail * 3 + a.review
212}
213
214fn risk_tier(score: u32) -> &'static str {
215    match score {
216        0 => "COMPLIANT",
217        1..=3 => "LOW",
218        4..=6 => "MEDIUM",
219        7..=9 => "HIGH",
220        _ => "CRITICAL",
221    }
222}
223
224fn decision_symbol(d: GateDecision) -> &'static str {
225    match d {
226        GateDecision::Pass => "PASS",
227        GateDecision::Review => "REVIEW",
228        GateDecision::Fail => "FAIL",
229    }
230}
231
232fn main() {
233    let soc2 = OpaProfile::soc2_preset().expect("SOC2 preset should load");
234    let profiles = repo_profiles();
235
236    let mut assessments: Vec<RepoAssessment> =
237        profiles.iter().map(|p| assess_repo(p, &soc2)).collect();
238
239    // Sort by risk score descending (worst first)
240    assessments.sort_by(|a, b| risk_score(b).cmp(&risk_score(a)));
241
242    // ---- Fleet Summary Table ----
243    println!("==========================================================================");
244    println!("  FLEET-WIDE ASPM ASSESSMENT — SOC2 Preset (4 ASPM Controls x 5 Repos)");
245    println!("==========================================================================");
246    println!();
247    println!(
248        "  {:<20} {:>4}  {:>6}  {:>4}  {:>5}  {:<10}",
249        "Repository", "Pass", "Review", "Fail", "Score", "Risk Tier"
250    );
251    println!(
252        "  {:-<20} {:->4}  {:->6}  {:->4}  {:->5}  {:-<10}",
253        "", "", "", "", "", ""
254    );
255
256    for a in &assessments {
257        let score = risk_score(a);
258        println!(
259            "  {:<20} {:>4}  {:>6}  {:>4}  {:>5}  {:<10}",
260            a.name,
261            a.pass,
262            a.review,
263            a.fail,
264            score,
265            risk_tier(score),
266        );
267    }
268
269    // ---- Fleet Totals ----
270    let total_pass: u32 = assessments.iter().map(|a| a.pass).sum();
271    let total_review: u32 = assessments.iter().map(|a| a.review).sum();
272    let total_fail: u32 = assessments.iter().map(|a| a.fail).sum();
273    let total = total_pass + total_review + total_fail;
274
275    println!();
276    println!(
277        "  Fleet totals: {} pass / {} review / {} fail out of {} checks",
278        total_pass, total_review, total_fail, total
279    );
280    println!(
281        "  Fleet pass rate: {:.0}%",
282        if total > 0 {
283            (total_pass as f64 / total as f64) * 100.0
284        } else {
285            0.0
286        }
287    );
288
289    // ---- Per-Repo Detail ----
290    println!();
291    println!("==========================================================================");
292    println!("  PER-REPO FINDINGS (ordered by risk, worst first)");
293    println!("==========================================================================");
294
295    for a in &assessments {
296        let score = risk_score(a);
297        println!();
298        println!(
299            "  [{:<10}] {} — {}",
300            risk_tier(score),
301            a.name,
302            a.description
303        );
304
305        for (control_id, decision, rationale) in &a.details {
306            let symbol = decision_symbol(*decision);
307            println!("    {:<6}  {:<26}  {}", symbol, control_id, rationale);
308        }
309    }
310
311    // ---- Actionable Remediation Priorities ----
312    println!();
313    println!("==========================================================================");
314    println!("  REMEDIATION PRIORITIES");
315    println!("==========================================================================");
316    println!();
317
318    // Collect all failures across fleet, grouped by control
319    let control_ids = [
320        builtin::CODEOWNERS_COVERAGE,
321        builtin::SECRET_SCANNING,
322        builtin::VULNERABILITY_SCANNING,
323        builtin::SECURITY_POLICY,
324    ];
325
326    for cid in &control_ids {
327        let failing_repos: Vec<&str> = assessments
328            .iter()
329            .filter(|a| {
330                a.details
331                    .iter()
332                    .any(|(id, d, _)| id == *cid && *d == GateDecision::Fail)
333            })
334            .map(|a| a.name)
335            .collect();
336
337        let review_repos: Vec<&str> = assessments
338            .iter()
339            .filter(|a| {
340                a.details
341                    .iter()
342                    .any(|(id, d, _)| id == *cid && *d == GateDecision::Review)
343            })
344            .map(|a| a.name)
345            .collect();
346
347        if !failing_repos.is_empty() || !review_repos.is_empty() {
348            println!("  {}", cid);
349            if !failing_repos.is_empty() {
350                println!(
351                    "    FAIL ({} repos):   {}",
352                    failing_repos.len(),
353                    failing_repos.join(", ")
354                );
355            }
356            if !review_repos.is_empty() {
357                println!(
358                    "    REVIEW ({} repos): {}",
359                    review_repos.len(),
360                    review_repos.join(", ")
361                );
362            }
363            println!();
364        }
365    }
366
367    // ---- Worst Repo Call-out ----
368    if let Some(worst) = assessments.first() {
369        let score = risk_score(worst);
370        println!(
371            "  Worst-posture repo: {} (score={}, tier={})",
372            worst.name,
373            score,
374            risk_tier(score)
375        );
376        println!("  Recommended: prioritize enabling secret scanning and CODEOWNERS");
377        println!("  for repos in CRITICAL/HIGH tiers before next SOC2 audit window.");
378    }
379
380    println!();
381}