1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static CC_PAN: LazyLock<Regex> = LazyLock::new(|| {
9 Regex::new(r"\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b").unwrap()
10});
11
12static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
23 Regex::new(
24 &[
25 r"AKIA[0-9A-Z]{16}",
26 r"|-----BEGIN\s*(?:RSA\s*)?PRIVATE\s*KEY",
27 r"|-----BEGIN\s*CERTIFICATE",
28 r"|-----BEGIN\s*DSA\s*PRIVATE",
29 r"|-----BEGIN\s*EC\s*PRIVATE",
30 r"|-----BEGIN\s*PGP\s*PRIVATE",
31 r"|sk-[A-Za-z0-9]{32,}",
32 r"|(?i:mongodb(?:\+srv)?://[^/\s@]+@[^/\s]+)",
33 r"|(?i:mysql://[^/\s@]+@[^/\s]+)",
34 r"|(?i:postgres(?:ql)?://[^/\s@]+@[^/\s]+)",
35 r"|(?i:redis://[^/\s@]+@[^/\s]+)",
36 r"|(?i:jdbc:[a-z]+://)",
37 ]
38 .concat(),
39 )
40 .unwrap()
41});
42
43fn luhn_valid(pan: &str) -> bool {
44 let mut len = 0;
45 let mut sum = 0u32;
46 for (i, b) in pan.bytes().rev().filter(|b| b.is_ascii_digit()).enumerate() {
47 len += 1;
48 let d = (b - b'0') as u32;
49 if i % 2 == 1 {
50 let doubled = d * 2;
51 sum += if doubled > 9 { doubled - 9 } else { doubled };
52 } else {
53 sum += d;
54 }
55 }
56 len >= 13 && sum.is_multiple_of(10)
57}
58
59pub struct DataLeakDetector;
60
61impl Detector for DataLeakDetector {
62 fn name(&self) -> &'static str {
63 "data_leak"
64 }
65
66 fn detect(&self, input: &str) -> Option<DetectionResult> {
67 if let Some(m) = CC_PAN.find(input)
68 && luhn_valid(m.as_str())
69 {
70 return Some(DetectionResult {
71 attack_type: self.name().into(),
72 category: AttackCategory::File,
73 severity: Severity::Critical,
74 matched_pattern: m.as_str().to_string(),
75 offset: m.start(),
76 message: "Sensitive data leak detected (credit card)".into(),
77 });
78 }
79 regex_detect(
80 std::slice::from_ref(&*PATTERNS),
81 self.name(),
82 AttackCategory::File,
83 Severity::Critical,
84 "Sensitive data leak detected",
85 input,
86 )
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::test_helpers::assert_clean;
94
95 #[test]
96 fn name_returns_attack_type() {
97 assert_eq!(DataLeakDetector.name(), "data_leak");
98 }
99
100 #[test]
101 fn detects_valid_credit_cards() {
102 for payload in ["4111111111111111", "4242424242424242", "5555555555554444"] {
103 let r = DataLeakDetector
104 .detect(payload)
105 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
106 assert_eq!(r.attack_type, "data_leak");
107 assert_eq!(r.category, AttackCategory::File);
108 assert_eq!(r.severity, Severity::Critical);
109 assert_eq!(r.matched_pattern, payload);
110 assert!(
111 r.offset <= payload.len(),
112 "offset out of range for {:?}",
113 payload
114 );
115 }
116 }
117
118 #[test]
119 fn detects_cloud_and_api_keys() {
120 for payload in [
121 "AKIAIOSFODNN7EXAMPLE",
122 "AWS_ACCESS_KEY=AKIA1234567890ABCDEF",
123 "sk-abcdefghijklmnopqrstuvwxyz123456",
124 "key=sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh",
125 ] {
126 let r = DataLeakDetector
127 .detect(payload)
128 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
129 assert!(
130 !r.matched_pattern.is_empty(),
131 "matched_pattern empty for {:?}",
132 payload
133 );
134 assert!(
135 r.offset <= payload.len(),
136 "offset out of range for {:?}",
137 payload
138 );
139 }
140 }
141
142 #[test]
143 fn detects_private_keys_and_certificates() {
144 for payload in [
145 "-----BEGIN RSA PRIVATE KEY-----",
146 "-----BEGIN PRIVATE KEY-----",
147 "-----BEGIN EC PRIVATE KEY-----",
148 "-----BEGIN DSA PRIVATE KEY-----",
149 "-----BEGIN PGP PRIVATE KEY BLOCK-----",
150 "-----BEGIN CERTIFICATE-----",
151 ] {
152 let r = DataLeakDetector
153 .detect(payload)
154 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
155 assert!(
156 !r.matched_pattern.is_empty(),
157 "matched_pattern empty for {:?}",
158 payload
159 );
160 assert!(
161 r.offset <= payload.len(),
162 "offset out of range for {:?}",
163 payload
164 );
165 }
166 }
167
168 #[test]
169 fn detects_database_connection_strings() {
170 for payload in [
171 "mongodb://admin:password@localhost:27017/db",
172 "mongodb+srv://admin@cluster.example.com/db",
173 "mysql://root:secret@db:3306/app",
174 "postgresql://user:pass@pg:5432/db",
175 "postgres://user:pass@pg:5432/db",
176 "redis://:secret@cache:6379/0",
177 "jdbc:mysql://localhost:3306/app",
178 ] {
179 let r = DataLeakDetector
180 .detect(payload)
181 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
182 assert!(
183 !r.matched_pattern.is_empty(),
184 "matched_pattern empty for {:?}",
185 payload
186 );
187 assert!(
188 r.offset <= payload.len(),
189 "offset out of range for {:?}",
190 payload
191 );
192 }
193 }
194
195 #[test]
198 fn credentialed_connection_strings_are_still_critical() {
199 for payload in [
200 "mongodb://admin:password@localhost:27017/db",
201 "mongodb+srv://admin@cluster.example.com/db",
202 "postgres://app:pw@10.0.0.5:5432/db",
203 "redis://:secret@cache:6379/0",
204 ] {
205 let r = DataLeakDetector
206 .detect(payload)
207 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
208 assert_eq!(r.severity, Severity::Critical);
209 }
210 }
211
212 #[test]
216 fn connection_urls_without_credentials_are_clean() {
217 for input in [
218 "redis://shared-memory",
219 "memory: redis://shared-memory",
220 "postgres://localhost:5432/app",
221 "mysql://db.internal:3306",
222 "MONGODB_URI=mongodb://localhost",
223 "| **data_leak** | 数据库连接串 `mongodb://`/`mysql://`/`postgresql://`/`redis://`/`jdbc:` | Critical |",
224 ] {
225 assert_clean(&DataLeakDetector, input);
226 }
227 }
228
229 #[test]
230 fn ignores_benign_inputs() {
231 for input in [
232 "Hello, this is a normal text input.",
233 "4111111111111112",
234 "AKIA",
235 "AKIAIOSFODNN7EXAMPL",
236 "sk-ab",
237 "-----BEGIN PUBLIC KEY-----",
238 "mongodb",
239 "mysql://",
240 "redis://",
241 "https://example.com/db",
242 "jdbc:mysql:thin@localhost",
243 ] {
244 assert!(
245 DataLeakDetector.detect(input).is_none(),
246 "false positive: {:?}",
247 input
248 );
249 }
250 }
251
252 #[test]
253 fn edge_cases() {
254 assert!(DataLeakDetector.detect("").is_none());
255 assert!(DataLeakDetector.detect(" ").is_none());
256 assert!(DataLeakDetector.detect("カード番号は秘密です").is_none());
257 assert!(
258 DataLeakDetector
259 .detect("card 4111 1111 1111 1111")
260 .is_none()
261 ); }
263}