use regex::Regex;
use std::fmt;
pub const REDACTED_MARKER: &str = "[REDACTED]";
pub trait RedactedDisplay {
fn redacted_display(&self) -> &'static str {
REDACTED_MARKER
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct Secret<T> {
_value: T,
}
impl<T> Secret<T> {
pub fn new(value: T) -> Self {
Self { _value: value }
}
}
impl<T> RedactedDisplay for Secret<T> {}
impl<T> fmt::Display for Secret<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.redacted_display())
}
}
impl<T> fmt::Debug for Secret<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.redacted_display())
}
}
pub fn redact_text(input: &str) -> String {
let output = redact_pem_blocks(input);
let output = redact_bearer_tokens(&output);
let output = redact_url_credentials(&output);
let output = redact_keyed_values(&output);
redact_token_shapes(&output)
}
#[allow(dead_code)]
pub fn redact_pii(input: &str) -> String {
redact_emails(&redact_text(input))
}
fn redact_bearer_tokens(input: &str) -> String {
static_regex(r"(?i)((?:\bauthorization\s*[:=]\s*)?\bbearer\s+)([A-Za-z0-9._~+/=-]{6,})")
.replace_all(input, format!("$1{REDACTED_MARKER}"))
.into_owned()
}
fn redact_keyed_values(input: &str) -> String {
static_regex(
r#"(?i)(["']?[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?token|token|secret|password|credential)[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(["']?)[^"',\s}]+(["']?)"#,
)
.replace_all(input, format!("$1$2{REDACTED_MARKER}$3"))
.into_owned()
}
fn redact_token_shapes(input: &str) -> String {
static_regex(
r"(?x)
\b(?:
sk-ant-[A-Za-z0-9._-]{6,}
| sk-[A-Za-z0-9._-]{6,}
| AIza[A-Za-z0-9._-]{6,}
| xox[abprs]-[A-Za-z0-9-]{6,}
| gh[posru]_[A-Za-z0-9]{20,}
| github_pat_[A-Za-z0-9_]{20,}
| glpat-[A-Za-z0-9_-]{16,}
| AKIA[A-Z0-9]{12,}
| eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}
)\b",
)
.replace_all(input, REDACTED_MARKER)
.into_owned()
}
fn redact_url_credentials(input: &str) -> String {
static_regex(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@")
.replace_all(input, format!("$1{REDACTED_MARKER}@"))
.into_owned()
}
fn redact_pem_blocks(input: &str) -> String {
static_regex(r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----")
.replace_all(input, REDACTED_MARKER)
.into_owned()
}
fn redact_emails(input: &str) -> String {
static_regex(r"(?i)\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
.replace_all(input, REDACTED_MARKER)
.into_owned()
}
fn static_regex(pattern: &str) -> Regex {
Regex::new(pattern).expect("redaction regex must be valid")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn secret_display_and_debug_are_redacted() {
let secret = Secret::new(String::from("sk-test-secret"));
let display = format!("{secret}");
let debug = format!("{secret:?}");
let event = json!({ "token": secret.to_string() }).to_string();
assert_eq!(display, REDACTED_MARKER);
assert_eq!(debug, REDACTED_MARKER);
assert!(event.contains(REDACTED_MARKER));
assert!(!display.contains("sk-test-secret"));
assert!(!debug.contains("sk-test-secret"));
assert!(!event.contains("sk-test-secret"));
}
#[test]
fn text_scrubber_redacts_known_token_shapes_and_keyed_values() {
let rendered = redact_text(
r#"OPENAI_API_KEY=sk-test-secret token: "sk-ant-secret-value" Authorization: Bearer sk-live-secret"#,
);
assert!(rendered.contains("OPENAI_API_KEY=[REDACTED]"));
assert!(rendered.contains("token: \"[REDACTED]\""));
assert!(rendered.contains("Authorization: Bearer [REDACTED]"));
assert!(!rendered.contains("sk-test-secret"));
assert!(!rendered.contains("sk-ant-secret-value"));
assert!(!rendered.contains("sk-live-secret"));
}
#[test]
fn redacts_provider_token_formats() {
let input = concat!(
"slack=xoxb-123456789012-abcdefabcdef ",
"gh=ghp_0123456789012345678901234567890123 ",
"gl=glpat-ABCDEFGHIJKLMNOPQRST ",
"aws=AKIAIOSFODNN7EXAMPLE ",
"jwt=eyJhbGciOiJI.eyJzdWIiOiE.SflKxwRJSMabc"
);
let out = redact_text(input);
for leaked in [
"xoxb-123456789012",
"ghp_0123456789012345678901234567890123",
"glpat-ABCDEFGHIJKLMNOPQRST",
"AKIAIOSFODNN7EXAMPLE",
"eyJzdWIiOiE",
] {
assert!(!out.contains(leaked), "vazou {leaked} em: {out}");
}
}
#[test]
fn redacts_url_credentials_and_pem() {
let url = redact_text("clone https://user:s3cretpw@git.example.com/repo.git");
assert!(!url.contains("s3cretpw"), "{url}");
assert!(url.contains("https://[REDACTED]@git.example.com"));
let pem = redact_text("k:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretline\n-----END RSA PRIVATE KEY-----\nfim");
assert!(!pem.contains("MIIEsecretline"), "{pem}");
assert!(pem.contains("fim"));
}
#[test]
fn email_is_pii_not_secret() {
let input = "autor: alan@example.com aprovou";
assert!(redact_text(input).contains("alan@example.com"));
assert!(!redact_pii(input).contains("alan@example.com"));
assert!(redact_pii(input).contains(REDACTED_MARKER));
}
}