use std::fmt;
use secrecy::{ExposeSecret, SecretString};
pub const MASK: &str = "[REDACTED]";
pub const MIN_TOKEN_LEN: usize = 12;
pub const CREDENTIAL_PREFIXES: &[&str] = &[
"sk-", "sk_", "rk-", "AIza", "ya29.", "xai-", "gsk_", "r8_", "hf_", "ghp_", "github_pat_", "AKIA", "ASIA", "glpat-", "nvapi-", "co-", ];
pub const CREDENTIAL_KEY_NAMES: &[&str] = &[
"authorization",
"apikey",
"xapikey",
"apisecret",
"accesstoken",
"refreshtoken",
"idtoken",
"bearer",
"token",
"secret",
"password",
"credential",
"credentials",
"sessiontoken",
];
pub struct ApiKey(SecretString);
impl Clone for ApiKey {
fn clone(&self) -> Self {
Self::new(self.expose())
}
}
impl ApiKey {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(SecretString::from(value.into()))
}
#[must_use]
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.expose_secret().is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.expose_secret().len()
}
#[must_use]
pub fn fingerprint(&self) -> String {
let digest = turnframe_core::hash::Digest::of_bytes(self.0.expose_secret().as_bytes());
digest.as_str().chars().take(8).collect()
}
}
impl fmt::Debug for ApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("ApiKey(REDACTED)")
}
}
impl From<String> for ApiKey {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for ApiKey {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl<'de> serde::Deserialize<'de> for ApiKey {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self::new)
}
}
pub trait Redactor: Send + Sync + fmt::Debug {
fn redact(&self, text: &str) -> String;
fn would_redact(&self, text: &str) -> bool {
self.redact(text) != text
}
}
#[derive(Debug, Clone, Default)]
pub struct DefaultRedactor {
literals: Vec<String>,
}
impl DefaultRedactor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_secret(mut self, key: &ApiKey) -> Self {
let value = key.expose();
if !value.is_empty() {
self.literals.push(value.to_owned());
self.sort_literals();
}
self
}
#[must_use]
pub fn with_literal(mut self, literal: impl Into<String>) -> Self {
let literal = literal.into();
if !literal.is_empty() {
self.literals.push(literal);
self.sort_literals();
}
self
}
fn sort_literals(&mut self) {
self.literals
.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
self.literals.dedup();
}
#[must_use]
pub fn literal_count(&self) -> usize {
self.literals.len()
}
}
fn is_token_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~' | '+' | '/')
}
fn normalize_key_name(raw: &str) -> String {
raw.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.map(|ch| ch.to_ascii_lowercase())
.collect()
}
fn has_credential_shape(token: &str) -> bool {
token.len() >= MIN_TOKEN_LEN
&& CREDENTIAL_PREFIXES
.iter()
.any(|prefix| token.starts_with(prefix))
}
fn tokenize(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut chars = text.chars().peekable();
loop {
let mut token = String::new();
while chars.peek().is_some_and(|ch| is_token_char(*ch)) {
if let Some(ch) = chars.next() {
token.push(ch);
}
}
let mut separator = String::new();
while chars.peek().is_some_and(|ch| !is_token_char(*ch)) {
if let Some(ch) = chars.next() {
separator.push(ch);
}
}
if token.is_empty() && separator.is_empty() {
break;
}
out.push((token, separator));
}
out
}
fn is_assignment_separator(separator: &str) -> bool {
!separator.is_empty()
&& separator
.chars()
.all(|ch| matches!(ch, ':' | '=' | ' ' | '\t' | '"' | '\'' | ',' | '{' | '>'))
}
impl Redactor for DefaultRedactor {
fn redact(&self, text: &str) -> String {
let mut work = text.to_owned();
for literal in &self.literals {
if work.contains(literal.as_str()) {
work = work.replace(literal.as_str(), MASK);
}
}
let runs = tokenize(&work);
let mut out = String::with_capacity(work.len());
let mut expect_value = false;
for (token, separator) in runs {
if token.is_empty() {
out.push_str(&separator);
continue;
}
let normalized = normalize_key_name(&token);
let is_key_name = CREDENTIAL_KEY_NAMES.contains(&normalized.as_str());
let masked = (expect_value && !is_key_name) || has_credential_shape(&token);
if masked {
out.push_str(MASK);
expect_value = false;
} else {
out.push_str(&token);
}
if is_key_name {
expect_value = is_assignment_separator(&separator);
} else if expect_value {
expect_value = false;
}
out.push_str(&separator);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLANTED: &str = "sk-live-abcdefghijklmnopqrstuvwxyz0123456789";
#[test]
fn api_key_never_renders_its_value() {
let key = ApiKey::new(PLANTED);
let debug = format!("{key:?}");
assert_eq!(debug, "ApiKey(REDACTED)");
assert!(!debug.contains("sk-live"));
#[derive(Debug)]
#[allow(dead_code)]
struct Config {
key: ApiKey,
endpoint: &'static str,
}
let rendered = format!(
"{:?}",
Config {
key,
endpoint: "https://example.test"
}
);
assert!(!rendered.contains("abcdefg"), "{rendered}");
assert!(rendered.contains("REDACTED"));
}
#[test]
fn fingerprint_identifies_without_revealing() {
let key = ApiKey::new(PLANTED);
let fingerprint = key.fingerprint();
assert_eq!(fingerprint.len(), 8);
assert!(!PLANTED.contains(fingerprint.as_str()));
assert_eq!(key.len(), PLANTED.len());
assert!(!key.is_empty());
assert!(ApiKey::new("").is_empty());
}
#[test]
fn api_key_deserializes_from_a_plain_string() {
let key: ApiKey = serde_json::from_str("\"sk-from-config\"").unwrap();
assert_eq!(key.expose(), "sk-from-config");
}
#[test]
fn registered_literals_are_masked_anywhere() {
let redactor = DefaultRedactor::new().with_secret(&ApiKey::new("plain-word-secret"));
assert_eq!(redactor.literal_count(), 1);
let masked = redactor.redact("the value plain-word-secret appears mid sentence");
assert_eq!(masked, "the value [REDACTED] appears mid sentence");
assert!(redactor.would_redact("plain-word-secret"));
assert!(!redactor.would_redact("nothing to see"));
}
#[test]
fn bearer_tokens_are_masked_by_key_name() {
let redactor = DefaultRedactor::new();
assert_eq!(
redactor.redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"),
"Authorization: Bearer [REDACTED]"
);
assert_eq!(
redactor.redact("authorization: bearer shortish"),
"authorization: bearer [REDACTED]"
);
assert_eq!(
redactor.redact("{\"x-api-key\": \"opaque-value-1\"}"),
"{\"x-api-key\": \"[REDACTED]\"}"
);
assert_eq!(
redactor.redact("api_key=opaque&model=gpt-4o"),
"api_key=[REDACTED]&model=gpt-4o"
);
}
#[test]
fn provider_key_shapes_are_masked_without_a_key_name() {
let redactor = DefaultRedactor::new();
for planted in [
"sk-ant-api03-0123456789abcdef",
"sk-proj-0123456789abcdef",
"AIzaSyA0123456789abcdef",
"gsk_0123456789abcdef",
"AKIAIOSFODNN7EXAMPLE",
"ghp_0123456789abcdefghij",
] {
let line = format!("call failed with {planted} configured");
let masked = redactor.redact(&line);
assert!(!masked.contains(planted), "{planted} survived: {masked}");
assert!(masked.contains(MASK));
}
}
#[test]
fn ordinary_identifiers_survive() {
let redactor = DefaultRedactor::new();
for benign in [
"turnframe.provider.latency_ms",
"request_id=0192f0aa-1b2c-7def-8000-0123456789ab",
"model=gpt-4o-2024-08-06 provider=openai",
"finish=tool_calls usage.input=1234",
"the sky is blue",
] {
assert_eq!(redactor.redact(benign), benign, "over-redacted {benign}");
}
}
#[test]
fn redaction_preserves_everything_that_is_not_a_secret() {
let redactor = DefaultRedactor::new().with_literal("hunter2");
let line = "level=warn provider=openai attempt=2 password=hunter2 latency_ms=134";
assert_eq!(
redactor.redact(line),
"level=warn provider=openai attempt=2 password=[REDACTED] latency_ms=134"
);
}
#[test]
fn a_key_name_without_a_value_does_not_eat_the_next_word() {
let redactor = DefaultRedactor::new();
assert_eq!(
redactor.redact("the token\nwas rejected"),
"the token\nwas rejected"
);
}
#[test]
fn longest_literal_wins() {
let redactor = DefaultRedactor::new()
.with_literal("abc")
.with_literal("abcdef");
assert_eq!(redactor.redact("abcdef"), MASK);
}
}