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.secret_scanning_enabled {
32            return vec![ControlFinding::violated(
33                self.id(),
34                "Secret scanning is not enabled — credentials may be exposed in source code",
35                vec!["repository".to_string()],
36            )];
37        }
38
39        if posture.secret_push_protection_enabled {
40            vec![ControlFinding::satisfied(
41                self.id(),
42                "Secret scanning with push protection is enabled",
43                vec!["repository:secret-scanning:prevention".to_string()],
44            )]
45        } else {
46            // Detection-only: scanning enabled but push protection off.
47            // Still satisfied (detecting leaks is better than nothing),
48            // but rationale notes the gap for remediation.
49            vec![ControlFinding::satisfied(
50                self.id(),
51                "Secret scanning is enabled (detection only — \
52                 consider enabling push protection for prevention)",
53                vec!["repository:secret-scanning:detection".to_string()],
54            )]
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::control::ControlStatus;
63    use crate::evidence::{EvidenceGap, EvidenceState, RepositoryPosture};
64
65    fn posture(secret_scanning: bool) -> RepositoryPosture {
66        RepositoryPosture {
67            codeowners_entries: vec![],
68            secret_scanning_enabled: secret_scanning,
69            secret_push_protection_enabled: false,
70            vulnerability_scanning_enabled: false,
71            code_scanning_enabled: false,
72            security_policy_present: false,
73            security_policy_has_disclosure: false,
74            default_branch_protected: false,
75        }
76    }
77
78    fn bundle(state: EvidenceState<RepositoryPosture>) -> EvidenceBundle {
79        EvidenceBundle {
80            repository_posture: state,
81            ..Default::default()
82        }
83    }
84
85    #[test]
86    fn not_applicable_when_posture_not_applicable() {
87        let findings = SecretScanningControl.evaluate(&bundle(EvidenceState::not_applicable()));
88        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
89    }
90
91    #[test]
92    fn indeterminate_when_posture_missing() {
93        let findings = SecretScanningControl.evaluate(&bundle(EvidenceState::missing(vec![
94            EvidenceGap::CollectionFailed {
95                source: "github".to_string(),
96                subject: "posture".to_string(),
97                detail: "API error".to_string(),
98            },
99        ])));
100        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
101    }
102
103    #[test]
104    fn satisfied_when_enabled() {
105        let findings =
106            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(posture(true))));
107        assert_eq!(findings[0].status, ControlStatus::Satisfied);
108    }
109
110    #[test]
111    fn violated_when_disabled() {
112        let findings =
113            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(posture(false))));
114        assert_eq!(findings[0].status, ControlStatus::Violated);
115        assert!(findings[0].rationale.contains("not enabled"));
116    }
117
118    #[test]
119    fn satisfied_with_push_protection_has_prevention_tier() {
120        let mut p = posture(true);
121        p.secret_push_protection_enabled = true;
122        let findings = SecretScanningControl.evaluate(&bundle(EvidenceState::complete(p)));
123        assert_eq!(findings[0].status, ControlStatus::Satisfied);
124        assert!(findings[0].rationale.contains("push protection"));
125        assert!(findings[0].subjects[0].contains("prevention"));
126    }
127
128    #[test]
129    fn satisfied_detection_only_has_detection_tier() {
130        let findings =
131            SecretScanningControl.evaluate(&bundle(EvidenceState::complete(posture(true))));
132        assert_eq!(findings[0].status, ControlStatus::Satisfied);
133        assert!(findings[0].rationale.contains("detection only"));
134        assert!(findings[0].subjects[0].contains("detection"));
135    }
136}