Skip to main content

libverify_core/controls/
dependency_completeness.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, RegistryProvenanceCapability};
3
4/// Verifies that ALL dependencies (direct AND transitive) meet L3 verification (Dependencies L4).
5///
6/// Requires:
7/// - Every dependency (regardless of `is_direct`) has `Verified` + `signer_identity` + `transparency_log_uri`
8/// - At least one transitive dependency exists (otherwise the check is trivially satisfied
9///   and the control returns NotApplicable — a project with only direct deps should use L3)
10///
11/// This is the strictest dependency verification level. It ensures the entire
12/// dependency tree — not just direct dependencies — is fully provenance-verified.
13///
14/// **Registry scoping**: Only evaluates dependencies from registries that support
15/// the full trust chain (L3). Dependencies from non-L3 registries are excluded.
16pub struct DependencyCompletenessControl;
17
18impl Control for DependencyCompletenessControl {
19    fn id(&self) -> ControlId {
20        builtin::id(builtin::DEPENDENCY_COMPLETENESS)
21    }
22
23    fn description(&self) -> &'static str {
24        "All dependencies (direct and transitive) must be fully provenance-verified"
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 L3 (full trust chain)
54                let in_scope: Vec<_> = value
55                    .iter()
56                    .filter(|d| {
57                        d.registry_provenance_capability()
58                            >= RegistryProvenanceCapability::FullTrustChain
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 registries with full trust chain support \
69                             ({skipped} dependenc(ies) from other registries skipped)",
70                        ),
71                    )];
72                }
73
74                let total = in_scope.len();
75                let direct_count = in_scope.iter().filter(|d| d.is_direct).count();
76                let transitive_count = total - direct_count;
77
78                let subjects: Vec<String> = in_scope
79                    .iter()
80                    .map(|d| {
81                        let kind = if d.is_direct { "direct" } else { "transitive" };
82                        format!("{}@{} ({})", d.name, d.version, kind)
83                    })
84                    .collect();
85
86                // L4 requires L3-level verification for ALL deps
87                let lacking: Vec<String> = in_scope
88                    .iter()
89                    .filter(|d| {
90                        !d.verification.is_cryptographically_signed()
91                            || d.signer_identity.is_none()
92                            || d.transparency_log_uri.is_none()
93                    })
94                    .map(|d| {
95                        let kind = if d.is_direct { "direct" } else { "transitive" };
96                        let mut reasons = Vec::new();
97                        if !d.verification.is_cryptographically_signed() {
98                            reasons.push("no signature");
99                        }
100                        if d.signer_identity.is_none() {
101                            reasons.push("no signer_identity");
102                        }
103                        if d.transparency_log_uri.is_none() {
104                            reasons.push("no transparency_log");
105                        }
106                        format!("{}@{} [{kind}] ({})", d.name, d.version, reasons.join(", "))
107                    })
108                    .collect();
109
110                let gaps = match &evidence.dependency_signatures {
111                    EvidenceState::Partial { gaps, .. } => gaps.as_slice(),
112                    _ => &[],
113                };
114
115                // Partial evidence with gaps means we can't guarantee completeness
116                if !gaps.is_empty() {
117                    let mut finding = ControlFinding::violated(
118                        id,
119                        format!(
120                            "Cannot guarantee completeness: {} evidence gap(s) — \
121                             transitive dependencies may be missing from evaluation",
122                            gaps.len()
123                        ),
124                        subjects,
125                    );
126                    finding.evidence_gaps = gaps.to_vec();
127                    return vec![finding];
128                }
129
130                let skip_note = if skipped > 0 {
131                    format!(" [{skipped} non-L3 registr(ies) skipped]")
132                } else {
133                    String::new()
134                };
135
136                if lacking.is_empty() {
137                    vec![ControlFinding::satisfied(
138                        id,
139                        format!(
140                            "All {total} dependenc(ies) ({direct_count} direct, \
141                             {transitive_count} transitive) fully verified with provenance{skip_note}",
142                        ),
143                        subjects,
144                    )]
145                } else {
146                    vec![ControlFinding::violated(
147                        id,
148                        format!(
149                            "{}/{total} dependenc(ies) lack full provenance: {}{skip_note}",
150                            lacking.len(),
151                            lacking.join("; ")
152                        ),
153                        subjects,
154                    )]
155                }
156            }
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::control::ControlStatus;
165    use crate::evidence::{DependencySignatureEvidence, EvidenceGap, VerificationOutcome};
166
167    fn npm_dep_l3(name: &str, is_direct: bool) -> DependencySignatureEvidence {
168        DependencySignatureEvidence {
169            name: name.to_string(),
170            version: "1.0.0".to_string(),
171            registry: Some("registry.npmjs.org".to_string()),
172            verification: VerificationOutcome::Verified,
173            signature_mechanism: Some("sigstore".to_string()),
174            signer_identity: Some("https://github.com/login/oauth".to_string()),
175            source_repo: Some("owner/repo".to_string()),
176            source_commit: Some("abc123".to_string()),
177            pinned_digest: None,
178            actual_digest: None,
179            transparency_log_uri: Some(
180                "https://rekor.sigstore.dev/api/v1/log/entries/abc".to_string(),
181            ),
182            is_direct,
183        }
184    }
185
186    fn npm_dep_checksum(name: &str, is_direct: bool) -> DependencySignatureEvidence {
187        DependencySignatureEvidence {
188            name: name.to_string(),
189            version: "1.0.0".to_string(),
190            registry: Some("registry.npmjs.org".to_string()),
191            verification: VerificationOutcome::ChecksumMatch,
192            signature_mechanism: Some("checksum".to_string()),
193            signer_identity: None,
194            source_repo: None,
195            source_commit: None,
196            pinned_digest: Some("sha512-abc".to_string()),
197            actual_digest: None,
198            transparency_log_uri: None,
199            is_direct,
200        }
201    }
202
203    fn cargo_dep(name: &str) -> DependencySignatureEvidence {
204        DependencySignatureEvidence {
205            name: name.to_string(),
206            version: "1.0.0".to_string(),
207            registry: Some("crates.io".to_string()),
208            verification: VerificationOutcome::ChecksumMatch,
209            signature_mechanism: Some("checksum".to_string()),
210            signer_identity: None,
211            source_repo: None,
212            source_commit: None,
213            pinned_digest: Some("sha256:abc".to_string()),
214            actual_digest: None,
215            transparency_log_uri: None,
216            is_direct: true,
217        }
218    }
219
220    fn bundle(deps: Vec<DependencySignatureEvidence>) -> EvidenceBundle {
221        EvidenceBundle {
222            dependency_signatures: EvidenceState::complete(deps),
223            ..Default::default()
224        }
225    }
226
227    #[test]
228    fn satisfied_when_all_npm_deps_fully_verified() {
229        let evidence = bundle(vec![
230            npm_dep_l3("react", true),
231            npm_dep_l3("react-dom", false),
232            npm_dep_l3("express", true),
233            npm_dep_l3("body-parser", false),
234        ]);
235        let findings = DependencyCompletenessControl.evaluate(&evidence);
236        assert_eq!(findings[0].status, ControlStatus::Satisfied);
237        assert!(findings[0].rationale.contains("2 direct"));
238        assert!(findings[0].rationale.contains("2 transitive"));
239    }
240
241    #[test]
242    fn violated_when_npm_transitive_dep_lacks_provenance() {
243        let evidence = bundle(vec![
244            npm_dep_l3("react", true),
245            npm_dep_checksum("scheduler", false),
246        ]);
247        let findings = DependencyCompletenessControl.evaluate(&evidence);
248        assert_eq!(findings[0].status, ControlStatus::Violated);
249        assert!(
250            findings[0]
251                .rationale
252                .contains("scheduler@1.0.0 [transitive]")
253        );
254    }
255
256    #[test]
257    fn violated_when_npm_direct_dep_lacks_provenance() {
258        let evidence = bundle(vec![
259            npm_dep_checksum("lodash", true),
260            npm_dep_l3("express", false),
261        ]);
262        let findings = DependencyCompletenessControl.evaluate(&evidence);
263        assert_eq!(findings[0].status, ControlStatus::Violated);
264        assert!(findings[0].rationale.contains("lodash@1.0.0 [direct]"));
265    }
266
267    #[test]
268    fn not_applicable_when_only_cargo_deps() {
269        let findings = DependencyCompletenessControl.evaluate(&bundle(vec![cargo_dep("serde")]));
270        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
271    }
272
273    #[test]
274    fn mixed_registries_only_evaluates_npm() {
275        let evidence = bundle(vec![cargo_dep("serde"), npm_dep_l3("react", true)]);
276        let findings = DependencyCompletenessControl.evaluate(&evidence);
277        assert_eq!(findings[0].status, ControlStatus::Satisfied);
278        assert!(findings[0].rationale.contains("skipped"));
279    }
280
281    #[test]
282    fn violated_when_partial_evidence_has_gaps() {
283        let evidence = EvidenceBundle {
284            dependency_signatures: EvidenceState::partial(
285                vec![npm_dep_l3("react", true)],
286                vec![EvidenceGap::Truncated {
287                    source: "github-tree-api".to_string(),
288                    subject: "repository-tree".to_string(),
289                }],
290            ),
291            ..Default::default()
292        };
293        let findings = DependencyCompletenessControl.evaluate(&evidence);
294        assert_eq!(findings[0].status, ControlStatus::Violated);
295        assert!(
296            findings[0]
297                .rationale
298                .contains("Cannot guarantee completeness")
299        );
300    }
301
302    #[test]
303    fn not_applicable_when_empty() {
304        let findings = DependencyCompletenessControl.evaluate(&bundle(vec![]));
305        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
306    }
307
308    #[test]
309    fn indeterminate_when_evidence_missing() {
310        let evidence = EvidenceBundle {
311            dependency_signatures: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
312                source: "registry".to_string(),
313                subject: "deps".to_string(),
314                detail: "timeout".to_string(),
315            }]),
316            ..Default::default()
317        };
318        let findings = DependencyCompletenessControl.evaluate(&evidence);
319        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
320        assert_eq!(findings[0].evidence_gaps.len(), 1);
321    }
322}