pub const ALWAYS_REDACTED: &[&str] = &[
"cookie",
"set-cookie",
"authorization",
"proxy-authorization",
];
pub const REDACTED_SUFFIXES: &[&str] = &["-token", "-secret"];
pub const URL_SECRET_NAMES: &[&str] = &[
"access_token",
"api_key",
"auth",
"authorization",
"code",
"handoff",
"handoff_secret",
"id_token",
"key",
"password",
"passwd",
"session",
"sessionid",
"sig",
"signature",
"token",
];
#[must_use]
pub fn should_redact(header_name: &str) -> bool {
let lower = header_name.to_ascii_lowercase();
if ALWAYS_REDACTED.iter().any(|h| *h == lower) {
return true;
}
REDACTED_SUFFIXES
.iter()
.any(|suffix| lower.ends_with(suffix))
}
pub const REDACTED_VALUE: &str = "[redacted]";
#[must_use]
fn url_redactor() -> agent_first_data::Redactor {
agent_first_data::Redactor::new().secret_names(URL_SECRET_NAMES.iter().copied())
}
#[must_use]
pub fn redact_url(url: &str) -> String {
url_redactor().url(url)
}
#[must_use]
pub fn redact_url_fields(value: &serde_json::Value) -> serde_json::Value {
let mut value = value.clone();
redact_url_fields_in_place(&mut value);
value
}
#[must_use]
pub fn redact_value(value: &serde_json::Value) -> serde_json::Value {
agent_first_data::Redactor::new().value(&redact_url_fields(value))
}
fn redact_url_fields_in_place(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(fields) => {
for (name, value) in fields {
if (name.ends_with("_url") || name.ends_with("_URL"))
&& let serde_json::Value::String(url) = value
{
*url = redact_url(url);
} else {
redact_url_fields_in_place(value);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
redact_url_fields_in_place(value);
}
}
_ => {}
}
}
#[must_use]
pub fn redact_userinfo_passwords(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(pos) = rest.find("://") {
out.push_str(&rest[..pos + 3]);
let after = &rest[pos + 3..];
let auth_end = after
.find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
.unwrap_or(after.len());
let authority = &after[..auth_end];
match (authority.find('@'), authority.find(':')) {
(Some(at), Some(colon)) if colon < at => {
out.push_str(&authority[..colon]);
out.push_str(":***");
out.push_str(&authority[at..]);
}
_ => out.push_str(authority),
}
rest = &after[auth_end..];
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn always_redacted_headers_match_case_insensitively() {
for h in ["Cookie", "COOKIE", "cookie", "Set-Cookie", "SET-COOKIE"] {
assert!(should_redact(h), "{h}");
}
assert!(should_redact("Authorization"));
assert!(should_redact("Proxy-Authorization"));
}
#[test]
fn suffix_match_catches_token_and_secret_headers() {
assert!(should_redact("X-Api-Token"));
assert!(should_redact("x-csrf-token"));
assert!(should_redact("X-Shared-Secret"));
assert!(should_redact("x-bearer-secret"));
}
#[test]
fn unrelated_headers_pass_through() {
for h in ["Content-Type", "User-Agent", "Accept", "X-Trace-Id"] {
assert!(!should_redact(h), "{h}");
}
}
#[test]
fn url_redaction_covers_userinfo_and_legacy_query_names() {
assert_eq!(
redact_url("https://user:pass@example.test/path?token=abc&safe=ok&next_secret=hidden"),
"https://user:***@example.test/path?token=***&safe=ok&next_secret=***"
);
}
#[test]
fn url_compatibility_names_do_not_redact_ordinary_fields() {
let redacted = redact_value(&serde_json::json!({
"code": "fetch",
"token": "public field",
"request_url": "https://example.test/?code=otp&token=bearer&safe=ok",
"token_secret": "private"
}));
assert_eq!(redacted["code"], "fetch");
assert_eq!(redacted["token"], "public field");
assert_eq!(
redacted["request_url"],
"https://example.test/?code=***&token=***&safe=ok"
);
assert_eq!(redacted["token_secret"], "***");
}
#[test]
fn userinfo_password_is_masked_in_text() {
assert_eq!(
redact_userinfo_passwords("launch --proxy-server=http://user:pass@proxy:8080 done"),
"launch --proxy-server=http://user:***@proxy:8080 done"
);
assert_eq!(
redact_userinfo_passwords("http://user@host/x"),
"http://user@host/x"
);
assert_eq!(
redact_userinfo_passwords("connect socks5://10.0.0.5:1080 now"),
"connect socks5://10.0.0.5:1080 now"
);
assert_eq!(redact_userinfo_passwords("no url here"), "no url here");
}
}