Skip to main content

libverify_core/controls/
container_signature.rs

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