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 run.starts_with("gh")
&& matches!(
run.as_bytes().get(2),
Some(b'p' | b'o' | b'u' | b's' | b'r')
)
&& run.as_bytes().get(3) == Some(&b'_')
&& len >= 20
{
return Some("GITHUB");
}
if run.starts_with(concat!("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),
}
}