lean_ctx/core/sensitivity/
classify.rs1use std::path::Path;
8use std::sync::OnceLock;
9
10use regex::Regex;
11
12use super::SensitivityLevel;
13use crate::core::{io_boundary, secret_detection};
14
15pub 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
26pub 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
37pub 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
43pub(super) fn redact_sensitive(text: &str) -> String {
46 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
57fn 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
64fn 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
95fn is_card(text: &str, candidate: ®ex::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: ®ex::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 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 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 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 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 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 assert_eq!(
211 classify(Some(Path::new("/x/.env")), "PORT=8080"),
212 SensitivityLevel::Secret
213 );
214 }
215}