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