Skip to main content

libverify_core/controls/
sbom_attestation.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Validates that the latest release includes an SBOM artifact (SPDX or CycloneDX).
5///
6/// Maps to SOC2 CC7.1: system operations monitoring.
7/// Supply chain transparency — an SBOM enables consumers to audit the
8/// dependency tree of released artifacts, supporting vulnerability triage
9/// and licence compliance.
10///
11/// Evaluation tiers:
12/// - **Satisfied**: latest release includes an SBOM artifact
13/// - **Violated**: latest release exists but contains no SBOM
14/// - **NotApplicable**: no release exists (library-only or pre-release project)
15pub struct SbomAttestationControl;
16
17impl Control for SbomAttestationControl {
18    fn id(&self) -> ControlId {
19        builtin::id(builtin::SBOM_ATTESTATION)
20    }
21
22    fn description(&self) -> &'static str {
23        "Latest release must include an SBOM artifact (SPDX or CycloneDX)"
24    }
25
26    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
27        let posture = match ControlFinding::extract_posture(self.id(), evidence) {
28            Ok(p) => p,
29            Err(findings) => return findings,
30        };
31
32        if posture.release_has_sbom {
33            vec![ControlFinding::satisfied(
34                self.id(),
35                "Latest release includes an SBOM artifact",
36                vec!["release:sbom".to_string()],
37            )]
38        } else {
39            vec![ControlFinding::violated(
40                self.id(),
41                "Latest release does not include an SBOM artifact (SPDX or CycloneDX)",
42                vec!["release".to_string()],
43            )]
44        }
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use crate::control::ControlStatus;
52    use crate::evidence::{EvidenceGap, EvidenceState, RepositoryPosture};
53
54    fn posture(has_sbom: bool) -> RepositoryPosture {
55        RepositoryPosture {
56            release_has_sbom: has_sbom,
57            ..Default::default()
58        }
59    }
60
61    fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
62        EvidenceBundle {
63            repository_posture: state,
64            ..Default::default()
65        }
66    }
67
68    #[test]
69    fn not_applicable_when_posture_not_applicable() {
70        let findings = SbomAttestationControl.evaluate(&bundle(EvidenceState::not_applicable()));
71        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
72    }
73
74    #[test]
75    fn indeterminate_when_posture_missing() {
76        let findings = SbomAttestationControl.evaluate(&bundle(EvidenceState::missing(vec![
77            EvidenceGap::CollectionFailed {
78                source: "github".to_string(),
79                subject: "posture".to_string(),
80                detail: "API error".to_string(),
81            },
82        ])));
83        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
84    }
85
86    #[test]
87    fn satisfied_when_sbom_present() {
88        let findings =
89            SbomAttestationControl.evaluate(&bundle(EvidenceState::complete(posture(true))));
90        assert_eq!(findings[0].status, ControlStatus::Satisfied);
91        assert!(findings[0].rationale.contains("SBOM"));
92    }
93
94    #[test]
95    fn violated_when_sbom_absent() {
96        let findings =
97            SbomAttestationControl.evaluate(&bundle(EvidenceState::complete(posture(false))));
98        assert_eq!(findings[0].status, ControlStatus::Violated);
99        assert!(findings[0].rationale.contains("does not include"));
100    }
101}