use std::borrow::Cow;
pub const SECRET_PLACEHOLDER_OPEN: &str = "[[CR_SECRET_";
pub const SECRET_PLACEHOLDER_CLOSE: &str = "]]";
const MIN_ASSIGNED_VALUE_LEN: usize = 8;
const NAME_KEYWORDS: &[&str] = &[
"SECRET",
"TOKEN",
"PASSWORD",
"PASSWD",
"API_KEY",
"APIKEY",
"PRIVATE_KEY",
"ACCESS_KEY",
"CREDENTIAL",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretSpan {
pub start: usize,
pub end: usize,
pub kind: &'static str,
}
fn is_tok(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'
}
fn classify(run: &str, after_bearer: bool) -> Option<&'static str> {
let len = run.len();
if run.starts_with("sk-ant-") && len >= 20 {
return Some("ANTHROPIC");
}
if run.starts_with("sk-") && len >= 20 {
return Some("APIKEY");
}
if ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"]
.iter()
.any(|p| run.starts_with(p))
&& len >= 20
{
return Some("GITHUB");
}
if run.starts_with("github_pat_") && len >= 30 {
return Some("GITHUB");
}
if (run.starts_with("AKIA") || run.starts_with("ASIA"))
&& len == 20
&& run
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return Some("AWS");
}
if run.starts_with("AIza") && len == 39 {
return Some("GOOGLE");
}
if run.starts_with("xox")
&& len >= 15
&& matches!(
run.as_bytes().get(3),
Some(b'b' | b'a' | b'p' | b'r' | b's')
)
&& run.as_bytes().get(4) == Some(&b'-')
{
return Some("SLACK");
}
if run.starts_with("eyJ") && len >= 30 {
let parts: Vec<&str> = run.split('.').collect();
if parts.len() == 3 && parts.iter().all(|p| !p.is_empty()) {
return Some("JWT");
}
}
if after_bearer && len >= 16 {
return Some("BEARER");
}
None
}
fn name_is_secretish(name: &str) -> bool {
let norm = name.to_ascii_uppercase().replace('-', "_");
NAME_KEYWORDS.iter().any(|k| norm.contains(k))
}
fn ends_with_bearer(prefix: &str) -> bool {
let b = prefix.as_bytes();
b.len() >= 7 && b[b.len() - 7..].eq_ignore_ascii_case(b"bearer ")
}
fn assigned_value_span(s: &str, name_end: usize) -> Option<(usize, usize)> {
let b = s.as_bytes();
let n = b.len();
let mut i = name_end;
if i < n && (b[i] == b'"' || b[i] == b'\'') {
i += 1;
}
while i < n && b[i] == b' ' {
i += 1;
}
if i >= n || (b[i] != b'=' && b[i] != b':') {
return None;
}
i += 1;
while i < n && b[i] == b' ' {
i += 1;
}
if i < n && (b[i] == b'"' || b[i] == b'\'') {
i += 1;
}
let vs = i;
while i < n
&& !b[i].is_ascii_whitespace()
&& !matches!(b[i], b'"' | b'\'' | b',' | b';' | b'}' | b']')
{
i += 1;
}
if i > vs {
Some((vs, i))
} else {
None
}
}
fn pem_spans(text: &str) -> Vec<SecretSpan> {
let mut spans = Vec::new();
let mut base = 0usize;
while let Some(rel) = text[base..].find("-----BEGIN ") {
let begin = base + rel;
let after = &text[begin..];
let header_end = match after[11..].find("-----") {
Some(e) => e + 11 + 5,
None => break,
};
if !after[..header_end].contains("PRIVATE KEY") {
base = begin + header_end;
continue;
}
let block_end = match after.find("-----END ") {
Some(e) => after[e + 9..]
.find("-----")
.map(|x| e + 9 + x + 5)
.unwrap_or(after.len()),
None => after.len(),
};
spans.push(SecretSpan {
start: begin,
end: begin + block_end,
kind: "PRIVATEKEY",
});
base = begin + block_end;
}
spans
}
pub fn find_secrets(text: &str) -> Vec<SecretSpan> {
let pem = pem_spans(text);
let in_pem = |i: usize| pem.iter().any(|s| i >= s.start && i < s.end);
let bytes = text.as_bytes();
let n = bytes.len();
let mut found: Vec<SecretSpan> = Vec::new();
let mut i = 0usize;
while i < n {
if !is_tok(bytes[i]) {
i += 1;
continue;
}
let start = i;
while i < n && is_tok(bytes[i]) {
i += 1;
}
let mut end = i;
while end > start && bytes[end - 1] == b'.' {
end -= 1;
}
if end == start || in_pem(start) {
continue;
}
let run = &text[start..end];
if let Some(kind) = classify(run, ends_with_bearer(&text[..start])) {
found.push(SecretSpan { start, end, kind });
continue;
}
if name_is_secretish(run) {
if let Some((vs, ve)) = assigned_value_span(text, end) {
let value = &text[vs..ve];
if value.len() >= MIN_ASSIGNED_VALUE_LEN
&& !value.starts_with(SECRET_PLACEHOLDER_OPEN)
{
found.push(SecretSpan {
start: vs,
end: ve,
kind: "ASSIGNED",
});
i = ve;
}
}
}
}
found.extend(pem);
found.sort_by_key(|s| s.start);
found
}
pub fn redact_irreversible(text: &str) -> Cow<'_, str> {
let spans = find_secrets(text);
if spans.is_empty() {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
let mut copied = 0usize;
for s in spans {
out.push_str(&text[copied..s.start]);
out.push_str(SECRET_PLACEHOLDER_OPEN);
out.push_str(s.kind);
out.push_str("_REDACTED");
out.push_str(SECRET_PLACEHOLDER_CLOSE);
copied = s.end;
}
out.push_str(&text[copied..]);
Cow::Owned(out)
}
fn walk(v: &mut serde_json::Value, changed: &mut bool) {
match v {
serde_json::Value::String(s) => {
if let Cow::Owned(r) = redact_irreversible(s) {
*s = r;
*changed = true;
}
}
serde_json::Value::Array(a) => a.iter_mut().for_each(|x| walk(x, changed)),
serde_json::Value::Object(o) => o.iter_mut().for_each(|(_, x)| walk(x, changed)),
_ => {}
}
}
pub fn redact_json_payload(payload: &str) -> Cow<'_, str> {
match serde_json::from_str::<serde_json::Value>(payload) {
Ok(mut v) => {
let mut changed = false;
walk(&mut v, &mut changed);
if !changed {
return Cow::Borrowed(payload);
}
match serde_json::to_string(&v) {
Ok(s) => Cow::Owned(s),
Err(_) => redact_irreversible(payload),
}
}
Err(_) => redact_irreversible(payload),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_sk() -> String {
format!("sk-{}", "FAKE".repeat(8))
}
#[test]
fn irreversible_redaction_removes_value_and_is_idempotent() {
let t = format!("a {} b", fake_sk());
let r = redact_irreversible(&t).into_owned();
assert!(!r.contains("FAKE"));
assert!(r.contains("[[CR_SECRET_APIKEY_REDACTED]]"));
assert_eq!(redact_irreversible(&r), r);
}
#[test]
fn clean_text_is_borrowed() {
assert!(matches!(
redact_irreversible("nothing here"),
Cow::Borrowed(_)
));
}
#[test]
fn json_payload_secrets_in_nested_escaped_strings_are_redacted() {
let inner = "{\"api_key\": \"fake-value-12345\"}";
let payload = serde_json::json!({"content": inner, "n": 1}).to_string();
let out = redact_json_payload(&payload);
assert!(!out.contains("fake-value-12345"));
assert!(serde_json::from_str::<serde_json::Value>(&out).is_ok());
}
#[test]
fn json_payload_without_secrets_is_byte_identical() {
let p = r#"{"a":"hello","b":[1,2]}"#;
assert!(matches!(redact_json_payload(p), Cow::Borrowed(_)));
}
#[test]
fn pem_block_is_one_span() {
let t = format!(
"x -----BEGIN {k}-----\nFAKE\n-----END {k}----- y",
k = "PRIVATE KEY"
);
let s = find_secrets(&t);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, "PRIVATEKEY");
}
}