#![forbid(unsafe_code)]
pub const ADVICE: &str = "keep the secret in a keyring (OS keychain, pass, systemd-credentials) and store a reference in memory instead; memory privacy flags are not encryption";
#[must_use]
pub fn credential_shaped_content(content: &str) -> Vec<&'static str> {
let mut kinds: Vec<&'static str> = Vec::new();
let push = |k: &'static str, kinds: &mut Vec<&'static str>| {
if !kinds.contains(&k) {
kinds.push(k);
}
};
if content.contains("-----BEGIN") && content.contains("PRIVATE KEY") {
push("private_key_pem", &mut kinds);
}
if token_after(content, "AKIA", 16, |c| {
c.is_ascii_uppercase() || c.is_ascii_digit()
}) {
push("aws_access_key_id", &mut kinds);
}
let alnum = |c: char| c.is_ascii_alphanumeric() || c == '_';
if token_after(content, "ghp_", 30, alnum)
|| token_after(content, "gho_", 30, alnum)
|| token_after(content, "github_pat_", 20, alnum)
{
push("github_token", &mut kinds);
}
if token_after(content, "sk-", 20, |c| {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}) {
push("openai_style_key", &mut kinds);
}
if ["xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-"]
.iter()
.any(|p| token_after(content, p, 10, |c| c.is_ascii_alphanumeric() || c == '-'))
{
push("slack_token", &mut kinds);
}
if content.match_indices("eyJ").count() >= 2 {
push("jwt", &mut kinds);
}
if assignment_shaped(content) {
push("credential_assignment", &mut kinds);
}
kinds
}
fn token_after(
haystack: &str,
prefix: &str,
min_len: usize,
charset: impl Fn(char) -> bool,
) -> bool {
let mut from = 0usize;
while let Some(pos) = haystack[from..].find(prefix) {
let abs = from + pos + prefix.len();
let run = haystack[abs..].chars().take_while(|c| charset(*c)).count();
if run >= min_len {
return true;
}
from = abs;
}
false
}
const ASSIGNMENT_KEYS: &[&str] = &[
"password",
"passwd",
"api_key",
"api-key",
"apikey",
"secret",
"access_token",
"secret_access_key",
"aws_secret_access_key",
"secret_key",
"client_secret",
"private_key",
"auth_token",
"refresh_token",
];
fn assignment_shaped(content: &str) -> bool {
let lower = content.to_lowercase();
for key in ASSIGNMENT_KEYS {
let mut from = 0usize;
while let Some(pos) = lower[from..].find(key) {
let abs = from + pos + key.len();
let rest = lower[abs..].trim_start();
let rest = rest.strip_prefix('"').unwrap_or(rest).trim_start();
let Some(delim) = rest.chars().next() else {
break;
};
if delim == ':' || delim == '=' {
let value = rest[1..].trim_start();
let value = value.strip_prefix(['"', '\'']).unwrap_or(value);
let is_marker = value
.get(..10)
.is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
if !is_marker {
let run: usize = value
.chars()
.take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
.map(char::len_utf8)
.sum();
if run >= 16 {
return true;
}
}
}
from = abs;
}
}
false
}
#[must_use]
pub fn redact_credential_content(content: &str) -> (String, Vec<&'static str>) {
let kinds = credential_shaped_content(content);
if kinds.is_empty() {
return (content.to_string(), kinds);
}
let mut out = content.to_string();
if kinds.contains(&"private_key_pem") {
while let Some((start, end)) = pem_block_span(&out) {
out.replace_range(start..end, "[REDACTED:private_key_pem]");
}
out = out.replace("PRIVATE KEY-----", "[REDACTED:pem-key]");
out = out.replace("-----BEGIN", "[REDACTED:pem-begin]");
out = out.replace("-----END", "[REDACTED:pem-end]");
}
if kinds.contains(&"credential_assignment") {
while let Some((start, end)) = assignment_value_span(&out) {
out.replace_range(start..end, "[REDACTED:credential_assignment]");
}
}
if kinds.contains(&"jwt") {
while let Some(pos) = out.find("eyJ") {
out.replace_range(pos..pos + "eyJ".len(), "[REDACTED:jwt]");
}
}
type TokenSpec = (&'static str, &'static str, usize, fn(char) -> bool);
let token_specs: &[TokenSpec] = &[
("aws_access_key_id", "AKIA", 16, |c: char| {
c.is_ascii_uppercase() || c.is_ascii_digit()
}),
("github_token", "ghp_", 30, |c: char| {
c.is_ascii_alphanumeric() || c == '_'
}),
("github_token", "gho_", 30, |c: char| {
c.is_ascii_alphanumeric() || c == '_'
}),
("github_token", "github_pat_", 20, |c: char| {
c.is_ascii_alphanumeric() || c == '_'
}),
("openai_style_key", "sk-", 20, |c: char| {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}),
("slack_token", "xoxb-", 10, |c: char| {
c.is_ascii_alphanumeric() || c == '-'
}),
("slack_token", "xoxp-", 10, |c: char| {
c.is_ascii_alphanumeric() || c == '-'
}),
("slack_token", "xoxa-", 10, |c: char| {
c.is_ascii_alphanumeric() || c == '-'
}),
("slack_token", "xoxr-", 10, |c: char| {
c.is_ascii_alphanumeric() || c == '-'
}),
("slack_token", "xoxs-", 10, |c: char| {
c.is_ascii_alphanumeric() || c == '-'
}),
];
for (kind, prefix, min_len, charset) in token_specs {
while let Some((start, end)) = prefixed_token_span(&out, prefix, *min_len, *charset) {
out.replace_range(start..end, &format!("[REDACTED:{kind}]"));
}
}
(out, kinds)
}
fn pem_block_span(text: &str) -> Option<(usize, usize)> {
let begin = text.find("-----BEGIN")?;
let key_at = text[begin..].find("PRIVATE KEY-----")? + begin;
let end_at = text[key_at..].find("-----END")? + key_at;
let marker_at = text[end_at..].find("PRIVATE KEY-----")? + end_at;
Some((begin, marker_at + "PRIVATE KEY-----".len()))
}
fn assignment_value_span(text: &str) -> Option<(usize, usize)> {
for key in ASSIGNMENT_KEYS {
let mut from = 0usize;
while let Some(pos) = find_ascii_case_insensitive(text, key, from) {
let after = pos + key.len();
let rest_raw = &text[after..];
let quoted = rest_raw.strip_prefix('"').is_some();
let rest = rest_raw.strip_prefix('"').unwrap_or(rest_raw);
let ws = rest.len() - rest.trim_start().len();
let delim_pos = after + usize::from(quoted) + ws;
let delim = text[delim_pos..].chars().next();
if matches!(delim, Some(':' | '=')) {
let tail = &text[delim_pos + 1..];
let vws = tail.len() - tail.trim_start().len();
let mut vstart = delim_pos + 1 + vws;
if let Some(quote) = text[vstart..].chars().next() {
if quote == '"' || quote == '\'' {
vstart += quote.len_utf8();
}
}
let mut bytes = 0usize;
for c in text[vstart..].chars() {
if c.is_whitespace() || c == '"' || c == '\'' {
break;
}
bytes += c.len_utf8();
}
let is_marker = text[vstart..]
.get(..10)
.is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
if bytes >= 16 && !is_marker {
return Some((vstart, vstart + bytes));
}
}
from = after;
}
}
None
}
fn prefixed_token_span(
text: &str,
prefix: &str,
min_len: usize,
charset: fn(char) -> bool,
) -> Option<(usize, usize)> {
let mut from = 0usize;
while let Some(pos) = text[from..].find(prefix) {
let start = from + pos;
let value_start = start + prefix.len();
let mut bytes = 0usize;
let mut count = 0usize;
for c in text[value_start..].chars() {
if !charset(c) {
break;
}
bytes += c.len_utf8();
count += 1;
}
if count >= min_len {
return Some((start, value_start + bytes));
}
from = value_start;
}
None
}
fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
let h = haystack.as_bytes();
let n = needle.as_bytes();
if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
return None;
}
(from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_private_keys_aws_and_github() {
let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
}
#[test]
fn detects_sk_slack_jwt_and_assignments() {
let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
let slack = format!(
"xoxb-{}-{}-{}",
"123456789012", "1234567890123", "abcdefghijklmnop"
);
assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
assert_eq!(
credential_shaped_content(assign),
vec!["credential_assignment"]
);
}
#[test]
fn detects_aws_secret_and_compound_assignment_keys() {
let aws = "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
assert_eq!(
credential_shaped_content(aws),
vec!["credential_assignment"]
);
let (redacted, kinds) = redact_credential_content(aws);
assert!(kinds.contains(&"credential_assignment"));
assert_eq!(
redacted,
"AWS_SECRET_ACCESS_KEY=[REDACTED:credential_assignment]"
);
assert!(
credential_shaped_content(&redacted).is_empty(),
"redacted AWS secret must read clean: {redacted}"
);
let (twice, _) = redact_credential_content(&redacted);
assert_eq!(redacted, twice);
for text in [
"secret_access_key=0123456789abcdef",
"secret_key: 0123456789abcdef",
"refresh_token=0123456789abcdef",
"auth_token=0123456789abcdef",
] {
assert_eq!(
credential_shaped_content(text),
vec!["credential_assignment"],
"{text}"
);
}
assert!(
credential_shaped_content("the aws secret access key rotation policy was updated")
.is_empty()
);
}
#[test]
fn ordinary_prose_stays_clean() {
assert!(
credential_shaped_content("remember that the password policy requires rotation")
.is_empty()
);
assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
assert!(credential_shaped_content("short token: abc123").is_empty());
assert!(
credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
);
assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
}
#[test]
fn dedupes_kinds() {
let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
}
#[test]
fn redacts_private_key_blocks() {
let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
let (redacted, kinds) = redact_credential_content(pem);
assert!(kinds.contains(&"private_key_pem"));
assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
}
#[test]
fn redacts_assignment_values_and_tokens() {
let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
let (redacted, _) = redact_credential_content(text);
assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
assert!(!redacted.contains("correct-horse-battery-staple"));
assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
assert!(redacted.contains("[REDACTED:openai_style_key]"));
let aws = "id AKIAIOSFODNN7EXAMPLE here";
let (redacted, _) = redact_credential_content(aws);
assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
}
#[test]
fn redacts_json_style_assignment_values() {
let json = r#"{"api_key": "supersecretvalue12345", "note": "plain"}"#;
let (redacted, kinds) = redact_credential_content(json);
assert!(
kinds.contains(&"credential_assignment"),
"JSON assignment must be detected: {kinds:?}"
);
assert!(
!redacted.contains("supersecretvalue12345"),
"JSON assignment value must be scrubbed: {redacted}"
);
assert!(
redacted.contains("plain"),
"non-secret values stay: {redacted}"
);
}
#[test]
fn clean_content_passes_through_unchanged() {
let text = "remember that the password policy requires rotation";
let (redacted, kinds) = redact_credential_content(text);
assert!(kinds.is_empty());
assert_eq!(redacted, text);
}
#[test]
fn redaction_is_idempotent() {
let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
let (once, _) = redact_credential_content(text);
let (twice, kinds) = redact_credential_content(&once);
assert_eq!(once, twice);
assert!(
kinds.is_empty(),
"redacted marker must read clean: {kinds:?}"
);
}
#[test]
fn assignment_marker_does_not_retrigger_detection() {
let text = "db password=correct-horse-battery-staple";
let (once, _) = redact_credential_content(text);
assert!(
credential_shaped_content(&once).is_empty(),
"redacted assignment must read clean: {once}"
);
let (twice, kinds) = redact_credential_content(&once);
assert_eq!(once, twice);
assert!(kinds.is_empty());
}
#[test]
fn short_jwt_fragments_are_redacted_too() {
let text = "tokens eyJab and eyJcd appeared in logs";
let (once, kinds) = redact_credential_content(text);
assert!(kinds.contains(&"jwt"));
assert!(
credential_shaped_content(&once).is_empty(),
"short fragments must read clean after redaction: {once}"
);
let (twice, _) = redact_credential_content(&once);
assert_eq!(once, twice);
}
#[test]
fn pem_fragments_are_redacted_too() {
let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
let (once, kinds) = redact_credential_content(text);
assert!(kinds.contains(&"private_key_pem"));
assert!(
credential_shaped_content(&once).is_empty(),
"PEM fragments must read clean after redaction: {once}"
);
let (twice, _) = redact_credential_content(&once);
assert_eq!(once, twice);
}
}