Files
maCopy/src-tauri/src/redact.rs
T
ilia e9461ed4f3
CI / skip-ci-check (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 3s
CI / node-ci (pull_request) Successful in 17s
Add copy-only, color swatches, DB speed pragmas, and living docs
- Copy only (right-click -> Copy): write to clipboard without
  auto-pasting or hiding the window
- Color swatches: hex/rgb/hsl entries show a preview chip
- SQLite WAL + synchronous=NORMAL so the UI can read while the
  clipboard poller writes
- Memoize list rows (React.memo) so selection/focus changes don't
  re-render every row's preview
- Add docs/GUIDE.md (living install + usage doc), docs/SHARING.md,
  docs/TESTING.md, docs/LAUNCH-AT-LOGIN.md, docs/PRODUCT.md,
  CHANGELOG.md, ROADMAP.md
- Add homelab Gitea Actions CI (.gitea/workflows/ci.yml) + gitleaks
  allowlist
- Bump version to 0.2.0
2026-07-14 21:58:31 -04:00

225 lines
6.0 KiB
Rust

//! 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<Regex> = OnceLock::new();
static IBAN_RE: OnceLock<Regex> = OnceLock::new();
static ROUTING_RE: OnceLock<Regex> = OnceLock::new();
static SECRET_LABEL_RE: OnceLock<Regex> = OnceLock::new();
static JWT_RE: OnceLock<Regex> = 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<SensitiveKind> {
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)
);
}
}