Skip to main content

libverify_core/controls/
secret_scanning_push_protection.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Validates that secret scanning push protection is enabled.
5///
6/// Maps to PCI DSS Req 3.5.1, NIST SI-7, SOC2 CC6.1 / CC6.6.
7/// Push protection actively blocks credential commits at push time,
8/// going beyond detection-only secret scanning.
9///
10/// Requires secret scanning to be enabled as a prerequisite.
11pub struct SecretScanningPushProtectionControl;
12
13impl Control for SecretScanningPushProtectionControl {
14    fn id(&self) -> ControlId {
15        builtin::id(builtin::SECRET_SCANNING_PUSH_PROTECTION)
16    }
17
18    fn description(&self) -> &'static str {
19        "Secret scanning push protection must be enabled to block credential commits"
20    }
21
22    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
23        let posture = match ControlFinding::extract_posture(self.id(), evidence) {
24            Ok(p) => p,
25            Err(findings) => return findings,
26        };
27
28        if !posture.security_analysis_available {
29            return vec![ControlFinding::indeterminate(
30                self.id(),
31                "Cannot determine push protection status — API token may lack sufficient permissions",
32                vec!["repository".into()],
33                vec![],
34            )];
35        }
36
37        if !posture.secret_scanning_enabled {
38            return vec![ControlFinding::violated(
39                self.id(),
40                "Secret scanning is not enabled — push protection requires secret scanning",
41                vec!["repository".into()],
42            )];
43        }
44
45        if posture.secret_push_protection_enabled {
46            vec![ControlFinding::satisfied(
47                self.id(),
48                "Secret scanning push protection is enabled — credential commits are blocked",
49                vec!["repository:secret-scanning:push-protection".into()],
50            )]
51        } else {
52            vec![ControlFinding::violated(
53                self.id(),
54                "Secret scanning push protection is not enabled — credentials can be pushed to the repository",
55                vec!["repository:secret-scanning:push-protection".into()],
56            )]
57        }
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::control::ControlStatus;
65    use crate::evidence::{EvidenceGap, EvidenceState, RepositoryPosture};
66
67    fn posture(secret_scanning: bool, push_protection: bool) -> RepositoryPosture {
68        RepositoryPosture {
69            security_analysis_available: true,
70            secret_scanning_enabled: secret_scanning,
71            secret_push_protection_enabled: push_protection,
72            ..Default::default()
73        }
74    }
75
76    fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
77        EvidenceBundle {
78            repository_posture: state,
79            ..Default::default()
80        }
81    }
82
83    #[test]
84    fn indeterminate_when_security_analysis_unavailable() {
85        let findings = SecretScanningPushProtectionControl.evaluate(&bundle(
86            EvidenceState::complete(RepositoryPosture {
87                security_analysis_available: false,
88                ..Default::default()
89            }),
90        ));
91        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
92        assert!(findings[0].rationale.contains("permissions"));
93    }
94
95    #[test]
96    fn not_applicable_when_posture_not_applicable() {
97        let findings =
98            SecretScanningPushProtectionControl.evaluate(&bundle(EvidenceState::not_applicable()));
99        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
100    }
101
102    #[test]
103    fn indeterminate_when_posture_missing() {
104        let findings =
105            SecretScanningPushProtectionControl.evaluate(&bundle(EvidenceState::missing(vec![
106                EvidenceGap::CollectionFailed {
107                    source: "github".to_string(),
108                    subject: "posture".to_string(),
109                    detail: "API error".to_string(),
110                },
111            ])));
112        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
113    }
114
115    #[test]
116    fn satisfied_when_push_protection_enabled() {
117        let findings = SecretScanningPushProtectionControl
118            .evaluate(&bundle(EvidenceState::complete(posture(true, true))));
119        assert_eq!(findings[0].status, ControlStatus::Satisfied);
120        assert!(findings[0].rationale.contains("push protection is enabled"));
121    }
122
123    #[test]
124    fn violated_when_push_protection_disabled() {
125        let findings = SecretScanningPushProtectionControl
126            .evaluate(&bundle(EvidenceState::complete(posture(true, false))));
127        assert_eq!(findings[0].status, ControlStatus::Violated);
128        assert!(
129            findings[0]
130                .rationale
131                .contains("push protection is not enabled")
132        );
133    }
134
135    #[test]
136    fn violated_when_secret_scanning_disabled() {
137        let findings = SecretScanningPushProtectionControl
138            .evaluate(&bundle(EvidenceState::complete(posture(false, false))));
139        assert_eq!(findings[0].status, ControlStatus::Violated);
140        assert!(
141            findings[0]
142                .rationale
143                .contains("Secret scanning is not enabled")
144        );
145    }
146}