pub(crate) const CREDENTIAL_WORDS: &[&str] = &[
"password",
"passwd",
"passphrase",
"pwd",
"password_hash",
"secret",
"token",
"jwt",
"api_key",
"apikey",
"private_key",
"privatekey",
"encryption_key",
"signing_key",
"secret_key",
"access_key",
"credential",
"credentials",
"database_url",
];
#[cfg(any(feature = "mcp", feature = "_http_layers"))]
const REDACT_ONLY_WORDS: &[&str] = &[
"pass",
"pin",
"ssn",
"bearer",
"key",
"signature",
"sig",
"auth",
"authorization",
"cookie",
"session",
"sessionid",
"sessid",
"otp",
"totp",
"code_verifier",
];
#[cfg(any(feature = "mcp", feature = "_http_layers"))]
pub(crate) fn secret_words() -> impl Iterator<Item = &'static str> {
CREDENTIAL_WORDS.iter().chain(REDACT_ONLY_WORDS).copied()
}
pub(crate) fn is_credential_column_name(name: &str) -> bool {
let name = name.to_ascii_lowercase();
CREDENTIAL_WORDS.iter().any(|w| {
name.strip_suffix(w)
.is_some_and(|rest| rest.is_empty() || rest.ends_with('_'))
})
}
#[cfg(any(feature = "mcp", feature = "_http_layers"))]
const JOINED_WORDS: &[&str] = &[
"password",
"passwd",
"passphrase",
"token",
"secret",
"apikey",
"sessionid",
"sessid",
"privatekey",
"credential",
"signature",
"authorization",
"cookie",
];
#[cfg(any(feature = "mcp", feature = "_http_layers"))]
fn word_ranges(key: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
let mut prev: Option<char> = None;
let mut chars = key.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if !c.is_alphanumeric() {
if let Some(s) = start.take() {
out.push((s, i));
}
prev = None;
continue;
}
let next = chars.peek().map(|&(_, n)| n);
let split = prev.is_some_and(|p| {
(p.is_lowercase() && c.is_uppercase())
|| (p.is_alphabetic() != c.is_alphabetic())
|| (p.is_uppercase() && c.is_uppercase() && next.is_some_and(char::is_lowercase))
});
if split {
if let Some(s) = start.take() {
out.push((s, i));
}
}
start.get_or_insert(i);
prev = Some(c);
}
if let Some(s) = start {
out.push((s, key.len()));
}
out
}
#[cfg(any(feature = "mcp", feature = "_http_layers"))]
#[derive(Clone, Debug, Default)]
pub(crate) struct SecretNames {
runs: Vec<Box<[Box<str>]>>,
joined: Vec<Box<str>>,
}
#[cfg(any(feature = "mcp", feature = "_http_layers"))]
impl SecretNames {
pub(crate) fn new<'a>(names: impl IntoIterator<Item = &'a str>) -> Self {
let mut out = Self::default();
for name in names {
let words: Box<[Box<str>]> = word_ranges(name)
.into_iter()
.map(|(a, b)| name[a..b].to_lowercase().into_boxed_str())
.collect();
if words.is_empty() || out.runs.contains(&words) {
continue;
}
let flat: String = words.concat();
if (words.len() > 1 || JOINED_WORDS.contains(&flat.as_str()))
&& !out.joined.iter().any(|j| **j == *flat)
{
out.joined.push(flat.into_boxed_str());
}
out.runs.push(words);
}
out
}
pub(crate) fn matches(&self, key: &str) -> bool {
let lower = key.to_lowercase();
let ranges = if key.is_ascii() {
word_ranges(key)
} else {
word_ranges(&lower)
};
let words: Vec<&str> = ranges.iter().map(|&(a, b)| &lower[a..b]).collect();
let by_word = self.runs.iter().any(|want| {
words
.windows(want.len())
.any(|w| w.iter().zip(want.iter()).all(|(a, b)| *a == &**b))
});
by_word || {
let flat: String = lower.chars().filter(|c| c.is_alphanumeric()).collect();
self.joined.iter().any(|j| flat.contains(&**j))
}
}
}
#[cfg(feature = "mcp")]
pub(crate) fn is_secret(key: &str) -> bool {
static NAMES: std::sync::LazyLock<SecretNames> =
std::sync::LazyLock::new(|| SecretNames::new(secret_words()));
NAMES.matches(key)
}
#[cfg(test)]
mod column_tests {
#[test]
fn credential_columns_match_by_word_suffix() {
for n in [
"passwd",
"encryption_key",
"db_password",
"Reset_Token",
"aws_access_key",
"database_url",
] {
assert!(super::is_credential_column_name(n), "{n}");
}
for n in [
"password_changed_at",
"secret_prefix",
"token_ttl",
"access_key_id",
"notoken",
"storage_key",
] {
assert!(!super::is_credential_column_name(n), "{n}");
}
}
}
#[cfg(all(test, any(feature = "mcp", feature = "_http_layers")))]
mod tests {
use super::*;
fn defaults() -> SecretNames {
SecretNames::new(secret_words())
}
#[test]
fn hides_secret_names_in_any_spelling() {
let names = defaults();
for k in [
"password1",
"password2",
"token2",
"user[password]",
"data[token]",
"user.password",
"accesstoken",
"refreshtoken",
"authtoken",
"clientsecret",
"secretkey",
"privatekey",
"xapikey",
"csrftoken",
"JSESSIONID",
"PHPSESSID",
"PASSWORDConfirm",
"ssn",
"pass",
"X-Api-Key",
"newPassword",
"api_key",
] {
assert!(names.matches(k), "{k} must be hidden");
}
}
#[test]
fn credential_words_are_redaction_words() {
let names = defaults();
for w in CREDENTIAL_WORDS {
assert!(names.matches(w), "{w}");
}
}
#[test]
fn leaves_ordinary_names_alone() {
let names = defaults();
for k in [
"keyboard", "page", "sort_by", "monkey", "author", "pinned", "passport",
] {
assert!(!names.matches(k), "{k} must stay visible");
}
}
#[test]
fn splits_on_every_boundary() {
let words = |k: &str| -> Vec<String> {
word_ranges(k)
.iter()
.map(|&(a, b)| k[a..b].to_owned())
.collect()
};
assert_eq!(words("PASSWORDConfirm"), ["PASSWORD", "Confirm"]);
assert_eq!(words("token2"), ["token", "2"]);
assert_eq!(words("user[password]"), ["user", "password"]);
assert_eq!(words("newPassword"), ["new", "Password"]);
}
}