const SECRET_KEY_SUFFIXES: &[&str] = &["_KEY", "_SECRET", "_PASSWORD", "_TOKEN"];
const SECRET_VALUE_PREFIXES: &[&str] = &[
"sk-",
"ghp_",
"ghs_",
"gho_",
"ghu_",
"dckr_pat_",
"glpat-",
"AIZA",
"xox",
];
const MIN_SECRET_LEN: usize = 10;
fn is_secret(key: &str, value: &str) -> bool {
if value.len() < MIN_SECRET_LEN {
return false;
}
let key_upper = key.to_uppercase();
if SECRET_KEY_SUFFIXES.iter().any(|s| key_upper.ends_with(s)) {
return true;
}
SECRET_VALUE_PREFIXES.iter().any(|p| value.starts_with(p))
}
pub fn redact_string(input: &str, env: &[(String, String)]) -> String {
let mut secrets: Vec<(&str, &str)> = env
.iter()
.filter(|(k, v)| is_secret(k, v))
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
secrets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
let mut result = input.to_string();
for (key, value) in secrets {
result = result.replace(value, &format!("${}", key));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_by_key_suffix() {
let env = vec![
(
"DOCKER_PASSWORD".to_string(),
"mysecretpassword123".to_string(),
),
("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
];
let result = redact_string("Login with mysecretpassword123 succeeded", &env);
assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
assert!(!result.contains("mysecretpassword123"));
}
#[test]
fn test_redact_by_value_prefix() {
let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
let result = redact_string("Using token ghp_abc123def456ghi789", &env);
assert_eq!(result, "Using token $MY_TOKEN");
}
#[test]
fn test_redact_ignores_short_values() {
let env = vec![("API_KEY".to_string(), "short".to_string())];
let result = redact_string("Value is short", &env);
assert_eq!(result, "Value is short");
}
#[test]
fn test_redact_longer_values_first() {
let env = vec![
("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
];
let result = redact_string("secret: abcdefghijklmnop", &env);
assert_eq!(result, "secret: $LONG_TOKEN");
}
#[test]
fn test_redact_no_secrets() {
let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
let result = redact_string("PATH is set", &env);
assert_eq!(result, "PATH is set");
}
#[test]
fn test_redact_multiple_occurrences() {
let env = vec![(
"REGISTRY_PASSWORD".to_string(),
"supersecret123".to_string(),
)];
let result = redact_string("auth supersecret123 retry supersecret123", &env);
assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
}
#[test]
fn test_is_secret_key_suffixes() {
assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
assert!(is_secret("API_TOKEN", "longvalue1234"));
assert!(is_secret("signing_key", "longvalue1234")); assert!(is_secret("MY_SECRET", "longvalue1234"));
assert!(!is_secret("MY_CONFIG", "longvalue1234"));
}
#[test]
fn test_is_secret_value_prefixes() {
assert!(is_secret("ANYTHING", "ghp_1234567890"));
assert!(is_secret("ANYTHING", "sk-1234567890"));
assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
assert!(is_secret("ANYTHING", "glpat-1234567890"));
assert!(!is_secret("ANYTHING", "regular_value1234"));
}
#[test]
fn test_redact_sort_stability_same_length() {
let env = vec![
("B_SECRET".to_string(), "same_length_val".to_string()),
("A_SECRET".to_string(), "same_length_val".to_string()),
];
let result = redact_string("found same_length_val here", &env);
assert_eq!(result, "found $A_SECRET here");
}
#[test]
fn test_redact_deterministic_with_different_lengths() {
let env = vec![
("Z_TOKEN".to_string(), "short_secret_val".to_string()),
(
"A_TOKEN".to_string(),
"a_longer_secret_value_here".to_string(),
),
];
let result = redact_string("prefix a_longer_secret_value_here suffix", &env);
assert_eq!(result, "prefix $A_TOKEN suffix");
}
}