Skip to main content

libverify_core/controls/
dependency_signature.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::dependency_signature_severity;
4use crate::verdict::Severity;
5
6/// Verifies that all dependencies have been checked for integrity or provenance.
7///
8/// Distinguishes two levels of verification:
9/// - **`Verified`**: Cryptographic signature confirmed (Sigstore, PGP, cosign)
10/// - **`ChecksumMatch`**: Integrity hash matched (Cargo.lock checksum, npm SRI hash)
11///   — confirms download integrity but NOT authenticity
12///
13/// Both levels pass this control, but the rationale clearly reports the breakdown
14/// (e.g. "140 checksum, 2 sigstore") so consumers can distinguish trust levels.
15///
16/// When evidence is `Partial` (some dependencies could not be checked), the control
17/// propagates the evidence gaps into the finding and appends a warning to the rationale.
18pub 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
74/// Summarize verification mechanisms for rationale output.
75/// e.g. "142 checksum, 3 sigstore" or "checksum-pinned, not cryptographically signed"
76fn 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
89/// Returns true if pinned and actual digests both exist and differ.
90///
91/// `actual_digest` is populated by build-time adapters (not lock-file parsers).
92/// Lock-file-only collection sets `pinned_digest` but leaves `actual_digest` as None,
93/// so this check only fires when a build-time adapter provides the actual hash.
94fn 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    // --- NotApplicable ---
246
247    #[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    // --- Indeterminate ---
267
268    #[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    // --- Satisfied ---
285
286    #[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    // --- Violated ---
307
308    #[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    // --- Partial evidence handling ---
355
356    #[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    // --- npm provenance ---
418
419    #[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        // Critical: artifact replacement attack where signature is valid
487        // but the actual artifact was swapped after signing.
488        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}