Skip to main content

trace_weft_core/
redactor.rs

1use 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(&current_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}