- 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
225 lines
6.0 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|