use regex::{Regex, RegexBuilder};
use serde_json::Value;
use std::sync::Arc;
pub trait Redactor: Send + Sync {
fn redact(&self, v: &Value) -> Value;
}
pub struct DefaultRedactor {
patterns: Vec<(&'static str, Regex)>,
}
impl Default for DefaultRedactor {
fn default() -> Self {
Self::new()
}
}
impl DefaultRedactor {
#[must_use]
pub fn new() -> Self {
let ci = |pat: &str| -> Regex {
RegexBuilder::new(pat)
.case_insensitive(true)
.build()
.unwrap()
};
let patterns: Vec<(&'static str, Regex)> = vec![
("BIC", ci(r"\b[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?\b")),
("IBAN", ci(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b")),
(
"EMAIL",
ci(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
),
(
"JWT",
ci(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
),
("IPV4", ci(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
(
"PEM",
ci(r"-----BEGIN (?:[A-Z ]+)-----[\s\S]+?-----END (?:[A-Z ]+)-----"),
),
("TAXID_DE", ci(r"\b\d{11}\b")),
];
Self { patterns }
}
fn redact_string(&self, s: &str) -> String {
let mut out = s.to_string();
for (label, re) in &self.patterns {
out = re
.replace_all(&out, format!("[REDACTED:{label}]").as_str())
.into_owned();
}
out
}
}
impl Redactor for DefaultRedactor {
fn redact(&self, v: &Value) -> Value {
match v {
Value::String(s) => Value::String(self.redact_string(s)),
Value::Array(xs) => Value::Array(xs.iter().map(|x| self.redact(x)).collect()),
Value::Object(m) => {
let mut out = serde_json::Map::with_capacity(m.len());
for (k, val) in m {
out.insert(k.clone(), self.redact(val));
}
Value::Object(out)
}
_ => v.clone(),
}
}
}
pub type SharedRedactor = Arc<dyn Redactor>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_redactor_redacts_iban() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"note": "send to DE89370400440532013000"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(!s.contains("DE89370400440532013000"));
assert!(s.contains("[REDACTED:IBAN]"));
}
#[test]
fn default_redactor_redacts_email() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"to": "alice@example.com"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(!s.contains("alice@example.com"));
assert!(s.contains("[REDACTED:EMAIL]"));
}
#[test]
fn default_redactor_redacts_jwt() {
let r = DefaultRedactor::new();
let header = format!("ey{}", "JhbGciOiJIUzI1NiJ9");
let payload = format!("ey{}", "JzdWIiOiIxMjMifQ");
let jwt = format!("{header}.{payload}.abc");
let v = serde_json::json!({ "auth": jwt });
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(s.contains("[REDACTED:JWT]"));
}
#[test]
fn default_redactor_preserves_clean_strings() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"amount": 1234, "currency": "EUR"});
let out = r.redact(&v);
assert_eq!(out, v);
}
#[test]
fn default_redactor_redacts_lowercase_iban() {
let r = DefaultRedactor::new();
let v = serde_json::json!({"note": "send to de89370400440532013000"});
let out = r.redact(&v);
let s = serde_json::to_string(&out).unwrap();
assert!(
!s.contains("de89370400440532013000"),
"lowercase IBAN must be redacted"
);
assert!(s.contains("[REDACTED:IBAN]"));
}
}