use super::{AtpEvent, RedactionRule, RedactionType};
use serde_json::Value;
use std::collections::HashSet;
pub fn apply_redaction(event: &mut AtpEvent, rules: &[RedactionRule]) {
let mut redacted_fields = HashSet::new();
redact_value(&mut event.data, rules, &mut redacted_fields, "data");
redact_context_fields(event, rules, &mut redacted_fields);
event.redacted_fields = redacted_fields.into_iter().collect();
event.redacted_fields.sort(); }
pub fn redact_json_value(
value: &mut Value,
rules: &[RedactionRule],
root_path: &str,
) -> Vec<String> {
let mut redacted_fields = HashSet::new();
redact_value(value, rules, &mut redacted_fields, root_path);
let mut fields = redacted_fields.into_iter().collect::<Vec<_>>();
fields.sort();
fields
}
fn redact_value(
value: &mut Value,
rules: &[RedactionRule],
redacted_fields: &mut HashSet<String>,
field_path: &str,
) {
match value {
Value::Object(obj) => {
for (key, val) in obj.iter_mut() {
let current_path = if field_path.is_empty() {
key.clone()
} else {
format!("{}.{}", field_path, key)
};
if should_redact_field(¤t_path, rules) {
let replacement = get_redaction_replacement(¤t_path, rules);
*val = Value::String(replacement);
redacted_fields.insert(current_path.clone());
} else {
redact_value(val, rules, redacted_fields, ¤t_path);
}
}
}
Value::Array(arr) => {
for (idx, val) in arr.iter_mut().enumerate() {
let current_path = format!("{}[{}]", field_path, idx);
redact_value(val, rules, redacted_fields, ¤t_path);
}
}
Value::String(s) => {
if let Some(redacted) = redact_string_patterns(s, rules, field_path) {
*value = Value::String(redacted);
redacted_fields.insert(field_path.to_string());
}
}
_ => {
}
}
}
fn redact_context_fields(
event: &mut AtpEvent,
rules: &[RedactionRule],
redacted_fields: &mut HashSet<String>,
) {
if should_redact_field("context.session_id", rules) {
event.context.session_id = get_redaction_replacement("context.session_id", rules);
redacted_fields.insert("context.session_id".to_string());
}
if let Some(ref mut transfer_id) = event.context.transfer_id {
if should_redact_field("context.transfer_id", rules) {
*transfer_id = get_redaction_replacement("context.transfer_id", rules);
redacted_fields.insert("context.transfer_id".to_string());
}
}
if let Some(ref mut connection_id) = event.context.connection_id {
if should_redact_field("context.connection_id", rules) {
*connection_id = get_redaction_replacement("context.connection_id", rules);
redacted_fields.insert("context.connection_id".to_string());
}
}
if let Some(ref mut peer_id) = event.context.peer_id {
if should_redact_field("context.peer_id", rules) {
*peer_id = get_redaction_replacement("context.peer_id", rules);
redacted_fields.insert("context.peer_id".to_string());
}
}
}
fn should_redact_field(field_path: &str, rules: &[RedactionRule]) -> bool {
rules
.iter()
.any(|rule| field_matches_pattern(field_path, &rule.field_pattern))
}
fn get_redaction_replacement(field_path: &str, rules: &[RedactionRule]) -> String {
for rule in rules {
if field_matches_pattern(field_path, &rule.field_pattern) {
return rule.replacement.clone();
}
}
"[REDACTED]".to_string()
}
fn field_matches_pattern(field_path: &str, pattern: &str) -> bool {
if field_path == pattern {
return true;
}
if let Some(suffix_pattern) = pattern.strip_prefix(".*") {
let suffix = suffix_pattern.trim_end_matches('$').replace(r"\.", ".");
return field_path.ends_with(&suffix);
}
if pattern
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return field_path
.split('.')
.any(|segment| segment_matches_simple_pattern(segment, pattern));
}
if pattern.contains('*') {
return wildcard_matches(field_path, pattern);
}
if pattern.contains('.') {
return false;
}
true
}
fn segment_matches_simple_pattern(segment: &str, pattern: &str) -> bool {
let base_segment = segment.split_once('[').map_or(segment, |(base, _)| base);
base_segment == pattern
|| base_segment
.strip_suffix(pattern)
.is_some_and(|prefix| prefix.ends_with('_'))
}
fn wildcard_matches(text: &str, pattern: &str) -> bool {
let normalized = pattern.trim_end_matches('$').replace(r"\.", ".");
let mut rest = text;
let mut last_part = "";
for part in normalized.split('*').filter(|part| !part.is_empty()) {
let Some(idx) = rest.find(part) else {
return false;
};
let next = idx + part.len();
rest = &rest[next..];
last_part = part;
}
last_part.is_empty() || normalized.ends_with('*') || text.ends_with(last_part)
}
fn redact_string_patterns(text: &str, rules: &[RedactionRule], field_path: &str) -> Option<String> {
let mut result = text.to_string();
let mut modified = false;
for rule in rules {
match &rule.redaction_type {
RedactionType::PrivateKey => {
if is_private_key_pattern(text) {
return Some(rule.replacement.clone());
}
}
RedactionType::AuthToken => {
if is_auth_token_pattern(text) {
return Some(rule.replacement.clone());
}
}
RedactionType::CapabilitySecret => {
if is_capability_secret_pattern(text) {
return Some(rule.replacement.clone());
}
}
RedactionType::SensitivePath => {
if is_sensitive_path(text) {
result = redact_path_components(&result);
modified = true;
}
}
RedactionType::ContentHash => {
if field_path.contains("content_hash") || field_path.contains("hash") {
if is_hash_pattern(text) {
return Some(rule.replacement.clone());
}
}
}
RedactionType::Custom(pattern) => {
if string_matches_pattern(text, pattern) {
result.clone_from(&rule.replacement);
modified = true;
}
}
}
}
if modified { Some(result) } else { None }
}
fn string_matches_pattern(text: &str, pattern: &str) -> bool {
if text == pattern || text.contains(pattern) {
return true;
}
if let Some(suffix_pattern) = pattern.strip_prefix(".*") {
let suffix = suffix_pattern.trim_end_matches('$').replace(r"\.", ".");
return text.ends_with(&suffix);
}
false
}
fn is_private_key_pattern(text: &str) -> bool {
text.contains("-----BEGIN PRIVATE KEY-----")
|| text.contains("-----BEGIN RSA PRIVATE KEY-----")
|| text.contains("-----BEGIN EC PRIVATE KEY-----")
|| text.len() > 32
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '=' || c == '+' || c == '/')
}
fn is_auth_token_pattern(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
if lower.contains("token=")
|| lower.contains("password=")
|| lower.contains("secret=")
|| lower.contains("authorization: bearer ")
{
return true;
}
if text.matches('.').count() == 2 && text.len() > 100 {
return true;
}
if text.starts_with("Bearer ") && text.len() > 50 {
return true;
}
if (text.starts_with("sk-") || text.starts_with("pk-") || text.starts_with("api-"))
&& text.len() > 20
{
return true;
}
false
}
fn is_capability_secret_pattern(text: &str) -> bool {
text.starts_with("MDAxM") || (text.starts_with("cap://") && text.len() > 50) ||
(text.len() > 32 && text.chars().all(|c| c.is_ascii_hexdigit()))
}
fn is_sensitive_path(text: &str) -> bool {
let sensitive_patterns = [
"/.ssh/",
"/.gnupg/",
"/private/",
"/secrets/",
".key",
".pem",
".p12",
".pfx",
"password",
"passwd",
"/home/",
"/Users/",
];
sensitive_patterns
.iter()
.any(|pattern| text.contains(pattern))
}
fn redact_path_components(path: &str) -> String {
let parts: Vec<&str> = path.split('/').collect();
let mut result = Vec::new();
let mut redact_next_user = false;
for part in parts {
if redact_next_user && !part.is_empty() {
result.push("[USER]");
redact_next_user = false;
continue;
}
let lower = part.to_ascii_lowercase();
if lower == "home" || part == "Users" {
result.push(part);
redact_next_user = true;
} else if lower == ".ssh" || lower == ".gnupg" || lower == "private" || lower == "secrets" {
result.push("[SENSITIVE_DIR]");
} else if is_sensitive_file_component(&lower) {
result.push("[SENSITIVE_FILE]");
} else if part.len() > 20 && part.chars().all(|c| c.is_ascii_alphanumeric()) {
result.push("[ID]");
} else {
result.push(part);
}
}
result.join("/")
}
fn is_sensitive_file_component(lower: &str) -> bool {
let extension = std::path::Path::new(lower)
.extension()
.and_then(std::ffi::OsStr::to_str);
lower.starts_with("id_")
|| matches!(extension, Some("key" | "pem" | "p12" | "pfx"))
|| lower.contains("password")
|| lower.contains("passwd")
|| lower.contains("secret")
}
fn is_hash_pattern(text: &str) -> bool {
if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
if text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
if text.len() == 32 && text.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_private_key_redaction() {
let rule = RedactionRule {
field_pattern: "private_key".to_string(),
redaction_type: RedactionType::PrivateKey,
replacement: "[REDACTED_KEY]".to_string(),
};
let mut event = AtpEvent {
schema_version: super::super::ATP_LOG_EVENT_SCHEMA_VERSION.to_string(),
timestamp: "2026-05-20T12:00:00Z".to_string(),
level: super::super::Level::Info,
subsystem: super::super::AtpSubsystem::Security,
event_type: "key_generated".to_string(),
data: json!({
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ..."
}),
context: super::super::EventContext {
session_id: "session123".to_string(),
transfer_id: None,
connection_id: None,
peer_id: None,
test_case_id: None,
trace_id: "trace123".to_string(),
span_id: "span123".to_string(),
},
redacted_fields: Vec::new(),
};
apply_redaction(&mut event, &[rule]);
assert_eq!(event.data["private_key"], "[REDACTED_KEY]");
assert!(
event
.redacted_fields
.contains(&"data.private_key".to_string())
);
}
#[test]
fn test_path_redaction() {
let rule = RedactionRule {
field_pattern: "file_path".to_string(),
redaction_type: RedactionType::SensitivePath,
replacement: "[REDACTED_PATH]".to_string(),
};
let mut event = AtpEvent {
schema_version: super::super::ATP_LOG_EVENT_SCHEMA_VERSION.to_string(),
timestamp: "2026-05-20T12:00:00Z".to_string(),
level: super::super::Level::Info,
subsystem: super::super::AtpSubsystem::Disk,
event_type: "file_read_started".to_string(),
data: json!({
"file_path": "/home/user/.ssh/id_rsa"
}),
context: super::super::EventContext {
session_id: "session123".to_string(),
transfer_id: None,
connection_id: None,
peer_id: None,
test_case_id: None,
trace_id: "trace123".to_string(),
span_id: "span123".to_string(),
},
redacted_fields: Vec::new(),
};
apply_redaction(&mut event, &[rule]);
assert_eq!(event.data["file_path"], "[REDACTED_PATH]");
}
#[test]
fn path_rule_does_not_redact_path_id_metadata() {
let mut value = json!({
"file_path": "/home/user/project.log",
"path_id": "direct-1",
"path": "/home/user/.ssh/id_ed25519"
});
let redacted =
redact_json_value(&mut value, &super::super::default_redaction_rules(), "data");
assert_eq!(value["file_path"], "[REDACTED_PATH]");
assert_eq!(value["path_id"], "direct-1");
assert_eq!(value["path"], "[REDACTED_PATH]");
assert_eq!(
redacted,
vec!["data.file_path".to_string(), "data.path".to_string()]
);
}
#[test]
fn sensitive_path_content_masks_username_and_key_basename() {
let mut value = json!({
"message": "failed to read /home/alice/.ssh/id_ed25519 during transfer"
});
let redacted =
redact_json_value(&mut value, &super::super::default_redaction_rules(), "data");
let message = value["message"].as_str().expect("message stays string");
assert!(message.contains("/home/[USER]/[SENSITIVE_DIR]/[SENSITIVE_FILE]"));
assert!(!message.contains("alice"));
assert!(!message.contains(".ssh"));
assert!(!message.contains("id_ed25519"));
assert_eq!(redacted, vec!["data.message".to_string()]);
}
#[test]
fn wildcard_custom_field_pattern_matches_nested_token_field() {
let rules = [RedactionRule {
field_pattern: "user.*token".to_string(),
redaction_type: RedactionType::AuthToken,
replacement: "[REDACTED_TOKEN]".to_string(),
}];
let mut value = json!({
"user": {
"access_token": "plain-value-that-must-not-leak"
},
"other": "safe"
});
let redacted = redact_json_value(&mut value, &rules, "data");
assert_eq!(value["user"]["access_token"], "[REDACTED_TOKEN]");
assert_eq!(value["other"], "safe");
assert_eq!(redacted, vec!["data.user.access_token".to_string()]);
}
#[test]
fn unsupported_custom_field_pattern_fails_closed() {
let rules = [RedactionRule {
field_pattern: "secret[0-9]".to_string(),
redaction_type: RedactionType::AuthToken,
replacement: "[REDACTED_CUSTOM]".to_string(),
}];
let mut value = json!({
"public_note": "not a token"
});
let redacted = redact_json_value(&mut value, &rules, "data");
assert_eq!(value["public_note"], "[REDACTED_CUSTOM]");
assert_eq!(redacted, vec!["data.public_note".to_string()]);
}
}