#![allow(dead_code)]
use regex::Regex;
use std::borrow::Cow;
use std::sync::LazyLock;
static SSH_PRIVATE_KEY_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:[/~][\w./\-]*?)?\.ssh/id_(?:rsa|ed25519|ecdsa|dsa)(?:\.pub)?")
.expect("ssh key path regex must compile")
});
static PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
(
Regex::new(r"(?:gh[pousr]_[A-Za-z0-9_]{20,})").unwrap(),
"[GITHUB_TOKEN_REDACTED]",
),
(
Regex::new(r"(?:xox[abprso]-[A-Za-z0-9-]{10,})").unwrap(),
"[SLACK_TOKEN_REDACTED]",
),
(
Regex::new(r"sk-ant-[A-Za-z0-9_\-]{20,}").unwrap(),
"[ANTHROPIC_KEY_REDACTED]",
),
(
Regex::new(r"sk-(?:proj-)?[A-Za-z0-9_\-]{20,}").unwrap(),
"[OPENAI_KEY_REDACTED]",
),
(
Regex::new(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{20,}").unwrap(),
"[STRIPE_KEY_REDACTED]",
),
(
Regex::new(r"glpat-[A-Za-z0-9_\-]{20,}").unwrap(),
"[GITLAB_TOKEN_REDACTED]",
),
(
Regex::new(r"npm_[A-Za-z0-9]{30,}").unwrap(),
"[NPM_TOKEN_REDACTED]",
),
(
Regex::new(r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}").unwrap(),
"[JWT_REDACTED]",
),
(
Regex::new(
r#"(?i)(api[_-]?key|api[_-]?token|access[_-]?token|auth[_-]?token|secret[_-]?key|private[_-]?key)\s*[:=]\s*['"]?[\w\-./+=]{8,}['"]?"#,
).unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(r"(?i)bearer\s+[\w\-._~+/]+=*").unwrap(),
"Bearer [REDACTED]",
),
(
Regex::new(r#"(?i)(authorization)\s*[:=]\s*['"]?[\w\s\-._~+/]+=*['"]?"#).unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]+['"]?"#).unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(r#"(?i)(secret|credential)\s*[:=]\s*['"]?[^\s'"]+['"]?"#).unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(r#"(?i)(aws[_-]?access[_-]?key[_-]?id|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?session[_-]?token)\s*[:=]\s*['"]?[\w/+=]+['"]?"#).unwrap(),
"$1=[REDACTED]",
),
(
Regex::new(r"(?i)(token|secret|key|sig|signature|fingerprint|digest)\s*[:=]\s*[0-9a-fA-F]{32,}").unwrap(),
"$1=[HEX_TOKEN_REDACTED]",
),
(
Regex::new(r"-----BEGIN[^-]+PRIVATE KEY-----[\s\S]*?-----END[^-]+PRIVATE KEY-----").unwrap(),
"[PRIVATE_KEY_REDACTED]",
),
(
Regex::new(r"(?:[/~][\w./\-]*?)?\.gnupg/[\w./\-]+").unwrap(),
"[GNUPG_PATH_REDACTED]",
),
(
Regex::new(r"[\w.+-]+@[\w.-]+\.\w{2,}").unwrap(),
"[EMAIL_REDACTED]",
),
]
});
#[derive(Debug, Clone)]
pub struct Sanitizer {
home_dir: Option<String>,
custom_patterns: Vec<(Regex, String)>,
}
impl Default for Sanitizer {
fn default() -> Self {
Self::new()
}
}
impl Sanitizer {
pub fn new() -> Self {
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().to_string());
Self {
home_dir,
custom_patterns: Vec::new(),
}
}
pub fn add_pattern(&mut self, pattern: &str, replacement: &str) -> Result<(), regex::Error> {
let regex = Regex::new(pattern)?;
self.custom_patterns.push((regex, replacement.to_string()));
Ok(())
}
fn sanitize_cow<'a>(&self, input: &'a str) -> Cow<'a, str> {
let mut result: Cow<'a, str> = Cow::Borrowed(input);
if let Some(ref home) = self.home_dir {
if !home.is_empty() && result.contains(home) {
result = Cow::Owned(result.replace(home, "~"));
}
}
let next = SSH_PRIVATE_KEY_PATH_RE.replace_all(&result, |caps: ®ex::Captures| {
let full = caps.get(0).map(|m| m.as_str()).unwrap_or("");
if full.ends_with(".pub") {
full.to_string()
} else {
"[SSH_KEY_PATH_REDACTED]".to_string()
}
});
if let Cow::Owned(owned) = next {
result = Cow::Owned(owned);
}
for (pattern, replacement) in PATTERNS.iter() {
let next = pattern.replace_all(&result, *replacement);
if let Cow::Owned(owned) = next {
result = Cow::Owned(owned);
}
}
for (pattern, replacement) in &self.custom_patterns {
let next = pattern.replace_all(&result, replacement.as_str());
if let Cow::Owned(owned) = next {
result = Cow::Owned(owned);
}
}
result
}
pub fn sanitize(&self, input: &str) -> String {
match self.sanitize_cow(input) {
std::borrow::Cow::Borrowed(s) => s.to_owned(),
std::borrow::Cow::Owned(s) => s,
}
}
pub fn sanitize_borrowed<'a>(&self, input: &'a str) -> std::borrow::Cow<'a, str> {
self.sanitize_cow(input)
}
pub fn sanitize_env(&self, vars: &[(String, String)]) -> Vec<(String, String)> {
let sensitive_keys = [
"api_key",
"api_token",
"access_token",
"auth_token",
"secret",
"password",
"passwd",
"pwd",
"credential",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"github_token",
"gh_token",
"npm_token",
"private_key",
"ssh_key",
];
vars.iter()
.map(|(key, value)| {
let key_lower = key.to_lowercase();
let is_sensitive = sensitive_keys.iter().any(|&s| key_lower.contains(s));
if is_sensitive {
(key.clone(), "[REDACTED]".to_string())
} else {
(key.clone(), self.sanitize(value))
}
})
.collect()
}
pub fn sanitize_json(&self, value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::Value::String(self.sanitize(s)),
serde_json::Value::Object(map) => {
let sanitized: serde_json::Map<String, serde_json::Value> = map
.iter()
.map(|(k, v)| {
let key_lower = k.to_lowercase();
if key_lower.contains("key")
|| key_lower.contains("token")
|| key_lower.contains("secret")
|| key_lower.contains("password")
|| key_lower.contains("credential")
{
(
k.clone(),
serde_json::Value::String("[REDACTED]".to_string()),
)
} else {
(k.clone(), self.sanitize_json(v))
}
})
.collect();
serde_json::Value::Object(sanitized)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(|v| self.sanitize_json(v)).collect())
}
other => other.clone(),
}
}
}
pub fn redact_for_display(s: &str) -> Cow<'_, str> {
if !s.chars().any(is_display_unsafe) {
return Cow::Borrowed(s);
}
Cow::Owned(s.chars().map(replace_unsafe).collect())
}
fn is_display_unsafe(c: char) -> bool {
if c.is_ascii_control() && c != '\t' {
return true;
}
let code = c as u32;
matches!(
code,
0x80..=0x9F
| 0x200B..=0x200F
| 0x202A..=0x202E
| 0x2028 | 0x2029
| 0x2060..=0x2064
| 0xFEFF
)
}
fn replace_unsafe(c: char) -> char {
if is_display_unsafe(c) { '?' } else { c }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_api_key() {
let sanitizer = Sanitizer::new();
let input = "api_key=sk_live_abc123def456";
let output = sanitizer.sanitize(input);
assert!(output.contains("[REDACTED]"));
assert!(!output.contains("abc123"));
}
#[test]
fn test_sanitize_bearer_token() {
let sanitizer = Sanitizer::new();
let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
let output = sanitizer.sanitize(input);
assert!(output.contains("[REDACTED]"));
assert!(!output.contains("eyJhbGc"));
}
#[test]
fn test_sanitize_password() {
let sanitizer = Sanitizer::new();
let input = "password=mysecretpassword123";
let output = sanitizer.sanitize(input);
assert!(output.contains("[REDACTED]"));
assert!(!output.contains("mysecret"));
}
#[test]
fn test_sanitize_email() {
let sanitizer = Sanitizer::new();
let input = "user email: john.doe@example.com";
let output = sanitizer.sanitize(input);
assert!(output.contains("[EMAIL_REDACTED]"));
assert!(!output.contains("john.doe"));
}
#[test]
fn test_sanitize_github_token() {
let sanitizer = Sanitizer::new();
let input = "GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
let output = sanitizer.sanitize(input);
assert!(output.contains("REDACTED"));
}
#[test]
fn test_sanitize_home_dir() {
let sanitizer = Sanitizer::new();
if let Some(ref home) = sanitizer.home_dir {
let input = format!("{}/some/path", home);
let output = sanitizer.sanitize(&input);
assert!(output.starts_with("~/"));
}
}
#[test]
fn test_sanitize_env() {
let sanitizer = Sanitizer::new();
let vars = vec![
("PATH".to_string(), "/usr/bin".to_string()),
("API_KEY".to_string(), "secret123".to_string()),
("HOME".to_string(), "/Users/test".to_string()),
];
let result = sanitizer.sanitize_env(&vars);
assert_eq!(result[0].1, "/usr/bin"); assert_eq!(result[1].1, "[REDACTED]"); }
#[test]
fn test_sanitize_json() {
let sanitizer = Sanitizer::new();
let json = serde_json::json!({
"name": "test",
"api_key": "secret123",
"nested": {
"token": "abc123"
}
});
let result = sanitizer.sanitize_json(&json);
assert_eq!(result["api_key"], "[REDACTED]");
assert_eq!(result["nested"]["token"], "[REDACTED]");
assert_eq!(result["name"], "test");
}
#[test]
fn test_custom_pattern() {
let mut sanitizer = Sanitizer::new();
sanitizer
.add_pattern(r"custom_\d+", "[CUSTOM_REDACTED]")
.unwrap();
let input = "data: custom_12345";
let output = sanitizer.sanitize(input);
assert!(output.contains("[CUSTOM_REDACTED]"));
}
#[test]
fn commit_sha_is_preserved() {
let s = Sanitizer::new();
let cases = [
"commit a1b2c3d4e5f6789012345678901234567890abcd",
"config_hash: sha256:dfd5145fe2aa5956a600e35848765273f5798ce6def01bd08ecec088a1268d91",
"image: sha256:c3641f8020d6e4d10cc1f93b0f8f3c2e2d3f5a8e9c0b1d4f5a6b7c8d9e0f1a2",
];
for input in cases {
let out = s.sanitize(input);
assert!(
!out.contains("HEX_TOKEN_REDACTED"),
"commit-sha-shaped value over-redacted: input={input:?} output={out:?}"
);
}
}
#[test]
fn benign_documentation_strings_are_preserved() {
let s = Sanitizer::new();
for input in [
"see api_key_documentation_url for the schema",
"use the password reset flow described in the doc",
] {
let out = s.sanitize(input);
let _ = out; }
}
#[test]
fn redacts_anthropic_keys() {
let s = Sanitizer::new();
let input = "key sk-ant-api03-AAABBBCCCDDDEEEFFFGGGHHH";
let out = s.sanitize(input);
assert!(out.contains("ANTHROPIC_KEY_REDACTED"), "got {out:?}");
}
#[test]
fn redacts_openai_project_keys() {
let s = Sanitizer::new();
let input = "OPENAI_API_KEY=sk-proj-AAABBBCCCDDDEEEFFFGGGHHH";
let out = s.sanitize(input);
assert!(!out.contains("AAABBBCCCDDDEEEFFFGGGHHH"), "got {out:?}");
}
#[test]
fn redacts_jwt_three_segment() {
let s = Sanitizer::new();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYifQ.a1b2c3d4e5f6";
let out = s.sanitize(&format!("token: {token}"));
assert!(out.contains("JWT_REDACTED"), "got {out:?}");
}
#[test]
fn redacts_slack_tokens() {
let s = Sanitizer::new();
for prefix in ["xoxa-", "xoxb-", "xoxp-", "xoxr-"] {
let token = format!("{prefix}1234567890-abcdefghij-XXXXX");
let out = s.sanitize(&format!("slack {token}"));
assert!(out.contains("SLACK_TOKEN_REDACTED"), "got {out:?}");
}
}
#[test]
fn redacts_gitlab_pat() {
let s = Sanitizer::new();
let out = s.sanitize("gitlab pat glpat-aaaaaaaaaaaaaaaaaaaa");
assert!(out.contains("GITLAB_TOKEN_REDACTED"), "got {out:?}");
}
#[test]
fn redacts_ssh_private_key_paths() {
let s = Sanitizer::new();
for path in [
"/home/alice/.ssh/id_rsa",
"/Users/bob/.ssh/id_ed25519",
"~/.ssh/id_ecdsa",
] {
let out = s.sanitize(path);
assert!(out.contains("SSH_KEY_PATH_REDACTED"), "got {out:?}");
}
}
#[test]
fn ssh_public_key_paths_pass_through() {
let s = Sanitizer::new();
let out = s.sanitize("~/.ssh/id_ed25519.pub");
assert!(!out.contains("SSH_KEY_PATH_REDACTED"), "got {out:?}");
}
#[test]
fn redacts_gnupg_paths() {
let s = Sanitizer::new();
let out = s.sanitize("/Users/alice/.gnupg/secring.gpg");
assert!(out.contains("GNUPG_PATH_REDACTED"), "got {out:?}");
}
#[test]
fn redact_for_display_fast_path_borrows() {
let safe = "dotnet-ef 1.2.3 (latest)";
match redact_for_display(safe) {
Cow::Borrowed(s) => assert_eq!(s, safe),
Cow::Owned(_) => panic!("safe string should borrow, not clone"),
}
}
#[test]
fn redact_for_display_strips_c0_controls() {
for (input, label) in [
("\u{1b}[2J\u{1b}[Hwiped", "ESC clear-screen"),
("name\u{07}beep", "BEL"),
("name\u{00}rest", "NUL splitter"),
("name\u{7f}", "DEL"),
("line1\nfake-log-line", "LF injection"),
("line1\rfake", "CR overwrite"),
] {
let out = redact_for_display(input);
assert!(matches!(out, Cow::Owned(_)), "{}: expected owned", label);
assert!(
!out.chars().any(|c| c.is_ascii_control() && c != '\t'),
"{}: result still contains control byte: {:?}",
label,
out
);
}
assert_eq!(redact_for_display("a\tb"), Cow::Borrowed("a\tb"));
}
#[test]
fn redact_for_display_strips_c1_controls() {
for codepoint in [0x80u32, 0x85, 0x9B, 0x9F] {
let c = char::from_u32(codepoint).unwrap();
let input = format!("safe{}evil", c);
let out = redact_for_display(&input);
assert!(
matches!(out, Cow::Owned(_)),
"C1 U+{:04X} should be redacted",
codepoint
);
assert!(
!out.contains(c),
"C1 U+{:04X} survived redaction",
codepoint
);
}
}
#[test]
fn redact_for_display_strips_trojan_source_chars() {
for (codepoint, label) in [
(0x202Eu32, "RTL override"),
(0x202D, "LTR override"),
(0x200B, "ZWSP"),
(0x200E, "LRM"),
(0x2060, "word joiner"),
(0xFEFF, "BOM"),
] {
let c = char::from_u32(codepoint).unwrap();
let input = format!("csharpier{}EVIL", c);
let out = redact_for_display(&input);
assert!(matches!(out, Cow::Owned(_)), "{} should be redacted", label);
assert!(!out.contains(c), "{} survived redaction", label);
}
}
#[test]
fn redact_for_display_strips_line_separators() {
for codepoint in [0x2028u32, 0x2029] {
let c = char::from_u32(codepoint).unwrap();
let input = format!("safe{}fake-log", c);
let out = redact_for_display(&input);
assert!(
matches!(out, Cow::Owned(_)),
"U+{:04X} should be redacted",
codepoint
);
}
}
}