Skip to main content

lean_ctx/core/sensitivity/
classify.rs

1//! Classification + redaction helpers for the sensitivity model.
2//!
3//! Only high-precision signals raise a level, to avoid false positives:
4//! - secret-like paths ([`is_secret_like`]) and detected secrets → `Secret`
5//! - Luhn-validated card numbers and mod-97-validated IBANs → `Confidential`
6
7use std::path::Path;
8use std::sync::OnceLock;
9
10use regex::Regex;
11
12use super::SensitivityLevel;
13use crate::core::{io_boundary, secret_detection};
14
15/// Classify a source path. Secret-like paths (keys, `.env`, `.ssh/…`) → `Secret`.
16/// Everything else stays `Public` — path alone is not enough to infer lower
17/// confidential levels without guessing.
18pub fn classify_path(path: &Path) -> SensitivityLevel {
19    if io_boundary::is_secret_like(path).is_some() {
20        SensitivityLevel::Secret
21    } else {
22        SensitivityLevel::Public
23    }
24}
25
26/// Classify free text by content.
27pub fn classify_content(content: &str) -> SensitivityLevel {
28    if !secret_detection::detect_secrets(content).is_empty() {
29        return SensitivityLevel::Secret;
30    }
31    if has_credit_card(content) || has_iban(content) {
32        return SensitivityLevel::Confidential;
33    }
34    SensitivityLevel::Public
35}
36
37/// Combined classification: the maximum of path- and content-derived levels.
38pub fn classify(path: Option<&Path>, content: &str) -> SensitivityLevel {
39    let from_path = path.map(classify_path).unwrap_or_default();
40    from_path.max(classify_content(content))
41}
42
43/// Redact the spans that raise sensitivity: known secrets (via the config-driven
44/// scanner) plus PII (card numbers, IBANs) the secret scanner does not cover.
45pub(super) fn redact_sensitive(text: &str) -> String {
46    // Force redaction on regardless of the global `secret_detection` toggle: a
47    // sensitivity floor must always mask, not merely detect.
48    let forced = crate::core::config::SecretDetectionConfig {
49        enabled: true,
50        redact: true,
51        ..Default::default()
52    };
53    let (secrets_masked, _) = secret_detection::scan_and_redact(text, &forced);
54    let cards_masked = redact_credit_cards(&secrets_masked);
55    redact_ibans(&cards_masked)
56}
57
58// ---- Card numbers (Luhn) ---------------------------------------------------
59
60fn card_re() -> &'static Regex {
61    static RE: OnceLock<Regex> = OnceLock::new();
62    RE.get_or_init(|| Regex::new(r"\b\d(?:[ -]?\d){12,18}\b").expect("valid card regex"))
63}
64
65/// Luhn checksum over decimal digits. Length 13–19 (standard PAN range).
66fn luhn_valid(digits: &[u8]) -> bool {
67    if digits.len() < 13 || digits.len() > 19 {
68        return false;
69    }
70    let mut sum = 0u32;
71    let mut double = false;
72    for &d in digits.iter().rev() {
73        if d > 9 {
74            return false;
75        }
76        let mut v = d as u32;
77        if double {
78            v *= 2;
79            if v > 9 {
80                v -= 9;
81            }
82        }
83        sum += v;
84        double = !double;
85    }
86    sum.is_multiple_of(10)
87}
88
89fn digits_of(s: &str) -> Vec<u8> {
90    s.chars()
91        .filter(char::is_ascii_digit)
92        .map(|c| c as u8 - b'0')
93        .collect()
94}
95
96fn has_credit_card(content: &str) -> bool {
97    card_re()
98        .find_iter(content)
99        .any(|m| luhn_valid(&digits_of(m.as_str())))
100}
101
102fn redact_credit_cards(text: &str) -> String {
103    card_re()
104        .replace_all(text, |caps: &regex::Captures| {
105            let m = caps.get(0).map(|x| x.as_str()).unwrap_or_default();
106            if luhn_valid(&digits_of(m)) {
107                "[REDACTED:card]".to_string()
108            } else {
109                m.to_string()
110            }
111        })
112        .into_owned()
113}
114
115// ---- IBANs (ISO 7064 mod-97) ----------------------------------------------
116
117fn iban_re() -> &'static Regex {
118    static RE: OnceLock<Regex> = OnceLock::new();
119    RE.get_or_init(|| {
120        Regex::new(r"\b[A-Za-z]{2}\d{2}[A-Za-z0-9]{11,30}\b").expect("valid iban regex")
121    })
122}
123
124/// Validate an IBAN candidate via the ISO 7064 mod-97 checksum.
125fn iban_valid(candidate: &str) -> bool {
126    let s: String = candidate.chars().filter(|c| !c.is_whitespace()).collect();
127    if s.len() < 15 || s.len() > 34 {
128        return false;
129    }
130    if !s.is_char_boundary(4) {
131        return false;
132    }
133    // Move the first four characters to the end, then map letters A..Z -> 10..35.
134    let rearranged = format!("{}{}", &s[4..], &s[..4]);
135    let mut rem: u32 = 0;
136    for c in rearranged.chars() {
137        if let Some(d) = c.to_digit(10) {
138            rem = (rem * 10 + d) % 97;
139        } else if c.is_ascii_alphabetic() {
140            let val = (c.to_ascii_uppercase() as u8 - b'A' + 10) as u32;
141            rem = (rem * 100 + val) % 97;
142        } else {
143            return false;
144        }
145    }
146    rem == 1
147}
148
149fn has_iban(content: &str) -> bool {
150    iban_re().find_iter(content).any(|m| iban_valid(m.as_str()))
151}
152
153fn redact_ibans(text: &str) -> String {
154    iban_re()
155        .replace_all(text, |caps: &regex::Captures| {
156            let m = caps.get(0).map(|x| x.as_str()).unwrap_or_default();
157            if iban_valid(m) {
158                "[REDACTED:iban]".to_string()
159            } else {
160                m.to_string()
161            }
162        })
163        .into_owned()
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn secret_content_is_secret() {
172        // GitHub token shape is detected by secret_detection.
173        let t = "export TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
174        assert_eq!(classify_content(t), SensitivityLevel::Secret);
175    }
176
177    #[test]
178    fn benign_content_is_public() {
179        assert_eq!(
180            classify_content("the quick brown fox jumps over 12 lazy dogs"),
181            SensitivityLevel::Public
182        );
183    }
184
185    #[test]
186    fn luhn_valid_card_is_confidential() {
187        // 4111 1111 1111 1111 is the canonical Visa test number (Luhn-valid).
188        assert_eq!(
189            classify_content("card: 4111 1111 1111 1111 on file"),
190            SensitivityLevel::Confidential
191        );
192    }
193
194    #[test]
195    fn random_16_digits_not_flagged_unless_luhn() {
196        // 1234567890123456 is NOT Luhn-valid → must stay public.
197        assert_eq!(
198            classify_content("order id 1234567890123456"),
199            SensitivityLevel::Public
200        );
201    }
202
203    #[test]
204    fn valid_iban_is_confidential() {
205        // DE89 3704 0044 0532 0130 00 is a well-known valid test IBAN.
206        assert_eq!(
207            classify_content("pay to DE89370400440532013000 now"),
208            SensitivityLevel::Confidential
209        );
210    }
211
212    #[test]
213    fn invalid_iban_not_flagged() {
214        assert_eq!(
215            classify_content("ref DE00370400440532013000"),
216            SensitivityLevel::Public
217        );
218    }
219
220    #[test]
221    fn redact_masks_card_and_iban_keeps_text() {
222        let red = redact_sensitive("card 4111 1111 1111 1111 iban DE89370400440532013000 end");
223        assert!(red.contains("[REDACTED:card]"));
224        assert!(red.contains("[REDACTED:iban]"));
225        assert!(red.contains("end"));
226        assert!(!red.contains("4111 1111 1111 1111"));
227    }
228
229    #[test]
230    fn secret_like_path_is_secret() {
231        assert_eq!(
232            classify_path(Path::new("/home/u/.ssh/id_rsa")),
233            SensitivityLevel::Secret
234        );
235        assert_eq!(
236            classify_path(Path::new("src/main.rs")),
237            SensitivityLevel::Public
238        );
239    }
240
241    #[test]
242    fn classify_takes_max_of_path_and_content() {
243        // Benign content but secret path → Secret.
244        assert_eq!(
245            classify(Some(Path::new("/x/.env")), "PORT=8080"),
246            SensitivityLevel::Secret
247        );
248    }
249}