Skip to main content

libverify_core/controls/
dependency_provenance.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, RegistryProvenanceCapability};
3
4/// Verifies that all dependencies have cryptographic provenance (Dependencies L2).
5///
6/// Requires every dependency to have:
7/// - `VerificationOutcome::Verified` (not just `ChecksumMatch`)
8/// - `source_repo` present (provenance links to source)
9///
10/// This is stricter than L1 (`dependency-signature`) which accepts checksum-only verification.
11///
12/// **Registry scoping**: Only evaluates dependencies from registries that support
13/// cryptographic provenance (L2+). Dependencies from checksum-only registries
14/// (e.g. crates.io) are excluded to avoid false positives. If all dependencies
15/// are from checksum-only registries, the control returns `NotApplicable`.
16pub 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                // Filter to registries that support L2+ provenance
54                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}