Skip to main content

libverify_core/controls/
provenance_authenticity.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3use crate::integrity::provenance_authenticity_severity;
4use crate::verdict::Severity;
5
6/// Verifies that provenance attestations are cryptographically signed and authenticated
7/// with traceable signer information.
8pub struct ProvenanceAuthenticityControl;
9
10impl Control for ProvenanceAuthenticityControl {
11    fn id(&self) -> ControlId {
12        builtin::id(builtin::PROVENANCE_AUTHENTICITY)
13    }
14
15    fn description(&self) -> &'static str {
16        "Provenance attestation must be cryptographically signed"
17    }
18
19    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
20        let id = self.id();
21
22        match &evidence.artifact_attestations {
23            EvidenceState::NotApplicable => {
24                vec![ControlFinding::not_applicable(
25                    id,
26                    "Artifact attestation evidence is not applicable",
27                )]
28            }
29            EvidenceState::Missing { gaps } => {
30                vec![ControlFinding::indeterminate(
31                    id,
32                    "Artifact attestation evidence could not be collected",
33                    Vec::new(),
34                    gaps.clone(),
35                )]
36            }
37            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
38                if value.is_empty() {
39                    return vec![ControlFinding::not_applicable(
40                        id,
41                        "No artifact attestations were present",
42                    )];
43                }
44
45                let subjects: Vec<String> = value.iter().map(|a| a.subject.clone()).collect();
46
47                let unauthenticated: Vec<String> = value
48                    .iter()
49                    .filter(|a| !a.verification.is_verified() || a.signer_workflow.is_none())
50                    .map(|a| {
51                        let mut reasons = Vec::new();
52                        if !a.verification.is_verified() {
53                            if let Some(kind) = a.verification.failure_kind() {
54                                reasons.push(kind);
55                            } else {
56                                reasons.push("unverified");
57                            }
58                        }
59                        if a.signer_workflow.is_none() {
60                            reasons.push("no signer info");
61                        }
62                        format!("{} ({})", a.subject, reasons.join(", "))
63                    })
64                    .collect();
65
66                let finding = match provenance_authenticity_severity(unauthenticated.len()) {
67                    Severity::Pass => ControlFinding::satisfied(
68                        id,
69                        format!(
70                            "All {} attestation(s) are verified with traceable signer",
71                            value.len()
72                        ),
73                        subjects,
74                    ),
75                    _ => ControlFinding::violated(
76                        id,
77                        format!(
78                            "Unauthenticated attestation(s): {}",
79                            unauthenticated.join("; ")
80                        ),
81                        subjects,
82                    ),
83                };
84                vec![finding]
85            }
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::control::ControlStatus;
94    use crate::evidence::{ArtifactAttestation, EvidenceGap, VerificationOutcome};
95
96    fn make_attestation(
97        subject: &str,
98        verified: bool,
99        signer_workflow: Option<&str>,
100    ) -> ArtifactAttestation {
101        ArtifactAttestation {
102            subject: subject.to_string(),
103            subject_digest: None,
104            predicate_type: "https://slsa.dev/provenance/v1".to_string(),
105            signer_workflow: signer_workflow.map(|s| s.to_string()),
106            source_repo: Some("owner/repo".to_string()),
107            verification: if verified {
108                VerificationOutcome::Verified
109            } else {
110                VerificationOutcome::SignatureInvalid {
111                    detail: "signature mismatch".to_string(),
112                }
113            },
114        }
115    }
116
117    fn make_bundle(attestations: Vec<ArtifactAttestation>) -> EvidenceBundle {
118        EvidenceBundle {
119            artifact_attestations: EvidenceState::complete(attestations),
120            ..Default::default()
121        }
122    }
123
124    // --- NotApplicable ---
125
126    #[test]
127    fn not_applicable_when_evidence_state_is_not_applicable() {
128        let evidence = EvidenceBundle {
129            artifact_attestations: EvidenceState::not_applicable(),
130            ..Default::default()
131        };
132        let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
133        assert_eq!(findings.len(), 1);
134        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
135        assert_eq!(
136            findings[0].control_id,
137            builtin::id(builtin::PROVENANCE_AUTHENTICITY)
138        );
139    }
140
141    #[test]
142    fn not_applicable_when_attestation_list_empty() {
143        let findings = ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![]));
144        assert_eq!(findings.len(), 1);
145        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
146    }
147
148    // --- Indeterminate ---
149
150    #[test]
151    fn indeterminate_when_evidence_missing() {
152        let evidence = EvidenceBundle {
153            artifact_attestations: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
154                source: "gh-attestation".to_string(),
155                subject: "binary".to_string(),
156                detail: "API returned 403".to_string(),
157            }]),
158            ..Default::default()
159        };
160        let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
161        assert_eq!(findings.len(), 1);
162        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
163        assert_eq!(findings[0].evidence_gaps.len(), 1);
164    }
165
166    // --- Satisfied ---
167
168    #[test]
169    fn satisfied_when_all_verified_with_signer() {
170        let findings = ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![
171            make_attestation("app:v1.0", true, Some(".github/workflows/release.yml")),
172            make_attestation("app:v1.1", true, Some(".github/workflows/ci.yml")),
173        ]));
174        assert_eq!(findings.len(), 1);
175        assert_eq!(findings[0].status, ControlStatus::Satisfied);
176        assert_eq!(findings[0].subjects.len(), 2);
177        assert!(
178            findings[0]
179                .rationale
180                .contains("2 attestation(s) are verified")
181        );
182    }
183
184    #[test]
185    fn satisfied_single_attestation() {
186        let findings =
187            ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![make_attestation(
188                "binary",
189                true,
190                Some(".github/workflows/release.yml"),
191            )]));
192        assert_eq!(findings[0].status, ControlStatus::Satisfied);
193        assert_eq!(findings[0].subjects, vec!["binary"]);
194    }
195
196    // --- Violated ---
197
198    #[test]
199    fn violated_when_attestation_not_verified() {
200        let findings = ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![
201            make_attestation("app:v1.0", true, Some(".github/workflows/release.yml")),
202            make_attestation("app:v1.1", false, Some(".github/workflows/ci.yml")),
203        ]));
204        assert_eq!(findings.len(), 1);
205        assert_eq!(findings[0].status, ControlStatus::Violated);
206        assert!(findings[0].rationale.contains("app:v1.1"));
207        assert!(findings[0].rationale.contains("signature_invalid"));
208    }
209
210    #[test]
211    fn violated_when_signer_workflow_missing() {
212        let findings = ProvenanceAuthenticityControl
213            .evaluate(&make_bundle(vec![make_attestation("app:v1.0", true, None)]));
214        assert_eq!(findings.len(), 1);
215        assert_eq!(findings[0].status, ControlStatus::Violated);
216        assert!(findings[0].rationale.contains("app:v1.0"));
217        assert!(findings[0].rationale.contains("no signer info"));
218    }
219
220    #[test]
221    fn violated_when_both_unverified_and_no_signer() {
222        let findings =
223            ProvenanceAuthenticityControl.evaluate(&make_bundle(vec![make_attestation(
224                "app:v1.0", false, None,
225            )]));
226        assert_eq!(findings[0].status, ControlStatus::Violated);
227        assert!(findings[0].rationale.contains("signature_invalid"));
228        assert!(findings[0].rationale.contains("no signer info"));
229    }
230
231    // --- Edge cases ---
232
233    #[test]
234    fn partial_evidence_with_authenticated_attestations_satisfied() {
235        let evidence = EvidenceBundle {
236            artifact_attestations: EvidenceState::partial(
237                vec![make_attestation(
238                    "partial-binary",
239                    true,
240                    Some(".github/workflows/release.yml"),
241                )],
242                vec![EvidenceGap::Truncated {
243                    source: "gh-attestation".to_string(),
244                    subject: "attestation-list".to_string(),
245                }],
246            ),
247            ..Default::default()
248        };
249        let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
250        assert_eq!(findings[0].status, ControlStatus::Satisfied);
251    }
252
253    #[test]
254    fn partial_evidence_with_unauthenticated_attestation_violated() {
255        let evidence = EvidenceBundle {
256            artifact_attestations: EvidenceState::partial(
257                vec![make_attestation("partial-binary", false, None)],
258                vec![EvidenceGap::Truncated {
259                    source: "gh-attestation".to_string(),
260                    subject: "attestation-list".to_string(),
261                }],
262            ),
263            ..Default::default()
264        };
265        let findings = ProvenanceAuthenticityControl.evaluate(&evidence);
266        assert_eq!(findings[0].status, ControlStatus::Violated);
267    }
268
269    #[test]
270    fn correct_control_id() {
271        assert_eq!(
272            ProvenanceAuthenticityControl.id(),
273            builtin::id(builtin::PROVENANCE_AUTHENTICITY)
274        );
275    }
276}