1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::build_isolation_severity;
4use crate::verdict::Severity;
5
6pub struct BuildIsolationControl;
8
9impl Control for BuildIsolationControl {
10 fn id(&self) -> ControlId {
11 builtin::id(builtin::BUILD_ISOLATION)
12 }
13
14 fn description(&self) -> &'static str {
15 "Build must run in an isolated, ephemeral environment"
16 }
17
18 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
19 let id = self.id();
20
21 match &evidence.build_platform {
22 EvidenceState::NotApplicable => {
23 vec![ControlFinding::not_applicable(
24 id,
25 "Build platform evidence is not applicable",
26 )]
27 }
28 EvidenceState::Missing { gaps } => {
29 vec![ControlFinding::indeterminate(
30 id,
31 "Build platform evidence could not be collected",
32 Vec::new(),
33 gaps.clone(),
34 )]
35 }
36 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
37 if value.is_empty() {
38 return vec![ControlFinding::not_applicable(
39 id,
40 "No build platform evidence was present",
41 )];
42 }
43
44 let subjects: Vec<String> = value.iter().map(|p| p.platform.clone()).collect();
45
46 let violations: Vec<String> = value
47 .iter()
48 .filter(|p| !p.isolated || !p.ephemeral || !p.signing_key_isolated)
49 .map(|p| {
50 let mut failed = Vec::new();
51 if !p.isolated {
52 failed.push("not isolated");
53 }
54 if !p.ephemeral {
55 failed.push("not ephemeral");
56 }
57 if !p.signing_key_isolated {
58 failed.push("signing key not isolated");
59 }
60 format!("{} ({})", p.platform, failed.join(", "))
61 })
62 .collect();
63
64 let finding = match build_isolation_severity(violations.len()) {
65 Severity::Pass => ControlFinding::satisfied(
66 id,
67 format!(
68 "All {} build platform(s) are isolated, ephemeral, and have signing key isolation",
69 value.len()
70 ),
71 subjects,
72 ),
73 _ => ControlFinding::violated(
74 id,
75 format!("Build isolation violation(s): {}", violations.join("; ")),
76 subjects,
77 ),
78 };
79 vec![finding]
80 }
81 }
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::control::ControlStatus;
89 use crate::evidence::{BuildPlatformEvidence, EvidenceGap};
90
91 fn make_platform(
92 name: &str,
93 isolated: bool,
94 ephemeral: bool,
95 signing_key_isolated: bool,
96 ) -> BuildPlatformEvidence {
97 BuildPlatformEvidence {
98 platform: name.to_string(),
99 hosted: true,
100 ephemeral,
101 isolated,
102 runner_labels: vec!["ubuntu-latest".to_string()],
103 signing_key_isolated,
104 }
105 }
106
107 fn make_bundle(platforms: Vec<BuildPlatformEvidence>) -> EvidenceBundle {
108 EvidenceBundle {
109 build_platform: EvidenceState::complete(platforms),
110 ..Default::default()
111 }
112 }
113
114 #[test]
117 fn not_applicable_when_evidence_state_is_not_applicable() {
118 let evidence = EvidenceBundle {
119 build_platform: EvidenceState::not_applicable(),
120 ..Default::default()
121 };
122 let findings = BuildIsolationControl.evaluate(&evidence);
123 assert_eq!(findings.len(), 1);
124 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
125 assert_eq!(
126 findings[0].control_id,
127 builtin::id(builtin::BUILD_ISOLATION)
128 );
129 }
130
131 #[test]
132 fn not_applicable_when_platform_list_empty() {
133 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![]));
134 assert_eq!(findings.len(), 1);
135 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
136 }
137
138 #[test]
141 fn indeterminate_when_evidence_missing() {
142 let evidence = EvidenceBundle {
143 build_platform: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
144 source: "github".to_string(),
145 subject: "build-platform".to_string(),
146 detail: "API returned 403".to_string(),
147 }]),
148 ..Default::default()
149 };
150 let findings = BuildIsolationControl.evaluate(&evidence);
151 assert_eq!(findings.len(), 1);
152 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
153 assert_eq!(findings[0].evidence_gaps.len(), 1);
154 }
155
156 #[test]
159 fn satisfied_when_all_conditions_met() {
160 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![
161 make_platform("github-actions", true, true, true),
162 make_platform("cloud-build", true, true, true),
163 ]));
164 assert_eq!(findings.len(), 1);
165 assert_eq!(findings[0].status, ControlStatus::Satisfied);
166 assert_eq!(findings[0].subjects.len(), 2);
167 assert!(findings[0].rationale.contains("2 build platform(s)"));
168 }
169
170 #[test]
171 fn satisfied_single_fully_isolated_platform() {
172 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![make_platform(
173 "github-actions",
174 true,
175 true,
176 true,
177 )]));
178 assert_eq!(findings[0].status, ControlStatus::Satisfied);
179 assert_eq!(findings[0].subjects, vec!["github-actions"]);
180 }
181
182 #[test]
185 fn violated_when_not_isolated() {
186 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![make_platform(
187 "shared-runner",
188 false,
189 true,
190 true,
191 )]));
192 assert_eq!(findings[0].status, ControlStatus::Violated);
193 assert!(findings[0].rationale.contains("shared-runner"));
194 assert!(findings[0].rationale.contains("not isolated"));
195 }
196
197 #[test]
198 fn violated_when_not_ephemeral() {
199 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![make_platform(
200 "persistent-runner",
201 true,
202 false,
203 true,
204 )]));
205 assert_eq!(findings[0].status, ControlStatus::Violated);
206 assert!(findings[0].rationale.contains("persistent-runner"));
207 assert!(findings[0].rationale.contains("not ephemeral"));
208 }
209
210 #[test]
211 fn violated_when_signing_key_not_isolated() {
212 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![make_platform(
213 "leaky-runner",
214 true,
215 true,
216 false,
217 )]));
218 assert_eq!(findings[0].status, ControlStatus::Violated);
219 assert!(findings[0].rationale.contains("leaky-runner"));
220 assert!(findings[0].rationale.contains("signing key not isolated"));
221 }
222
223 #[test]
224 fn violated_reports_multiple_failures() {
225 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![make_platform(
226 "bad-runner",
227 false,
228 false,
229 false,
230 )]));
231 assert_eq!(findings[0].status, ControlStatus::Violated);
232 assert!(findings[0].rationale.contains("not isolated"));
233 assert!(findings[0].rationale.contains("not ephemeral"));
234 assert!(findings[0].rationale.contains("signing key not isolated"));
235 }
236
237 #[test]
238 fn violated_when_any_platform_fails() {
239 let findings = BuildIsolationControl.evaluate(&make_bundle(vec![
240 make_platform("github-actions", true, true, true),
241 make_platform("self-hosted", false, false, false),
242 ]));
243 assert_eq!(findings[0].status, ControlStatus::Violated);
244 assert!(findings[0].rationale.contains("self-hosted"));
245 assert_eq!(findings[0].subjects.len(), 2);
246 }
247
248 #[test]
251 fn partial_evidence_with_isolated_platforms_satisfied() {
252 let evidence = EvidenceBundle {
253 build_platform: EvidenceState::partial(
254 vec![make_platform("github-actions", true, true, true)],
255 vec![EvidenceGap::Truncated {
256 source: "github".to_string(),
257 subject: "build-platforms".to_string(),
258 }],
259 ),
260 ..Default::default()
261 };
262 let findings = BuildIsolationControl.evaluate(&evidence);
263 assert_eq!(findings[0].status, ControlStatus::Satisfied);
264 }
265
266 #[test]
267 fn partial_evidence_with_non_isolated_platform_violated() {
268 let evidence = EvidenceBundle {
269 build_platform: EvidenceState::partial(
270 vec![make_platform("shared-runner", false, true, true)],
271 vec![EvidenceGap::Truncated {
272 source: "github".to_string(),
273 subject: "build-platforms".to_string(),
274 }],
275 ),
276 ..Default::default()
277 };
278 let findings = BuildIsolationControl.evaluate(&evidence);
279 assert_eq!(findings[0].status, ControlStatus::Violated);
280 }
281
282 #[test]
283 fn correct_control_id() {
284 assert_eq!(
285 BuildIsolationControl.id(),
286 builtin::id(builtin::BUILD_ISOLATION)
287 );
288 }
289}