pub(crate) use crate::security::is_credential_like_key;
use regex::Regex;
use std::sync::OnceLock;
pub(crate) fn redact_sensitive_text(text: &str) -> String {
let out = pem_private_key_regex().replace_all(text, "<redacted>");
let out = data_url_base64_regex().replace_all(&out, "data:<redacted>;base64,<redacted>");
let out = github_token_regex().replace_all(&out, "<redacted>");
let out = aws_access_key_regex().replace_all(&out, "<redacted>");
let out = secret_token_regex().replace_all(&out, "<redacted>");
let out = bearer_regex().replace_all(&out, "${prefix}<redacted>");
assignment_regex()
.replace_all(&out, "${key}${sep}<redacted>")
.into_owned()
}
pub(crate) fn sanitize_display_text(text: &str) -> String {
redact_sensitive_text(&sanitize_display_controls(text))
}
pub(crate) fn sanitize_display_controls(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
if ch == '\r' {
if chars.peek() == Some(&'\n') {
continue;
}
out.push('\n');
continue;
}
if ch.is_control() && !matches!(ch, '\n' | '\t') {
continue;
}
out.push(ch);
continue;
}
match chars.next() {
Some('[') => {
for ch in chars.by_ref() {
if ('@'..='~').contains(&ch) {
break;
}
}
}
Some(']' | 'P' | '^' | '_' | 'X') => {
let mut escaped = false;
for ch in chars.by_ref() {
if escaped && ch == '\\' {
break;
}
if ch == '\u{7}' {
break;
}
escaped = ch == '\u{1b}';
}
}
Some(_) | None => {}
}
}
out
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum SecretSuppression {
#[default]
Inactive,
AwaitingMarker(char),
Draining(char),
}
#[derive(Debug, Default, Clone)]
pub(crate) struct StreamingRedactor {
pending: String,
suppression: SecretSuppression,
}
impl StreamingRedactor {
const HOLD_BACK_CHARS: usize = 96;
const MAX_PENDING_CHARS: usize = Self::HOLD_BACK_CHARS * 8;
pub(crate) fn push(&mut self, text: &str) -> String {
self.pending.push_str(text);
let mut emitted = String::new();
loop {
match self.suppression {
SecretSuppression::AwaitingMarker(terminator) => {
emitted.push_str(&self.start_pending_secret(terminator));
if matches!(self.suppression, SecretSuppression::AwaitingMarker(_)) {
break;
}
continue;
}
SecretSuppression::Draining(terminator) => {
self.drain_suppressed_secret(terminator);
if self.suppression == SecretSuppression::Inactive {
continue;
}
break;
}
SecretSuppression::Inactive => {}
}
let char_count = self.pending.chars().count();
if char_count <= Self::HOLD_BACK_CHARS {
if pending_may_start_secret(&self.pending) {
break;
}
emitted.push_str(&redact_sensitive_text(&std::mem::take(&mut self.pending)));
break;
}
let emit_chars = char_count - Self::HOLD_BACK_CHARS;
let split = char_split_index(&self.pending, emit_chars);
let candidate = &self.pending[..split];
let safe_split = candidate
.rfind(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '}' | ']'))
.map(|index| index + self.pending[index..].chars().next().unwrap().len_utf8())
.unwrap_or(0);
let value_start = forced_secret_value_start(&self.pending);
if safe_split != 0
&& value_start.is_none()
&& forced_secret_introducer(&self.pending).is_none()
{
emitted.push_str(&redact_sensitive_text(
&self.pending.drain(..safe_split).collect::<String>(),
));
continue;
}
if char_count <= Self::MAX_PENDING_CHARS {
break;
}
let forced_split = char_split_index(&self.pending, char_count - Self::HOLD_BACK_CHARS);
if let Some((value_start, terminator)) =
value_start.filter(|(value_start, _)| *value_start < forced_split)
{
let mut redacted = self.pending.drain(..value_start).collect::<String>();
redacted.push_str("<redacted>");
if matches!(terminator, '"' | '\'') {
self.pending.drain(..terminator.len_utf8());
}
self.suppression = SecretSuppression::Draining(terminator);
emitted.push_str(&redacted);
continue;
}
if let Some((prefix_end, terminator)) = forced_secret_introducer(&self.pending) {
emitted.push_str(&self.pending.drain(..prefix_end).collect::<String>());
self.suppression = SecretSuppression::AwaitingMarker(terminator);
continue;
}
emitted.push_str(&redact_sensitive_text(
&self.pending.drain(..forced_split).collect::<String>(),
));
}
emitted
}
fn start_pending_secret(&mut self, terminator: char) -> String {
let Some(value_start) = self.pending.find(|ch: char| !ch.is_whitespace()) else {
return std::mem::take(&mut self.pending);
};
let value_start_char = self.pending[value_start..].chars().next().unwrap();
let terminator = if matches!(value_start_char, '"' | '\'') {
value_start_char
} else {
terminator
};
let mut emitted = self.pending.drain(..value_start).collect::<String>();
emitted.push_str("<redacted>");
if matches!(terminator, '"' | '\'') {
self.pending.drain(..terminator.len_utf8());
}
self.suppression = SecretSuppression::Draining(terminator);
emitted
}
fn drain_suppressed_secret(&mut self, terminator: char) {
let delimiter = self.pending.find(|ch: char| {
if terminator == ' ' {
ch.is_whitespace() || matches!(ch, ',' | ';' | '}' | ']')
} else {
ch == terminator
}
});
match delimiter {
Some(index) => {
let split = if terminator == ' ' {
index
} else {
index + self.pending[index..].chars().next().unwrap().len_utf8()
};
self.pending.drain(..split);
self.suppression = SecretSuppression::Inactive;
}
None => {
let char_count = self.pending.chars().count();
if char_count > Self::HOLD_BACK_CHARS {
let split = char_split_index(&self.pending, char_count - Self::HOLD_BACK_CHARS);
self.pending.drain(..split);
}
}
}
}
pub(crate) fn flush(&mut self) -> String {
if self.suppression != SecretSuppression::Inactive {
let mut emitted = String::new();
if let SecretSuppression::AwaitingMarker(terminator) = self.suppression {
emitted.push_str(&self.start_pending_secret(terminator));
}
if let SecretSuppression::Draining(terminator) = self.suppression {
self.drain_suppressed_secret(terminator);
}
if self.suppression != SecretSuppression::Inactive {
self.pending.clear();
self.suppression = SecretSuppression::Inactive;
return emitted;
}
if !self.pending.is_empty() {
emitted.push_str(&redact_sensitive_text(&std::mem::take(&mut self.pending)));
}
return emitted;
}
let pending = std::mem::take(&mut self.pending);
if let Some((value_start, _)) =
forced_secret_value_start(&pending).filter(|(value_start, terminator)| {
matches!(terminator, '"' | '\'')
&& !pending[*value_start + terminator.len_utf8()..].contains(*terminator)
})
{
let mut emitted = pending[..value_start].to_owned();
emitted.push_str("<redacted>");
return emitted;
}
redact_sensitive_text(&pending)
}
}
fn forced_secret_introducer(text: &str) -> Option<(usize, char)> {
let lower = text.to_ascii_lowercase();
if lower.starts_with("bearer")
&& lower["bearer".len()..]
.chars()
.next()
.is_some_and(char::is_whitespace)
{
let end = text["bearer".len()..]
.find(|ch: char| !ch.is_whitespace())
.unwrap_or(text.len() - "bearer".len())
+ "bearer".len();
return Some((end, ' '));
}
let separator = text.find(['=', ':'])?;
let key =
text[..separator].trim_matches(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\''));
if !is_credential_like_key(key) {
return None;
}
let end = text[separator + 1..]
.find(|ch: char| !ch.is_whitespace())
.unwrap_or(text.len() - separator - 1)
+ separator
+ 1;
Some((end, ' '))
}
fn forced_secret_value_start(text: &str) -> Option<(usize, char)> {
let (prefix_end, _) = forced_secret_introducer(text)?;
let value_start = text[prefix_end..].find(|ch: char| !ch.is_whitespace())? + prefix_end;
let value_start_char = text[value_start..].chars().next()?;
Some((
value_start,
if matches!(value_start_char, '"' | '\'') {
value_start_char
} else {
' '
},
))
}
fn char_split_index(text: &str, chars: usize) -> usize {
text.char_indices()
.nth(chars)
.map(|(index, _)| index)
.unwrap_or(text.len())
}
fn pending_may_start_secret(text: &str) -> bool {
if let Some((_, _terminator)) =
forced_secret_value_start(text).filter(|(value_start, terminator)| {
matches!(terminator, '"' | '\'')
&& !text[*value_start + terminator.len_utf8()..].contains(*terminator)
})
{
return true;
}
let trimmed_end = text.trim_end_matches(char::is_whitespace);
let token = trimmed_end
.rsplit(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '{' | '['))
.next()
.unwrap_or(text)
.trim_matches(|ch: char| matches!(ch, '"' | '\''));
let normalized = token.to_ascii_lowercase().replace('-', "_");
if let Some(separator) = trimmed_end.find(['=', ':']) {
let key = trimmed_end[..separator]
.trim_matches(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\''));
if is_credential_like_key(key)
&& (redact_sensitive_text(text) == text || !text.ends_with(char::is_whitespace))
{
return true;
}
}
if trimmed_end.len() != text.len()
&& normalized != "bearer"
&& redact_sensitive_text(text) != text
{
return false;
}
if normalized.is_empty() {
return false;
}
if trimmed_end
.split_whitespace()
.rev()
.nth(1)
.is_some_and(|word| word.eq_ignore_ascii_case("bearer"))
{
return true;
}
if normalized == "bearer" || normalized.starts_with("bearer ") {
return true;
}
if normalized.contains('=') || normalized.contains(':') {
return normalized.split(['=', ':']).next().is_some_and(|key| {
let key = key.trim_matches(|ch: char| matches!(ch, '"' | '\''));
is_credential_like_key(key)
});
}
if normalized == "s"
|| normalized == "sk"
|| normalized.starts_with("sk_")
|| normalized == "e"
|| normalized == "ey"
|| normalized.starts_with("eyj")
{
return true;
}
[
"api_",
"api_k",
"api_ke",
"api_key",
"apikey",
"authorization",
"bearer",
"credential",
"aws_",
"session_",
"password",
"passwd",
"secret",
"token",
"access_",
"access_token",
"refresh_",
"refresh_token",
"auth_",
"auth_token",
"id_",
"id_token",
"client_",
"client_secret",
"exa_",
"exa_api_key",
"account_",
"account_id",
"accountid",
"chatgpt_",
"chatgpt_account_id",
]
.iter()
.any(|prefix| prefix.starts_with(normalized.as_str()) || normalized.starts_with(prefix))
}
fn pem_private_key_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----")
.unwrap()
})
}
fn github_token_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b").unwrap()
})
}
fn aws_access_key_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b").unwrap())
}
fn data_url_base64_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"data:[A-Za-z0-9.+_-]+/[A-Za-z0-9.+_-]+;base64,[A-Za-z0-9+/=_-]+").unwrap()
})
}
fn secret_token_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"\b(?:sk-[A-Za-z0-9_.-]+|eyJ[A-Za-z0-9_.-]+)\b").unwrap())
}
fn bearer_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(?i)(?P<prefix>\bbearer\s+)[A-Za-z0-9._~+/=-]{8,}").unwrap())
}
fn assignment_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r#"(?ix)
(?P<key>["']?\b(?:authorization|bearer|credentials?|(?:aws[_-]?)?secret[_-]access[_-]key|(?:aws[_-]?)?session[_-]token|secret[_-]key|exa[_-]?api[_-]?key|api[_-]?key|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|auth[_-]?token|id[_-]?token|client[_-]?secret|account[_-]?id|accountId|chatgpt-account-id)\b["']?)
(?P<sep>\s*[:=]\s*)
(?P<value>"[^"]*"|'[^']*'|[^\s,;}]+)
"#,
)
.unwrap()
})
}