Skip to main content

libverify_core/controls/
build_provenance.rs

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