libverify_core/controls/
container_provenance.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4pub struct ContainerProvenanceControl;
16
17impl Control for ContainerProvenanceControl {
18 fn id(&self) -> ControlId {
19 builtin::id(builtin::CONTAINER_PROVENANCE)
20 }
21
22 fn description(&self) -> &'static str {
23 "Container images must include SLSA provenance attestation (requires external evidence)"
24 }
25
26 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
27 let id = self.id();
28
29 match &evidence.container_images {
30 EvidenceState::NotApplicable => {
31 vec![ControlFinding::not_applicable(
32 id,
33 "Container image evidence is not applicable",
34 )]
35 }
36 EvidenceState::Missing { gaps } => {
37 vec![ControlFinding::indeterminate(
38 id,
39 "Container image evidence could not be collected",
40 Vec::new(),
41 gaps.clone(),
42 )]
43 }
44 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
45 if value.is_empty() {
46 return vec![ControlFinding::not_applicable(
47 id,
48 "No container images were present",
49 )];
50 }
51
52 let gaps = match &evidence.container_images {
53 EvidenceState::Partial { gaps, .. } => gaps.as_slice(),
54 _ => &[],
55 };
56
57 let missing_provenance: Vec<&str> = value
58 .iter()
59 .filter(|img| !img.provenance_present)
60 .map(|img| img.reference.as_str())
61 .collect();
62
63 let gap_suffix = if gaps.is_empty() {
64 String::new()
65 } else {
66 format!(
67 " (WARNING: {} evidence gap(s) — images without provenance may be hidden)",
68 gaps.len()
69 )
70 };
71
72 let mut finding = if missing_provenance.is_empty() {
73 ControlFinding::satisfied(
74 id,
75 format!(
76 "All {} container image(s) have SLSA provenance attestations{}",
77 value.len(),
78 gap_suffix,
79 ),
80 value.iter().map(|img| img.reference.clone()).collect(),
81 )
82 } else {
83 ControlFinding::violated(
84 id,
85 format!(
86 "Container image(s) missing SLSA provenance: {}{}",
87 missing_provenance.join("; "),
88 gap_suffix,
89 ),
90 value.iter().map(|img| img.reference.clone()).collect(),
91 )
92 };
93
94 if !gaps.is_empty() {
95 finding.evidence_gaps = gaps.to_vec();
96 }
97
98 vec![finding]
99 }
100 }
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::control::ControlStatus;
108 use crate::evidence::{ContainerImageEvidence, EvidenceGap, VerificationOutcome};
109
110 fn make_image(reference: &str, has_provenance: bool) -> ContainerImageEvidence {
111 ContainerImageEvidence {
112 reference: reference.to_string(),
113 digest: Some("sha256:abcdef1234567890".to_string()),
114 signature_verified: true,
115 provenance_present: has_provenance,
116 sbom_present: false,
117 signer_identity: None,
118 source_repo: if has_provenance {
119 Some("owner/repo".to_string())
120 } else {
121 None
122 },
123 verification: VerificationOutcome::Verified,
124 }
125 }
126
127 fn make_bundle(images: Vec<ContainerImageEvidence>) -> EvidenceBundle {
128 EvidenceBundle {
129 container_images: EvidenceState::complete(images),
130 ..Default::default()
131 }
132 }
133
134 #[test]
135 fn not_applicable_when_evidence_not_applicable() {
136 let evidence = EvidenceBundle::default();
137 let findings = ContainerProvenanceControl.evaluate(&evidence);
138 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
139 }
140
141 #[test]
142 fn not_applicable_when_empty_list() {
143 let findings = ContainerProvenanceControl.evaluate(&make_bundle(vec![]));
144 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
145 }
146
147 #[test]
148 fn indeterminate_when_missing() {
149 let evidence = EvidenceBundle {
150 container_images: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
151 source: "container-registry".to_string(),
152 subject: "images".to_string(),
153 detail: "registry unreachable".to_string(),
154 }]),
155 ..Default::default()
156 };
157 let findings = ContainerProvenanceControl.evaluate(&evidence);
158 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
159 assert_eq!(findings[0].evidence_gaps.len(), 1);
160 }
161
162 #[test]
163 fn satisfied_when_all_have_provenance() {
164 let findings = ContainerProvenanceControl.evaluate(&make_bundle(vec![
165 make_image("ghcr.io/owner/repo:v1.0.0", true),
166 make_image("ghcr.io/owner/repo:v1.0.1", true),
167 ]));
168 assert_eq!(findings[0].status, ControlStatus::Satisfied);
169 assert!(
170 findings[0]
171 .rationale
172 .contains("2 container image(s) have SLSA provenance")
173 );
174 }
175
176 #[test]
177 fn violated_when_missing_provenance() {
178 let findings = ContainerProvenanceControl.evaluate(&make_bundle(vec![
179 make_image("ghcr.io/owner/repo:v1.0.0", true),
180 make_image("ghcr.io/owner/repo:latest", false),
181 ]));
182 assert_eq!(findings[0].status, ControlStatus::Violated);
183 assert!(findings[0].rationale.contains("ghcr.io/owner/repo:latest"));
184 }
185
186 #[test]
187 fn violated_when_all_missing_provenance() {
188 let findings = ContainerProvenanceControl.evaluate(&make_bundle(vec![make_image(
189 "ghcr.io/owner/repo:v1",
190 false,
191 )]));
192 assert_eq!(findings[0].status, ControlStatus::Violated);
193 }
194
195 #[test]
196 fn partial_evidence_with_gaps() {
197 let evidence = EvidenceBundle {
198 container_images: EvidenceState::partial(
199 vec![make_image("ghcr.io/owner/repo:v1.0.0", true)],
200 vec![EvidenceGap::Truncated {
201 source: "container-registry".to_string(),
202 subject: "image-list".to_string(),
203 }],
204 ),
205 ..Default::default()
206 };
207 let findings = ContainerProvenanceControl.evaluate(&evidence);
208 assert_eq!(findings[0].status, ControlStatus::Satisfied);
209 assert!(findings[0].rationale.contains("evidence gap"));
210 assert_eq!(findings[0].evidence_gaps.len(), 1);
211 }
212
213 #[test]
214 fn correct_control_id() {
215 assert_eq!(
216 ContainerProvenanceControl.id(),
217 builtin::id(builtin::CONTAINER_PROVENANCE)
218 );
219 }
220}