use std::sync::OnceLock;
use regex::Regex;
use serde_json::{Map, Value};
fn detectors() -> &'static [(Regex, &'static str)] {
static DETECTORS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
DETECTORS.get_or_init(|| {
vec![
(
Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap(),
"<redacted:email>",
),
(Regex::new(r"\d{3}-\d{2}-\d{4}").unwrap(), "<redacted:ssn>"),
(
Regex::new(r"(?:\d[ -]?){13,19}").unwrap(),
"<redacted:card>",
),
(
Regex::new(r"(?:\+?\d[\s.\-]?){9,14}\d").unwrap(),
"<redacted:phone>",
),
(
Regex::new(r"(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)")
.unwrap(),
"<redacted:ip>",
),
]
})
}
fn sensitive_key() -> &'static Regex {
static KEY: OnceLock<Regex> = OnceLock::new();
KEY.get_or_init(|| {
Regex::new(r"(?i)email|phone|ssn|dob|birth|address|name|card|account|token|secret|password")
.unwrap()
})
}
fn replace_guarded(input: &str, re: &Regex, repl: &str) -> String {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut last = 0;
for m in re.find_iter(input) {
let before_is_digit = m.start() > 0 && bytes[m.start() - 1].is_ascii_digit();
let after_is_digit = m.end() < bytes.len() && bytes[m.end()].is_ascii_digit();
if before_is_digit || after_is_digit {
continue;
}
out.push_str(&input[last..m.start()]);
out.push_str(repl);
last = m.end();
}
out.push_str(&input[last..]);
out
}
pub type Extra<'a> = Option<&'a (dyn Fn(&str) -> String + Send + Sync)>;
pub fn redact_string(s: &str) -> String {
redact_string_with(s, None)
}
pub fn redact_string_with(s: &str, extra: Extra<'_>) -> String {
let mut out = s.to_string();
for (re, repl) in detectors() {
out = replace_guarded(&out, re, repl);
}
match extra {
Some(f) => f(&out),
None => out,
}
}
pub fn redact_value(value: &Value) -> Value {
redact_value_with(value, None)
}
pub fn redact_value_with(value: &Value, extra: Extra<'_>) -> Value {
redact_with_key(value, None, extra)
}
fn redact_with_key(value: &Value, key_hint: Option<&str>, extra: Extra<'_>) -> Value {
match value {
Value::String(s) => {
if key_hint.is_some_and(|k| sensitive_key().is_match(k)) {
Value::String("<redacted>".to_string())
} else {
Value::String(redact_string_with(s, extra))
}
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|v| redact_with_key(v, None, extra))
.collect(),
),
Value::Object(map) => {
let mut out = Map::new();
for (k, v) in map {
out.insert(k.clone(), redact_with_key(v, Some(k), extra));
}
Value::Object(out)
}
other => other.clone(),
}
}