1#![forbid(unsafe_code)]
46#![deny(missing_docs)]
47
48use regex::Regex;
49use rlg::log::Log;
50use serde_json::Value;
51use std::sync::LazyLock;
52
53pub const DEFAULT_MARKER: &str = "[REDACTED]";
55
56pub const CREDIT_CARD: &str = r"\b(?:\d[ -]?){12,18}\d\b";
64
65pub const JWT: &str =
67 r"\beyJ[A-Za-z0-9_=-]+\.[A-Za-z0-9._=-]+\.[A-Za-z0-9._=-]+\b";
68
69pub const BEARER_TOKEN: &str = r"(?i)Bearer\s+[A-Za-z0-9._~+/-]+=*";
71
72pub const EMAIL: &str =
74 r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b";
75
76pub const IPV4: &str = r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b";
78
79pub const AWS_ACCESS_KEY: &str =
81 r"\b(?:AKIA|ASIA|AGPA|ANPA|ANVA|AROA|AIPA)[A-Z0-9]{16}\b";
82
83const DEFAULT_SOURCES: [&str; 6] =
86 [CREDIT_CARD, JWT, BEARER_TOKEN, EMAIL, IPV4, AWS_ACCESS_KEY];
87
88static DEFAULT_COMBINED: LazyLock<Regex> = LazyLock::new(|| {
92 build_combined(&DEFAULT_SOURCES)
93 .expect("built-in patterns must compile as an alternation")
94});
95
96fn build_combined<S: AsRef<str>>(
100 sources: &[S],
101) -> Result<Regex, regex::Error> {
102 debug_assert!(!sources.is_empty(), "must not build from empty set");
103 let alternation = sources
104 .iter()
105 .map(|p| format!("(?:{})", p.as_ref()))
106 .collect::<Vec<_>>()
107 .join("|");
108 Regex::new(&alternation)
109}
110
111#[derive(Debug, Clone)]
119pub struct Redactor {
120 sources: Vec<String>,
123 combined: Option<Regex>,
127 marker: String,
129}
130
131impl Default for Redactor {
132 fn default() -> Self {
133 Self::empty()
134 }
135}
136
137impl Redactor {
138 #[must_use]
141 pub fn empty() -> Self {
142 Self {
143 sources: Vec::new(),
144 combined: None,
145 marker: DEFAULT_MARKER.to_string(),
146 }
147 }
148
149 #[must_use]
157 pub fn with_defaults() -> Self {
158 Self {
159 sources: DEFAULT_SOURCES
160 .iter()
161 .map(|s| (*s).to_string())
162 .collect(),
163 combined: Some(DEFAULT_COMBINED.clone()),
164 marker: DEFAULT_MARKER.to_string(),
165 }
166 }
167
168 pub fn with_pattern(
177 mut self,
178 pattern: &str,
179 ) -> Result<Self, regex::Error> {
180 let _ = Regex::new(pattern)?;
183 self.sources.push(pattern.to_string());
184 self.combined = Some(build_combined(&self.sources)?);
188 Ok(self)
189 }
190
191 #[must_use]
193 pub fn marker(mut self, marker: impl Into<String>) -> Self {
194 self.marker = marker.into();
195 self
196 }
197
198 #[must_use]
201 pub fn scrub(&self, mut log: Log) -> Log {
202 log.description = self.apply(&log.description);
203 for value in log.attributes.values_mut() {
204 *value =
205 self.scrub_value(std::mem::replace(value, Value::Null));
206 }
207 log
208 }
209
210 #[must_use]
216 pub fn apply(&self, input: &str) -> String {
217 match &self.combined {
218 None => input.to_string(),
219 Some(re) => {
220 re.replace_all(input, self.marker.as_str()).into_owned()
221 }
222 }
223 }
224
225 fn scrub_value(&self, v: Value) -> Value {
226 match v {
227 Value::String(s) => Value::String(self.apply(&s)),
228 Value::Array(items) => Value::Array(
229 items
230 .into_iter()
231 .map(|i| self.scrub_value(i))
232 .collect(),
233 ),
234 Value::Object(map) => Value::Object(
235 map.into_iter()
236 .map(|(k, v)| (k, self.scrub_value(v)))
237 .collect(),
238 ),
239 other => other,
240 }
241 }
242
243 #[must_use]
245 pub fn len(&self) -> usize {
246 self.sources.len()
247 }
248
249 #[must_use]
251 pub fn is_empty(&self) -> bool {
252 self.sources.is_empty()
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use rlg::log_level::LogLevel;
260
261 #[test]
262 fn empty_redactor_is_a_no_op() {
263 let r = Redactor::empty();
264 let log = Log::info("4111-1111-1111-1111");
265 let out = r.scrub(log.clone());
266 assert_eq!(out.description, log.description);
267 }
268
269 #[test]
270 fn default_marker_is_redacted() {
271 assert_eq!(DEFAULT_MARKER, "[REDACTED]");
272 let r = Redactor::with_defaults();
273 let out = r.apply("email me at user@example.com");
274 assert!(out.contains("[REDACTED]"));
275 assert!(!out.contains("user@example.com"));
276 }
277
278 #[test]
279 fn custom_marker_replaces_default() {
280 let r = Redactor::with_defaults().marker("***");
281 let out = r.apply("ip 192.168.0.1 down");
282 assert!(out.contains("***"));
283 assert!(!out.contains("192.168.0.1"));
284 }
285
286 #[test]
287 fn credit_card_pattern_matches_visa() {
288 let r = Redactor::empty().with_pattern(CREDIT_CARD).unwrap();
289 assert!(!r.apply("4111-1111-1111-1111").contains("4111"));
290 assert!(!r.apply("4111 1111 1111 1111").contains("4111"));
291 assert!(!r.apply("4111111111111111").contains("4111"));
292 }
293
294 #[test]
295 fn jwt_pattern_matches() {
296 let r = Redactor::empty().with_pattern(JWT).unwrap();
297 let token =
298 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdEFGHijk";
299 let out = r.apply(&format!("auth={token} ok"));
300 assert!(!out.contains("eyJ"));
301 assert!(out.contains("[REDACTED]"));
302 }
303
304 #[test]
305 fn bearer_token_pattern_matches() {
306 let r = Redactor::empty().with_pattern(BEARER_TOKEN).unwrap();
307 let out = r.apply("Authorization: Bearer abc123XYZ.foo");
308 assert!(out.contains("[REDACTED]"));
309 assert!(!out.contains("abc123XYZ"));
310 }
311
312 #[test]
313 fn email_pattern_matches() {
314 let r = Redactor::empty().with_pattern(EMAIL).unwrap();
315 let out = r.apply("sent to alice+test@example.co.uk today");
316 assert!(out.contains("[REDACTED]"));
317 assert!(!out.contains("alice"));
318 }
319
320 #[test]
321 fn ipv4_pattern_matches() {
322 let r = Redactor::empty().with_pattern(IPV4).unwrap();
323 let out = r.apply("client 10.0.1.42 disconnected");
324 assert!(out.contains("[REDACTED]"));
325 assert!(!out.contains("10.0.1.42"));
326 }
327
328 #[test]
329 fn aws_key_pattern_matches() {
330 let r = Redactor::empty().with_pattern(AWS_ACCESS_KEY).unwrap();
331 let out = r.apply("AKIAIOSFODNN7EXAMPLE leaked");
332 assert!(out.contains("[REDACTED]"));
333 }
334
335 #[test]
336 fn scrub_walks_string_attributes() {
337 let r = Redactor::with_defaults();
338 let log = Log::build(LogLevel::INFO, "user@host.com signed in")
339 .with("email", "other@host.com")
340 .with("session_id_num", 42_u64);
341 let out = r.scrub(log);
342 assert!(!out.description.contains("user@host.com"));
343 assert_eq!(
345 out.attributes.get("session_id_num"),
346 Some(&serde_json::json!(42_u64))
347 );
348 let email = out.attributes.get("email").unwrap();
350 assert!(email.as_str().unwrap().contains("[REDACTED]"));
351 }
352
353 #[test]
354 fn scrub_recurses_into_nested_json() {
355 let r = Redactor::with_defaults();
356 let log = Log::info("x").with(
357 "payload",
358 serde_json::json!({
359 "user": { "email": "x@y.com" },
360 "ips": ["10.0.0.1", "192.168.0.1"]
361 }),
362 );
363 let out = r.scrub(log);
364 let payload = out.attributes.get("payload").unwrap();
365 let serialised = payload.to_string();
366 assert!(!serialised.contains("x@y.com"));
367 assert!(!serialised.contains("10.0.0.1"));
368 }
369
370 #[test]
371 fn with_pattern_rejects_invalid_regex() {
372 let r = Redactor::empty().with_pattern("[unclosed");
373 assert!(r.is_err());
374 }
375
376 #[test]
377 fn len_reflects_patterns_loaded() {
378 let r = Redactor::with_defaults();
379 assert_eq!(r.len(), 6);
380 assert!(!r.is_empty());
381 assert!(Redactor::empty().is_empty());
382 }
383
384 #[test]
387 fn fusion_scans_all_pattern_kinds_in_one_pass() {
388 let r = Redactor::with_defaults();
392 let out = r.apply(
393 "cc 4111-1111-1111-1111, jwt \
394 eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd, \
395 Bearer xyz.abc, alice@example.com, 10.0.0.1, \
396 AKIAIOSFODNN7EXAMPLE",
397 );
398 for needle in [
399 "4111",
400 "eyJhbGciOiJIUzI1NiJ9",
401 "xyz.abc",
402 "alice@example.com",
403 "10.0.0.1",
404 "AKIAIOSFODNN7EXAMPLE",
405 ] {
406 assert!(
407 !out.contains(needle),
408 "fusion missed {needle:?} in output {out:?}"
409 );
410 }
411 assert!(out.matches("[REDACTED]").count() >= 6);
412 }
413
414 #[test]
415 fn fusion_prefers_leftmost_match_across_pattern_kinds() {
416 let r = Redactor::empty()
420 .with_pattern(CREDIT_CARD)
421 .unwrap()
422 .with_pattern(IPV4)
423 .unwrap();
424 let out = r.apply("card 4111-1111-1111-1111 from 10.0.0.1");
425 assert!(!out.contains("4111"));
426 assert!(!out.contains("10.0.0.1"));
427 assert_eq!(out.matches("[REDACTED]").count(), 2);
428 }
429
430 #[test]
431 fn fusion_compiles_alternation_from_chained_with_pattern() {
432 let r = Redactor::empty()
436 .with_pattern(r"AAA-\d+")
437 .unwrap()
438 .with_pattern(r"BBB-\d+")
439 .unwrap()
440 .with_pattern(r"CCC-\d+")
441 .unwrap();
442 assert_eq!(r.len(), 3);
443 let out = r.apply("AAA-1 BBB-2 CCC-3 DDD-4");
444 assert!(out.contains("[REDACTED] [REDACTED] [REDACTED] DDD-4"));
445 }
446}