use std::borrow::Cow;
pub fn redact(s: &str) -> Cow<'_, str> {
let suspicious = s.contains("eyJ")
|| s.contains("PRIVATE KEY")
|| s.contains("sk-")
|| s.contains("AKIA")
|| s.contains("gh")
|| s.contains("xox")
|| s.contains("AIza")
|| s.contains("_live_");
if !suspicious {
return Cow::Borrowed(s);
}
let step1 = redact_pem_blocks(s);
let step2 = redact_tokens(&step1);
if step2 == s {
Cow::Borrowed(s)
} else {
Cow::Owned(step2)
}
}
fn redact_pem_blocks(s: &str) -> Cow<'_, str> {
if !s.contains("PRIVATE KEY-----") {
return Cow::Borrowed(s);
}
const BEGIN: &str = "-----BEGIN ";
let mut out = String::with_capacity(s.len());
let mut i = 0;
let mut changed = false;
while i < s.len() {
let Some(rel) = s[i..].find(BEGIN) else {
out.push_str(&s[i..]);
break;
};
let begin = i + rel;
let after = &s[begin + BEGIN.len()..];
let Some(hdr_rel) = after.find("-----") else {
out.push_str(&s[i..]);
break;
};
let header = &after[..hdr_rel];
let hdr_end = begin + BEGIN.len() + hdr_rel + 5;
if !header.contains("PRIVATE KEY") {
out.push_str(&s[i..hdr_end]);
i = hdr_end;
continue;
}
let footer = s[hdr_end..].find("-----END").and_then(|e| {
s[hdr_end + e..]
.find("PRIVATE KEY-----")
.map(|k| e + k + "PRIVATE KEY-----".len())
});
out.push_str(&s[i..begin]);
out.push_str("[redacted:private-key]");
changed = true;
i = match footer {
Some(f) => hdr_end + f,
None => s.len(), };
}
if changed {
Cow::Owned(out)
} else {
Cow::Borrowed(s)
}
}
fn is_token_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')
}
fn secret_kind(t: &str) -> Option<&'static str> {
if t.starts_with("eyJ") {
let parts: Vec<&str> = t.split('.').collect();
if parts.len() == 3 && parts[1].starts_with("eyJ") && parts.iter().all(|p| !p.is_empty()) {
return Some("jwt");
}
}
if t.starts_with("sk-") && t.len() >= 20 {
return Some("openai");
}
if (t.starts_with("sk_live_") || t.starts_with("rk_live_")) && t.len() >= 20 {
return Some("stripe");
}
if t.starts_with("AIza") && t.len() >= 30 {
return Some("google");
}
if t.starts_with("AKIA")
&& t.len() == 20
&& t[4..]
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return Some("aws");
}
if t.starts_with("github_pat_") && t.len() >= 40 {
return Some("github");
}
if matches!(
&t[..t.len().min(4)],
"ghp_" | "gho_" | "ghu_" | "ghs_" | "ghr_"
) && t.len() >= 20
{
return Some("github");
}
if t.len() >= 15
&& matches!(
&t[..t.len().min(5)],
"xoxb-" | "xoxp-" | "xoxa-" | "xoxr-" | "xoxs-"
)
{
return Some("slack");
}
None
}
fn flush_token(token: &mut String, out: &mut String) {
if token.is_empty() {
return;
}
let core = token.trim_end_matches('.');
match secret_kind(core) {
Some(kind) => {
out.push_str("[redacted:");
out.push_str(kind);
out.push(']');
out.push_str(&token[core.len()..]); }
None => out.push_str(token),
}
token.clear();
}
fn redact_tokens(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut token = String::new();
for ch in s.chars() {
if is_token_char(ch) {
token.push(ch);
} else {
flush_token(&mut token, &mut out);
out.push(ch);
}
}
flush_token(&mut token, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::redact;
#[test]
fn redacts_a_jwt_but_keeps_surrounding_text() {
let s = "here: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U end";
let out = redact(s);
assert!(!out.contains("eyJhbGci"), "jwt gone: {out}");
assert!(
out.contains("here:") && out.contains("end"),
"context kept: {out}"
);
assert!(out.contains("[redacted:jwt]"), "marker: {out}");
}
#[test]
fn redacts_prefixed_api_tokens() {
for (raw, kind) in [
("sk-abcdef012345678901234567890123", "openai"),
("AKIAIOSFODNN7EXAMPLE", "aws"),
("ghp_016C7f9aBcDeFgHiJkLmNoPqRsTuVwXyZ012", "github"),
(concat!("xox", "b-1234567890-abcdefghijklmnop"), "slack"),
] {
let s = format!("key={raw} rest");
let out = redact(&s);
assert!(!out.contains(raw), "{kind} token redacted: {out}");
assert!(
out.contains("key=") && out.contains("rest"),
"context kept: {out}"
);
assert!(out.contains("[redacted:"), "marker for {kind}: {out}");
}
}
#[test]
fn redacts_a_pem_private_key_block() {
let s = "cfg\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAA\nAAAAABG5vbmUAAA\n-----END OPENSSH PRIVATE KEY-----\ndone";
let out = redact(s);
assert!(!out.contains("b3BlbnNz"), "key body gone: {out}");
assert!(!out.contains("BEGIN OPENSSH"), "header gone: {out}");
assert!(
out.contains("cfg") && out.contains("done"),
"context kept: {out}"
);
assert!(out.contains("[redacted:private-key]"), "marker: {out}");
}
#[test]
fn redacts_a_jwt_with_trailing_punctuation() {
let s = "issued eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.abc123_def-XYZ.";
let out = redact(s);
assert!(!out.contains("eyJhbGci"), "jwt gone: {out}");
assert!(out.contains("[redacted:jwt]"), "marker: {out}");
assert!(out.trim_end().ends_with('.'), "trailing period kept: {out}");
}
#[test]
fn redacts_more_provider_tokens() {
for raw in [
"AIzaSyA1234567890abcdefghijklmnopqrstuvw",
"sk_live_0123456789abcdefghijklmnop",
"github_pat_11ABCDE0000abcdefghij_0123456789abcdefghijklmnopqrstuvwxyzABCD",
] {
let s = format!("k={raw} z");
let out = redact(&s);
assert!(!out.contains(raw), "redacted: {out}");
assert!(
out.contains("[redacted:") && out.contains("k=") && out.contains("z"),
"{out}"
);
}
}
#[test]
fn leaves_ordinary_code_and_prose_untouched() {
for ok in [
"let sk = 3; // a short name, not a secret",
"the ghost wrote authenticate() and returned early",
"https://example.com/path?x=1 and a sha like abc123",
"AKIA is a prefix but AKIAshort is too short",
] {
assert_eq!(redact(ok), ok, "false positive on: {ok}");
}
}
}