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) || has_iban(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 let cards_masked = redact_credit_cards(&secrets_masked);
55 redact_ibans(&cards_masked)
56}
57
58fn 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
65fn 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: ®ex::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
115fn 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
124fn 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 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: ®ex::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 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 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 assert_eq!(
198 classify_content("order id 1234567890123456"),
199 SensitivityLevel::Public
200 );
201 }
202
203 #[test]
204 fn valid_iban_is_confidential() {
205 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 assert_eq!(
245 classify(Some(Path::new("/x/.env")), "PORT=8080"),
246 SensitivityLevel::Secret
247 );
248 }
249}