1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{regex_detect, AttackCategory, DetectionResult, Detector, Severity};
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(&PATTERNS, self.name(), AttackCategory::File, Severity::Critical, "Sensitive data leak detected", input)
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn name_returns_attack_type() {
75 assert_eq!(DataLeakDetector.name(), "data_leak");
76 }
77
78 #[test]
79 fn detects_valid_credit_cards() {
80 for payload in ["4111111111111111", "4242424242424242", "5555555555554444"] {
81 let r = DataLeakDetector
82 .detect(payload)
83 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
84 assert_eq!(r.attack_type, "data_leak");
85 assert_eq!(r.category, AttackCategory::File);
86 assert_eq!(r.severity, Severity::Critical);
87 assert_eq!(r.matched_pattern, payload);
88 assert!(
89 r.offset <= payload.len(),
90 "offset out of range for {:?}",
91 payload
92 );
93 }
94 }
95
96 #[test]
97 fn detects_cloud_and_api_keys() {
98 for payload in [
99 "AKIAIOSFODNN7EXAMPLE",
100 "AWS_ACCESS_KEY=AKIA1234567890ABCDEF",
101 "sk-abcdefghijklmnopqrstuvwxyz123456",
102 "key=sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh",
103 ] {
104 let r = DataLeakDetector
105 .detect(payload)
106 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
107 assert!(
108 !r.matched_pattern.is_empty(),
109 "matched_pattern empty for {:?}",
110 payload
111 );
112 assert!(
113 r.offset <= payload.len(),
114 "offset out of range for {:?}",
115 payload
116 );
117 }
118 }
119
120 #[test]
121 fn detects_private_keys_and_certificates() {
122 for payload in [
123 "-----BEGIN RSA PRIVATE KEY-----",
124 "-----BEGIN PRIVATE KEY-----",
125 "-----BEGIN EC PRIVATE KEY-----",
126 "-----BEGIN DSA PRIVATE KEY-----",
127 "-----BEGIN PGP PRIVATE KEY BLOCK-----",
128 "-----BEGIN CERTIFICATE-----",
129 ] {
130 let r = DataLeakDetector
131 .detect(payload)
132 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
133 assert!(
134 !r.matched_pattern.is_empty(),
135 "matched_pattern empty for {:?}",
136 payload
137 );
138 assert!(
139 r.offset <= payload.len(),
140 "offset out of range for {:?}",
141 payload
142 );
143 }
144 }
145
146 #[test]
147 fn detects_database_connection_strings() {
148 for payload in [
149 "mongodb://admin:password@localhost:27017/db",
150 "mongodb+srv://admin@cluster.example.com/db",
151 "mysql://root:secret@db:3306/app",
152 "postgresql://user:pass@pg:5432/db",
153 "postgres://user:pass@pg:5432/db",
154 "redis://:secret@cache:6379/0",
155 "jdbc:mysql://localhost:3306/app",
156 ] {
157 let r = DataLeakDetector
158 .detect(payload)
159 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
160 assert!(
161 !r.matched_pattern.is_empty(),
162 "matched_pattern empty for {:?}",
163 payload
164 );
165 assert!(
166 r.offset <= payload.len(),
167 "offset out of range for {:?}",
168 payload
169 );
170 }
171 }
172
173 #[test]
174 fn ignores_benign_inputs() {
175 for input in [
176 "Hello, this is a normal text input.",
177 "4111111111111112",
178 "AKIA",
179 "AKIAIOSFODNN7EXAMPL",
180 "sk-ab",
181 "-----BEGIN PUBLIC KEY-----",
182 "mongodb",
183 "mysql://",
184 "redis://",
185 "https://example.com/db",
186 "jdbc:mysql:thin@localhost",
187 ] {
188 assert!(
189 DataLeakDetector.detect(input).is_none(),
190 "false positive: {:?}",
191 input
192 );
193 }
194 }
195
196 #[test]
197 fn edge_cases() {
198 assert!(DataLeakDetector.detect("").is_none());
199 assert!(DataLeakDetector.detect(" ").is_none());
200 assert!(DataLeakDetector.detect("カード番号は秘密です").is_none());
201 assert!(
202 DataLeakDetector
203 .detect("card 4111 1111 1111 1111")
204 .is_none()
205 ); }
207}