1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, RegistryProvenanceCapability};
3
4pub struct DependencySignerVerifiedControl;
19
20impl Control for DependencySignerVerifiedControl {
21 fn id(&self) -> ControlId {
22 builtin::id(builtin::DEPENDENCY_SIGNER_VERIFIED)
23 }
24
25 fn description(&self) -> &'static str {
26 "All dependencies must have verified signer identity and transparency log"
27 }
28
29 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
30 let id = self.id();
31
32 match &evidence.dependency_signatures {
33 EvidenceState::NotApplicable => {
34 vec![ControlFinding::not_applicable(
35 id,
36 "Dependency evidence is not applicable",
37 )]
38 }
39 EvidenceState::Missing { gaps } => {
40 vec![ControlFinding::indeterminate(
41 id,
42 "Dependency evidence could not be collected",
43 Vec::new(),
44 gaps.clone(),
45 )]
46 }
47 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
48 if value.is_empty() {
49 return vec![ControlFinding::not_applicable(
50 id,
51 "No dependencies were present",
52 )];
53 }
54
55 let in_scope: Vec<_> = value
57 .iter()
58 .filter(|d| {
59 d.registry_provenance_capability()
60 >= RegistryProvenanceCapability::FullTrustChain
61 })
62 .collect();
63
64 let skipped = value.len() - in_scope.len();
65
66 if in_scope.is_empty() {
67 return vec![ControlFinding::not_applicable(
68 id,
69 format!(
70 "No dependencies from registries with full trust chain support \
71 ({skipped} dependenc(ies) from other registries skipped)",
72 ),
73 )];
74 }
75
76 let subjects: Vec<String> = in_scope
77 .iter()
78 .map(|d| format!("{}@{}", d.name, d.version))
79 .collect();
80
81 let lacking: Vec<String> = in_scope
82 .iter()
83 .filter(|d| {
84 !d.verification.is_cryptographically_signed()
85 || d.signer_identity.is_none()
86 || d.transparency_log_uri.is_none()
87 })
88 .map(|d| {
89 let mut reasons = Vec::new();
90 if !d.verification.is_cryptographically_signed() {
91 reasons.push("no signature");
92 }
93 if d.signer_identity.is_none() {
94 reasons.push("no signer_identity");
95 }
96 if d.transparency_log_uri.is_none() {
97 reasons.push("no transparency_log");
98 }
99 format!("{}@{} ({})", d.name, d.version, reasons.join(", "))
100 })
101 .collect();
102
103 let gaps = evidence.dependency_signatures.gaps();
104 let gap_suffix = if gaps.is_empty() {
105 String::new()
106 } else {
107 format!(" (WARNING: {} evidence gap(s))", gaps.len())
108 };
109
110 let skip_note = if skipped > 0 {
111 format!(" [{skipped} non-L3 registr(ies) skipped]")
112 } else {
113 String::new()
114 };
115
116 if lacking.is_empty() {
117 let mut finding = ControlFinding::satisfied(
118 id,
119 format!(
120 "All {} dependenc(ies) have verified signer identity with transparency log{}{}",
121 in_scope.len(),
122 skip_note,
123 gap_suffix,
124 ),
125 subjects,
126 );
127 if !gaps.is_empty() {
128 finding.evidence_gaps = gaps.to_vec();
129 }
130 vec![finding]
131 } else {
132 let mut finding = ControlFinding::violated(
133 id,
134 format!(
135 "Dependenc(ies) lacking signer verification: {}{}{}",
136 lacking.join("; "),
137 skip_note,
138 gap_suffix,
139 ),
140 subjects,
141 );
142 if !gaps.is_empty() {
143 finding.evidence_gaps = gaps.to_vec();
144 }
145 vec![finding]
146 }
147 }
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::control::ControlStatus;
156 use crate::evidence::{DependencySignatureEvidence, VerificationOutcome};
157
158 fn npm_dep_full(name: &str) -> DependencySignatureEvidence {
159 DependencySignatureEvidence {
160 name: name.to_string(),
161 version: "1.0.0".to_string(),
162 registry: Some("registry.npmjs.org".to_string()),
163 verification: VerificationOutcome::Verified,
164 signature_mechanism: Some("sigstore".to_string()),
165 signer_identity: Some("https://github.com/login/oauth".to_string()),
166 source_repo: Some("owner/repo".to_string()),
167 source_commit: Some("abc123".to_string()),
168 pinned_digest: None,
169 actual_digest: None,
170 transparency_log_uri: Some(
171 "https://rekor.sigstore.dev/api/v1/log/entries/abc".to_string(),
172 ),
173 is_direct: true,
174 }
175 }
176
177 fn npm_dep_no_signer(name: &str) -> DependencySignatureEvidence {
178 let mut d = npm_dep_full(name);
179 d.signer_identity = None;
180 d
181 }
182
183 fn npm_dep_no_tlog(name: &str) -> DependencySignatureEvidence {
184 let mut d = npm_dep_full(name);
185 d.transparency_log_uri = None;
186 d
187 }
188
189 fn cargo_dep(name: &str) -> DependencySignatureEvidence {
190 DependencySignatureEvidence {
191 name: name.to_string(),
192 version: "1.0.0".to_string(),
193 registry: Some("crates.io".to_string()),
194 verification: VerificationOutcome::ChecksumMatch,
195 signature_mechanism: Some("checksum".to_string()),
196 signer_identity: None,
197 source_repo: None,
198 source_commit: None,
199 pinned_digest: Some("sha256:abc".to_string()),
200 actual_digest: None,
201 transparency_log_uri: None,
202 is_direct: true,
203 }
204 }
205
206 fn bundle(deps: Vec<DependencySignatureEvidence>) -> EvidenceBundle {
207 EvidenceBundle {
208 dependency_signatures: EvidenceState::complete(deps),
209 ..Default::default()
210 }
211 }
212
213 #[test]
214 fn satisfied_with_full_trust_chain() {
215 let findings = DependencySignerVerifiedControl.evaluate(&bundle(vec![
216 npm_dep_full("react"),
217 npm_dep_full("express"),
218 ]));
219 assert_eq!(findings[0].status, ControlStatus::Satisfied);
220 }
221
222 #[test]
223 fn violated_when_signer_identity_missing() {
224 let findings =
225 DependencySignerVerifiedControl.evaluate(&bundle(vec![npm_dep_no_signer("lodash")]));
226 assert_eq!(findings[0].status, ControlStatus::Violated);
227 assert!(findings[0].rationale.contains("no signer_identity"));
228 }
229
230 #[test]
231 fn violated_when_transparency_log_missing() {
232 let findings =
233 DependencySignerVerifiedControl.evaluate(&bundle(vec![npm_dep_no_tlog("lodash")]));
234 assert_eq!(findings[0].status, ControlStatus::Violated);
235 assert!(findings[0].rationale.contains("no transparency_log"));
236 }
237
238 #[test]
239 fn not_applicable_when_only_cargo_deps() {
240 let findings = DependencySignerVerifiedControl.evaluate(&bundle(vec![cargo_dep("serde")]));
241 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
242 assert!(findings[0].rationale.contains("skipped"));
243 }
244
245 #[test]
246 fn mixed_registries_only_evaluates_npm() {
247 let evidence = bundle(vec![cargo_dep("serde"), npm_dep_full("react")]);
248 let findings = DependencySignerVerifiedControl.evaluate(&evidence);
249 assert_eq!(findings[0].status, ControlStatus::Satisfied);
250 assert!(findings[0].rationale.contains("1 dependenc(ies)"));
251 }
252
253 #[test]
254 fn indeterminate_when_evidence_missing() {
255 let evidence = EvidenceBundle {
256 dependency_signatures: EvidenceState::missing(vec![
257 crate::evidence::EvidenceGap::CollectionFailed {
258 source: "registry".to_string(),
259 subject: "deps".to_string(),
260 detail: "timeout".to_string(),
261 },
262 ]),
263 ..Default::default()
264 };
265 let findings = DependencySignerVerifiedControl.evaluate(&evidence);
266 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
267 assert_eq!(findings[0].evidence_gaps.len(), 1);
268 }
269
270 #[test]
271 fn partial_evidence_propagates_gaps_in_rationale() {
272 let evidence = EvidenceBundle {
273 dependency_signatures: EvidenceState::partial(
274 vec![npm_dep_full("react")],
275 vec![crate::evidence::EvidenceGap::Truncated {
276 source: "tree-api".to_string(),
277 subject: "repo-tree".to_string(),
278 }],
279 ),
280 ..Default::default()
281 };
282 let findings = DependencySignerVerifiedControl.evaluate(&evidence);
283 assert!(
284 findings[0].rationale.contains("evidence gap"),
285 "rationale should warn about gaps: {}",
286 findings[0].rationale
287 );
288 assert_eq!(findings[0].evidence_gaps.len(), 1);
289 }
290
291 #[test]
292 fn violated_when_both_signer_and_tlog_missing() {
293 let mut d = npm_dep_full("pkg");
294 d.signer_identity = None;
295 d.transparency_log_uri = None;
296 let findings = DependencySignerVerifiedControl.evaluate(&bundle(vec![d]));
297 assert_eq!(findings[0].status, ControlStatus::Violated);
298 assert!(findings[0].rationale.contains("no signer_identity"));
299 assert!(findings[0].rationale.contains("no transparency_log"));
300 }
301}