Skip to main content

libverify_core/controls/
dependency_signer_verified.rs

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