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
8pub 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 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 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(), 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![]), EvidenceState::complete(vec![]),
312 );
313 let evidence = EvidenceBundle {
314 change_requests: vec![change],
315 check_runs: EvidenceState::complete(vec![]), ..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}