use regex::Regex;
use serde::{Deserialize, Serialize};
pub const REDACTED: &str = "[REDACTED]";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedactionConfig {
pub patterns: Vec<String>,
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
patterns: vec![
"password".into(),
"passwd".into(),
"pwd".into(),
"token".into(),
"access_token".into(),
"refresh_token".into(),
"secret".into(),
"client_secret".into(),
"key".into(),
"api_key".into(),
"apikey".into(),
"api-key".into(),
"authorization".into(),
"auth".into(),
"credential".into(),
"cred".into(),
"private".into(),
"private_key".into(),
"ssn".into(),
"social_security".into(),
"credit_card".into(),
"card_number".into(),
"cvv".into(),
"cvc".into(),
],
}
}
}
#[derive(Debug, Clone)]
pub struct CompiledRedaction {
patterns: Vec<Regex>,
}
impl CompiledRedaction {
pub fn redact(&self, value: &serde_json::Value) -> serde_json::Value {
redact_value(value, &self.patterns)
}
}
impl RedactionConfig {
pub fn compile(&self) -> CompiledRedaction {
let patterns = self
.patterns
.iter()
.filter_map(|p| Regex::new(&format!("(?i){}", regex::escape(p))).ok())
.collect();
CompiledRedaction { patterns }
}
}
pub fn redact_arguments(value: &serde_json::Value, config: &RedactionConfig) -> serde_json::Value {
config.compile().redact(value)
}
fn has_letter_boundary_match(key: &str, pattern: &Regex) -> bool {
for m in pattern.find_iter(key) {
let before = key[..m.start()].chars().next_back();
let after = key[m.end()..].chars().next();
let preceded_by_letter = before.is_some_and(|c| c.is_ascii_alphabetic());
let followed_by_letter = after.is_some_and(|c| c.is_ascii_alphabetic());
if !preceded_by_letter && !followed_by_letter {
return true;
}
}
false
}
fn redact_value(value: &serde_json::Value, patterns: &[Regex]) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut redacted = serde_json::Map::new();
for (k, v) in map {
if patterns.iter().any(|p| has_letter_boundary_match(k, p)) {
redacted.insert(k.clone(), serde_json::Value::String(REDACTED.into()));
} else {
redacted.insert(k.clone(), redact_value(v, patterns));
}
}
serde_json::Value::Object(redacted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(|v| redact_value(v, patterns)).collect())
}
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn redacts_sensitive_fields() {
let config = RedactionConfig::default();
let input = json!({
"path": "/etc/hosts",
"api_key": "sk-12345",
"password": "hunter2",
"nested": {
"access_token": "abc",
"count": 42
}
});
let redacted = redact_arguments(&input, &config);
assert_eq!(redacted["path"], "/etc/hosts");
assert_eq!(redacted["api_key"], REDACTED);
assert_eq!(redacted["password"], REDACTED);
assert_eq!(redacted["nested"]["access_token"], REDACTED);
assert_eq!(redacted["nested"]["count"], 42);
}
#[test]
fn redaction_is_case_insensitive() {
let config = RedactionConfig {
patterns: vec!["secret".into()],
};
let input = json!({
"SECRET_VALUE": "classified",
"my_Secret": "also classified",
"public": "visible"
});
let redacted = redact_arguments(&input, &config);
assert_eq!(redacted["SECRET_VALUE"], REDACTED);
assert_eq!(redacted["my_Secret"], REDACTED);
assert_eq!(redacted["public"], "visible");
}
#[test]
fn redacts_inside_arrays() {
let config = RedactionConfig {
patterns: vec!["token".into()],
};
let input = json!([
{"token": "abc", "id": 1},
{"token": "def", "id": 2}
]);
let redacted = redact_arguments(&input, &config);
let arr = redacted.as_array().unwrap();
assert_eq!(arr[0]["token"], REDACTED);
assert_eq!(arr[0]["id"], 1);
assert_eq!(arr[1]["token"], REDACTED);
}
#[test]
fn empty_patterns_redact_nothing() {
let config = RedactionConfig { patterns: vec![] };
let input = json!({"password": "hunter2", "secret": "x"});
let redacted = redact_arguments(&input, &config);
assert_eq!(redacted["password"], "hunter2");
assert_eq!(redacted["secret"], "x");
}
#[test]
fn scalar_values_pass_through() {
let config = RedactionConfig::default();
let input = json!("just a string");
assert_eq!(redact_arguments(&input, &config), json!("just a string"));
let input = json!(42);
assert_eq!(redact_arguments(&input, &config), json!(42));
}
#[test]
fn redaction_is_word_boundary_match() {
let config = RedactionConfig {
patterns: vec!["key".into()],
};
let input = json!({
"api_key": "secret-1",
"key_id": "secret-2",
"monkey": "banana",
"keyboard": "qwerty",
"unrelated": "visible"
});
let redacted = redact_arguments(&input, &config);
assert_eq!(
redacted["api_key"], REDACTED,
"api_key has 'key' at boundary"
);
assert_eq!(redacted["key_id"], REDACTED, "key_id has 'key' at boundary");
assert_eq!(
redacted["monkey"], "banana",
"monkey should not be redacted"
);
assert_eq!(
redacted["keyboard"], "qwerty",
"keyboard should not be redacted"
);
assert_eq!(redacted["unrelated"], "visible");
}
#[test]
fn redaction_does_not_match_unrelated() {
let config = RedactionConfig {
patterns: vec!["token".into()],
};
let input = json!({
"access_token": "secret",
"token_type": "bearer",
"tokelau_island": "pacific",
"notation": "musical"
});
let redacted = redact_arguments(&input, &config);
assert_eq!(redacted["access_token"], REDACTED);
assert_eq!(redacted["token_type"], REDACTED);
assert_eq!(redacted["tokelau_island"], "pacific");
assert_eq!(redacted["notation"], "musical");
}
#[test]
fn deeply_nested_json_redaction() {
let config = RedactionConfig {
patterns: vec!["secret".into()],
};
let mut value = json!({"secret": "deep-secret-value", "visible": "ok"});
for _ in 0..10 {
value = json!({"level": value});
}
let redacted = redact_arguments(&value, &config);
let mut current = &redacted;
for _ in 0..10 {
current = ¤t["level"];
}
assert_eq!(
current["secret"], REDACTED,
"deeply nested 'secret' field must be redacted"
);
assert_eq!(
current["visible"], "ok",
"non-secret field at depth must be preserved"
);
}
#[test]
fn does_not_redact_non_sensitive_substrings() {
let config = RedactionConfig::default();
let input = json!({
"keyboard": "mechanical",
"monkey": "curious george",
"author": "Jane Doe",
"authenticate_method": "oauth2"
});
let redacted = redact_arguments(&input, &config);
assert_eq!(
redacted["keyboard"], "mechanical",
"keyboard should not be redacted"
);
assert_eq!(
redacted["monkey"], "curious george",
"monkey should not be redacted"
);
assert_eq!(
redacted["author"], "Jane Doe",
"author should not be redacted"
);
assert_eq!(
redacted["authenticate_method"], "oauth2",
"authenticate_method should not be redacted"
);
}
#[test]
fn still_redacts_sensitive_compound_fields() {
let config = RedactionConfig::default();
let input = json!({
"api_key": "sk-12345",
"api-key": "sk-67890",
"x-auth-token": "bearer-abc",
"user_password": "hunter2"
});
let redacted = redact_arguments(&input, &config);
assert_eq!(redacted["api_key"], "[REDACTED]");
assert_eq!(redacted["api-key"], "[REDACTED]");
assert_eq!(redacted["x-auth-token"], "[REDACTED]");
assert_eq!(redacted["user_password"], "[REDACTED]");
}
#[test]
fn compiled_redaction_matches_redact_arguments() {
let config = RedactionConfig::default();
let compiled = config.compile();
let input = json!({
"path": "/etc/hosts",
"api_key": "sk-12345",
"password": "hunter2",
"nested": {
"access_token": "abc",
"count": 42
}
});
let result_compiled = compiled.redact(&input);
let result_wrapper = redact_arguments(&input, &config);
assert_eq!(
result_compiled, result_wrapper,
"compiled and wrapper should produce identical output"
);
}
#[test]
fn compiled_redaction_reusable_across_calls() {
let config = RedactionConfig {
patterns: vec!["secret".into(), "key".into()],
};
let compiled = config.compile();
let input1 = json!({"secret": "val1", "public": "ok"});
let input2 = json!({"api_key": "val2", "name": "test"});
let r1 = compiled.redact(&input1);
let r2 = compiled.redact(&input2);
assert_eq!(r1["secret"], REDACTED);
assert_eq!(r1["public"], "ok");
assert_eq!(r2["api_key"], REDACTED);
assert_eq!(r2["name"], "test");
}
#[test]
fn compiled_redaction_empty_patterns() {
let config = RedactionConfig { patterns: vec![] };
let compiled = config.compile();
let input = json!({"password": "hunter2", "secret": "x"});
let redacted = compiled.redact(&input);
assert_eq!(redacted["password"], "hunter2");
assert_eq!(redacted["secret"], "x");
}
}