From fbd6aab7fe7025ecfa19d6638212e898602e3196 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 31 May 2026 20:47:59 -0500 Subject: [PATCH] fix(security): expand Password PII patterns; add regression tests Two credential patterns were missing from the PiiDetector, confirmed by live audit log showing was_pii_redacted: false with plaintext creds: 1. Abbreviated key form (pass: abc123!!): the pattern only matched password|passwd|pwd. Added pass, passphrase, secret with a word boundary to prevent substring false positives (bypass:, compass:). 2. Natural language form (Is the password password123 good): added a second Password sub-pattern for keyword-adjacent values without a key separator. Value must contain a digit or special char to avoid flagging plain words (password strength, password policy). 5 new regression tests added. 233/233 Rust tests pass. --- src-tauri/src/pii/detector.rs | 85 +++++++++++++++++++++++++++++++++++ src-tauri/src/pii/patterns.rs | 16 ++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/pii/detector.rs b/src-tauri/src/pii/detector.rs index 757b4e46..23fb9582 100644 --- a/src-tauri/src/pii/detector.rs +++ b/src-tauri/src/pii/detector.rs @@ -86,6 +86,91 @@ mod tests { assert!(spans.iter().any(|s| s.pii_type == "Bearer")); } + #[test] + fn test_detect_password_keyword() { + let detector = PiiDetector::new(); + // Full keyword forms + assert!(detector + .detect("password: hunter2") + .iter() + .any(|s| s.pii_type == "Password")); + assert!(detector + .detect("passwd=hunter2") + .iter() + .any(|s| s.pii_type == "Password")); + assert!(detector + .detect("pwd: hunter2") + .iter() + .any(|s| s.pii_type == "Password")); + } + + #[test] + fn test_detect_pass_abbreviation() { + let detector = PiiDetector::new(); + // Abbreviated form used in credential files (was the failing case) + let text = "user: alpha\npass: abc123!!"; + let spans = detector.detect(text); + assert!( + spans.iter().any(|s| s.pii_type == "Password"), + "Expected Password span for 'pass: abc123!!' — got: {spans:?}" + ); + } + + #[test] + fn test_detect_secret_keyword() { + let detector = PiiDetector::new(); + assert!(detector + .detect("secret: mysecretvalue") + .iter() + .any(|s| s.pii_type == "Password")); + assert!(detector + .detect("passphrase: correct horse battery staple") + .iter() + .any(|s| s.pii_type == "Password")); + } + + #[test] + fn test_detect_password_natural_language() { + let detector = PiiDetector::new(); + // Direct juxtaposition: "password " (was the second failing case) + let spans = detector.detect("Is the password password123 good"); + assert!( + spans.iter().any(|s| s.pii_type == "Password"), + "Expected Password span for natural-language 'password password123' — got: {spans:?}" + ); + // "password is X" + assert!(detector + .detect("my password is hunter2") + .iter() + .any(|s| s.pii_type == "Password")); + // Value must have digit or special — plain words should not trigger + assert!( + !detector + .detect("password strength") + .iter() + .any(|s| s.pii_type == "Password"), + "False positive: 'password strength' should not match" + ); + assert!( + !detector + .detect("password policy") + .iter() + .any(|s| s.pii_type == "Password"), + "False positive: 'password policy' should not match" + ); + } + + #[test] + fn test_password_no_false_positive_bypass() { + let detector = PiiDetector::new(); + // "bypass" contains "pass" as a substring — must NOT match + let spans = detector.detect("bypass: enabled"); + assert!( + !spans.iter().any(|s| s.pii_type == "Password"), + "False positive: 'bypass:' should not match Password pattern" + ); + } + #[test] fn test_no_overlap() { let detector = PiiDetector::new(); diff --git a/src-tauri/src/pii/patterns.rs b/src-tauri/src/pii/patterns.rs index ab28f0c3..1601b167 100644 --- a/src-tauri/src/pii/patterns.rs +++ b/src-tauri/src/pii/patterns.rs @@ -22,10 +22,22 @@ pub fn get_patterns() -> Vec<(PiiType, Regex)> { ) .unwrap(), ), - // Password + // Password (key=value / config file form) — word-boundary anchored ( PiiType::Password, - Regex::new(r"(?i)(?:password|passwd|pwd)\s*[=:]\s*\S+").unwrap(), + Regex::new( + r"(?i)\b(?:password|passwd|passphrase|pass|pwd|secret)\s*[=:]\s*\S+", + ) + .unwrap(), + ), + // Password (natural language form): "password is X", "password X" + // Value must contain at least one digit or special char to avoid flagging plain words. + ( + PiiType::Password, + Regex::new( + r"(?i)\b(?:password|passwd|passphrase)\s+(?:is\s+|was\s+)?[A-Za-z0-9!@#$%^&*_\-+=@#,.]*[0-9!@#$%^&*_\-+=@#][A-Za-z0-9!@#$%^&*_\-+=@#,.]*", + ) + .unwrap(), ), // SSN (check before phone to avoid partial matches) (