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
12/// 泄露形态:**凭据出现在不该出现的地方**。`AKIA`/PEM/`sk-` 本身就是秘密,出现即
13/// 泄露;连接串不是 —— 秘密是 URL 里的 userinfo,不是 scheme 本身。`redis://shared-memory`、
14/// `postgres://localhost:5432/app` 这种没有 `@` 的地址是配置项的常态(本仓库
15/// README 的检测器表就写着 `mongodb://`/`mysql://`/`postgresql://`/`redis://`),
16/// 收紧前它们全是 Critical。故连接串一律要求 `@`:`user:pass@`(`mysql://root:secret@db`)
17/// 与只有 user 的 `mongodb+srv://admin@cluster` 都算 —— 后者是既有的正例,界就划在这里。
18/// 十二条分支合成一条 alternation。前六条(密钥 / 证书前缀)大小写中性、必须
19/// 保持中性,后六条是 `(?i)`;flags 不一致,故逐条包裹。
20/// 顺序照旧:`-----BEGIN\s*(?:RSA\s*)?PRIVATE\s*KEY` 排在 `DSA`/`EC`/`PGP` 之前,
21/// 合并后同一位置仍按原次序取分支(alternation 是 leftmost-first),行为不变。
22static 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    /// 带 userinfo 的连接串仍是 Critical:`user:pass@` 与只有 user 的 `user@` 都算,
196    /// 界划在「URL 里有没有 userinfo」,不是划在「有没有密码」。
197    #[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    /// 不带 userinfo 的连接 URL **不是**泄露 —— 它只是指向一条服务的地址,配置里
213    /// 到处都有。收紧前这些全部命中 Critical,本仓库 README 的检测器表因此被自己的
214    /// data_leak 判成 Critical。
215    #[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        ); // spaced digits
262    }
263}