Skip to main content

libverify_core/controls/
branch_protection_enforcement.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3    ApprovalDisposition, CheckConclusion, EvidenceBundle, EvidenceState, GovernedChange,
4};
5use crate::integrity::{branch_protection_enforcement_severity, is_approver_independent};
6use crate::verdict::Severity;
7
8/// Source L3: Verifies that continuous technical controls were actually enforced
9/// by checking factual evidence: all CI checks passed AND an independent review
10/// approved the change.
11///
12/// Instead of checking branch protection API settings (which require admin permissions),
13/// this control examines whether the enforcement actually happened.
14/// See ADR-0002 for rationale.
15pub struct BranchProtectionEnforcementControl;
16
17impl Control for BranchProtectionEnforcementControl {
18    fn id(&self) -> ControlId {
19        builtin::id(builtin::BRANCH_PROTECTION_ENFORCEMENT)
20    }
21
22    fn description(&self) -> &'static str {
23        "Branch protection rules must be continuously enforced"
24    }
25
26    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
27        let id = self.id();
28
29        if evidence.change_requests.is_empty() {
30            return vec![ControlFinding::not_applicable(
31                id,
32                "No governed changes were supplied",
33            )];
34        }
35
36        evidence
37            .change_requests
38            .iter()
39            .map(|cr| evaluate_change(id.clone(), cr, &evidence.check_runs))
40            .collect()
41    }
42}
43
44fn evaluate_change(
45    id: ControlId,
46    change: &GovernedChange,
47    check_runs: &EvidenceState<Vec<crate::evidence::CheckRunEvidence>>,
48) -> ControlFinding {
49    let subject = change.id.to_string();
50
51    if change.is_bot_submitted() {
52        return ControlFinding::not_applicable(
53            id,
54            format!("{subject}: bot-submitted change; enforcement verified on constituent PRs"),
55        );
56    }
57
58    let mut violations = Vec::new();
59
60    // Check 1: CI checks all passed
61    match check_runs {
62        EvidenceState::NotApplicable => {}
63        EvidenceState::Missing { gaps } => {
64            return ControlFinding::indeterminate(
65                id,
66                "Check runs evidence could not be collected",
67                vec![subject],
68                gaps.clone(),
69            );
70        }
71        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
72            if value.is_empty() {
73                violations.push("no CI checks were executed".to_string());
74            } else {
75                let failed: Vec<&str> = value
76                    .iter()
77                    .filter(|r| is_failing_conclusion(&r.conclusion))
78                    .map(|r| r.name.as_str())
79                    .collect();
80                if !failed.is_empty() {
81                    violations.push(format!("CI check(s) failed: {}", failed.join(", ")));
82                }
83            }
84        }
85    }
86
87    // Check 2: Independent review approval exists
88    match &change.approval_decisions {
89        EvidenceState::Missing { gaps } => {
90            return ControlFinding::indeterminate(
91                id,
92                "Approval evidence could not be collected",
93                vec![subject],
94                gaps.clone(),
95            );
96        }
97        EvidenceState::NotApplicable => {}
98        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
99            let authors: Vec<&str> = change
100                .source_revisions
101                .value()
102                .map(|revs| {
103                    revs.iter()
104                        .filter_map(|r| r.authored_by.as_deref())
105                        .collect()
106                })
107                .unwrap_or_default();
108
109            let requester = change.submitted_by.as_deref().unwrap_or("");
110
111            let has_independent = value.iter().any(|a| {
112                if a.disposition != ApprovalDisposition::Approved {
113                    return false;
114                }
115                let is_commit_author = authors.contains(&a.actor.as_str());
116                let is_pr_author = a.actor == requester;
117                is_approver_independent(is_commit_author, is_pr_author)
118            });
119
120            if !has_independent {
121                violations.push("no independent review approval found".to_string());
122            }
123        }
124    }
125
126    match branch_protection_enforcement_severity(violations.len()) {
127        Severity::Pass => ControlFinding::satisfied(
128            id,
129            "Technical controls were enforced: CI checks passed and independent review approved",
130            vec![subject],
131        ),
132        _ => ControlFinding::violated(
133            id,
134            format!("Enforcement gaps: {}", violations.join("; ")),
135            vec![subject],
136        ),
137    }
138}
139
140fn is_failing_conclusion(conclusion: &CheckConclusion) -> bool {
141    matches!(
142        conclusion,
143        CheckConclusion::Failure
144            | CheckConclusion::Cancelled
145            | CheckConclusion::TimedOut
146            | CheckConclusion::ActionRequired
147            | CheckConclusion::Pending
148    )
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::control::ControlStatus;
155    use crate::evidence::{
156        ApprovalDecision, AuthenticityEvidence, ChangeRequestId, CheckRunEvidence, EvidenceGap,
157        SourceRevision,
158    };
159
160    fn make_change(
161        approvals: EvidenceState<Vec<ApprovalDecision>>,
162        revisions: EvidenceState<Vec<SourceRevision>>,
163    ) -> GovernedChange {
164        GovernedChange {
165            id: ChangeRequestId::new("test", "owner/repo#1"),
166            title: "feat: test".to_string(),
167            summary: None,
168            submitted_by: Some("author".to_string()),
169            changed_assets: EvidenceState::complete(vec![]),
170            approval_decisions: approvals,
171            source_revisions: revisions,
172            work_item_refs: EvidenceState::complete(vec![]),
173        }
174    }
175
176    fn make_approved_change() -> GovernedChange {
177        make_change(
178            EvidenceState::complete(vec![ApprovalDecision {
179                actor: "reviewer".to_string(),
180                disposition: ApprovalDisposition::Approved,
181                submitted_at: Some("2026-03-15T00:00:00Z".to_string()),
182            }]),
183            EvidenceState::complete(vec![SourceRevision {
184                id: "abc123".to_string(),
185                authored_by: Some("author".to_string()),
186                committed_at: Some("2026-03-14T00:00:00Z".to_string()),
187                merge: false,
188                authenticity: EvidenceState::complete(AuthenticityEvidence::new(
189                    true,
190                    Some("gpg".to_string()),
191                )),
192            }]),
193        )
194    }
195
196    fn passing_checks() -> EvidenceState<Vec<CheckRunEvidence>> {
197        EvidenceState::complete(vec![
198            CheckRunEvidence {
199                name: "ci/build".to_string(),
200                conclusion: CheckConclusion::Success,
201                app_slug: None,
202            },
203            CheckRunEvidence {
204                name: "ci/test".to_string(),
205                conclusion: CheckConclusion::Success,
206                app_slug: None,
207            },
208        ])
209    }
210
211    fn failing_checks() -> EvidenceState<Vec<CheckRunEvidence>> {
212        EvidenceState::complete(vec![
213            CheckRunEvidence {
214                name: "ci/build".to_string(),
215                conclusion: CheckConclusion::Success,
216                app_slug: None,
217            },
218            CheckRunEvidence {
219                name: "ci/test".to_string(),
220                conclusion: CheckConclusion::Failure,
221                app_slug: None,
222            },
223        ])
224    }
225
226    #[test]
227    fn not_applicable_when_no_changes() {
228        let evidence = EvidenceBundle {
229            check_runs: passing_checks(),
230            ..Default::default()
231        };
232        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
233        assert_eq!(findings.len(), 1);
234        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
235    }
236
237    #[test]
238    fn satisfied_when_checks_pass_and_independent_review() {
239        let evidence = EvidenceBundle {
240            change_requests: vec![make_approved_change()],
241            check_runs: passing_checks(),
242            ..Default::default()
243        };
244        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
245        assert_eq!(findings.len(), 1);
246        assert_eq!(findings[0].status, ControlStatus::Satisfied);
247        assert!(
248            findings[0]
249                .rationale
250                .contains("Technical controls were enforced")
251        );
252    }
253
254    #[test]
255    fn violated_when_checks_fail() {
256        let evidence = EvidenceBundle {
257            change_requests: vec![make_approved_change()],
258            check_runs: failing_checks(),
259            ..Default::default()
260        };
261        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
262        assert_eq!(findings.len(), 1);
263        assert_eq!(findings[0].status, ControlStatus::Violated);
264        assert!(findings[0].rationale.contains("CI check(s) failed"));
265    }
266
267    #[test]
268    fn violated_when_no_independent_review() {
269        let change = make_change(
270            EvidenceState::complete(vec![ApprovalDecision {
271                actor: "author".to_string(), // self-approval
272                disposition: ApprovalDisposition::Approved,
273                submitted_at: None,
274            }]),
275            EvidenceState::complete(vec![SourceRevision {
276                id: "abc123".to_string(),
277                authored_by: Some("author".to_string()),
278                committed_at: None,
279                merge: false,
280                authenticity: EvidenceState::not_applicable(),
281            }]),
282        );
283        let evidence = EvidenceBundle {
284            change_requests: vec![change],
285            check_runs: passing_checks(),
286            ..Default::default()
287        };
288        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
289        assert_eq!(findings.len(), 1);
290        assert_eq!(findings[0].status, ControlStatus::Violated);
291        assert!(findings[0].rationale.contains("no independent review"));
292    }
293
294    #[test]
295    fn violated_when_no_checks_executed() {
296        let evidence = EvidenceBundle {
297            change_requests: vec![make_approved_change()],
298            check_runs: EvidenceState::complete(vec![]),
299            ..Default::default()
300        };
301        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
302        assert_eq!(findings.len(), 1);
303        assert_eq!(findings[0].status, ControlStatus::Violated);
304        assert!(findings[0].rationale.contains("no CI checks were executed"));
305    }
306
307    #[test]
308    fn violated_reports_both_gaps() {
309        let change = make_change(
310            EvidenceState::complete(vec![]), // no approvals
311            EvidenceState::complete(vec![]),
312        );
313        let evidence = EvidenceBundle {
314            change_requests: vec![change],
315            check_runs: EvidenceState::complete(vec![]), // no checks
316            ..Default::default()
317        };
318        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
319        assert_eq!(findings[0].status, ControlStatus::Violated);
320        assert!(findings[0].rationale.contains("no CI checks"));
321        assert!(findings[0].rationale.contains("no independent review"));
322    }
323
324    #[test]
325    fn indeterminate_when_check_runs_missing() {
326        let evidence = EvidenceBundle {
327            change_requests: vec![make_approved_change()],
328            check_runs: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
329                source: "github".to_string(),
330                subject: "check-runs".to_string(),
331                detail: "API returned 403".to_string(),
332            }]),
333            ..Default::default()
334        };
335        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
336        assert_eq!(findings.len(), 1);
337        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
338    }
339
340    #[test]
341    fn indeterminate_when_approvals_missing() {
342        let change = make_change(
343            EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
344                source: "github".to_string(),
345                subject: "reviews".to_string(),
346                detail: "API returned 403".to_string(),
347            }]),
348            EvidenceState::complete(vec![]),
349        );
350        let evidence = EvidenceBundle {
351            change_requests: vec![change],
352            check_runs: passing_checks(),
353            ..Default::default()
354        };
355        let findings = BranchProtectionEnforcementControl.evaluate(&evidence);
356        assert_eq!(findings.len(), 1);
357        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
358    }
359
360    #[test]
361    fn correct_control_id() {
362        assert_eq!(
363            BranchProtectionEnforcementControl.id(),
364            builtin::id(builtin::BRANCH_PROTECTION_ENFORCEMENT)
365        );
366    }
367}