libverify_core/controls/
secret_scanning.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::EvidenceBundle;
3
4pub 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 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}