//! Detect and redact sensitive clipboard text for list previews. //! Full plaintext stays in SQLite and is used only on paste. use regex::Regex; use std::sync::OnceLock; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SensitiveKind { Password, Card, Banking, Secret, } static CARD_RE: OnceLock = OnceLock::new(); static IBAN_RE: OnceLock = OnceLock::new(); static ROUTING_RE: OnceLock = OnceLock::new(); static SECRET_LABEL_RE: OnceLock = OnceLock::new(); static JWT_RE: OnceLock = OnceLock::new(); fn card_re() -> &'static Regex { CARD_RE.get_or_init(|| { Regex::new(r"(?x)\b(?:\d[ -]*?){13,19}\b").expect("card regex") }) } fn iban_re() -> &'static Regex { IBAN_RE.get_or_init(|| { Regex::new(r"(?i)\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b").expect("iban regex") }) } fn routing_re() -> &'static Regex { ROUTING_RE.get_or_init(|| Regex::new(r"\b\d{9}\b").expect("routing regex")) } fn secret_label_re() -> &'static Regex { SECRET_LABEL_RE.get_or_init(|| { Regex::new( r"(?i)\b(password|passwd|passphrase|secret|api[_-]?key|token|auth|pin|cvv|routing|account\s*#?|acct)\b\s*[:=]\s*\S+", ) .expect("secret label regex") }) } fn jwt_re() -> &'static Regex { JWT_RE.get_or_init(|| { Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b") .expect("jwt regex") }) } /// Luhn check for probable card numbers (digits only). fn luhn_ok(digits: &str) -> bool { if digits.len() < 13 || digits.len() > 19 { return false; } let mut sum = 0; let mut alt = false; for c in digits.chars().rev() { let mut n = match c.to_digit(10) { Some(d) => d as i32, None => return false, }; if alt { n *= 2; if n > 9 { n -= 9; } } sum += n; alt = !alt; } sum % 10 == 0 } fn extract_digits(s: &str) -> String { s.chars().filter(|c| c.is_ascii_digit()).collect() } fn looks_like_password(text: &str) -> bool { let t = text.trim(); if t.contains('\n') || t.contains(' ') { return false; } let len = t.chars().count(); if !(10..=128).contains(&len) { return false; } // Skip obvious URLs / emails / paths if t.contains("://") || t.contains('@') || t.starts_with('/') || t.starts_with('~') { return false; } let has_lower = t.chars().any(|c| c.is_ascii_lowercase()); let has_upper = t.chars().any(|c| c.is_ascii_uppercase()); let has_digit = t.chars().any(|c| c.is_ascii_digit()); let has_sym = t.chars().any(|c| !c.is_ascii_alphanumeric()); let classes = [has_lower, has_upper, has_digit, has_sym] .iter() .filter(|&&x| x) .count(); classes >= 3 } fn banking_keywords(text: &str) -> bool { let lower = text.to_ascii_lowercase(); const KEYS: &[&str] = &[ "routing number", "account number", "bank account", "swift", "iban", "wire transfer", "sort code", "transit number", ]; KEYS.iter().any(|k| lower.contains(k)) } pub fn classify_sensitive(text: &str) -> Option { let t = text.trim(); if t.is_empty() { return None; } if secret_label_re().is_match(t) || jwt_re().is_match(t) { return Some(SensitiveKind::Secret); } for m in card_re().find_iter(t) { let digits = extract_digits(m.as_str()); if luhn_ok(&digits) { return Some(SensitiveKind::Card); } } if iban_re().is_match(t) || banking_keywords(t) { return Some(SensitiveKind::Banking); } // ABA routing often appears near account language — alone it's weak, so require banking cue or "routing" if routing_re().is_match(t) && (t.to_ascii_lowercase().contains("routing") || t.to_ascii_lowercase().contains("aba") || banking_keywords(t)) { return Some(SensitiveKind::Banking); } if looks_like_password(t) { return Some(SensitiveKind::Password); } None } /// Redacted list preview. Full value remains in the DB for paste. pub fn redact_preview(text: &str, kind: SensitiveKind) -> String { match kind { SensitiveKind::Password => "•••••••• (password)".into(), SensitiveKind::Secret => "•••••••• (secret)".into(), SensitiveKind::Card => { let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect(); let last4 = if digits.len() >= 4 { &digits[digits.len() - 4..] } else { "????" }; format!("•••• •••• •••• {} (card)", last4) } SensitiveKind::Banking => "•••••••• (banking)".into(), } } #[cfg(test)] mod tests { use super::*; #[test] fn detects_password_like() { assert_eq!( classify_sensitive("Tr0ub4dor&3xY!"), Some(SensitiveKind::Password) ); } #[test] fn ignores_normal_sentence() { assert_eq!(classify_sensitive("hello world this is fine"), None); } #[test] fn detects_labeled_password() { assert_eq!( classify_sensitive("password: hunter2secret"), Some(SensitiveKind::Secret) ); } #[test] fn detects_visa_test_card() { // Visa test number that passes Luhn assert_eq!( classify_sensitive("4111111111111111"), Some(SensitiveKind::Card) ); } #[test] fn card_preview_keeps_last4() { let p = redact_preview("4111111111111111", SensitiveKind::Card); assert!(p.contains("1111")); assert!(p.contains("••••")); assert!(!p.contains("4111111111111111")); } #[test] fn detects_iban() { assert_eq!( classify_sensitive("DE89370400440532013000"), Some(SensitiveKind::Banking) ); } }