1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::dependency_signature_severity;
4use crate::verdict::Severity;
5
6pub struct DependencySignatureControl;
19
20impl Control for DependencySignatureControl {
21 fn id(&self) -> ControlId {
22 builtin::id(builtin::DEPENDENCY_SIGNATURE)
23 }
24
25 fn description(&self) -> &'static str {
26 "All dependencies must have verified integrity (checksum or signature)"
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 signature evidence is not applicable",
37 )]
38 }
39 EvidenceState::Missing { gaps } => {
40 vec![ControlFinding::indeterminate(
41 id,
42 "Dependency signature evidence could not be collected",
43 Vec::new(),
44 gaps.clone(),
45 )]
46 }
47 EvidenceState::Complete { value } => {
48 if value.is_empty() {
49 return vec![ControlFinding::not_applicable(
50 id,
51 "No dependencies were present",
52 )];
53 }
54 evaluate_deps(&id, value, &[])
55 }
56 EvidenceState::Partial { value, gaps } => {
57 if value.is_empty() {
58 return vec![ControlFinding::indeterminate(
59 id,
60 format!(
61 "No verified dependencies available; {} evidence gap(s) reported",
62 gaps.len()
63 ),
64 Vec::new(),
65 gaps.clone(),
66 )];
67 }
68 evaluate_deps(&id, value, gaps)
69 }
70 }
71 }
72}
73
74fn summarize_mechanisms(deps: &[crate::evidence::DependencySignatureEvidence]) -> String {
77 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
78 for dep in deps {
79 let mechanism = dep.signature_mechanism.as_deref().unwrap_or("unknown");
80 *counts.entry(mechanism).or_default() += 1;
81 }
82 counts
83 .iter()
84 .map(|(mechanism, count)| format!("{count} {mechanism}"))
85 .collect::<Vec<_>>()
86 .join(", ")
87}
88
89fn has_digest_mismatch(dep: &crate::evidence::DependencySignatureEvidence) -> bool {
95 match (&dep.pinned_digest, &dep.actual_digest) {
96 (Some(pinned), Some(actual)) => pinned != actual,
97 _ => false,
98 }
99}
100
101fn evaluate_deps(
102 id: &ControlId,
103 deps: &[crate::evidence::DependencySignatureEvidence],
104 gaps: &[crate::evidence::EvidenceGap],
105) -> Vec<ControlFinding> {
106 let subjects: Vec<String> = deps
107 .iter()
108 .map(|d| format!("{}@{}", d.name, d.version))
109 .collect();
110
111 let unverified: Vec<String> = deps
112 .iter()
113 .filter(|d| !d.verification.is_verified() || has_digest_mismatch(d))
114 .map(|d| {
115 if has_digest_mismatch(d) {
116 format!("{}@{} (digest_mismatch)", d.name, d.version)
117 } else {
118 let reason = d.verification.failure_kind().unwrap_or("unverified");
119 format!("{}@{} ({})", d.name, d.version, reason)
120 }
121 })
122 .collect();
123
124 let gap_suffix = if gaps.is_empty() {
125 String::new()
126 } else {
127 format!(
128 " (WARNING: {} evidence gap(s) — unverified dependencies may be hidden)",
129 gaps.len()
130 )
131 };
132
133 let mut finding = match dependency_signature_severity(unverified.len()) {
134 Severity::Pass => {
135 let mechanism_summary = summarize_mechanisms(deps);
136 ControlFinding::satisfied(
137 id.clone(),
138 format!(
139 "All {} dependenc(ies) verified ({}){}",
140 deps.len(),
141 mechanism_summary,
142 gap_suffix,
143 ),
144 subjects,
145 )
146 }
147 _ => ControlFinding::violated(
148 id.clone(),
149 format!(
150 "Unverified dependency(ies): {}{}",
151 unverified.join("; "),
152 gap_suffix,
153 ),
154 subjects,
155 ),
156 };
157
158 if !gaps.is_empty() {
159 finding.evidence_gaps = gaps.to_vec();
160 }
161
162 vec![finding]
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::control::ControlStatus;
169 use crate::evidence::{DependencySignatureEvidence, EvidenceGap, VerificationOutcome};
170
171 fn make_dep(name: &str, version: &str, verified: bool) -> DependencySignatureEvidence {
172 DependencySignatureEvidence {
173 name: name.to_string(),
174 version: version.to_string(),
175 registry: Some("crates.io".to_string()),
176 verification: if verified {
177 VerificationOutcome::Verified
178 } else {
179 VerificationOutcome::AttestationAbsent {
180 detail: "no signature found".to_string(),
181 }
182 },
183 signature_mechanism: if verified {
184 Some("sigstore".to_string())
185 } else {
186 None
187 },
188 signer_identity: None,
189 source_repo: None,
190 source_commit: None,
191 pinned_digest: None,
192 actual_digest: None,
193 transparency_log_uri: None,
194 is_direct: true,
195 }
196 }
197
198 fn make_npm_dep(
199 name: &str,
200 version: &str,
201 verified: bool,
202 source_repo: Option<&str>,
203 ) -> DependencySignatureEvidence {
204 DependencySignatureEvidence {
205 name: name.to_string(),
206 version: version.to_string(),
207 registry: Some("registry.npmjs.org".to_string()),
208 verification: if verified {
209 VerificationOutcome::Verified
210 } else {
211 VerificationOutcome::AttestationAbsent {
212 detail: "npm provenance not found".to_string(),
213 }
214 },
215 signature_mechanism: if verified {
216 Some("sigstore".to_string())
217 } else {
218 None
219 },
220 signer_identity: if verified {
221 Some("https://github.com/login/oauth".to_string())
222 } else {
223 None
224 },
225 source_repo: source_repo.map(str::to_string),
226 source_commit: None,
227 pinned_digest: None,
228 actual_digest: None,
229 is_direct: true,
230 transparency_log_uri: if verified {
231 Some("https://rekor.sigstore.dev/api/v1/log/entries/...".to_string())
232 } else {
233 None
234 },
235 }
236 }
237
238 fn make_bundle(deps: Vec<DependencySignatureEvidence>) -> EvidenceBundle {
239 EvidenceBundle {
240 dependency_signatures: EvidenceState::complete(deps),
241 ..Default::default()
242 }
243 }
244
245 #[test]
248 fn not_applicable_when_evidence_state_is_not_applicable() {
249 let evidence = EvidenceBundle::default();
250 let findings = DependencySignatureControl.evaluate(&evidence);
251 assert_eq!(findings.len(), 1);
252 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
253 assert_eq!(
254 findings[0].control_id,
255 builtin::id(builtin::DEPENDENCY_SIGNATURE)
256 );
257 }
258
259 #[test]
260 fn not_applicable_when_dependency_list_empty() {
261 let findings = DependencySignatureControl.evaluate(&make_bundle(vec![]));
262 assert_eq!(findings.len(), 1);
263 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
264 }
265
266 #[test]
269 fn indeterminate_when_evidence_missing() {
270 let evidence = EvidenceBundle {
271 dependency_signatures: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
272 source: "package-registry".to_string(),
273 subject: "dependencies".to_string(),
274 detail: "registry unreachable".to_string(),
275 }]),
276 ..Default::default()
277 };
278 let findings = DependencySignatureControl.evaluate(&evidence);
279 assert_eq!(findings.len(), 1);
280 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
281 assert_eq!(findings[0].evidence_gaps.len(), 1);
282 }
283
284 #[test]
287 fn satisfied_when_all_signed() {
288 let findings = DependencySignatureControl.evaluate(&make_bundle(vec![
289 make_dep("serde", "1.0.204", true),
290 make_dep("tokio", "1.38.0", true),
291 ]));
292 assert_eq!(findings.len(), 1);
293 assert_eq!(findings[0].status, ControlStatus::Satisfied);
294 assert_eq!(findings[0].subjects.len(), 2);
295 assert!(findings[0].rationale.contains("2 dependenc(ies) verified"));
296 }
297
298 #[test]
299 fn satisfied_single_dependency() {
300 let findings = DependencySignatureControl
301 .evaluate(&make_bundle(vec![make_dep("serde", "1.0.204", true)]));
302 assert_eq!(findings[0].status, ControlStatus::Satisfied);
303 assert_eq!(findings[0].subjects, vec!["serde@1.0.204"]);
304 }
305
306 #[test]
309 fn violated_when_dependency_unsigned() {
310 let findings = DependencySignatureControl.evaluate(&make_bundle(vec![
311 make_dep("serde", "1.0.204", true),
312 make_dep("sketchy-lib", "0.1.0", false),
313 ]));
314 assert_eq!(findings.len(), 1);
315 assert_eq!(findings[0].status, ControlStatus::Violated);
316 assert!(findings[0].rationale.contains("sketchy-lib@0.1.0"));
317 assert!(findings[0].rationale.contains("attestation_absent"));
318 }
319
320 #[test]
321 fn violated_when_all_unsigned() {
322 let findings = DependencySignatureControl.evaluate(&make_bundle(vec![
323 make_dep("foo", "1.0.0", false),
324 make_dep("bar", "2.0.0", false),
325 ]));
326 assert_eq!(findings[0].status, ControlStatus::Violated);
327 assert!(findings[0].rationale.contains("foo@1.0.0"));
328 assert!(findings[0].rationale.contains("bar@2.0.0"));
329 }
330
331 #[test]
332 fn violated_with_signature_invalid_reason() {
333 let evidence = make_bundle(vec![DependencySignatureEvidence {
334 name: "tampered-pkg".to_string(),
335 version: "1.0.0".to_string(),
336 registry: Some("registry.npmjs.org".to_string()),
337 verification: VerificationOutcome::SignatureInvalid {
338 detail: "ECDSA signature mismatch".to_string(),
339 },
340 signature_mechanism: Some("sigstore".to_string()),
341 signer_identity: None,
342 source_repo: None,
343 source_commit: None,
344 pinned_digest: None,
345 actual_digest: None,
346 is_direct: true,
347 transparency_log_uri: None,
348 }]);
349 let findings = DependencySignatureControl.evaluate(&evidence);
350 assert_eq!(findings[0].status, ControlStatus::Violated);
351 assert!(findings[0].rationale.contains("signature_invalid"));
352 }
353
354 #[test]
357 fn partial_evidence_with_signed_deps_includes_gap_warning() {
358 let evidence = EvidenceBundle {
359 dependency_signatures: EvidenceState::partial(
360 vec![make_dep("serde", "1.0.204", true)],
361 vec![EvidenceGap::Truncated {
362 source: "package-registry".to_string(),
363 subject: "dependency-list".to_string(),
364 }],
365 ),
366 ..Default::default()
367 };
368 let findings = DependencySignatureControl.evaluate(&evidence);
369 assert_eq!(findings[0].status, ControlStatus::Satisfied);
370 assert!(
371 findings[0].rationale.contains("evidence gap"),
372 "Partial evidence must warn about gaps in rationale: {}",
373 findings[0].rationale
374 );
375 assert_eq!(
376 findings[0].evidence_gaps.len(),
377 1,
378 "Partial evidence gaps must propagate to finding"
379 );
380 }
381
382 #[test]
383 fn partial_evidence_with_unsigned_dep_violated() {
384 let evidence = EvidenceBundle {
385 dependency_signatures: EvidenceState::partial(
386 vec![make_dep("sketchy", "0.1.0", false)],
387 vec![EvidenceGap::Truncated {
388 source: "package-registry".to_string(),
389 subject: "dependency-list".to_string(),
390 }],
391 ),
392 ..Default::default()
393 };
394 let findings = DependencySignatureControl.evaluate(&evidence);
395 assert_eq!(findings[0].status, ControlStatus::Violated);
396 assert!(findings[0].rationale.contains("evidence gap"));
397 assert_eq!(findings[0].evidence_gaps.len(), 1);
398 }
399
400 #[test]
401 fn partial_evidence_empty_deps_is_indeterminate() {
402 let evidence = EvidenceBundle {
403 dependency_signatures: EvidenceState::partial(
404 vec![],
405 vec![EvidenceGap::CollectionFailed {
406 source: "npm-registry".to_string(),
407 subject: "audit-signatures".to_string(),
408 detail: "timeout".to_string(),
409 }],
410 ),
411 ..Default::default()
412 };
413 let findings = DependencySignatureControl.evaluate(&evidence);
414 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
415 }
416
417 #[test]
420 fn npm_provenance_satisfied_with_source_repo() {
421 let findings = DependencySignatureControl.evaluate(&make_bundle(vec![
422 make_npm_dep("react", "18.3.1", true, Some("facebook/react")),
423 make_npm_dep("express", "4.18.2", true, Some("expressjs/express")),
424 ]));
425 assert_eq!(findings[0].status, ControlStatus::Satisfied);
426 }
427
428 #[test]
429 fn npm_provenance_mixed_legacy_violated() {
430 let findings = DependencySignatureControl.evaluate(&make_bundle(vec![
431 make_npm_dep("react", "18.3.1", true, Some("facebook/react")),
432 make_npm_dep("lodash", "4.17.21", false, None),
433 ]));
434 assert_eq!(findings[0].status, ControlStatus::Violated);
435 assert!(findings[0].rationale.contains("lodash@4.17.21"));
436 }
437
438 #[test]
439 fn violated_with_digest_mismatch() {
440 let evidence = make_bundle(vec![DependencySignatureEvidence {
441 name: "replaced-pkg".to_string(),
442 version: "1.0.0".to_string(),
443 registry: Some("registry.npmjs.org".to_string()),
444 verification: VerificationOutcome::DigestMismatch {
445 detail: "sha512 mismatch: expected abc..., got def...".to_string(),
446 },
447 signature_mechanism: None,
448 signer_identity: None,
449 source_repo: None,
450 source_commit: None,
451 pinned_digest: Some("sha512:abc123".to_string()),
452 actual_digest: Some("sha512:def456".to_string()),
453 transparency_log_uri: None,
454 is_direct: false,
455 }]);
456 let findings = DependencySignatureControl.evaluate(&evidence);
457 assert_eq!(findings[0].status, ControlStatus::Violated);
458 assert!(findings[0].rationale.contains("digest_mismatch"));
459 }
460
461 #[test]
462 fn violated_with_signer_mismatch() {
463 let evidence = make_bundle(vec![DependencySignatureEvidence {
464 name: "hijacked-pkg".to_string(),
465 version: "2.0.0".to_string(),
466 registry: Some("registry.npmjs.org".to_string()),
467 verification: VerificationOutcome::SignerMismatch {
468 detail: "expected signer: alice@example.com, got: eve@attacker.com".to_string(),
469 },
470 signature_mechanism: Some("sigstore".to_string()),
471 signer_identity: Some("eve@attacker.com".to_string()),
472 source_repo: None,
473 source_commit: None,
474 pinned_digest: None,
475 actual_digest: None,
476 transparency_log_uri: None,
477 is_direct: true,
478 }]);
479 let findings = DependencySignatureControl.evaluate(&evidence);
480 assert_eq!(findings[0].status, ControlStatus::Violated);
481 assert!(findings[0].rationale.contains("signer_mismatch"));
482 }
483
484 #[test]
485 fn violated_when_verified_but_digest_differs() {
486 let evidence = make_bundle(vec![DependencySignatureEvidence {
489 name: "swapped-pkg".to_string(),
490 version: "1.0.0".to_string(),
491 registry: Some("crates.io".to_string()),
492 verification: VerificationOutcome::Verified,
493 signature_mechanism: Some("sigstore".to_string()),
494 signer_identity: Some("legit@example.com".to_string()),
495 source_repo: Some("owner/repo".to_string()),
496 source_commit: None,
497 pinned_digest: Some("sha512:expected".to_string()),
498 actual_digest: Some("sha512:tampered".to_string()),
499 transparency_log_uri: None,
500 is_direct: true,
501 }]);
502 let findings = DependencySignatureControl.evaluate(&evidence);
503 assert_eq!(
504 findings[0].status,
505 ControlStatus::Violated,
506 "Verified signature with mismatched digest must be Violated"
507 );
508 assert!(findings[0].rationale.contains("digest_mismatch"));
509 }
510
511 #[test]
512 fn satisfied_when_verified_and_digests_match() {
513 let evidence = make_bundle(vec![DependencySignatureEvidence {
514 name: "good-pkg".to_string(),
515 version: "1.0.0".to_string(),
516 registry: Some("crates.io".to_string()),
517 verification: VerificationOutcome::Verified,
518 signature_mechanism: Some("sigstore".to_string()),
519 signer_identity: None,
520 source_repo: None,
521 source_commit: None,
522 pinned_digest: Some("sha512:abc".to_string()),
523 actual_digest: Some("sha512:abc".to_string()),
524 transparency_log_uri: None,
525 is_direct: true,
526 }]);
527 let findings = DependencySignatureControl.evaluate(&evidence);
528 assert_eq!(findings[0].status, ControlStatus::Satisfied);
529 }
530
531 #[test]
532 fn correct_control_id() {
533 assert_eq!(
534 DependencySignatureControl.id(),
535 builtin::id(builtin::DEPENDENCY_SIGNATURE)
536 );
537 }
538}