Skip to main content

libverify_core/controls/
secret_scanning.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4/// Validates that secret scanning is enabled on the repository.
5///
6/// Maps to SOC2 CC6.1 / CC6.6: protect credentials and prevent leakage.
7/// ASPM signal — secret scanning prevents accidental exposure of API keys,
8/// tokens, and other credentials in source code.
9///
10/// Evaluation tiers:
11/// - **Satisfied**: scanning enabled AND push protection enabled (prevention)
12/// - **Satisfied (with caveat)**: scanning enabled but push protection off (detection only)
13/// - **Violated**: scanning not enabled
14pub struct SecretScanningControl;
15
16impl Control for SecretScanningControl {
17    fn id(&self) -> ControlId {
18        builtin::id(builtin::SECRET_SCANNING)
19    }
20
21    fn description(&self) -> &'static str {
22        "Secret scanning must be enabled to prevent credential leakage"
23    }
24
25    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
26        let posture = match ControlFinding::extract_posture(self.id(), evidence) {
27            Ok(p) => p,
28            Err(findings) => return findings,
29        };
30
31        if !posture.security_analysis_available {
32            return vec![ControlFinding::indeterminate(
33                self.id(),
34                "Cannot determine secret scanning status — API token may lack sufficient permissions",
35                vec!["repository".to_string()],
36                vec![],
37            )];
38        }
39
40        if !posture.secret_scanning_enabled {
41            return vec![ControlFinding::violated(
42                self.id(),
43                "Secret scanning is not enabled — credentials may be exposed in source code",
44                vec!["repository".to_string()],
45            )];
46        }
47
48        if posture.secret_push_protection_enabled {
49            vec![ControlFinding::satisfied(
50                self.id(),
51                "Secret scanning with push protection is enabled",
52                vec!["repository:secret-scanning:prevention".to_string()],
53            )]
54        } else {
55            // Detection-only: scanning enabled but push protection off.
56            // Still satisfied (detecting leaks is better than nothing),
57            // but rationale notes the gap for remediation.
58            vec![ControlFinding::satisfied(
59                self.id(),
60                "Secret scanning is enabled (detection only — \
61                 consider enabling push protection for prevention)",
62                vec!["repository:secret-scanning:detection".to_string()],
63            )]
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::control::ControlStatus;
72    use crate::evidence::{EvidenceGap, EvidenceState, RepositoryPosture};
73
74    fn posture(secret_scanning: bool) -> RepositoryPosture {
75        RepositoryPosture {
76            security_analysis_available: true,
77            secret_scanning_enabled: secret_scanning,
78            ..Default::default()
79        }
80    }
81
82    fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
83        EvidenceBundle {
84            repository_posture: state,
85            ..Default::default()
86        }
87    }
88
89    #[test]
90    fn indeterminate_when_security_analysis_unavailable() {
91        let findings =
92            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(RepositoryPosture {
93                security_analysis_available: false,
94                ..Default::default()
95            })));
96        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
97        assert!(findings[0].rationale.contains("permissions"));
98    }
99
100    #[test]
101    fn not_applicable_when_posture_not_applicable() {
102        let findings = SecretScanningControl.evaluate(&bundle(EvidenceState::not_applicable()));
103        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
104    }
105
106    #[test]
107    fn indeterminate_when_posture_missing() {
108        let findings = SecretScanningControl.evaluate(&bundle(EvidenceState::missing(vec![
109            EvidenceGap::CollectionFailed {
110                source: "github".to_string(),
111                subject: "posture".to_string(),
112                detail: "API error".to_string(),
113            },
114        ])));
115        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
116    }
117
118    #[test]
119    fn satisfied_when_enabled() {
120        let findings =
121            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(posture(true))));
122        assert_eq!(findings[0].status, ControlStatus::Satisfied);
123    }
124
125    #[test]
126    fn violated_when_disabled() {
127        let findings =
128            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(posture(false))));
129        assert_eq!(findings[0].status, ControlStatus::Violated);
130        assert!(findings[0].rationale.contains("not enabled"));
131    }
132
133    #[test]
134    fn satisfied_with_push_protection_has_prevention_tier() {
135        let mut p = posture(true);
136        p.secret_push_protection_enabled = true;
137        let findings = SecretScanningControl.evaluate(&bundle(EvidenceState::complete(p)));
138        assert_eq!(findings[0].status, ControlStatus::Satisfied);
139        assert!(findings[0].rationale.contains("push protection"));
140        assert!(findings[0].subjects[0].contains("prevention"));
141    }
142
143    #[test]
144    fn satisfied_detection_only_has_detection_tier() {
145        let findings =
146            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(posture(true))));
147        assert_eq!(findings[0].status, ControlStatus::Satisfied);
148        assert!(findings[0].rationale.contains("detection only"));
149        assert!(findings[0].subjects[0].contains("detection"));
150    }
151}