1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::provenance_authenticity_severity;
4use crate::verdict::Severity;
5
6pub struct ProvenanceAuthenticityControl;
9
10impl Control for ProvenanceAuthenticityControl {
11 fn id(&self) -> ControlId {
12 builtin::id(builtin::PROVENANCE_AUTHENTICITY)
13 }
14
15 fn description(&self) -> &'static str {
16 "Provenance attestation must be cryptographically signed"
17 }
18
19 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
20 let id = self.id();
21
22 match &evidence.artifact_attestations {
23 EvidenceState::NotApplicable => {
24 vec![ControlFinding::not_applicable(
25 id,
26 "Artifact attestation evidence is not applicable",
27 )]
28 }
29 EvidenceState::Missing { gaps } => {
30 vec![ControlFinding::indeterminate(
31 id,
32 "Artifact attestation evidence could not be collected",
33 Vec::new(),
34 gaps.clone(),
35 )]
36 }
37 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
38 if value.is_empty() {
39 return vec![ControlFinding::not_applicable(
40 id,
41 "No artifact attestations were present",
42 )];
43 }
44
45 let subjects: Vec<String> = value.iter().map(|a| a.subject.clone()).collect();
46
47 let unauthenticated: Vec<String> = value
48 .iter()
49 .filter(|a| !a.verification.is_verified() || a.signer_workflow.is_none())
50 .map(|a| {
51 let mut reasons = Vec::new();
52 if !a.verification.is_verified() {
53 if let Some(kind) = a.verification.failure_kind() {
54 reasons.push(kind);
55 } else {
56 reasons.push("unverified");
57 }
58 }
59 if a.signer_workflow.is_none() {
60 reasons.push("no signer info");
61 }
62 format!("{} ({})", a.subject, reasons.join(", "))
63 })
64 .collect();
65
66 let finding = match provenance_authenticity_severity(unauthenticated.len()) {
67 Severity::Pass => ControlFinding::satisfied(
68 id,
69 format!(
70 "All {} attestation(s) are verified with traceable signer",
71 value.len()
72 ),
73 subjects,
74 ),
75 _ => ControlFinding::violated(
76 id,
77 format!(
78 "Unauthenticated attestation(s): {}",
79 unauthenticated.join("; ")
80 ),
81 subjects,
82 ),
83 };
84 vec![finding]
85 }
86 }
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::control::ControlStatus;
94 use crate::evidence::{ArtifactAttestation, EvidenceGap, VerificationOutcome};
95
96 fn make_attestation(
97 subject: &str,
98 verified: bool,
99 signer_workflow: Option<&str>,
100 ) -> ArtifactAttestation {
101 ArtifactAttestation {
102 subject: subject.to_string(),
103 subject_digest: None,
104 predicate_type: "https://slsa.dev/provenance/v1".to_string(),
105 signer_workflow: signer_workflow.map(|s| s.to_string()),
106 source_repo: Some("owner/repo".to_string()),
107 verification: if verified {
108 VerificationOutcome::Verified
109 } else {
110 VerificationOutcome::SignatureInvalid {
111 detail: "signature mismatch".to_string(),
112 }
113 },
114 }
115 }
116
117 fn make_bundle(attestations: Vec<ArtifactAttestation>) -> EvidenceBundle {
118 EvidenceBundle {
119 artifact_attestations: EvidenceState::complete(attestations),
120 ..Default::default()
121 }
122 }
123
124 #[test]
127 fn not_applicable_when_evidence_state_is_not_applicable() {
128 let evidence = EvidenceBundle {
129 artifact_attestations: EvidenceState::not_applicable(),
130 ..Default::default()
131 };
132 let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
133 assert_eq!(findings.len(), 1);
134 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
135 assert_eq!(
136 findings[0].control_id,
137 builtin::id(builtin::PROVENANCE_AUTHENTICITY)
138 );
139 }
140
141 #[test]
142 fn not_applicable_when_attestation_list_empty() {
143 let findings = ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![]));
144 assert_eq!(findings.len(), 1);
145 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
146 }
147
148 #[test]
151 fn indeterminate_when_evidence_missing() {
152 let evidence = EvidenceBundle {
153 artifact_attestations: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
154 source: "gh-attestation".to_string(),
155 subject: "binary".to_string(),
156 detail: "API returned 403".to_string(),
157 }]),
158 ..Default::default()
159 };
160 let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
161 assert_eq!(findings.len(), 1);
162 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
163 assert_eq!(findings[0].evidence_gaps.len(), 1);
164 }
165
166 #[test]
169 fn satisfied_when_all_verified_with_signer() {
170 let findings = ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![
171 make_attestation("app:v1.0", true, Some(".github/workflows/release.yml")),
172 make_attestation("app:v1.1", true, Some(".github/workflows/ci.yml")),
173 ]));
174 assert_eq!(findings.len(), 1);
175 assert_eq!(findings[0].status, ControlStatus::Satisfied);
176 assert_eq!(findings[0].subjects.len(), 2);
177 assert!(
178 findings[0]
179 .rationale
180 .contains("2 attestation(s) are verified")
181 );
182 }
183
184 #[test]
185 fn satisfied_single_attestation() {
186 let findings =
187 ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![make_attestation(
188 "binary",
189 true,
190 Some(".github/workflows/release.yml"),
191 )]));
192 assert_eq!(findings[0].status, ControlStatus::Satisfied);
193 assert_eq!(findings[0].subjects, vec!["binary"]);
194 }
195
196 #[test]
199 fn violated_when_attestation_not_verified() {
200 let findings = ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![
201 make_attestation("app:v1.0", true, Some(".github/workflows/release.yml")),
202 make_attestation("app:v1.1", false, Some(".github/workflows/ci.yml")),
203 ]));
204 assert_eq!(findings.len(), 1);
205 assert_eq!(findings[0].status, ControlStatus::Violated);
206 assert!(findings[0].rationale.contains("app:v1.1"));
207 assert!(findings[0].rationale.contains("signature_invalid"));
208 }
209
210 #[test]
211 fn violated_when_signer_workflow_missing() {
212 let findings = ProvenanceAuthenticityControl
213 .evaluate(&make_bundle(vec![make_attestation("app:v1.0", true, None)]));
214 assert_eq!(findings.len(), 1);
215 assert_eq!(findings[0].status, ControlStatus::Violated);
216 assert!(findings[0].rationale.contains("app:v1.0"));
217 assert!(findings[0].rationale.contains("no signer info"));
218 }
219
220 #[test]
221 fn violated_when_both_unverified_and_no_signer() {
222 let findings =
223 ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![make_attestation(
224 "app:v1.0", false, None,
225 )]));
226 assert_eq!(findings[0].status, ControlStatus::Violated);
227 assert!(findings[0].rationale.contains("signature_invalid"));
228 assert!(findings[0].rationale.contains("no signer info"));
229 }
230
231 #[test]
234 fn partial_evidence_with_authenticated_attestations_satisfied() {
235 let evidence = EvidenceBundle {
236 artifact_attestations: EvidenceState::partial(
237 vec![make_attestation(
238 "partial-binary",
239 true,
240 Some(".github/workflows/release.yml"),
241 )],
242 vec![EvidenceGap::Truncated {
243 source: "gh-attestation".to_string(),
244 subject: "attestation-list".to_string(),
245 }],
246 ),
247 ..Default::default()
248 };
249 let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
250 assert_eq!(findings[0].status, ControlStatus::Satisfied);
251 }
252
253 #[test]
254 fn partial_evidence_with_unauthenticated_attestation_violated() {
255 let evidence = EvidenceBundle {
256 artifact_attestations: EvidenceState::partial(
257 vec![make_attestation("partial-binary", false, None)],
258 vec![EvidenceGap::Truncated {
259 source: "gh-attestation".to_string(),
260 subject: "attestation-list".to_string(),
261 }],
262 ),
263 ..Default::default()
264 };
265 let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
266 assert_eq!(findings[0].status, ControlStatus::Violated);
267 }
268
269 #[test]
270 fn correct_control_id() {
271 assert_eq!(
272 ProvenanceAuthenticityControl.id(),
273 builtin::id(builtin::PROVENANCE_AUTHENTICITY)
274 );
275 }
276}