Skip to main content

security_rust/file/
data_leak.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use 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        ); // spaced digits
213    }
214}