libverify_core/controls/
container_signature.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4pub 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}