use std::collections::BTreeSet;
pub const REDACTION_ABSOLUTE_PATH: &str = "absolute_path";
pub const REDACTION_RELATIVE_PATH: &str = "relative_path";
pub const REDACTION_SECRET: &str = "secret";
const PATH_PLACEHOLDER: &str = "<path>";
const SECRET_PLACEHOLDER: &str = "<redacted>";
const SECRET_NAME_MARKERS: &[&str] = &[
"api_key",
"apikey",
"secret",
"token",
"password",
"passwd",
"credential",
"authorization",
"auth_token",
"access_key",
"private_key",
"session_key",
];
const SECRET_VALUE_PREFIXES: &[&str] = &[
"sk-",
"sk_",
"ghp_",
"gho_",
"ghs_",
"github_pat_",
"xoxb-",
"xoxp-",
"xapp-",
];
const AUTH_SCHEMES: &[&str] = &["Bearer", "Basic", "Digest", "Token"];
const SELF_EVIDENT_AUTH_SCHEME: &str = "Bearer";
const AWS_KEY_ID_PREFIXES: &[&str] = &[
"AKIA", "ASIA", "AGPA", "AIDA", "AROA", "ANPA", "ANVA", "ASCA", "ABIA", "ACCA",
];
const AWS_KEY_ID_LEN: usize = 20;
const CREDENTIAL_VALUE_MIN_LEN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Redaction {
text: String,
kinds: BTreeSet<String>,
}
impl Redaction {
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn into_text(self) -> String {
self.text
}
#[must_use]
pub fn redacted(&self) -> bool {
!self.kinds.is_empty()
}
#[must_use]
pub fn kinds(&self) -> Vec<String> {
self.kinds.iter().cloned().collect()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CredentialArm {
None,
IfShaped,
Certain,
}
impl CredentialArm {
const fn is_armed(self) -> bool {
!matches!(self, Self::None)
}
}
struct TokenOutcome {
text: Option<String>,
arm: CredentialArm,
}
impl TokenOutcome {
const fn keep() -> Self {
Self {
text: None,
arm: CredentialArm::None,
}
}
const fn keep_and_arm(arm: CredentialArm) -> Self {
Self { text: None, arm }
}
fn replace(text: String) -> Self {
Self {
text: Some(text),
arm: CredentialArm::None,
}
}
}
#[must_use]
pub fn redact_for_disclosure(input: &str) -> Redaction {
let mut kinds = BTreeSet::new();
let tokens: Vec<&str> = input.split(' ').collect();
let mut out: Vec<String> = Vec::with_capacity(tokens.len());
let mut arm = CredentialArm::None;
for (index, token) in tokens.iter().enumerate() {
if token.is_empty() {
out.push(String::new());
continue;
}
if arm.is_armed() {
if is_auth_scheme(token) {
if is_canonical_auth_scheme(token) {
arm = CredentialArm::Certain;
}
out.push((*token).to_string());
continue;
}
if arm == CredentialArm::Certain || looks_like_credential_value(token) {
kinds.insert(REDACTION_SECRET.to_string());
out.push(SECRET_PLACEHOLDER.to_string());
arm = CredentialArm::None;
continue;
}
}
let next = tokens[index + 1..]
.iter()
.copied()
.find(|candidate| !candidate.is_empty());
let outcome = redact_token(token, next, &mut kinds);
arm = outcome.arm;
out.push(outcome.text.unwrap_or_else(|| (*token).to_string()));
}
Redaction {
text: out.join(" "),
kinds,
}
}
fn redact_token(token: &str, next: Option<&str>, kinds: &mut BTreeSet<String>) -> TokenOutcome {
for separator in ['=', ':'] {
let Some((name, value)) = token.split_once(separator) else {
continue;
};
let lowered = name.to_ascii_lowercase();
let secret_name = SECRET_NAME_MARKERS
.iter()
.any(|marker| lowered.contains(marker));
if secret_name {
if is_auth_scheme(value) {
return TokenOutcome::keep_and_arm(if is_canonical_auth_scheme(value) {
CredentialArm::Certain
} else {
CredentialArm::IfShaped
});
}
if value.is_empty() {
return if next
.is_some_and(|next| is_auth_scheme(next) || looks_like_credential_value(next))
{
TokenOutcome::keep_and_arm(CredentialArm::IfShaped)
} else {
TokenOutcome::keep()
};
}
kinds.insert(REDACTION_SECRET.to_string());
return TokenOutcome::replace(format!("{name}{separator}{SECRET_PLACEHOLDER}"));
}
if let Some(kind) = classify_path(value) {
kinds.insert(kind.to_string());
return TokenOutcome::replace(format!("{name}{separator}{PATH_PLACEHOLDER}"));
}
}
if unwrap_token(token) == SELF_EVIDENT_AUTH_SCHEME {
return TokenOutcome::keep_and_arm(CredentialArm::Certain);
}
if is_auth_scheme(token) && next.is_some_and(looks_like_credential_value) {
return TokenOutcome::keep_and_arm(CredentialArm::IfShaped);
}
let lowered = token.to_ascii_lowercase();
if SECRET_VALUE_PREFIXES
.iter()
.any(|prefix| lowered.starts_with(prefix))
|| looks_like_aws_access_key(token)
{
kinds.insert(REDACTION_SECRET.to_string());
return TokenOutcome::replace(SECRET_PLACEHOLDER.to_string());
}
if let Some(kind) = classify_path(token) {
kinds.insert(kind.to_string());
return TokenOutcome::replace(PATH_PLACEHOLDER.to_string());
}
TokenOutcome::keep()
}
fn unwrap_token(token: &str) -> &str {
token
.trim_start_matches(['(', '[', '"', '\'', '<'])
.trim_end_matches([',', '.', ';', ':', ')', ']', '"', '\'', '>', '!', '?'])
}
fn is_auth_scheme(token: &str) -> bool {
let word = unwrap_token(token);
!word.is_empty()
&& word.chars().all(|ch| ch.is_ascii_alphabetic())
&& AUTH_SCHEMES
.iter()
.any(|scheme| scheme.eq_ignore_ascii_case(word))
}
fn is_canonical_auth_scheme(token: &str) -> bool {
AUTH_SCHEMES.contains(&unwrap_token(token))
}
fn looks_like_credential_value(token: &str) -> bool {
let value = unwrap_token(token);
if value.chars().count() < CREDENTIAL_VALUE_MIN_LEN {
return false;
}
let mut has_digit = false;
let mut has_alpha = false;
for ch in value.chars() {
if ch.is_ascii_digit() {
has_digit = true;
} else if ch.is_ascii_alphabetic() {
has_alpha = true;
} else if !matches!(ch, '-' | '_' | '.' | '=' | '+' | '/' | '~') {
return false;
}
}
has_digit && has_alpha
}
fn looks_like_aws_access_key(token: &str) -> bool {
let value = unwrap_token(token);
if value.len() < AWS_KEY_ID_LEN {
return false;
}
if !AWS_KEY_ID_PREFIXES
.iter()
.any(|prefix| value.starts_with(prefix))
{
return false;
}
value
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
}
fn looks_absolute(token: &str) -> bool {
let trimmed = token.trim_start_matches(['(', '[', '"', '\'']);
if trimmed.len() < 2 {
return false;
}
if let Some(rest) = trimmed.strip_prefix('/') {
return rest.starts_with(|ch: char| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_');
}
if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
return true;
}
if trimmed.starts_with("\\\\") {
return true;
}
let mut chars = trimmed.chars();
matches!(
(chars.next(), chars.next(), chars.next()),
(Some(drive), Some(':'), Some('\\' | '/')) if drive.is_ascii_alphabetic()
)
}
fn classify_path(token: &str) -> Option<&'static str> {
let raw = trim_path_punctuation(token);
let unescaped = unescape_path(raw);
let candidate = trim_path_punctuation(&unescaped);
if candidate.contains("://") {
return None;
}
if looks_absolute(raw) || looks_absolute(candidate) {
return Some(REDACTION_ABSOLUTE_PATH);
}
if looks_relative(candidate) {
return Some(REDACTION_RELATIVE_PATH);
}
None
}
fn trim_path_punctuation(token: &str) -> &str {
token
.trim_start_matches(['(', '[', '{', '"', '\'', '<', '`'])
.trim_end_matches([
',', ';', ':', '.', ')', ']', '}', '"', '\'', '>', '`', '!', '?',
])
}
fn unescape_path(token: &str) -> String {
let mut out = String::with_capacity(token.len());
let mut chars = token.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.peek() {
Some('/') => {
out.push('/');
chars.next();
}
Some('\\') => {
out.push('\\');
chars.next();
}
Some('"') | Some('\'') => {
chars.next();
}
_ => out.push('\\'),
}
}
out
}
fn looks_relative(candidate: &str) -> bool {
if !candidate.contains(['/', '\\']) {
return false;
}
let explicit_prefix = ["./", "../", ".\\", "..\\"]
.iter()
.any(|prefix| candidate.starts_with(prefix));
if explicit_prefix {
return true;
}
let trimmed = candidate.trim_end_matches(['/', '\\']);
let segments: Vec<&str> = trimmed.split(['/', '\\']).collect();
if segments.len() < 2 || segments.iter().any(|segment| segment.is_empty()) {
return false;
}
let last = segments[segments.len() - 1];
let Some((stem, extension)) = last.rsplit_once('.') else {
return false;
};
!stem.is_empty()
&& (1..=8).contains(&extension.chars().count())
&& extension.chars().all(|ch| ch.is_ascii_alphabetic())
}
#[must_use]
#[cfg(test)]
pub fn contains_redactable(input: &str) -> bool {
redact_for_disclosure(input).redacted()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absolute_paths_are_replaced_and_recorded() {
let redaction = redact_for_disclosure("fix /Users/hunter/src/app/main.rs and ~/notes.md");
assert!(!redaction.text().contains("/Users/"));
assert!(!redaction.text().contains("~/"));
assert!(redaction.text().contains(PATH_PLACEHOLDER));
assert!(redaction.redacted());
assert_eq!(redaction.kinds(), vec![REDACTION_ABSOLUTE_PATH.to_string()]);
}
#[test]
fn windows_paths_and_unc_shares_count_as_absolute() {
for token in ["C:\\Users\\hunter\\app", "\\\\share\\team\\notes"] {
let redaction = redact_for_disclosure(token);
assert!(redaction.redacted(), "{token} must be redacted");
assert_eq!(redaction.text(), PATH_PLACEHOLDER);
}
}
#[test]
fn secret_shaped_tokens_and_assignments_are_replaced() {
let redaction = redact_for_disclosure("use sk-live-abc123 and ZAI_API_KEY=zzz");
assert!(!redaction.text().contains("sk-live-abc123"));
assert!(!redaction.text().contains("zzz"));
assert!(
redaction.text().contains("ZAI_API_KEY=<redacted>"),
"the name stays, the value goes: {}",
redaction.text()
);
assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
}
#[test]
fn a_multi_token_authorization_header_loses_its_credential() {
let credentials = [
["sk", "-live-abc123def456"].concat(),
["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpX", "VCJ9"].concat(),
["abcdef0123456789", "abcdef"].concat(),
];
let headers = [
format!("Authorization: Bearer {}", credentials[0]),
format!("authorization: bearer {}", credentials[1]),
format!("-H Authorization:Bearer {}", credentials[2]),
];
for header in headers {
let redaction = redact_for_disclosure(&header);
let text = redaction.text();
assert!(redaction.redacted(), "{header} must be redacted");
assert!(
text.contains(SECRET_PLACEHOLDER),
"{header} must carry a placeholder: {text}"
);
for leaked in &credentials {
assert!(!text.contains(leaked), "{leaked} leaked through: {text}");
}
assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
}
}
#[test]
fn a_bare_bearer_token_is_removed_but_the_scheme_word_survives() {
let redaction = redact_for_disclosure("send Bearer 9f8e7d6c5b4a3f2e1d0c9b8a and retry");
let text = redaction.text();
assert!(
text.contains("Bearer"),
"the scheme keyword is not a secret"
);
assert!(!text.contains("9f8e7d6c5b4a3f2e1d0c9b8a"), "{text}");
assert!(text.ends_with("and retry"), "{text}");
}
#[test]
fn ordinary_words_and_identifiers_are_not_mistaken_for_secrets() {
for text in [
"ship the Asia region rollout",
"ASIA is a continent, not a key",
"the bearer of this note may enter",
"authorization: needed before merge",
"rename bearer_token_header to auth_header_name",
"aws_region defaults to us-east-1",
"pk_display is a public identifier",
] {
let redaction = redact_for_disclosure(text);
assert!(!redaction.redacted(), "{text} must survive: {redaction:?}");
assert_eq!(redaction.text(), text);
}
}
#[test]
fn adversarial_prose_survives_the_credential_state_machine() {
for text in [
"bearer shares responsibility for the rollout",
"the bearer of bad news is rarely thanked",
"bearer",
"each bearer token header is rewritten downstream",
"Token holders vote on the proposal",
"Basic auth is enabled for the staging endpoint",
"Digest the results before the review",
"authorization: needed before merge",
"authorization: bearer shares responsibility",
"variables like aws_region and pk_display stay readable",
"aws_ prefixed variables are documented in the runbook",
"pk_ and pub_ are conventions, not values",
"asia and akia are four letter strings",
"the variables were renamed in the same commit",
"internationalization is spelled with eighteen letters",
] {
let redaction = redact_for_disclosure(text);
assert!(
!redaction.redacted(),
"{text:?} is prose and must survive untouched: {redaction:?}"
);
assert_eq!(redaction.text(), text);
assert!(
redaction.kinds().is_empty(),
"{text:?} must not claim a redaction it did not make"
);
}
}
#[test]
fn adversarial_credentials_lose_the_whole_value() {
for (text, leaked) in [
("Bearer qqq", "qqq"),
("Authorization: Bearer qqq", "qqq"),
("authorization: Bearer qqq", "qqq"),
("Authorization: Bearer qqq.", "qqq"),
("-H \"Authorization: Bearer qqq\"", "qqq"),
("Authorization:Bearer qqq", "qqq"),
("send Bearer hunter2 now", "hunter2"),
(
"curl -H Authorization: Bearer sk-live-0000 -X POST",
"sk-live-0000",
),
] {
let redaction = redact_for_disclosure(text);
let redacted_text = redaction.text();
assert!(
redaction.redacted(),
"{text:?} carries a credential and must be redacted"
);
assert!(
!redacted_text.split(' ').any(|token| token == leaked
|| token.trim_end_matches(['.', ',', '"', '\'']) == leaked),
"{leaked:?} leaked through {text:?}: {redacted_text}"
);
assert!(
redacted_text.contains(SECRET_PLACEHOLDER),
"{text:?} must carry a placeholder: {redacted_text}"
);
assert!(
redaction.kinds().contains(&REDACTION_SECRET.to_string()),
"{text:?} must disclose the secret kind"
);
assert!(
redacted_text.to_ascii_lowercase().contains("bearer"),
"the scheme keyword must survive: {redacted_text}"
);
}
}
#[test]
fn capitalized_bearer_arms_even_in_prose_and_that_is_the_known_cost() {
let redaction = redact_for_disclosure("Bearer tokens are rotated weekly");
assert_eq!(redaction.text(), "Bearer <redacted> are rotated weekly");
let prose = redact_for_disclosure("bearer tokens are rotated weekly");
assert!(!prose.redacted());
}
#[test]
fn full_aws_access_key_ids_are_still_removed() {
for key in [
["AKIA", "IOSFODNN7EXAMPLE"].concat(),
["ASIA", "IOSFODNN7EXAMPLE"].concat(),
] {
let redaction = redact_for_disclosure(&format!("creds {key} rotated"));
assert!(!redaction.text().contains(&key), "{}", redaction.text());
assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
}
}
#[test]
fn ordinary_prose_is_left_alone() {
let redaction = redact_for_disclosure("refactor the parser and add a regression test");
assert!(!redaction.redacted());
assert_eq!(
redaction.text(),
"refactor the parser and add a regression test"
);
assert!(redaction.kinds().is_empty());
}
#[test]
fn repo_relative_paths_are_redacted_in_every_spelling_and_disclosed() {
for token in [
"crates/tui/src/main.rs",
"src/lib.rs",
"web/lib/deploy-preflight.test.ts",
".github/workflows/web.yml",
"crates\\tui\\src\\main.rs",
"crates\\/tui\\/src\\/main.rs",
"\\\"crates/tui/src/main.rs\\\"",
"\"crates/tui/src/main.rs\"",
"(crates/tui/src/main.rs)",
"./deploy.sh",
"../../secret/notes.md",
"..\\secret\\notes.md",
] {
let redaction = redact_for_disclosure(token);
assert!(redaction.redacted(), "{token} must be redacted");
assert!(
!redaction.text().contains("main.rs")
&& !redaction.text().contains("notes.md")
&& !redaction.text().contains("deploy"),
"{token} leaked: {}",
redaction.text()
);
assert!(
redaction
.kinds()
.contains(&REDACTION_RELATIVE_PATH.to_string()),
"{token} must disclose the relative_path kind: {:?}",
redaction.kinds()
);
}
let sentence = redact_for_disclosure("patch crates/tui/src/main.rs, then path=src/lib.rs");
assert_eq!(
sentence.text(),
"patch <path> then path=<path>",
"prose keeps its shape around the placeholder"
);
assert_eq!(
sentence.kinds(),
vec![REDACTION_RELATIVE_PATH.to_string()],
"one kind, honestly reported"
);
}
#[test]
fn prose_labels_and_bare_punctuation_are_not_paths() {
for token in [
"deepseek/deepseek-v4-flash",
"zai/glm-5.2",
"anthropic/claude-opus-5",
"workspace/glm-pair",
"a/b",
"and/or",
"read/write/execute",
"TODO/FIXME",
"provider/model/reasoning",
"/",
"~",
"5:30",
"v0.9.2",
"https://example.test/a/b.rs",
] {
let redaction = redact_for_disclosure(token);
assert!(!redaction.redacted(), "{token} must not be redacted");
assert_eq!(redaction.text(), token, "{token} must survive verbatim");
}
}
#[test]
fn an_extension_less_directory_is_the_known_residual() {
let redaction = redact_for_disclosure("look in crates/tui/src");
assert!(!redaction.redacted());
}
#[test]
fn contains_redactable_matches_the_redactor() {
assert!(contains_redactable("/Users/hunter"));
assert!(contains_redactable("token=abc"));
assert!(!contains_redactable("land a fix in the workflow crate"));
}
}