use serde_json::Value;
use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::LazyLock;
static DEFAULT_SENSITIVE_FIELDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"api_key",
"apikey",
"api-key",
"authorization",
"auth",
"token",
"access_token",
"refresh_token",
"bearer",
"secret",
"secret_key",
"private_key",
"password",
"passwd",
"credential",
"credentials",
"openai_api_key",
"anthropic_api_key",
"azure_api_key",
"google_api_key",
"x-api-key",
"x-auth-token",
"session",
"session_id",
"cookie",
"set-cookie",
"ssn",
"credit_card",
"card_number",
"cvv",
"pin",
]
.into_iter()
.collect()
});
const REDACTED: &str = "[REDACTED]";
#[derive(Debug, Clone)]
pub struct RedactionConfig {
pub additional_fields: HashSet<String>,
pub exclude_fields: HashSet<String>,
pub redact_by_pattern: bool,
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
additional_fields: HashSet::new(),
exclude_fields: HashSet::new(),
redact_by_pattern: true,
}
}
}
pub fn redact_value<'a>(key: &str, value: &'a str, config: &RedactionConfig) -> Cow<'a, str> {
let key_lower = key.to_lowercase();
if config
.exclude_fields
.iter()
.any(|f| f.to_lowercase() == key_lower)
{
return Cow::Borrowed(value);
}
if is_sensitive_field(&key_lower, config) {
return Cow::Borrowed(REDACTED);
}
if config.redact_by_pattern && looks_like_api_key(value) {
return Cow::Borrowed(REDACTED);
}
Cow::Borrowed(value)
}
fn is_sensitive_field(key_lower: &str, config: &RedactionConfig) -> bool {
if DEFAULT_SENSITIVE_FIELDS.contains(key_lower) {
return true;
}
if config
.additional_fields
.iter()
.any(|f| f.to_lowercase() == key_lower)
{
return true;
}
let sensitive_patterns = ["key", "token", "secret", "password", "auth", "credential"];
for pattern in sensitive_patterns {
if key_lower.contains(pattern) {
return true;
}
}
false
}
fn looks_like_api_key(value: &str) -> bool {
if value.len() < 20 || value.len() > 200 {
return false;
}
let prefixes = [
"sk-", "sk_", "pk_", "Bearer ", "Basic ", "ghp_", "gho_", "glpat-", "xoxb-", "xoxp-", ];
for prefix in prefixes {
if value.starts_with(prefix) {
return true;
}
}
let alphanumeric_ratio =
value.chars().filter(|c| c.is_alphanumeric()).count() as f64 / value.len() as f64;
if alphanumeric_ratio > 0.8 {
let has_upper = value.chars().any(|c| c.is_uppercase());
let has_lower = value.chars().any(|c| c.is_lowercase());
let has_digit = value.chars().any(|c| c.is_ascii_digit());
if has_upper && has_lower && has_digit {
return true;
}
}
false
}
pub fn redact_json_value(value: &mut Value, config: &RedactionConfig) {
match value {
Value::Object(map) => {
for (key, val) in map.iter_mut() {
let key_lower = key.to_lowercase();
if is_sensitive_field(&key_lower, config) {
*val = Value::String(REDACTED.to_string());
} else {
redact_json_value(val, config);
}
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
redact_json_value(item, config);
}
}
Value::String(s) => {
if config.redact_by_pattern && looks_like_api_key(s) {
*value = Value::String(REDACTED.to_string());
}
}
_ => {}
}
}
pub fn redact_headers(
headers: &[(String, String)],
config: &RedactionConfig,
) -> Vec<(String, String)> {
headers
.iter()
.map(|(key, value)| {
let redacted_value = redact_value(key, value, config);
(key.clone(), redacted_value.into_owned())
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_sensitive_fields() {
let config = RedactionConfig::default();
assert_eq!(redact_value("api_key", "sk-1234567890", &config), REDACTED);
assert_eq!(
redact_value("Authorization", "Bearer token123", &config),
REDACTED
);
assert_eq!(redact_value("password", "secret123", &config), REDACTED);
assert_eq!(redact_value("model", "gpt-4", &config), "gpt-4");
assert_eq!(
redact_value("message", "Hello world", &config),
"Hello world"
);
}
#[test]
fn test_looks_like_api_key() {
assert!(looks_like_api_key("sk-1234567890abcdefghij"));
assert!(looks_like_api_key("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6"));
assert!(!looks_like_api_key("Hello world"));
assert!(!looks_like_api_key("short"));
assert!(!looks_like_api_key("gpt-4-turbo"));
}
#[test]
fn test_redact_json_value() {
let config = RedactionConfig::default();
let mut json = serde_json::json!({
"model": "gpt-4",
"api_key": "sk-secret123",
"nested": {
"token": "bearer-xyz",
"data": "normal"
}
});
redact_json_value(&mut json, &config);
assert_eq!(json["model"], "gpt-4");
assert_eq!(json["api_key"], REDACTED);
assert_eq!(json["nested"]["token"], REDACTED);
assert_eq!(json["nested"]["data"], "normal");
}
#[test]
fn test_custom_fields() {
let mut config = RedactionConfig::default();
config.additional_fields.insert("custom_secret".to_string());
assert_eq!(redact_value("custom_secret", "my-value", &config), REDACTED);
}
#[test]
fn test_exclude_fields() {
let mut config = RedactionConfig::default();
config.exclude_fields.insert("api_key".to_string());
assert_eq!(
redact_value("api_key", "sk-1234567890", &config),
"sk-1234567890"
);
}
}