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 → `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) {
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 card numbers, which 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    redact_credit_cards(&secrets_masked)
55}
56
57// ---- Card numbers (Luhn) ---------------------------------------------------
58
59fn card_re() -> &'static Regex {
60    static RE: OnceLock<Regex> = OnceLock::new();
61    RE.get_or_init(|| Regex::new(r"\b\d(?:[ -]?\d){12,18}\b").expect("valid card regex"))
62}
63
64/// Luhn checksum over decimal digits. Length 13–19 (standard PAN range).
65fn luhn_valid(digits: &[u8]) -> bool {
66    if digits.len() < 13 || digits.len() > 19 {
67        return false;
68    }
69    let mut sum = 0u32;
70    let mut double = false;
71    for &d in digits.iter().rev() {
72        if d > 9 {
73            return false;
74        }
75        let mut v = d as u32;
76        if double {
77            v *= 2;
78            if v > 9 {
79                v -= 9;
80            }
81        }
82        sum += v;
83        double = !double;
84    }
85    sum.is_multiple_of(10)
86}
87
88fn digits_of(s: &str) -> Vec<u8> {
89    s.chars()
90        .filter(char::is_ascii_digit)
91        .map(|c| c as u8 - b'0')
92        .collect()
93}
94
95/// A card-shaped (see `card_layout_plausible`), Luhn-valid candidate that is
96/// not a segment of a version token (#2135).
97fn is_card(text: &str, candidate: &regex::Match<'_>) -> bool {
98    crate::core::input_filters::pii::card_layout_plausible(candidate.as_str())
99        && luhn_valid(&digits_of(candidate.as_str()))
100        && !crate::core::input_filters::pii::inside_version_token(text, candidate)
101}
102
103fn has_credit_card(content: &str) -> bool {
104    card_re().find_iter(content).any(|m| is_card(content, &m))
105}
106
107fn redact_credit_cards(text: &str) -> String {
108    card_re()
109        .replace_all(text, |caps: &regex::Captures| {
110            let m = caps.get(0).map(|x| x.as_str()).unwrap_or_default();
111            if caps.get(0).is_some_and(|matched| is_card(text, &matched)) {
112                "[REDACTED:card]".to_string()
113            } else {
114                m.to_string()
115            }
116        })
117        .into_owned()
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn secret_content_is_secret() {
126        // GitHub token shape is detected by secret_detection.
127        let t = concat!("export TOKEN=gh", "p_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
128        assert_eq!(classify_content(t), SensitivityLevel::Secret);
129    }
130
131    #[test]
132    fn benign_content_is_public() {
133        assert_eq!(
134            classify_content("the quick brown fox jumps over 12 lazy dogs"),
135            SensitivityLevel::Public
136        );
137    }
138
139    #[test]
140    fn luhn_valid_card_is_confidential() {
141        // 4111 1111 1111 1111 is the canonical Visa test number (Luhn-valid).
142        assert_eq!(
143            classify_content("card: 4111 1111 1111 1111 on file"),
144            SensitivityLevel::Confidential
145        );
146    }
147
148    #[test]
149    fn random_16_digits_not_flagged_unless_luhn() {
150        // 1234567890123456 is NOT Luhn-valid → must stay public.
151        assert_eq!(
152            classify_content("order id 1234567890123456"),
153            SensitivityLevel::Public
154        );
155    }
156
157    #[test]
158    fn redact_masks_card_and_leaves_an_iban_to_filter_packs() {
159        let red = redact_sensitive("card 4111 1111 1111 1111 iban DE89370400440532013000 end");
160        assert!(red.contains("[REDACTED:card]"));
161        // IBANs are a Hive filter pack, not a built-in detector.
162        assert!(red.contains("DE89370400440532013000"));
163        assert!(red.contains("end"));
164        assert!(!red.contains("4111 1111 1111 1111"));
165    }
166
167    #[test]
168    fn timestamped_names_are_not_cards_even_when_luhn_valid() {
169        let stamp = (0..10)
170            .map(|d| format!("20261006-16153{d}"))
171            .find(|s| luhn_valid(&digits_of(s)))
172            .expect("one check digit makes the stamp Luhn-valid");
173        let text = format!("session {stamp} archived");
174        assert!(!has_credit_card(&text));
175        assert_eq!(redact_credit_cards(&text), text);
176    }
177
178    #[test]
179    fn go_pseudo_version_stamps_are_not_cards() {
180        // #2135: the stamp segment of a Go pseudo-version.
181        let stamp = (0..10)
182            .map(|d| format!("2026090112454{d}"))
183            .find(|s| luhn_valid(&digits_of(s)))
184            .expect("one check digit makes the stamp Luhn-valid");
185        let text = format!("github.com/c/d v0.6.1-0.{stamp}-3de7acda5f88");
186        assert!(!has_credit_card(&text));
187        assert_eq!(redact_credit_cards(&text), text);
188        let card = format!(
189            "card {} on file",
190            ["4111", "1111", "1111", "1111"].join(" ")
191        );
192        assert!(has_credit_card(&card));
193    }
194
195    #[test]
196    fn secret_like_path_is_secret() {
197        assert_eq!(
198            classify_path(Path::new("/home/u/.ssh/id_rsa")),
199            SensitivityLevel::Secret
200        );
201        assert_eq!(
202            classify_path(Path::new("src/main.rs")),
203            SensitivityLevel::Public
204        );
205    }
206
207    #[test]
208    fn classify_takes_max_of_path_and_content() {
209        // Benign content but secret path → Secret.
210        assert_eq!(
211            classify(Some(Path::new("/x/.env")), "PORT=8080"),
212            SensitivityLevel::Secret
213        );
214    }
215}