Skip to main content

libverify_core/controls/
container_provenance.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4/// Verifies that container images have SLSA provenance attestations.
5///
6/// Maps to SOC2 PI1.4: processing integrity through artifact provenance.
7/// SLSA provenance attestations bind container images to the source commit
8/// and build workflow that produced them, enabling consumers to verify the
9/// build origin and integrity of the image.
10///
11/// Evaluation tiers:
12/// - **Satisfied**: all container images have provenance attestations
13/// - **Violated**: some container images lack provenance attestations
14/// - **NotApplicable**: no container images in evidence
15pub 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}