use std::collections::HashMap;
use serde_json::Value;
const REDACTED_PLACEHOLDER: &str = "[redacted]";
const DEFAULT_SENSITIVE_HEADER_NAMES: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"api-key",
"apikey",
"x-api-key",
"x-auth-token",
"x-access-token",
];
#[derive(Debug, Clone, Default)]
pub struct OutputRedactor {
sensitive_values: Vec<String>,
sensitive_header_names: Vec<String>,
}
impl OutputRedactor {
pub fn new(values: &[String]) -> Self {
Self::with_header_names(values, &[])
}
pub fn with_header_names(values: &[String], header_names: &[String]) -> Self {
let mut sensitive_values = values
.iter()
.filter(|value| !value.is_empty())
.cloned()
.collect::<Vec<_>>();
let mut sensitive_header_names = header_names
.iter()
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
sensitive_values.sort_by(|left, right| {
right
.len()
.cmp(&left.len())
.then_with(|| left.cmp(right))
});
sensitive_values.dedup();
sensitive_header_names.sort();
sensitive_header_names.dedup();
Self {
sensitive_values,
sensitive_header_names,
}
}
pub fn redact_text(&self, value: &str) -> String {
let mut redacted = value.to_string();
for sensitive in &self.sensitive_values {
if !redacted.contains(sensitive) {
continue;
}
redacted = redacted.replace(sensitive, REDACTED_PLACEHOLDER);
}
self.redact_header_lines(&redacted)
}
pub fn redact_optional_text(&self, value: Option<&str>) -> Option<String> {
value.map(|value| self.redact_text(value))
}
pub fn redact_string_map(
&self,
values: &HashMap<String, String>,
) -> HashMap<String, String> {
values
.iter()
.map(|(key, value)| {
let redacted = if self.is_sensitive_header_name(key) {
REDACTED_PLACEHOLDER.to_string()
} else {
self.redact_text(value)
};
(key.clone(), redacted)
})
.collect()
}
pub fn redact_json_value(&self, value: &Value) -> Value {
match value {
Value::String(text) => Value::String(self.redact_text(text)),
Value::Array(values) => Value::Array(
values
.iter()
.map(|value| self.redact_json_value(value))
.collect(),
),
Value::Object(values) => Value::Object(
values
.iter()
.map(|(key, value)| {
let redacted = if self.is_sensitive_header_name(key) {
Value::String(REDACTED_PLACEHOLDER.to_string())
} else {
self.redact_json_value(value)
};
(key.clone(), redacted)
})
.collect(),
),
_ => value.clone(),
}
}
fn is_sensitive_header_name(&self, name: &str) -> bool {
let normalized = name.trim().to_ascii_lowercase();
DEFAULT_SENSITIVE_HEADER_NAMES
.iter()
.any(|candidate| *candidate == normalized)
|| self
.sensitive_header_names
.iter()
.any(|candidate| candidate == &normalized)
|| normalized.ends_with("-api-key")
}
fn redact_header_lines(&self, value: &str) -> String {
if !value.contains(':') {
return value.to_string();
}
value
.split_inclusive('\n')
.map(|segment| {
let (line, newline) = match segment.strip_suffix('\n') {
Some(line) => (line, "\n"),
None => (segment, ""),
};
self.redact_header_line(line)
.map(|line| format!("{line}{newline}"))
.unwrap_or_else(|| segment.to_string())
})
.collect()
}
fn redact_header_line(&self, line: &str) -> Option<String> {
let separator = line.find(':')?;
let header_name = line[..separator].trim();
if !self.is_sensitive_header_name(header_name) {
return None;
}
let remainder = &line[separator + 1..];
let leading_whitespace_end = remainder
.char_indices()
.find(|(_, ch)| !ch.is_whitespace())
.map(|(index, _)| index)
.unwrap_or(remainder.len());
Some(format!(
"{}:{}{}",
&line[..separator],
&remainder[..leading_whitespace_end],
REDACTED_PLACEHOLDER
))
}
}
#[cfg(test)]
mod tests {
use super::OutputRedactor;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn redacts_text_and_json_values() {
let redactor = OutputRedactor::new(&[
"super-secret-token".to_string(),
"abc123".to_string(),
]);
assert_eq!(
redactor.redact_text("Bearer super-secret-token"),
"Bearer [redacted]"
);
assert_eq!(
redactor.redact_json_value(&json!({"token":"abc123"})),
json!({"token":"[redacted]"})
);
}
#[test]
fn redacts_map_values() {
let redactor = OutputRedactor::new(&["secret".to_string()]);
let values = HashMap::from([("token".to_string(), "secret".to_string())]);
assert_eq!(
redactor.redact_string_map(&values).get("token"),
Some(&"[redacted]".to_string())
);
}
#[test]
fn redacts_default_sensitive_headers_in_text_maps_and_json_values() {
let redactor = OutputRedactor::new(&[]);
assert_eq!(
redactor.redact_text(
"Authorization: Bearer dynamic-token\nCookie: session=abc\nX-Trace: keep"
),
"Authorization: [redacted]\nCookie: [redacted]\nX-Trace: keep"
);
let values = HashMap::from([
("Set-Cookie".to_string(), "session=abc; Path=/".to_string()),
("X-Trace".to_string(), "keep".to_string()),
]);
assert_eq!(
redactor.redact_string_map(&values).get("Set-Cookie"),
Some(&"[redacted]".to_string())
);
assert_eq!(
redactor.redact_string_map(&values).get("X-Trace"),
Some(&"keep".to_string())
);
assert_eq!(
redactor.redact_json_value(&json!({
"Authorization": "Bearer dynamic-token",
"nested": {
"X-Api-Key": "dynamic-key",
"status": "ok"
}
})),
json!({
"Authorization": "[redacted]",
"nested": {
"X-Api-Key": "[redacted]",
"status": "ok"
}
})
);
}
#[test]
fn redacts_configured_sensitive_headers() {
let redactor = OutputRedactor::with_header_names(
&[],
&["x-custom-secret".to_string()],
);
assert_eq!(
redactor.redact_text("X-Custom-Secret: token\nX-Trace: keep"),
"X-Custom-Secret: [redacted]\nX-Trace: keep"
);
let values = HashMap::from([
("X-Custom-Secret".to_string(), "token".to_string()),
("X-Trace".to_string(), "keep".to_string()),
]);
assert_eq!(
redactor.redact_string_map(&values).get("X-Custom-Secret"),
Some(&"[redacted]".to_string())
);
}
}