trace_weft_core/
redactor.rs1use crate::{RedactionResult, RedactionStatus, Redactor};
2use regex::Regex;
3use std::sync::Arc;
4
5pub struct RegexRedactor {
6 patterns: Vec<Regex>,
7}
8
9impl RegexRedactor {
10 pub fn new() -> Self {
11 Self { patterns: vec![] }
12 }
13
14 pub fn with_default_patterns() -> Self {
15 let mut redactor = Self::new();
16 if let Ok(email_re) = Regex::new(r"(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}") {
17 redactor.patterns.push(email_re);
18 }
19 if let Ok(key_re) = Regex::new(r"(?i)(sk-[a-zA-Z0-9]{32,})") {
20 redactor.patterns.push(key_re);
21 }
22 if let Ok(bearer_re) = Regex::new(r"(?i)(bearer\s+[a-zA-Z0-9\-\._~+/\\]+)") {
23 redactor.patterns.push(bearer_re);
24 }
25 if let Ok(secret_assignment_re) = Regex::new(
26 r#"(?i)\b(?:api[_-]?key|secret|token|client[_-]?secret)\s*[:=]\s*["']?[a-z0-9_\-]{16,}["']?"#,
27 ) {
28 redactor.patterns.push(secret_assignment_re);
29 }
30 if let Ok(phone_re) =
31 Regex::new(r"\+?(?:\d{1,3}[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}\b")
32 {
33 redactor.patterns.push(phone_re);
34 }
35 if let Ok(card_re) = Regex::new(r"\b(?:\d[ -]*?){13,19}\b") {
36 redactor.patterns.push(card_re);
37 }
38 redactor
39 }
40
41 pub fn add_pattern(&mut self, pattern: Regex) {
42 self.patterns.push(pattern);
43 }
44}
45
46impl Default for RegexRedactor {
47 fn default() -> Self {
48 Self::with_default_patterns()
49 }
50}
51
52impl Redactor for RegexRedactor {
53 fn redact(&self, input: &str) -> RedactionResult {
54 if self.patterns.is_empty() {
55 return RedactionResult {
56 redacted_text: input.to_string(),
57 status: RedactionStatus::Unredacted,
58 };
59 }
60
61 let mut current_text = input.to_string();
62 let mut was_redacted = false;
63
64 for re in &self.patterns {
65 let replaced = re.replace_all(¤t_text, "[REDACTED]");
66 if replaced != current_text {
67 was_redacted = true;
68 current_text = replaced.to_string();
69 }
70 }
71
72 RedactionResult {
73 redacted_text: current_text,
74 status: if was_redacted {
75 RedactionStatus::Redacted
76 } else {
77 RedactionStatus::Unredacted
78 },
79 }
80 }
81}
82
83pub type ArcRedactor = Arc<dyn Redactor>;
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 fn redactor() -> RegexRedactor {
90 RegexRedactor::with_default_patterns()
91 }
92
93 #[test]
94 fn redacts_email_addresses() {
95 let result = redactor().redact("contact me at jane.doe+test@example.co.uk please");
96 assert_eq!(result.redacted_text, "contact me at [REDACTED] please");
97 assert_eq!(result.status, RedactionStatus::Redacted);
98 }
99
100 #[test]
101 fn redacts_api_keys() {
102 let key = format!("sk-{}", "a1B2".repeat(10));
103 let result = redactor().redact(&format!("key={key}"));
104 assert_eq!(result.redacted_text, "key=[REDACTED]");
105 assert_eq!(result.status, RedactionStatus::Redacted);
106 }
107
108 #[test]
109 fn keeps_short_sk_prefixed_words() {
110 let result = redactor().redact("see sk-short for details");
111 assert_eq!(result.redacted_text, "see sk-short for details");
112 assert_eq!(result.status, RedactionStatus::Unredacted);
113 }
114
115 #[test]
116 fn redacts_bearer_tokens() {
117 let result = redactor().redact("Authorization: Bearer abc.DEF-123~xyz");
118 assert_eq!(result.redacted_text, "Authorization: [REDACTED]");
119 assert_eq!(result.status, RedactionStatus::Redacted);
120 }
121
122 #[test]
123 fn redacts_secret_assignments() {
124 let result = redactor().redact("api_key = tw_abcdefghijklmnopqrstuvwxyz");
125 assert_eq!(result.redacted_text, "[REDACTED]");
126 assert_eq!(result.status, RedactionStatus::Redacted);
127 }
128
129 #[test]
130 fn redacts_phone_numbers() {
131 let result = redactor().redact("call +1 (415) 555-2671 tomorrow");
132 assert_eq!(result.redacted_text, "call [REDACTED] tomorrow");
133 assert_eq!(result.status, RedactionStatus::Redacted);
134 }
135
136 #[test]
137 fn redacts_credit_card_like_numbers() {
138 let result = redactor().redact("card 4242 4242 4242 4242");
139 assert_eq!(result.redacted_text, "card [REDACTED]");
140 assert_eq!(result.status, RedactionStatus::Redacted);
141 }
142
143 #[test]
144 fn redacts_multiple_findings_in_one_text() {
145 let result = redactor().redact("a@b.com and Bearer tok123");
146 assert_eq!(result.redacted_text, "[REDACTED] and [REDACTED]");
147 assert_eq!(result.status, RedactionStatus::Redacted);
148 }
149
150 #[test]
151 fn leaves_clean_text_untouched() {
152 let result = redactor().redact("the quick brown fox");
153 assert_eq!(result.redacted_text, "the quick brown fox");
154 assert_eq!(result.status, RedactionStatus::Unredacted);
155 }
156
157 #[test]
158 fn empty_redactor_passes_content_through() {
159 let result = RegexRedactor::new().redact("a@b.com");
160 assert_eq!(result.redacted_text, "a@b.com");
161 assert_eq!(result.status, RedactionStatus::Unredacted);
162 }
163
164 #[test]
165 fn supports_user_configured_patterns() {
166 let mut redactor = RegexRedactor::new();
167 redactor.add_pattern(Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap());
168 let result = redactor.redact("ssn: 123-45-6789");
169 assert_eq!(result.redacted_text, "ssn: [REDACTED]");
170 assert_eq!(result.status, RedactionStatus::Redacted);
171 }
172
173 #[test]
174 fn default_uses_builtin_patterns() {
175 let result = RegexRedactor::default().redact("a@b.com");
176 assert_eq!(result.status, RedactionStatus::Redacted);
177 }
178}