mod patterns;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::Value as JsonValue;
use url::Url;
pub use patterns::scan_secret_patterns;
pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
pub const REDACTED_HEADER_VALUE: &str = REDACTED_PLACEHOLDER;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RedactionPolicy {
safe_headers: BTreeSet<String>,
deny_header_substrings: BTreeSet<String>,
extra_deny_header_substrings: BTreeSet<String>,
extra_field_names: BTreeSet<String>,
extra_url_params: BTreeSet<String>,
scan_strings: bool,
redact_url_userinfo: bool,
}
impl Default for RedactionPolicy {
fn default() -> Self {
Self {
safe_headers: default_safe_headers(),
deny_header_substrings: default_deny_header_substrings(),
extra_deny_header_substrings: BTreeSet::new(),
extra_field_names: BTreeSet::new(),
extra_url_params: BTreeSet::new(),
scan_strings: true,
redact_url_userinfo: true,
}
}
}
impl RedactionPolicy {
pub fn passthrough() -> Self {
Self {
safe_headers: BTreeSet::new(),
deny_header_substrings: BTreeSet::new(),
extra_deny_header_substrings: BTreeSet::new(),
extra_field_names: BTreeSet::new(),
extra_url_params: BTreeSet::new(),
scan_strings: false,
redact_url_userinfo: false,
}
}
pub fn with_safe_header(mut self, name: impl Into<String>) -> Self {
self.safe_headers.insert(name.into().to_ascii_lowercase());
self
}
pub fn with_deny_header_substring(mut self, fragment: impl Into<String>) -> Self {
self.extra_deny_header_substrings
.insert(fragment.into().to_ascii_lowercase());
self
}
pub fn with_extra_field(mut self, name: impl Into<String>) -> Self {
self.extra_field_names
.insert(name.into().to_ascii_lowercase());
self
}
pub fn with_extra_url_param(mut self, name: impl Into<String>) -> Self {
self.extra_url_params
.insert(name.into().to_ascii_lowercase());
self
}
pub fn disable_string_scan(mut self) -> Self {
self.scan_strings = false;
self
}
fn header_is_safe(&self, lower_name: &str) -> bool {
if self.safe_headers.contains(lower_name) {
return true;
}
lower_name.ends_with("-event")
|| lower_name.ends_with("-delivery")
|| lower_name.contains("timestamp")
|| lower_name.contains("request-id")
}
pub fn header_is_sensitive(&self, name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if self
.extra_deny_header_substrings
.iter()
.any(|fragment| lower.contains(fragment))
{
return true;
}
if self.header_is_safe(&lower) {
return false;
}
self.deny_header_substrings
.iter()
.any(|fragment| lower.contains(fragment))
}
pub fn field_is_sensitive(&self, name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if self.extra_field_names.contains(&lower) {
return true;
}
is_default_sensitive_field(&lower)
}
pub fn url_param_is_sensitive(&self, name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if self.extra_url_params.contains(&lower) {
return true;
}
is_default_sensitive_url_param(&lower)
}
pub fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
headers
.iter()
.map(|(name, value)| {
if self.header_is_sensitive(name) {
(name.clone(), REDACTED_HEADER_VALUE.to_string())
} else {
(name.clone(), value.clone())
}
})
.collect()
}
pub fn redact_url(&self, url: &str) -> String {
let Ok(mut parsed) = Url::parse(url) else {
return self.redact_string(url).into_owned();
};
let mut changed = false;
if self.redact_url_userinfo
&& (!parsed.username().is_empty() || parsed.password().is_some())
{
if parsed.set_username("").is_ok() {
changed = true;
}
if parsed.set_password(None).is_ok() {
changed = true;
}
}
let pairs: Vec<(String, String)> = parsed
.query_pairs()
.map(|(key, value)| {
if self.url_param_is_sensitive(&key) {
changed = true;
(key.into_owned(), REDACTED_PLACEHOLDER.to_string())
} else {
(key.into_owned(), value.into_owned())
}
})
.collect();
let original_query = parsed.query().map(str::to_string);
if !pairs.is_empty() {
parsed.set_query(None);
let mut query = parsed.query_pairs_mut();
for (key, value) in &pairs {
query.append_pair(key, value);
}
}
if !changed {
parsed.set_query(original_query.as_deref());
return parsed.to_string();
}
parsed.to_string()
}
pub fn redact_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
if !self.scan_strings {
return Cow::Borrowed(value);
}
match self.redact_url_in_string(value) {
Cow::Borrowed(_) => scan_secret_patterns(value, REDACTED_PLACEHOLDER),
Cow::Owned(url_scrubbed) => {
let pattern_scrubbed =
scan_secret_patterns(&url_scrubbed, REDACTED_PLACEHOLDER).into_owned();
Cow::Owned(pattern_scrubbed)
}
}
}
fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
if !self.redact_url_userinfo
|| !(value.starts_with("http://") || value.starts_with("https://"))
{
return Cow::Borrowed(value);
}
let trimmed = value.trim();
if trimmed.contains(char::is_whitespace) {
return Cow::Borrowed(value);
}
let redacted = self.redact_url(trimmed);
if redacted == trimmed {
Cow::Borrowed(value)
} else {
Cow::Owned(redacted)
}
}
pub fn redact_json_in_place(&self, value: &mut JsonValue) {
match value {
JsonValue::Object(map) => {
let mut keys_to_redact: Vec<String> = Vec::new();
for (key, child) in map.iter_mut() {
if self.field_is_sensitive(key) {
keys_to_redact.push(key.clone());
} else {
self.redact_json_in_place(child);
}
}
for key in keys_to_redact {
map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
}
}
JsonValue::Array(items) => {
for item in items.iter_mut() {
self.redact_json_in_place(item);
}
}
JsonValue::String(s) => {
let redacted = self.redact_string(s);
if let Cow::Owned(replacement) = redacted {
*s = replacement;
}
}
_ => {}
}
}
pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
let mut clone = value.clone();
self.redact_json_in_place(&mut clone);
clone
}
}
fn default_safe_headers() -> BTreeSet<String> {
BTreeSet::from([
"content-length".to_string(),
"content-type".to_string(),
"request-id".to_string(),
"user-agent".to_string(),
"x-a2a-delivery".to_string(),
"x-a2a-signature".to_string(),
"x-correlation-id".to_string(),
"x-github-delivery".to_string(),
"x-github-event".to_string(),
"x-github-hook-id".to_string(),
"x-hub-signature-256".to_string(),
"x-linear-signature".to_string(),
"x-notion-signature".to_string(),
"x-request-id".to_string(),
"x-slack-request-timestamp".to_string(),
"x-slack-signature".to_string(),
])
}
fn default_deny_header_substrings() -> BTreeSet<String> {
BTreeSet::from([
"authorization".to_string(),
"cookie".to_string(),
"secret".to_string(),
"token".to_string(),
"key".to_string(),
])
}
fn is_default_sensitive_url_param(lower: &str) -> bool {
matches!(
lower,
"api_key"
| "apikey"
| "access_token"
| "refresh_token"
| "id_token"
| "client_secret"
| "password"
| "secret"
| "token"
| "auth"
| "bearer"
| "sig"
| "signature"
) || lower.ends_with("_token")
|| lower.ends_with("_secret")
|| lower.ends_with("_password")
}
fn is_default_sensitive_field(lower: &str) -> bool {
matches!(
lower,
"authorization"
| "proxy-authorization"
| "cookie"
| "set-cookie"
| "api_key"
| "apikey"
| "api-key"
| "x-api-key"
| "x-auth-token"
| "x-csrf-token"
| "x-xsrf-token"
| "access_token"
| "refresh_token"
| "id_token"
| "bearer_token"
| "client_secret"
| "secret"
| "password"
| "passwd"
| "private_key"
| "session_token"
) || lower.ends_with("_token")
|| lower.ends_with("_secret")
|| lower.ends_with("_password")
|| lower.ends_with("_apikey")
|| lower.ends_with("_api_key")
}
thread_local! {
static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
}
pub fn push_policy(policy: RedactionPolicy) {
REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
}
pub fn pop_policy() {
REDACTION_POLICY_STACK.with(|stack| {
stack.borrow_mut().pop();
});
}
pub fn clear_policy_stack() {
REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
}
pub fn current_policy() -> RedactionPolicy {
REDACTION_POLICY_STACK.with(|stack| {
stack
.borrow()
.last()
.cloned()
.unwrap_or_else(RedactionPolicy::default)
})
}
pub struct PolicyGuard;
impl PolicyGuard {
pub fn new(policy: RedactionPolicy) -> Self {
push_policy(policy);
Self
}
}
impl Drop for PolicyGuard {
fn drop(&mut self) {
pop_policy();
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_headers() -> BTreeMap<String, String> {
BTreeMap::from([
("Authorization".to_string(), "Bearer secret123".to_string()),
("Cookie".to_string(), "session=abc".to_string()),
("Content-Type".to_string(), "application/json".to_string()),
("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
("User-Agent".to_string(), "Harn/1.0".to_string()),
("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
])
}
#[test]
fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
let policy = RedactionPolicy::default();
let redacted = policy.redact_headers(&sample_headers());
assert_eq!(
redacted.get("Authorization").unwrap(),
REDACTED_HEADER_VALUE
);
assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
assert_eq!(
redacted.get("X-Webhook-Token").unwrap(),
REDACTED_HEADER_VALUE
);
assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
}
#[test]
fn passthrough_policy_redacts_nothing() {
let policy = RedactionPolicy::passthrough();
let redacted = policy.redact_headers(&sample_headers());
assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
}
#[test]
fn host_can_extend_safe_and_deny_headers() {
let policy = RedactionPolicy::default()
.with_safe_header("X-Webhook-Token")
.with_deny_header_substring("delivery");
let redacted = policy.redact_headers(&sample_headers());
assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
assert_eq!(
redacted.get("X-GitHub-Delivery").unwrap(),
REDACTED_HEADER_VALUE,
"host explicitly forced delivery to be sensitive"
);
}
#[test]
fn redact_url_strips_userinfo_and_sensitive_query_params() {
let policy = RedactionPolicy::default();
let redacted =
policy.redact_url("https://user:pw@api.example.com/v1?api_key=abcdef&page=2");
assert!(redacted.contains("api_key=%5Bredacted%5D"));
assert!(redacted.contains("page=2"));
assert!(!redacted.contains("user:pw@"));
}
#[test]
fn redact_url_leaves_clean_urls_alone() {
let policy = RedactionPolicy::default();
let url = "https://api.example.com/v1?page=2";
assert_eq!(policy.redact_url(url), url);
}
#[test]
fn redact_json_strips_sensitive_field_names_recursively() {
let policy = RedactionPolicy::default();
let mut value = json!({
"headers": {
"authorization": "Bearer abc",
"x-trace-id": "trace_1",
},
"list": [
{ "auth_token": "tok_secret", "name": "alice" },
{ "name": "bob" },
],
"free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
"url": "https://api.example.com/v1?api_key=hideme",
});
policy.redact_json_in_place(&mut value);
assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
assert_eq!(value["headers"]["x-trace-id"], "trace_1");
assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
assert_eq!(value["list"][0]["name"], "alice");
let free_form = value["free_form"].as_str().unwrap();
assert!(free_form.contains(REDACTED_PLACEHOLDER));
assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
}
#[test]
fn policy_guard_pushes_and_pops_thread_local() {
clear_policy_stack();
assert_eq!(current_policy(), RedactionPolicy::default());
{
let policy = RedactionPolicy::default().with_extra_field("custom_token");
let _guard = PolicyGuard::new(policy.clone());
assert_eq!(current_policy(), policy);
}
assert_eq!(current_policy(), RedactionPolicy::default());
}
#[test]
fn redact_string_replaces_known_secret_patterns() {
let policy = RedactionPolicy::default();
let input =
"use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
let out = policy.redact_string(input);
assert!(out.contains(REDACTED_PLACEHOLDER));
assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
}
}