Skip to main content

libverify_core/controls/
build_isolation.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::build_isolation_severity;
4use crate::verdict::Severity;
5
6/// Verifies that builds run in isolated, ephemeral environments with signing key isolation.
7pub 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    // --- NotApplicable ---
115
116    #[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    // --- Indeterminate ---
139
140    #[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    // --- Satisfied ---
157
158    #[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    // --- Violated ---
183
184    #[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    // --- Edge cases ---
249
250    #[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}