use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::Value;
pub const DEFAULT_MASKED_FIELDS: &[&str] = &[
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"api-key",
"access_token",
"refresh_token",
"bearer",
"authorization",
"auth",
"credit_card",
"creditcard",
"card_number",
"cvv",
"ssn",
"social_security",
"pin",
"private_key",
"privatekey",
];
static EMAIL_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap());
static PHONE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b").unwrap());
static SSN_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap());
static CREDIT_CARD_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b").unwrap());
#[derive(Debug, Clone)]
pub struct MaskingConfig {
pub masked_fields: Vec<String>,
pub mask_emails: bool,
pub mask_phones: bool,
pub mask_ssn: bool,
pub mask_credit_cards: bool,
pub mask_char: char,
pub show_last_chars: usize,
}
impl Default for MaskingConfig {
fn default() -> Self {
Self {
masked_fields: DEFAULT_MASKED_FIELDS
.iter()
.map(|s| s.to_string())
.collect(),
mask_emails: true,
mask_phones: true,
mask_ssn: true,
mask_credit_cards: true,
mask_char: '*',
show_last_chars: 4,
}
}
}
impl MaskingConfig {
pub fn new() -> Self {
Self::default()
}
pub fn add_field(mut self, field: impl Into<String>) -> Self {
self.masked_fields.push(field.into());
self
}
pub fn mask_emails(mut self, mask: bool) -> Self {
self.mask_emails = mask;
self
}
pub fn mask_phones(mut self, mask: bool) -> Self {
self.mask_phones = mask;
self
}
pub fn mask_char(mut self, c: char) -> Self {
self.mask_char = c;
self
}
pub fn show_last_chars(mut self, n: usize) -> Self {
self.show_last_chars = n;
self
}
}
pub fn mask_string(input: &str, config: &MaskingConfig) -> String {
let mut result = input.to_string();
if config.mask_emails {
result = EMAIL_REGEX.replace_all(&result, "[EMAIL]").to_string();
}
if config.mask_phones {
result = PHONE_REGEX.replace_all(&result, "[PHONE]").to_string();
}
if config.mask_ssn {
result = SSN_REGEX.replace_all(&result, "[SSN]").to_string();
}
if config.mask_credit_cards {
result = CREDIT_CARD_REGEX.replace_all(&result, "[CARD]").to_string();
}
result
}
pub fn mask_value(value: &str, mask_char: char, show_last: usize) -> String {
if value.len() <= show_last {
return mask_char.to_string().repeat(value.len());
}
let masked_len = value.len() - show_last;
let masked_part = mask_char.to_string().repeat(masked_len);
let visible_part = &value[masked_len..];
format!("{}{}", masked_part, visible_part)
}
pub fn mask_json(value: &Value, config: &MaskingConfig) -> Value {
match value {
Value::Object(map) => {
let mut masked_map = serde_json::Map::new();
for (key, val) in map {
let key_lower = key.to_lowercase();
let should_mask = config
.masked_fields
.iter()
.any(|field| key_lower.contains(field));
if should_mask {
if let Value::String(s) = val {
masked_map.insert(
key.clone(),
Value::String(mask_value(s, config.mask_char, config.show_last_chars)),
);
} else {
masked_map.insert(key.clone(), Value::String("[REDACTED]".to_string()));
}
} else {
masked_map.insert(key.clone(), mask_json(val, config));
}
}
Value::Object(masked_map)
}
Value::Array(arr) => Value::Array(arr.iter().map(|v| mask_json(v, config)).collect()),
Value::String(s) => Value::String(mask_string(s, config)),
_ => value.clone(),
}
}
pub fn mask_body(body: &str, config: &MaskingConfig) -> String {
if let Ok(json) = serde_json::from_str::<Value>(body) {
let masked = mask_json(&json, config);
serde_json::to_string(&masked).unwrap_or_else(|_| body.to_string())
} else {
mask_string(body, config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_mask_value() {
assert_eq!(mask_value("secret123", '*', 3), "******123");
assert_eq!(mask_value("abc", '*', 3), "***");
assert_eq!(mask_value("ab", '*', 3), "**");
}
#[test]
fn test_mask_email() {
let config = MaskingConfig::default();
let masked = mask_string("Email: user@example.com", &config);
assert!(masked.contains("[EMAIL]"));
assert!(!masked.contains("user@example.com"));
}
#[test]
fn test_mask_phone() {
let config = MaskingConfig::default();
let masked = mask_string("Phone: 123-456-7890", &config);
assert!(masked.contains("[PHONE]"));
}
#[test]
fn test_mask_json_password() {
let config = MaskingConfig::default();
let data = json!({
"username": "alice",
"password": "secret123"
});
let masked = mask_json(&data, &config);
assert_eq!(masked["username"], "alice");
assert_ne!(masked["password"], "secret123");
assert!(masked["password"].as_str().unwrap().contains("*"));
}
#[test]
fn test_mask_json_nested() {
let config = MaskingConfig::default();
let data = json!({
"user": {
"name": "alice",
"password": "secret123"
}
});
let masked = mask_json(&data, &config);
assert_eq!(masked["user"]["name"], "alice");
assert_ne!(masked["user"]["password"], "secret123");
}
#[test]
fn test_mask_json_array() {
let config = MaskingConfig::default();
let data = json!([
{"username": "alice", "password": "secret1"},
{"username": "bob", "password": "secret2"}
]);
let masked = mask_json(&data, &config);
assert_eq!(masked[0]["username"], "alice");
assert_ne!(masked[0]["password"], "secret1");
}
#[test]
fn test_mask_body_json() {
let config = MaskingConfig::default();
let body = r#"{"username":"alice","password":"secret123"}"#;
let masked = mask_body(body, &config);
assert!(masked.contains("alice"));
assert!(!masked.contains("secret123"));
}
#[test]
fn test_mask_body_text() {
let config = MaskingConfig::default();
let body = "Email: user@example.com, Phone: 123-456-7890";
let masked = mask_body(body, &config);
assert!(masked.contains("[EMAIL]"));
assert!(masked.contains("[PHONE]"));
}
}