use std::collections::HashMap;
use std::sync::OnceLock;
use regex::Regex;
pub const REDACTED: &str = "[FILTERED]";
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<Value>),
Object(HashMap<String, Value>),
}
impl From<&str> for Value {
fn from(v: &str) -> Self {
Value::String(v.to_string())
}
}
impl From<String> for Value {
fn from(v: String) -> Self {
Value::String(v)
}
}
impl From<bool> for Value {
fn from(v: bool) -> Self {
Value::Bool(v)
}
}
impl From<i64> for Value {
fn from(v: i64) -> Self {
Value::Number(v as f64)
}
}
impl From<i32> for Value {
fn from(v: i32) -> Self {
Value::Number(v as f64)
}
}
impl From<u64> for Value {
fn from(v: u64) -> Self {
Value::Number(v as f64)
}
}
impl From<f64> for Value {
fn from(v: f64) -> Self {
Value::Number(v)
}
}
impl Value {
pub fn to_json(&self) -> String {
match self {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", *n as i64)
} else {
n.to_string()
}
}
Value::String(s) => json_string(s),
Value::Array(items) => {
let parts: Vec<String> = items.iter().map(Value::to_json).collect();
format!("[{}]", parts.join(","))
}
Value::Object(map) => {
let parts: Vec<String> = map
.iter()
.map(|(k, v)| format!("{}:{}", json_string(k), v.to_json()))
.collect();
format!("{{{}}}", parts.join(","))
}
}
}
}
pub fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
const SENSITIVE_KEYS: &[&str] = &[
"password",
"passwd",
"pwd",
"secret",
"apisecret",
"clientsecret",
"secretkey",
"token",
"accesstoken",
"refreshtoken",
"apikey",
"apitoken",
"authorization",
"authtoken",
"bearer",
"sessiontoken",
"csrftoken",
"creditcard",
"cardnumber",
"cardnum",
"cvv",
"cvv2",
"cvc",
"ssn",
"socialsecuritynumber",
"socialsecurity",
"privatekey",
];
fn patterns() -> &'static [(&'static str, Regex)] {
static PATTERNS: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
PATTERNS.get_or_init(|| {
vec![
(
"EMAIL",
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap(),
),
("SSN", Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap()),
(
"CREDIT CARD",
Regex::new(r"\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,4}\b").unwrap(),
),
(
"BEARER TOKEN",
Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*").unwrap(),
),
(
"JWT",
Regex::new(r"\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")
.unwrap(),
),
("AWS KEY", Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap()),
(
"STRIPE KEY",
Regex::new(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b").unwrap(),
),
(
"GITHUB TOKEN",
Regex::new(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b").unwrap(),
),
]
})
}
pub fn scrub_string(text: &str) -> String {
let mut result = text.to_string();
for (label, re) in patterns() {
result = re
.replace_all(&result, format!("[{label} FILTERED]").as_str())
.into_owned();
}
result
}
pub fn scrub_value(value: &Value, key: &str) -> Value {
if !matches!(value, Value::Null) && is_sensitive_key(key) {
return Value::String(REDACTED.to_string());
}
match value {
Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| (k.clone(), scrub_value(v, k)))
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(|v| scrub_value(v, key)).collect()),
Value::String(s) => Value::String(scrub_string(s)),
other => other.clone(),
}
}
fn is_sensitive_key(key: &str) -> bool {
if key.is_empty() {
return false;
}
let normalized: String = key
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect();
SENSITIVE_KEYS
.iter()
.any(|sensitive| normalized.contains(sensitive))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrub_string_email() {
assert_eq!(
scrub_string("contact user@example.com for help"),
"contact [EMAIL FILTERED] for help"
);
}
#[test]
fn scrub_string_credit_card() {
assert_ne!(
scrub_string("charged card 4242-4242-4242-4242 successfully"),
"charged card 4242-4242-4242-4242 successfully"
);
}
#[test]
fn scrub_string_leaves_ordinary_numeric_id_alone() {
let text = "order id 1234567890123456";
assert_eq!(scrub_string(text), text);
}
#[test]
fn scrub_string_ssn() {
assert_eq!(
scrub_string("ssn on file: 123-45-6789"),
"ssn on file: [SSN FILTERED]"
);
}
#[test]
fn scrub_string_known_token_formats() {
for text in [
"Authorization: Bearer abc123DEF.456-xyz",
"aws key AKIAABCDEFGHIJKLMNOP in use",
"stripe key sk_live_abcdefghijklmnop",
"github token ghp_abcdefghijklmnopqrstuvwxyz0123456789",
"jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQ",
] {
assert_ne!(scrub_string(text), text, "expected {text:?} to be redacted");
}
}
#[test]
fn scrub_value_redacts_whole_value_under_sensitive_key() {
assert_eq!(
scrub_value(&Value::Number(12345.0), "apiKey"),
Value::String(REDACTED.to_string())
);
}
#[test]
fn scrub_value_recurses_into_objects_and_arrays() {
let mut input = HashMap::new();
input.insert("password".to_string(), Value::String("hunter2".to_string()));
input.insert(
"note".to_string(),
Value::String("email me at user@example.com".to_string()),
);
let mut nested = HashMap::new();
nested.insert("token".to_string(), Value::String("abc".to_string()));
input.insert(
"items".to_string(),
Value::Array(vec![
Value::Object(nested),
Value::String("visit user@example.com".to_string()),
]),
);
let scrubbed = scrub_value(&Value::Object(input), "");
let Value::Object(map) = scrubbed else {
panic!("expected an object")
};
assert_eq!(map["password"], Value::String(REDACTED.to_string()));
assert_eq!(
map["note"],
Value::String("email me at [EMAIL FILTERED]".to_string())
);
let Value::Array(items) = &map["items"] else {
panic!("expected an array")
};
let Value::Object(first) = &items[0] else {
panic!("expected an object")
};
assert_eq!(first["token"], Value::String(REDACTED.to_string()));
assert_eq!(
items[1],
Value::String("visit [EMAIL FILTERED]".to_string())
);
}
#[test]
fn is_sensitive_key_ignores_case_and_punctuation() {
for key in ["API_KEY", "Api-Key", "apiKey", "X-Api-Key"] {
assert!(is_sensitive_key(key), "expected {key:?} to be sensitive");
}
assert!(!is_sensitive_key("username"));
}
#[test]
fn value_to_json() {
let mut map = HashMap::new();
map.insert("a".to_string(), Value::Number(1.0));
let value = Value::Object(map);
assert_eq!(value.to_json(), "{\"a\":1}");
assert_eq!(
Value::String("he said \"hi\"".to_string()).to_json(),
"\"he said \\\"hi\\\"\""
);
assert_eq!(
Value::Array(vec![Value::Bool(true), Value::Null]).to_json(),
"[true,null]"
);
}
}