1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::build_provenance_severity;
4use crate::verdict::Severity;
5
6pub struct BuildProvenanceControl;
8
9impl Control for BuildProvenanceControl {
10 fn id(&self) -> ControlId {
11 builtin::id(builtin::BUILD_PROVENANCE)
12 }
13
14 fn description(&self) -> &'static str {
15 "Artifacts must have verified SLSA provenance"
16 }
17
18 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
19 let id = self.id();
20
21 match &evidence.artifact_attestations {
22 EvidenceState::NotApplicable => {
23 vec![ControlFinding::not_applicable(
24 id,
25 "No artifact attestations apply to this context",
26 )]
27 }
28 EvidenceState::Missing { gaps } => {
29 vec![ControlFinding::indeterminate(
30 id,
31 "Artifact attestation 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 artifact attestations were present",
41 )];
42 }
43
44 let subjects: Vec<String> = value.iter().map(|a| a.subject.clone()).collect();
45
46 let unverified: Vec<&str> = value
47 .iter()
48 .filter(|a| !a.verification.is_verified())
49 .map(|a| a.subject.as_str())
50 .collect();
51
52 let finding = match build_provenance_severity(unverified.len()) {
53 Severity::Pass => ControlFinding::satisfied(
54 id,
55 format!(
56 "All {} artifact attestation(s) are cryptographically verified",
57 value.len()
58 ),
59 subjects,
60 ),
61 _ => ControlFinding::violated(
62 id,
63 format!(
64 "Unverified artifact attestation(s): {}",
65 unverified.join(", ")
66 ),
67 subjects,
68 ),
69 };
70 vec![finding]
71 }
72 }
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use crate::control::ControlStatus;
80 use crate::evidence::{ArtifactAttestation, EvidenceGap, VerificationOutcome};
81
82 fn make_attestation(subject: &str, verified: bool) -> ArtifactAttestation {
83 ArtifactAttestation {
84 subject: subject.to_string(),
85 subject_digest: None,
86 predicate_type: "https://slsa.dev/provenance/v1".to_string(),
87 signer_workflow: Some(".github/workflows/release.yml".to_string()),
88 source_repo: Some("owner/repo".to_string()),
89 verification: if verified {
90 VerificationOutcome::Verified
91 } else {
92 VerificationOutcome::SignatureInvalid {
93 detail: "signature mismatch".to_string(),
94 }
95 },
96 }
97 }
98
99 #[test]
100 fn not_applicable_when_evidence_state_is_not_applicable() {
101 let evidence = EvidenceBundle {
102 artifact_attestations: EvidenceState::not_applicable(),
103 ..Default::default()
104 };
105 let findings = BuildProvenanceControl.evaluate(&evidence);
106 assert_eq!(findings.len(), 1);
107 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
108 assert_eq!(
109 findings[0].control_id,
110 builtin::id(builtin::BUILD_PROVENANCE)
111 );
112 }
113
114 #[test]
115 fn indeterminate_when_evidence_missing() {
116 let evidence = EvidenceBundle {
117 artifact_attestations: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
118 source: "gh-attestation".to_string(),
119 subject: "binary".to_string(),
120 detail: "API returned 403".to_string(),
121 }]),
122 ..Default::default()
123 };
124 let findings = BuildProvenanceControl.evaluate(&evidence);
125 assert_eq!(findings.len(), 1);
126 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
127 assert_eq!(findings[0].evidence_gaps.len(), 1);
128 }
129
130 #[test]
131 fn not_applicable_when_attestation_list_empty() {
132 let evidence = EvidenceBundle {
133 artifact_attestations: EvidenceState::complete(vec![]),
134 ..Default::default()
135 };
136 let findings = BuildProvenanceControl.evaluate(&evidence);
137 assert_eq!(findings.len(), 1);
138 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
139 }
140
141 #[test]
142 fn satisfied_when_all_verified() {
143 let evidence = EvidenceBundle {
144 artifact_attestations: EvidenceState::complete(vec![
145 make_attestation("ghcr.io/owner/app:v1.0.0", true),
146 make_attestation("gh-verify-linux-amd64", true),
147 ]),
148 ..Default::default()
149 };
150 let findings = BuildProvenanceControl.evaluate(&evidence);
151 assert_eq!(findings.len(), 1);
152 assert_eq!(findings[0].status, ControlStatus::Satisfied);
153 assert_eq!(findings[0].subjects.len(), 2);
154 }
155
156 #[test]
157 fn violated_when_any_unverified() {
158 let evidence = EvidenceBundle {
159 artifact_attestations: EvidenceState::complete(vec![
160 make_attestation("ghcr.io/owner/app:v1.0.0", true),
161 make_attestation("gh-verify-linux-amd64", false),
162 ]),
163 ..Default::default()
164 };
165 let findings = BuildProvenanceControl.evaluate(&evidence);
166 assert_eq!(findings.len(), 1);
167 assert_eq!(findings[0].status, ControlStatus::Violated);
168 assert!(findings[0].rationale.contains("gh-verify-linux-amd64"));
169 assert_eq!(findings[0].subjects.len(), 2);
171 }
172
173 #[test]
174 fn violated_when_all_unverified() {
175 let evidence = EvidenceBundle {
176 artifact_attestations: EvidenceState::complete(vec![
177 make_attestation("artifact-a", false),
178 make_attestation("artifact-b", false),
179 ]),
180 ..Default::default()
181 };
182 let findings = BuildProvenanceControl.evaluate(&evidence);
183 assert_eq!(findings.len(), 1);
184 assert_eq!(findings[0].status, ControlStatus::Violated);
185 assert!(findings[0].rationale.contains("artifact-a"));
186 assert!(findings[0].rationale.contains("artifact-b"));
187 }
188
189 #[test]
190 fn single_verified_attestation_satisfied() {
191 let evidence = EvidenceBundle {
192 artifact_attestations: EvidenceState::complete(vec![make_attestation(
193 "single-binary",
194 true,
195 )]),
196 ..Default::default()
197 };
198 let findings = BuildProvenanceControl.evaluate(&evidence);
199 assert_eq!(findings[0].status, ControlStatus::Satisfied);
200 assert_eq!(findings[0].subjects, vec!["single-binary"]);
201 }
202
203 #[test]
204 fn partial_evidence_with_verified_attestations_satisfied() {
205 let evidence = EvidenceBundle {
206 artifact_attestations: EvidenceState::partial(
207 vec![make_attestation("partial-binary", true)],
208 vec![EvidenceGap::Truncated {
209 source: "gh-attestation".to_string(),
210 subject: "attestation-list".to_string(),
211 }],
212 ),
213 ..Default::default()
214 };
215 let findings = BuildProvenanceControl.evaluate(&evidence);
216 assert_eq!(findings[0].status, ControlStatus::Satisfied);
217 }
218
219 #[test]
220 fn partial_evidence_with_unverified_attestation_violated() {
221 let evidence = EvidenceBundle {
222 artifact_attestations: EvidenceState::partial(
223 vec![make_attestation("partial-binary", false)],
224 vec![EvidenceGap::Truncated {
225 source: "gh-attestation".to_string(),
226 subject: "attestation-list".to_string(),
227 }],
228 ),
229 ..Default::default()
230 };
231 let findings = BuildProvenanceControl.evaluate(&evidence);
232 assert_eq!(findings[0].status, ControlStatus::Violated);
233 }
234
235 #[test]
236 fn partial_evidence_with_empty_list_not_applicable() {
237 let evidence = EvidenceBundle {
238 artifact_attestations: EvidenceState::partial(
239 vec![],
240 vec![EvidenceGap::Truncated {
241 source: "gh-attestation".to_string(),
242 subject: "attestation-list".to_string(),
243 }],
244 ),
245 ..Default::default()
246 };
247 let findings = BuildProvenanceControl.evaluate(&evidence);
248 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
249 }
250
251 #[test]
252 fn correct_control_id() {
253 assert_eq!(
254 BuildProvenanceControl.id(),
255 builtin::id(builtin::BUILD_PROVENANCE)
256 );
257 }
258}