1use std::collections::HashMap;
13use std::sync::OnceLock;
14
15use regex::Regex;
16
17pub const REDACTED: &str = "[FILTERED]";
18
19#[derive(Clone, Debug, PartialEq)]
24pub enum Value {
25 Null,
26 Bool(bool),
27 Number(f64),
28 String(String),
29 Array(Vec<Value>),
30 Object(HashMap<String, Value>),
31}
32
33impl From<&str> for Value {
34 fn from(v: &str) -> Self {
35 Value::String(v.to_string())
36 }
37}
38impl From<String> for Value {
39 fn from(v: String) -> Self {
40 Value::String(v)
41 }
42}
43impl From<bool> for Value {
44 fn from(v: bool) -> Self {
45 Value::Bool(v)
46 }
47}
48impl From<i64> for Value {
49 fn from(v: i64) -> Self {
50 Value::Number(v as f64)
51 }
52}
53impl From<i32> for Value {
54 fn from(v: i32) -> Self {
55 Value::Number(v as f64)
56 }
57}
58impl From<u64> for Value {
59 fn from(v: u64) -> Self {
60 Value::Number(v as f64)
61 }
62}
63impl From<f64> for Value {
64 fn from(v: f64) -> Self {
65 Value::Number(v)
66 }
67}
68
69impl Value {
70 pub fn to_json(&self) -> String {
74 match self {
75 Value::Null => "null".to_string(),
76 Value::Bool(b) => b.to_string(),
77 Value::Number(n) => {
78 if n.fract() == 0.0 && n.abs() < 1e15 {
79 format!("{}", *n as i64)
80 } else {
81 n.to_string()
82 }
83 }
84 Value::String(s) => json_string(s),
85 Value::Array(items) => {
86 let parts: Vec<String> = items.iter().map(Value::to_json).collect();
87 format!("[{}]", parts.join(","))
88 }
89 Value::Object(map) => {
90 let parts: Vec<String> = map
91 .iter()
92 .map(|(k, v)| format!("{}:{}", json_string(k), v.to_json()))
93 .collect();
94 format!("{{{}}}", parts.join(","))
95 }
96 }
97 }
98}
99
100pub fn json_string(s: &str) -> String {
101 let mut out = String::with_capacity(s.len() + 2);
102 out.push('"');
103 for c in s.chars() {
104 match c {
105 '"' => out.push_str("\\\""),
106 '\\' => out.push_str("\\\\"),
107 '\n' => out.push_str("\\n"),
108 '\r' => out.push_str("\\r"),
109 '\t' => out.push_str("\\t"),
110 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
111 c => out.push(c),
112 }
113 }
114 out.push('"');
115 out
116}
117
118const SENSITIVE_KEYS: &[&str] = &[
119 "password",
120 "passwd",
121 "pwd",
122 "secret",
123 "apisecret",
124 "clientsecret",
125 "secretkey",
126 "token",
127 "accesstoken",
128 "refreshtoken",
129 "apikey",
130 "apitoken",
131 "authorization",
132 "authtoken",
133 "bearer",
134 "sessiontoken",
135 "csrftoken",
136 "creditcard",
137 "cardnumber",
138 "cardnum",
139 "cvv",
140 "cvv2",
141 "cvc",
142 "ssn",
143 "socialsecuritynumber",
144 "socialsecurity",
145 "privatekey",
146];
147
148fn patterns() -> &'static [(&'static str, Regex)] {
149 static PATTERNS: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
150 PATTERNS.get_or_init(|| {
151 vec![
152 (
153 "EMAIL",
154 Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap(),
155 ),
156 ("SSN", Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap()),
157 (
158 "CREDIT CARD",
159 Regex::new(r"\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,4}\b").unwrap(),
160 ),
161 (
162 "BEARER TOKEN",
163 Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*").unwrap(),
164 ),
165 (
166 "JWT",
167 Regex::new(r"\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")
168 .unwrap(),
169 ),
170 ("AWS KEY", Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap()),
171 (
172 "STRIPE KEY",
173 Regex::new(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b").unwrap(),
174 ),
175 (
176 "GITHUB TOKEN",
177 Regex::new(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b").unwrap(),
178 ),
179 ]
180 })
181}
182
183pub fn scrub_string(text: &str) -> String {
186 let mut result = text.to_string();
187 for (label, re) in patterns() {
188 result = re
189 .replace_all(&result, format!("[{label} FILTERED]").as_str())
190 .into_owned();
191 }
192 result
193}
194
195pub fn scrub_value(value: &Value, key: &str) -> Value {
199 if !matches!(value, Value::Null) && is_sensitive_key(key) {
200 return Value::String(REDACTED.to_string());
201 }
202
203 match value {
204 Value::Object(map) => Value::Object(
205 map.iter()
206 .map(|(k, v)| (k.clone(), scrub_value(v, k)))
207 .collect(),
208 ),
209 Value::Array(items) => Value::Array(items.iter().map(|v| scrub_value(v, key)).collect()),
210 Value::String(s) => Value::String(scrub_string(s)),
211 other => other.clone(),
212 }
213}
214
215fn is_sensitive_key(key: &str) -> bool {
216 if key.is_empty() {
217 return false;
218 }
219 let normalized: String = key
220 .chars()
221 .filter(|c| c.is_ascii_alphanumeric())
222 .map(|c| c.to_ascii_lowercase())
223 .collect();
224 SENSITIVE_KEYS
225 .iter()
226 .any(|sensitive| normalized.contains(sensitive))
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn scrub_string_email() {
235 assert_eq!(
236 scrub_string("contact user@example.com for help"),
237 "contact [EMAIL FILTERED] for help"
238 );
239 }
240
241 #[test]
242 fn scrub_string_credit_card() {
243 assert_ne!(
244 scrub_string("charged card 4242-4242-4242-4242 successfully"),
245 "charged card 4242-4242-4242-4242 successfully"
246 );
247 }
248
249 #[test]
250 fn scrub_string_leaves_ordinary_numeric_id_alone() {
251 let text = "order id 1234567890123456";
252 assert_eq!(scrub_string(text), text);
253 }
254
255 #[test]
256 fn scrub_string_ssn() {
257 assert_eq!(
258 scrub_string("ssn on file: 123-45-6789"),
259 "ssn on file: [SSN FILTERED]"
260 );
261 }
262
263 #[test]
264 fn scrub_string_known_token_formats() {
265 for text in [
266 "Authorization: Bearer abc123DEF.456-xyz",
267 "aws key AKIAABCDEFGHIJKLMNOP in use",
268 "stripe key sk_live_abcdefghijklmnop",
269 "github token ghp_abcdefghijklmnopqrstuvwxyz0123456789",
270 "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQ",
271 ] {
272 assert_ne!(scrub_string(text), text, "expected {text:?} to be redacted");
273 }
274 }
275
276 #[test]
277 fn scrub_value_redacts_whole_value_under_sensitive_key() {
278 assert_eq!(
279 scrub_value(&Value::Number(12345.0), "apiKey"),
280 Value::String(REDACTED.to_string())
281 );
282 }
283
284 #[test]
285 fn scrub_value_recurses_into_objects_and_arrays() {
286 let mut input = HashMap::new();
287 input.insert("password".to_string(), Value::String("hunter2".to_string()));
288 input.insert(
289 "note".to_string(),
290 Value::String("email me at user@example.com".to_string()),
291 );
292
293 let mut nested = HashMap::new();
294 nested.insert("token".to_string(), Value::String("abc".to_string()));
295 input.insert(
296 "items".to_string(),
297 Value::Array(vec![
298 Value::Object(nested),
299 Value::String("visit user@example.com".to_string()),
300 ]),
301 );
302
303 let scrubbed = scrub_value(&Value::Object(input), "");
304 let Value::Object(map) = scrubbed else {
305 panic!("expected an object")
306 };
307
308 assert_eq!(map["password"], Value::String(REDACTED.to_string()));
309 assert_eq!(
310 map["note"],
311 Value::String("email me at [EMAIL FILTERED]".to_string())
312 );
313
314 let Value::Array(items) = &map["items"] else {
315 panic!("expected an array")
316 };
317 let Value::Object(first) = &items[0] else {
318 panic!("expected an object")
319 };
320 assert_eq!(first["token"], Value::String(REDACTED.to_string()));
321 assert_eq!(
322 items[1],
323 Value::String("visit [EMAIL FILTERED]".to_string())
324 );
325 }
326
327 #[test]
328 fn is_sensitive_key_ignores_case_and_punctuation() {
329 for key in ["API_KEY", "Api-Key", "apiKey", "X-Api-Key"] {
330 assert!(is_sensitive_key(key), "expected {key:?} to be sensitive");
331 }
332 assert!(!is_sensitive_key("username"));
333 }
334
335 #[test]
336 fn value_to_json() {
337 let mut map = HashMap::new();
338 map.insert("a".to_string(), Value::Number(1.0));
339 let value = Value::Object(map);
340 assert_eq!(value.to_json(), "{\"a\":1}");
341
342 assert_eq!(
343 Value::String("he said \"hi\"".to_string()).to_json(),
344 "\"he said \\\"hi\\\"\""
345 );
346 assert_eq!(
347 Value::Array(vec![Value::Bool(true), Value::Null]).to_json(),
348 "[true,null]"
349 );
350 }
351}