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::{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        ); // spaced digits
206    }
207}