diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 833d278b..e49cf396 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -12,6 +12,7 @@ pub mod builtin; pub mod execute; pub mod mcp; pub mod rate_limiter; +pub mod redaction; pub mod schema_validator; pub mod wasm; diff --git a/src/tools/redaction.rs b/src/tools/redaction.rs new file mode 100644 index 00000000..f3bad800 --- /dev/null +++ b/src/tools/redaction.rs @@ -0,0 +1,251 @@ +use serde_json::{Map, Value}; + +const REDACTED: &str = "[REDACTED]"; +const SENSITIVE_EXACT: &[&str] = &[ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "api_key", + "access_token", + "refresh_token", + "session_token", + "id_token", + "token", + "password", + "passwd", + "secret", + "client_secret", + "private_key", + "apikey", + "apisecret", +]; + +const SENSITIVE_PARTS: &[&str] = &[ + "password", + "passwd", + "secret", + "credential", + "authorization", + "cookie", + "apikey", + "apisecret", +]; +const TOKEN_PARTS: &[&str] = &["token", "jwt"]; +const KEY_PARTS: &[&str] = &["key"]; +const CONTEXT_PARTS: &[&str] = &[ + "auth", + "oauth", + "authorization", + "api", + "access", + "refresh", + "session", + "bearer", + "private", + "client", + "id", + "app", + "user", + "application", + "account", +]; + +fn split_camel_case_key_parts(key: &str) -> Vec { + if key.is_empty() { + return Vec::new(); + } + + let chars: Vec = key.chars().collect(); + let mut parts = Vec::new(); + let mut start = 0; + + for i in 1..chars.len() { + let prev = chars[i - 1]; + let cur = chars[i]; + let next = chars.get(i + 1).copied(); + + let boundary = (prev.is_ascii_lowercase() && cur.is_ascii_uppercase()) + || (prev.is_ascii_alphabetic() && cur.is_ascii_digit()) + || (prev.is_ascii_digit() && cur.is_ascii_alphabetic()) + || (prev.is_ascii_uppercase() + && cur.is_ascii_uppercase() + && next.map(|n| n.is_ascii_lowercase()).unwrap_or(false)); + + if boundary { + parts.push(chars[start..i].iter().collect::()); + start = i; + } + } + + parts.push(chars[start..].iter().collect::()); + parts +} + +fn tokenize_key_parts(key: &str) -> Vec { + let mut parts = Vec::new(); + + for segment in key.split(|c: char| !c.is_ascii_alphanumeric()) { + if segment.is_empty() { + continue; + } + + parts.extend(split_camel_case_key_parts(segment)); + } + + parts.into_iter().map(|p| p.to_ascii_lowercase()).collect() +} + +fn has_exact(parts: &[String], candidates: &[&str]) -> bool { + parts + .iter() + .any(|part| candidates.iter().any(|candidate| part == candidate)) +} + +fn has_candidate_or_numbered_variant(parts: &[String], candidates: &[&str]) -> bool { + parts.iter().any(|part| { + candidates.iter().any(|candidate| { + if part == candidate { + return true; + } + let Some(suffix) = part.strip_prefix(candidate) else { + return false; + }; + !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) + }) + }) +} + +fn has_contextual_suffix(parts: &[String], candidates: &[&str]) -> bool { + parts.iter().any(|part| { + candidates.iter().any(|candidate| { + let Some(prefix) = part.strip_suffix(candidate) else { + return false; + }; + !prefix.is_empty() && CONTEXT_PARTS.contains(&prefix) + }) + }) +} + +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + if SENSITIVE_EXACT.contains(&lower.as_str()) { + return true; + } + + let parts = tokenize_key_parts(key); + if parts.is_empty() { + return false; + } + + if has_candidate_or_numbered_variant(&parts, SENSITIVE_PARTS) { + return true; + } + + let has_token = has_candidate_or_numbered_variant(&parts, TOKEN_PARTS); + let has_key = has_candidate_or_numbered_variant(&parts, KEY_PARTS); + + if has_token && has_key { + return true; + } + + if has_contextual_suffix(&parts, TOKEN_PARTS) || has_contextual_suffix(&parts, KEY_PARTS) { + return true; + } + + let has_context = has_exact(&parts, CONTEXT_PARTS); + has_context && (has_token || has_key) +} + +fn redact_in_place(value: &mut Value) { + match value { + Value::Object(map) => redact_object(map), + Value::Array(items) => { + for item in items { + redact_in_place(item); + } + } + _ => {} + } +} + +fn redact_object(map: &mut Map) { + for (key, val) in map { + if is_sensitive_key(key) { + *val = Value::String(REDACTED.to_string()); + } else { + redact_in_place(val); + } + } +} + +pub fn redact_sensitive_json(value: &Value) -> Value { + let mut cloned = value.clone(); + redact_in_place(&mut cloned); + cloned +} + +#[cfg(test)] +mod tests { + use super::{is_sensitive_key, redact_sensitive_json}; + + #[test] + fn redacts_exact_sensitive_keys() { + let input = serde_json::json!({ + "headers": { + "Authorization": "Bearer abc", + "x-api-key": "k-123", + "content-type": "application/json" + }, + "password": "p@ss" + }); + let out = redact_sensitive_json(&input); + assert_eq!(out["headers"]["Authorization"], "[REDACTED]"); + assert_eq!(out["headers"]["x-api-key"], "[REDACTED]"); + assert_eq!(out["headers"]["content-type"], "application/json"); + assert_eq!(out["password"], "[REDACTED]"); + } + + #[test] + fn redacts_nested_sensitive_keys() { + let input = serde_json::json!({ + "body": { + "clientSecret": "xyz", + "nested": [{"authToken": "123"}, {"query": "ok"}] + } + }); + let out = redact_sensitive_json(&input); + assert_eq!(out["body"]["clientSecret"], "[REDACTED]"); + assert_eq!(out["body"]["nested"][0]["authToken"], "[REDACTED]"); + assert_eq!(out["body"]["nested"][1]["query"], "ok"); + } + + #[test] + fn does_not_over_redact_common_non_sensitive_keys() { + assert!(!is_sensitive_key("author")); + assert!(!is_sensitive_key("authorize_user")); + assert!(!is_sensitive_key("token_count")); + assert!(!is_sensitive_key("tokenize")); + assert!(!is_sensitive_key("oauth_redirect_uri")); + } + + #[test] + fn still_redacts_expected_token_keys() { + assert!(is_sensitive_key("auth_token")); + assert!(is_sensitive_key("oauth_token")); + assert!(is_sensitive_key("accessToken")); + assert!(is_sensitive_key("apiKey")); + assert!(is_sensitive_key("token_key")); + assert!(is_sensitive_key("appTokenKey")); + assert!(is_sensitive_key("userJwt")); + } + + #[test] + fn redacts_lowercase_digit_suffix_segments() { + assert!(is_sensitive_key("password123")); + assert!(is_sensitive_key("secret99")); + assert!(is_sensitive_key("accounttoken2")); + } +}