use regex::Regex;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::sync::{Mutex, OnceLock};
use crate::classification::{classify_key, get_classification_policy};
use crate::receipts::record_redaction;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PIIMode {
Drop,
Redact,
Hash,
Truncate,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PIIRule {
pub path: Vec<String>,
pub mode: PIIMode,
pub truncate_to: usize,
}
impl PIIRule {
pub fn new(path: Vec<String>, mode: PIIMode, truncate_to: usize) -> Self {
Self {
path,
mode,
truncate_to,
}
}
}
const REDACTED: &str = "***";
const TRUNC_SUFFIX: &str = "...";
const DEFAULT_SENSITIVE: &[&str] = &[
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"auth",
"authorization",
"credential",
"private_key",
"ssn",
"credit_card",
"creditcard",
"cvv",
"pin",
"account_number",
"cookie",
];
#[derive(Clone, Debug)]
pub struct SecretPattern {
pub name: String,
pub pattern: Regex,
}
static RULES: OnceLock<Mutex<Vec<PIIRule>>> = OnceLock::new();
static CUSTOM_SECRET_PATTERNS: OnceLock<Mutex<Vec<(String, Regex)>>> = OnceLock::new();
#[cfg_attr(test, mutants::skip)] fn empty_pii_rules_mutex() -> Mutex<Vec<PIIRule>> {
Mutex::new(Vec::new())
}
fn rules() -> &'static Mutex<Vec<PIIRule>> {
RULES.get_or_init(empty_pii_rules_mutex)
}
#[cfg_attr(test, mutants::skip)] fn empty_custom_patterns_mutex() -> Mutex<Vec<(String, Regex)>> {
Mutex::new(Vec::new())
}
fn custom_secret_patterns() -> &'static Mutex<Vec<(String, Regex)>> {
CUSTOM_SECRET_PATTERNS.get_or_init(empty_custom_patterns_mutex)
}
fn compiled_builtin_secret_patterns() -> Vec<Regex> {
crate::secret_patterns_generated::PATTERNS
.iter()
.map(|(_name, pattern)| Regex::new(pattern).expect("generated pattern must be valid"))
.collect()
}
fn builtin_secret_patterns() -> &'static [Regex] {
static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
COMPILED
.get_or_init(compiled_builtin_secret_patterns)
.as_slice()
}
fn is_secret(value: &Value) -> bool {
let text = match value {
Value::String(s) => s,
_ => return false,
};
detect_secret_in_string(text)
}
pub(crate) fn detect_secret_in_string(text: &str) -> bool {
if text.len() < crate::secret_patterns_generated::MIN_SECRET_LENGTH {
return false;
}
for pattern in builtin_secret_patterns() {
if pattern.is_match(text) {
return true;
}
}
let patterns = crate::_lock::lock(custom_secret_patterns());
for (_, pattern) in patterns.iter() {
if pattern.is_match(text) {
return true;
}
}
false
}
pub(crate) const REDACTED_SENTINEL: &str = REDACTED;
pub fn register_secret_pattern(name: &str, pattern: Regex) {
let mut patterns = crate::_lock::lock(custom_secret_patterns());
if let Some((_, existing)) = patterns
.iter_mut()
.find(|(existing_name, _)| existing_name == name)
{
*existing = pattern;
} else {
patterns.push((name.to_string(), pattern));
}
}
pub fn get_secret_patterns() -> Vec<SecretPattern> {
let mut out: Vec<SecretPattern> = crate::secret_patterns_generated::PATTERNS
.iter()
.zip(builtin_secret_patterns().iter())
.map(|((name, _), p)| SecretPattern {
name: name.to_string(),
pattern: p.clone(),
})
.collect();
let patterns = crate::_lock::lock(custom_secret_patterns());
for (name, pattern) in patterns.iter() {
out.push(SecretPattern {
name: name.clone(),
pattern: pattern.clone(),
});
}
out
}
pub fn reset_secret_patterns_for_tests() {
crate::_lock::lock(custom_secret_patterns()).clear();
}
pub fn register_pii_rule(rule: PIIRule) {
crate::_lock::lock(rules()).push(rule);
}
pub fn replace_pii_rules(next: Vec<PIIRule>) {
*crate::_lock::lock(rules()) = next;
}
pub fn get_pii_rules() -> Vec<PIIRule> {
crate::_lock::lock(rules()).clone()
}
fn hash_value(value: &Value) -> String {
let mut hasher = Sha256::new();
match value {
Value::String(text) => hasher.update(text.as_bytes()),
_ => hasher.update(value.to_string().as_bytes()),
}
let digest = hasher.finalize();
format!("{:x}", digest)[..12].to_string()
}
fn mask_value(value: &Value, mode: &PIIMode, truncate_to: usize) -> Option<Value> {
match mode {
PIIMode::Drop => None,
PIIMode::Redact => Some(Value::String(REDACTED.to_string())),
PIIMode::Hash => Some(Value::String(hash_value(value))),
PIIMode::Truncate => {
let text = match value {
Value::String(value) => value.clone(),
_ => value.to_string(),
};
let char_count = text.chars().count();
if char_count <= truncate_to {
Some(Value::String(text))
} else {
let head: String = text.chars().take(truncate_to).collect();
Some(Value::String(format!("{head}{TRUNC_SUFFIX}")))
}
}
}
}
fn match_rule_path(rule_path: &[String], child_path: &[String]) -> bool {
if rule_path.len() != child_path.len() {
return false;
}
rule_path
.iter()
.zip(child_path.iter())
.all(|(rp, cp)| rp == "*" || rp == cp)
}
fn apply_rules(node: &Value, path: &[String], rules: &[PIIRule], max_depth: usize) -> Value {
if max_depth == 0 {
return node.clone();
}
match node {
Value::Object(map) => {
let mut out = Map::new();
for (key, value) in map {
let mut child_path = path.to_vec();
child_path.push(key.clone());
if let Some(rule) = rules
.iter()
.find(|rule| match_rule_path(&rule.path, &child_path))
{
if let Some(masked) = mask_value(value, &rule.mode, rule.truncate_to) {
out.insert(key.clone(), masked);
}
record_redaction(
&child_path.join("."),
&format!("{:?}", rule.mode).to_ascii_lowercase(),
value,
);
continue;
}
let lowered = key.to_ascii_lowercase();
if DEFAULT_SENSITIVE
.iter()
.any(|candidate| candidate == &lowered)
|| is_secret(value)
{
out.insert(key.clone(), Value::String(REDACTED.to_string()));
record_redaction(&child_path.join("."), "redact", value);
continue;
}
out.insert(
key.clone(),
apply_rules(value, &child_path, rules, max_depth - 1),
);
}
Value::Object(out)
}
Value::Array(values) => {
let mut star_path = path.to_vec();
star_path.push("*".to_string());
Value::Array(
values
.iter()
.map(|value| apply_rules(value, &star_path, rules, max_depth - 1))
.collect(),
)
}
_ => node.clone(),
}
}
fn annotate_governance_classes(cleaned: &mut Value) {
let Value::Object(map) = cleaned else {
return;
};
let policy = get_classification_policy();
let keys: Vec<String> = map.keys().cloned().collect();
for key in keys {
if let Some(label) = classify_key(&key) {
let action = policy.lookup_action(&label);
if action == "drop" {
map.remove(&key);
continue;
}
if matches!(action, "redact" | "hash" | "truncate") {
let current = map
.get(&key)
.cloned()
.expect("classification key snapshot must still exist");
let already_redacted = current.as_str().map(|s| s == REDACTED).unwrap_or(false);
if !already_redacted {
let mode = match action {
"redact" => PIIMode::Redact,
"hash" => PIIMode::Hash,
_ => PIIMode::Truncate,
};
let masked = mask_value(¤t, &mode, 8)
.expect("classification governance modes never drop values");
map.insert(key.clone(), masked);
}
}
map.insert(format!("__{key}__class"), Value::String(label));
}
}
}
pub fn sanitize_payload(payload: &Value, enabled: bool, max_depth: usize) -> Value {
if !enabled {
return payload.clone();
}
let rules = get_pii_rules();
let mut cleaned = apply_rules(payload, &[], &rules, max_depth.max(1));
annotate_governance_classes(&mut cleaned);
cleaned
}
#[cfg(test)]
#[path = "pii_tests.rs"]
mod tests;