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