use super::event::RedactionRecord;
use crate::internal::encoding::bytes_to_lower_hex;
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use std::collections::BTreeMap;
type HmacSha256 = Hmac<Sha256>;
pub const SENSITIVE_FIELD_NAMES: &[&str] = &[
"token",
"access_token",
"refresh_token",
"cookie",
"set-cookie",
"authorization",
"password",
"credential",
"secret",
"header",
"path",
];
#[must_use]
pub fn sha256_hex(value: &[u8]) -> String {
crate::internal::encoding::sha256_hex(value)
}
pub fn hmac_sha256_hex(secret: &[u8], value: &[u8]) -> Result<String, hmac::digest::InvalidLength> {
let mut mac = HmacSha256::new_from_slice(secret)?;
mac.update(value);
let result = mac.finalize().into_bytes();
Ok(bytes_to_lower_hex(&result))
}
#[must_use]
pub fn is_sensitive_field_name(name: &str) -> bool {
SENSITIVE_FIELD_NAMES.iter().any(|sensitive| {
let needle = sensitive.as_bytes();
name.len() >= needle.len()
&& name
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
})
}
#[must_use]
pub fn scrub_audit_metadata(
metadata: BTreeMap<String, Value>,
) -> (BTreeMap<String, Value>, Vec<RedactionRecord>) {
let mut redactions = Vec::new();
let scrubbed = metadata
.into_iter()
.map(|(key, value)| {
if is_sensitive_field_name(&key) {
redactions.push(RedactionRecord {
field_path: format!("metadata.{key}"),
reason: "sensitive field name".to_string(),
});
(key, Value::String("[REDACTED]".to_string()))
} else {
(key, value)
}
})
.collect();
(scrubbed, redactions)
}
#[cfg(test)]
mod scrub_tests {
use super::{is_sensitive_field_name, scrub_audit_metadata};
use serde_json::{Value, json};
use std::collections::BTreeMap;
#[test]
fn redacts_sensitive_metadata_keys() {
let mut metadata = BTreeMap::new();
metadata.insert("authorization".to_string(), json!("Bearer abc.def"));
metadata.insert("symbol".to_string(), json!("AAPL"));
let (scrubbed, redactions) = scrub_audit_metadata(metadata);
assert_eq!(
scrubbed.get("authorization"),
Some(&Value::String("[REDACTED]".to_string()))
);
assert_eq!(scrubbed.get("symbol"), Some(&json!("AAPL")));
assert_eq!(redactions.len(), 1);
assert_eq!(redactions[0].field_path, "metadata.authorization");
}
#[test]
fn leaves_non_sensitive_metadata_unchanged() {
let mut metadata = BTreeMap::new();
metadata.insert("symbol".to_string(), json!("AAPL"));
metadata.insert("quantity".to_string(), json!(100));
let (scrubbed, redactions) = scrub_audit_metadata(metadata.clone());
assert_eq!(scrubbed, metadata);
assert!(redactions.is_empty());
}
#[test]
fn redacts_value_regardless_of_payload_shape() {
let mut metadata = BTreeMap::new();
metadata.insert(
"session_cookie".to_string(),
json!({"nested": {"deep": "secret"}}),
);
let (scrubbed, redactions) = scrub_audit_metadata(metadata);
assert_eq!(
scrubbed.get("session_cookie"),
Some(&Value::String("[REDACTED]".to_string()))
);
assert_eq!(redactions.len(), 1);
}
#[test]
fn detects_sensitive_names_case_insensitively_without_exact_case() {
assert!(is_sensitive_field_name("X-AuthORIZATION-Header"));
assert!(is_sensitive_field_name("session_cookie"));
assert!(!is_sensitive_field_name("symbol"));
}
}