use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;
use std::sync::OnceLock;
use tracing::field::{Field, Visit};
static LOG_PSEUDONYM_KEY: OnceLock<Vec<u8>> = OnceLock::new();
const PSEUDONYM_DOMAIN: &[u8] = b"cratefield/log-pseudonym/v1\x00";
const UNKEYED_PSEUDONYM: &str = "000000000000";
pub fn set_log_pseudonym_key(key: &[u8]) {
let _ = LOG_PSEUDONYM_KEY.set(key.to_vec());
}
#[must_use]
pub fn is_secret_field(name: &str) -> bool {
let lowered = name.to_ascii_lowercase();
["secret", "token", "key", "authorization", "password"]
.iter()
.any(|needle| lowered.contains(needle))
}
#[must_use]
pub fn is_email_field(name: &str) -> bool {
let lowered = name.to_ascii_lowercase();
lowered.contains("email") || lowered == "subject"
}
#[must_use]
pub fn subject_hash(value: &str) -> String {
pseudonym_hex(LOG_PSEUDONYM_KEY.get().map(Vec::as_slice), value)
}
fn pseudonym_hex(key: Option<&[u8]>, value: &str) -> String {
use std::fmt::Write as _;
let digest = key.and_then(|secret| {
Hmac::<Sha256>::new_from_slice(secret).ok().map(|mut mac| {
mac.update(PSEUDONYM_DOMAIN);
mac.update(value.as_bytes());
mac.finalize().into_bytes()
})
});
let Some(digest) = digest else {
return UNKEYED_PSEUDONYM.to_owned();
};
let mut hex = String::with_capacity(12);
for byte in digest.iter().take(6) {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[must_use]
pub fn redacted_value(name: &str, value: &str) -> String {
if is_secret_field(name) {
"[redacted]".to_owned()
} else if is_email_field(name) && value.contains('@') {
format!("subject_hash:{}", subject_hash(value))
} else {
scrub_text(value)
}
}
const REDACTED: &str = "[redacted]";
const SIGV4_SECRET_NAMES: [&str; 5] = [
"X-Amz-Signature=",
"X-Amz-Credential=",
"X-Amz-Security-Token=",
"Credential=",
"Signature=",
];
fn sigv4_value_end(c: char) -> bool {
c.is_ascii_whitespace() || c == '&' || c == ','
}
fn url_body_byte(b: u8) -> bool {
!matches!(
b,
b' ' | b'\t'
| b'\n'
| b'\r'
| b'"'
| b'\''
| b'<'
| b'>'
| b'`'
| b','
| b';'
| b')'
| b'}'
| b'\\'
| b'|'
)
}
fn base64url_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
}
fn email_local_byte(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'.' | b'!'
| b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'/'
| b'='
| b'?'
| b'^'
| b'_'
| b'`'
| b'{'
| b'|'
| b'}'
| b'~'
)
}
fn email_domain_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'.' || b == b'-'
}
#[must_use]
pub fn scrub_text(value: &str) -> String {
let pem = scrub_pem_private_keys(value);
let urls = scrub_urls(&pem);
let paths = scrub_path_queries(&urls);
let tokens = scrub_dotted_tokens(&paths);
let github = scrub_github_tokens(&tokens);
let bearer = scrub_bearer(&github);
let sigv4 = scrub_sigv4(&bearer);
scrub_emails(&sigv4)
}
#[must_use]
pub fn scrub_request_url(message: &str, url: &str) -> String {
let Some((origin, target)) = origin_and_target(url) else {
return message.to_owned();
};
let reduced = message.replace(url, &origin);
if target.len() > 1 {
reduced.replace(target, REDACTED)
} else {
reduced
}
}
fn origin_and_target(url: &str) -> Option<(String, &str)> {
let colon = url.find("://")?;
let scheme = &url[..colon];
if scheme.is_empty()
|| !scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
{
return None;
}
let rest = &url[colon + 3..];
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, target) = rest.split_at(end);
let host = authority.rsplit('@').next().unwrap_or(authority);
if host.is_empty() {
return None;
}
Some((format!("{scheme}://{host}"), target))
}
fn scrub_urls(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut processed = 0;
let mut search = 0;
while let Some(relative) = value[search..].find("://") {
let colon = search + relative;
let mut scheme_start = colon;
while scheme_start > processed {
let b = bytes[scheme_start - 1];
if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.') {
scheme_start -= 1;
} else {
break;
}
}
if scheme_start == colon {
search = colon + 3;
continue;
}
let mut end = colon + 3;
while end < bytes.len() && url_body_byte(bytes[end]) {
end += 1;
}
out.push_str(&value[processed..scheme_start]);
out.push_str(&scrub_one_url(&value[scheme_start..end]));
processed = end;
search = end;
}
out.push_str(&value[processed..]);
out
}
fn scrub_one_url(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, tail) = rest.split_at(authority_end);
let mut out = String::with_capacity(url.len());
out.push_str(scheme);
out.push_str("://");
match authority.rfind('@') {
Some(at) => {
out.push_str(REDACTED);
out.push_str(&authority[at..]);
}
None => out.push_str(authority),
}
match tail.find('?') {
Some(query) => {
out.push_str(&tail[..query]);
out.push('?');
out.push_str(REDACTED);
}
None => out.push_str(tail),
}
out
}
fn scrub_path_queries(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while !rest.is_empty() {
let lead = rest
.find(|c: char| !c.is_ascii_whitespace())
.unwrap_or(rest.len());
out.push_str(&rest[..lead]);
let word_end = rest[lead..]
.find(|c: char| c.is_ascii_whitespace())
.map_or(rest.len(), |offset| lead + offset);
let word = &rest[lead..word_end];
match word.strip_prefix('/') {
Some(_) => match word.find('?') {
Some(query) => {
out.push_str(&word[..query]);
out.push('?');
out.push_str(REDACTED);
}
None => out.push_str(word),
},
None => out.push_str(word),
}
rest = &rest[word_end..];
}
out
}
fn scrub_dotted_tokens(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut processed = 0;
let mut search = 0;
while let Some(relative) = value[search..].find("eyJ") {
let start = search + relative;
if start > processed && (base64url_byte(bytes[start - 1]) || bytes[start - 1] == b'.') {
search = start + 3;
continue;
}
let mut end = start;
let mut segments = 0;
let mut all_segments_long = true;
loop {
let segment_start = end;
while end < bytes.len() && base64url_byte(bytes[end]) {
end += 1;
}
segments += 1;
if end - segment_start < 8 {
all_segments_long = false;
}
if end + 1 < bytes.len() && bytes[end] == b'.' && base64url_byte(bytes[end + 1]) {
end += 1;
continue;
}
break;
}
if segments >= 2 && all_segments_long {
out.push_str(&value[processed..start]);
out.push_str(REDACTED);
processed = end;
}
search = end.max(start + 3);
}
out.push_str(&value[processed..]);
out
}
fn scrub_bearer(value: &str) -> String {
const LABEL: &str = "Bearer ";
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut processed = 0;
let mut search = 0;
while let Some(relative) = value[search..].find(LABEL) {
let label_end = search + relative + LABEL.len();
let mut end = label_end;
while end < bytes.len()
&& (base64url_byte(bytes[end])
|| matches!(bytes[end], b'.' | b'+' | b'/' | b'=' | b'~'))
{
end += 1;
}
if end - label_end >= 8 {
out.push_str(&value[processed..label_end]);
out.push_str(REDACTED);
processed = end;
}
search = label_end.max(processed);
}
out.push_str(&value[processed..]);
out
}
fn scrub_sigv4(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut rest = value;
loop {
let found = SIGV4_SECRET_NAMES
.iter()
.filter_map(|name| rest.find(name).map(|at| (at, *name)))
.min_by_key(|(at, _)| *at);
let Some((at, name)) = found else {
out.push_str(rest);
return out;
};
let value_start = at + name.len();
out.push_str(&rest[..value_start]);
let end = rest[value_start..]
.find(sigv4_value_end)
.map_or(rest.len(), |offset| value_start + offset);
out.push_str(REDACTED);
rest = &rest[end..];
}
}
fn scrub_github_tokens(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut processed = 0;
let mut search = 0;
while let Some((start, body_start)) = github_token_at(bytes, search) {
let mut end = body_start;
while end < bytes.len() && token_body_byte(bytes[end]) {
end += 1;
}
if end - body_start >= MIN_GITHUB_TOKEN_BODY {
out.push_str(&value[processed..start]);
out.push_str(REDACTED);
processed = end;
search = end;
} else {
search = body_start;
}
}
out.push_str(&value[processed..]);
out
}
const GITHUB_TOKEN_PREFIXES: [&str; 6] = ["github_pat_", "ghs_", "ghu_", "gho_", "ghp_", "ghr_"];
const MIN_GITHUB_TOKEN_BODY: usize = 20;
fn token_body_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn github_token_at(bytes: &[u8], from: usize) -> Option<(usize, usize)> {
let mut at = from;
while at < bytes.len() {
let starts_token = at == 0 || !bytes[at - 1].is_ascii_alphanumeric();
if starts_token {
for prefix in GITHUB_TOKEN_PREFIXES {
if bytes[at..].starts_with(prefix.as_bytes()) {
return Some((at, at + prefix.len()));
}
}
}
at += 1;
}
None
}
fn scrub_pem_private_keys(value: &str) -> String {
const BEGIN: &str = "-----BEGIN ";
const END: &str = "-----END ";
const PRIVATE_KEY: &str = " PRIVATE KEY-----";
const MAX_HEADER_LEN: usize = 128;
let mut out = String::with_capacity(value.len());
let mut processed = 0;
let mut search = 0;
while let Some(relative) = value[search..].find(BEGIN) {
let start = search + relative;
let header_end = header_line_end(value, start, MAX_HEADER_LEN);
if !value[start..header_end].contains(PRIVATE_KEY) {
search = start + BEGIN.len();
continue;
}
let block_end = value[header_end..]
.find(END)
.and_then(|rel| {
let at = header_end + rel;
let line_end = header_line_end(value, at, MAX_HEADER_LEN);
let line = &value[at..line_end];
if !line.contains(PRIVATE_KEY) {
return None;
}
Some(line.rfind("-----").map_or(line_end, |o| at + o + 5))
})
.unwrap_or(value.len());
out.push_str(&value[processed..start]);
out.push_str(REDACTED);
processed = block_end;
search = block_end;
}
out.push_str(&value[processed..]);
out
}
fn header_line_end(value: &str, start: usize, max_len: usize) -> usize {
let mut window_end = start.saturating_add(max_len).min(value.len());
while window_end < value.len() && !value.is_char_boundary(window_end) {
window_end += 1;
}
value[start..window_end]
.find('\n')
.map_or(window_end, |offset| start + offset)
}
fn scrub_emails(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut processed = 0;
let mut search = 0;
while let Some(relative) = value[search..].find('@') {
let at = search + relative;
let mut start = at;
while start > processed && email_local_byte(bytes[start - 1]) {
start -= 1;
}
let mut end = at + 1;
while end < bytes.len() && email_domain_byte(bytes[end]) {
end += 1;
}
let local = &value[start..at];
let domain = &value[at + 1..end];
if email_shape_ok(local, domain) {
out.push_str(&value[processed..start]);
out.push_str("[subject_hash:");
out.push_str(&subject_hash(&value[start..end]));
out.push(']');
processed = end;
}
search = (at + 1).max(processed);
}
out.push_str(&value[processed..]);
out
}
fn email_shape_ok(local: &str, domain: &str) -> bool {
if local.is_empty() || local.len() > crate::email::MAX_LOCAL_BYTES {
return false;
}
if local.starts_with('.') || local.ends_with('.') {
return false;
}
if domain.is_empty() || !domain.contains('.') {
return false;
}
if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
return false;
}
if domain.len() > crate::email::MAX_EMAIL_BYTES {
return false;
}
domain
.split('.')
.all(|label| !label.is_empty() && !label.starts_with('-') && !label.ends_with('-'))
&& domain
.rsplit('.')
.next()
.is_some_and(|tld| tld.len() >= 2 && tld.bytes().all(|b| b.is_ascii_alphabetic()))
}
#[derive(Debug, Default)]
pub struct RedactingVisitor {
pub fields: BTreeMap<String, String>,
}
impl RedactingVisitor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn record(&mut self, name: &str, value: &str) {
self.fields
.insert(name.to_owned(), redacted_value(name, value));
}
}
impl Visit for RedactingVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.record(field.name(), value);
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.record(field.name(), &format!("{value:?}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex_prefix(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut hex = String::with_capacity(12);
for byte in bytes.iter().take(6) {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[test]
fn secret_names_are_detected_case_insensitively() {
for name in [
"authorization",
"Authorization",
"api_token",
"captchaToken",
"HARNESS_SECRET",
"CF_SAAS_API_TOKEN",
"kid_key",
"password",
] {
assert!(is_secret_field(name), "{name}");
}
assert!(!is_secret_field("outcome"));
assert!(is_secret_field("idempotency_key"));
assert_eq!(redacted_value("api_token", "cf-abcdef"), "[redacted]");
}
#[test]
fn subject_hash_without_a_key_is_the_fixed_placeholder() {
let hash = subject_hash("nick@example.com");
assert_eq!(hash, UNKEYED_PSEUDONYM, "no key, no digest");
assert_eq!(hash.len(), 12);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
assert!(!hash.contains('@'));
assert_eq!(
hash,
subject_hash("nick2@example.com"),
"unkeyed output must not vary with the address"
);
}
#[test]
fn pseudonym_hex_without_a_key_is_constant_and_not_a_bare_digest() {
use sha2::Digest as _;
for value in ["a@x.com", "b@x.com", "nick@example.com", ""] {
assert_eq!(pseudonym_hex(None, value), UNKEYED_PSEUDONYM, "{value}");
let digest: [u8; 32] = Sha256::digest(value.as_bytes()).into();
assert_ne!(
hex_prefix(&digest),
pseudonym_hex(None, value),
"the placeholder must not be a SHA-256 prefix of {value}"
);
}
assert_eq!(
pseudonym_hex(None, "a@x.com"),
pseudonym_hex(None, "b@x.com"),
"input-independent"
);
}
#[test]
fn pseudonym_hex_with_a_key_is_the_input_sensitive_hmac_form() {
let key: &[u8] = b"harness-secret-long-enough-for-this-test-0123456789";
let a = pseudonym_hex(Some(key), "a@x.com");
assert_eq!(a.len(), 12, "keyed pseudonym keeps the shape: {a}");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
assert_ne!(a, UNKEYED_PSEUDONYM, "keyed output is not the placeholder");
assert_eq!(
a,
pseudonym_hex(Some(key), "a@x.com"),
"stable under one key"
);
assert_ne!(
a,
pseudonym_hex(Some(key), "b@x.com"),
"keyed pseudonym stays input-sensitive"
);
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(PSEUDONYM_DOMAIN);
mac.update(b"a@x.com");
assert_eq!(a, hex_prefix(&mac.finalize().into_bytes()));
}
#[test]
fn visitor_rules_match_redacted_value() {
for (name, value) in [
("authorization", "Bearer super-secret-token"),
("token", "captcha-value"),
("email", "nick@example.com"),
("subject", "nick@example.com"),
("outcome", "sent"),
] {
let stored = redacted_value(name, value);
if is_secret_field(name) {
assert_eq!(stored, "[redacted]", "{name}");
} else if is_email_field(name) && value.contains('@') {
assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
assert!(!stored.contains('@'));
} else {
assert_eq!(stored, value, "{name}");
}
}
}
#[test]
fn non_email_values_in_email_fields_pass_through() {
assert_eq!(
redacted_value("email_domain", "factory0.ventures"),
"factory0.ventures"
);
}
#[test]
fn scrub_text_pseudonymises_emails_inside_generic_values() {
let driver = "duplicate key value violates unique constraint \
\"subscribers_email_normalized_key\"\n\
DETAIL: Key (email_normalized)=(nick@example.com) already exists.";
let scrubbed = scrub_text(driver);
assert!(!scrubbed.contains('@'), "{scrubbed}");
assert!(!scrubbed.contains("nick"), "{scrubbed}");
assert!(scrubbed.contains("[subject_hash:"), "{scrubbed}");
assert!(
scrubbed.contains(&subject_hash("nick@example.com")),
"the pseudonym is the same one the email fields use: {scrubbed}"
);
assert!(
scrubbed.contains("duplicate key value violates unique constraint"),
"the diagnostic itself survives: {scrubbed}"
);
assert_eq!(
redacted_value("message", "mailer rejected bob@example.com"),
format!(
"mailer rejected [subject_hash:{}]",
subject_hash("bob@example.com")
)
);
}
#[test]
fn scrub_text_drops_query_strings_from_urls_and_paths() {
let message = "GET https://api.factory0.ventures/v1/email-signup/confirm?token=abc \
redirected to /v1/waitlist/status?token=def";
let scrubbed = scrub_text(message);
assert!(!scrubbed.contains("token=abc"), "{scrubbed}");
assert!(!scrubbed.contains("token=def"), "{scrubbed}");
assert!(
scrubbed.contains("https://api.factory0.ventures/v1/email-signup/confirm?[redacted]"),
"{scrubbed}"
);
assert!(
scrubbed.contains("redirected to /v1/waitlist/status?[redacted]"),
"{scrubbed}"
);
let uri = scrub_text("/ui/waitlist/status?token=01J.secret");
assert_eq!(uri, "/ui/waitlist/status?[redacted]");
assert_eq!(
scrub_text("/v1/email-signup/confirm"),
"/v1/email-signup/confirm",
"a path without a query is untouched"
);
}
#[test]
fn scrub_text_redacts_url_credentials() {
let connect = "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app";
let scrubbed = scrub_text(connect);
assert!(!scrubbed.contains("sup3r-s3cret"), "{scrubbed}");
assert!(!scrubbed.contains("venture:"), "{scrubbed}");
assert!(
scrubbed.contains("postgres://[redacted]@db.internal:5432/app"),
"{scrubbed}"
);
}
#[test]
fn scrub_text_redacts_signed_tokens_wherever_they_appear() {
use crate::ports::signer::{Kid, Payload, Signer};
use crate::signer::HmacSigner;
let signer = HmacSigner::new("0".repeat(crate::signer::MIN_SECRET_BYTES), None)
.expect("test secret is long enough");
let token = signer.sign(&Payload {
purpose: "email-signup.confirm".to_owned(),
subject: "01J0000000000000000000000A".to_owned(),
exp: None,
kid: Kid::Cur,
});
assert!(token.starts_with("eyJ"), "test premise: {token}");
let scrubbed = scrub_text(&format!("confirm token {token} was rejected"));
assert!(!scrubbed.contains(&token), "{scrubbed}");
assert!(!scrubbed.contains("eyJ"), "{scrubbed}");
assert!(scrubbed.contains(REDACTED), "{scrubbed}");
assert!(
scrubbed.contains("confirm token") && scrubbed.contains("was rejected"),
"surrounding words survive: {scrubbed}"
);
let in_url = scrub_text(&format!("https://x.example/confirm?token={token}"));
assert!(!in_url.contains("eyJ"), "{in_url}");
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUoifQ.c2lnbmF0dXJlLXJ1bg";
assert!(!scrub_text(jwt).contains("eyJ"));
assert_eq!(scrub_text("eyJhYi.短"), "eyJhYi.短");
}
#[test]
fn scrub_text_redacts_bearer_credentials() {
let scrubbed = scrub_text("request carried Authorization: Bearer 01Jsupersecretvalue");
assert!(!scrubbed.contains("01Jsupersecretvalue"), "{scrubbed}");
assert!(scrubbed.contains("Bearer [redacted]"), "{scrubbed}");
}
#[test]
fn scrub_text_redacts_every_github_token_shape() {
let body = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; for prefix in ["ghs_", "ghu_", "gho_", "ghp_", "ghr_", "github_pat_"] {
let token = format!("{prefix}{body}");
let scrubbed = scrub_text(&format!(
"request failed: sent Authorization: token {token}"
));
assert!(!scrubbed.contains(&token), "{prefix}: {scrubbed}");
assert!(
!scrubbed.contains(body),
"{prefix}: token body survived: {scrubbed}"
);
assert!(scrubbed.contains(REDACTED), "{prefix}: {scrubbed}");
assert!(
scrubbed.contains("request failed") && scrubbed.contains("Authorization"),
"{prefix}: surrounding words should survive: {scrubbed}"
);
assert_eq!(scrub_text(&scrubbed), scrubbed, "{prefix}");
}
assert_eq!(scrub_text(&format!("ghp_{body}")), REDACTED);
let two = scrub_text(&format!("ghp_{body} and ghs_{body}"));
assert!(!two.contains(body), "{two}");
for (glued, leading) in [
(format!("prefix_ghp_{body}"), "prefix_"),
(format!("x_ghs_{body}"), "x_"),
(format!("KEY_github_pat_{body}"), "KEY_"),
] {
let scrubbed = scrub_text(&glued);
assert!(!scrubbed.contains(body), "glued: {scrubbed}");
assert!(scrubbed.contains(REDACTED), "glued: {scrubbed}");
assert!(
scrubbed.contains(leading),
"leading word survives: {scrubbed}"
);
assert!(
!scrubbed.contains("ghp_")
&& !scrubbed.contains("ghs_")
&& !scrubbed.contains("github_pat_"),
"no prefix survives: {scrubbed}"
);
}
}
#[test]
fn scrub_text_leaves_github_prefix_lookalikes_alone() {
for value in [
"ghost_town",
"ghs",
"ghp",
"highs_levels_of_the_thing",
"ghp_short",
"ghs_1234",
"github_pat_shortish",
] {
assert_eq!(scrub_text(value), value, "over-redacted: {value}");
}
}
#[test]
fn scrub_text_redacts_pem_private_key_blocks() {
let block = "-----BEGIN RSA PRIVATE KEY-----\n\
MIIEpAIBAAKCAQEAtqo1ZXcvbnmlkjihgfedcba0987654321\n\
c2FtcGxlLXNlY3JldC1rZXktbWF0ZXJpYWwtZ29lcy1oZXJl\n\
-----END RSA PRIVATE KEY-----";
let message = format!("failed to load signing key: {block} (from /etc/key.pem)");
let scrubbed = scrub_text(&message);
assert!(!scrubbed.contains("-----BEGIN"), "{scrubbed}");
assert!(!scrubbed.contains("PRIVATE KEY"), "{scrubbed}");
assert!(!scrubbed.contains("c2FtcGxlLXNlY3JldC"), "{scrubbed}");
assert!(scrubbed.contains(REDACTED), "{scrubbed}");
assert!(
scrubbed.contains("failed to load signing key")
&& scrubbed.contains("(from /etc/key.pem)"),
"surrounding words survive: {scrubbed}"
);
assert_eq!(scrub_text(&scrubbed), scrubbed, "idempotent");
let public = "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----";
assert_eq!(scrub_text(public), public);
let unterminated = "-----BEGIN PRIVATE KEY-----\nc2VjcmV0LWJvZHk=\nmore";
let half = scrub_text(unterminated);
assert!(!half.contains("c2VjcmV0LWJvZHk="), "{half}");
assert!(half.contains(REDACTED), "{half}");
}
#[test]
fn scrub_text_handles_a_very_long_line_of_begin_markers() {
let value = "-----BEGIN ".repeat(10_000);
assert_eq!(scrub_text(&value), value);
}
#[test]
fn scrub_text_leaves_ordinary_log_values_alone() {
for value in [
"sent",
"not_configured",
"01J8Z4QWERTYUIOPASDFGH",
"/v1/email-signup/confirm",
"factory0.ventures",
"cratefield-core@0.1.0",
"database did not answer within 2 s",
"[redacted]",
] {
assert_eq!(scrub_text(value), value, "{value}");
}
}
#[test]
fn scrub_text_is_idempotent() {
let nasty = "failed for nick@example.com with Bearer abcdefgh12345 at \
https://x.example/v1/confirm?token=eyJ and postgres://u:p@h/db; \
X-Amz-Credential=AKID%2F20260101%2Fauto%2Fs3%2Faws4_request \
Authorization: AWS4-HMAC-SHA256 Credential=AKID/20260101/auto/s3/aws4_request, \
Signature=abcdef";
let once = scrub_text(nasty);
assert_eq!(scrub_text(&once), once, "{once}");
}
#[test]
fn scrub_request_url_removes_a_credential_path_scrub_text_cannot_see() {
const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f9";
let url = &format!("https://api.push.apple.com/3/device/{TOKEN}");
assert!(
scrub_text(url).contains(TOKEN),
"if scrub_text ever learns to do this, this pass can go"
);
let message = format!("Fetch API cannot load: {url}. sending POST /3/device/{TOKEN}");
let safe = scrub_request_url(&message, url);
assert!(!safe.contains(TOKEN), "{safe}");
assert!(!safe.contains("/3/device"), "{safe}");
assert!(safe.contains("https://api.push.apple.com"), "{safe}");
assert!(safe.contains("Fetch API cannot load"), "{safe}");
assert!(safe.contains(REDACTED), "{safe}");
}
#[test]
fn scrub_request_url_keeps_the_port_and_never_the_userinfo() {
let url = "http://user:pw@127.0.0.1:8787/3/device/tok";
let safe = scrub_request_url(&format!("connect failed: {url}"), url);
assert_eq!(safe, "connect failed: http://127.0.0.1:8787");
}
#[test]
fn scrub_request_url_ignores_anything_that_is_not_an_absolute_url() {
for url in ["", "/3/device/tok", "api.push.apple.com", "://no-scheme/x"] {
let message = "the message is not the place to find out";
assert_eq!(scrub_request_url(message, url), message, "{url:?}");
}
}
#[test]
fn scrub_request_url_leaves_a_root_target_alone() {
let safe = scrub_request_url("GET / failed at https://x.example/", "https://x.example/");
assert_eq!(safe, "GET / failed at https://x.example");
}
#[test]
fn scrub_text_redacts_sigv4_credentials() {
const URL: &str = "https://examplebucket.s3.amazonaws.com/test.txt\
?X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request\
&X-Amz-Date=20130524T000000Z\
&X-Amz-Expires=86400\
&X-Amz-SignedHeaders=host\
&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404";
assert_eq!(
scrub_text(URL),
"https://examplebucket.s3.amazonaws.com/test.txt?[redacted]"
);
assert_eq!(redacted_value("uri", URL), scrub_text(URL));
assert_eq!(
scrub_text(
"X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential=AKID%2F20260101%2Fauto%2Fs3%2Faws4_request\
&X-Amz-Date=20260101T000000Z\
&X-Amz-Signature=abcd0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
),
"X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential=[redacted]\
&X-Amz-Date=20260101T000000Z\
&X-Amz-Signature=[redacted]"
);
assert_eq!(
scrub_text(
"Authorization: AWS4-HMAC-SHA256 \
Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
SignedHeaders=host;x-amz-date, \
Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
),
"Authorization: AWS4-HMAC-SHA256 \
Credential=[redacted], \
SignedHeaders=host;x-amz-date, \
Signature=[redacted]"
);
assert_eq!(
scrub_text(
"X-Amz-Security-Token=IQoJb3JpZ2luX2VjECTEMP\
&X-Amz-Signature=abcd0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
),
"X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"
);
assert_eq!(
scrub_text("X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"),
"X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"
);
}
}
type ErrorForwarder = fn(ControlLevel, &str);
static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlLevel {
Info,
Warn,
Error,
}
impl ControlLevel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ControlLevel::Info => "info",
ControlLevel::Warn => "warn",
ControlLevel::Error => "error",
}
}
}
pub fn set_error_forwarder(forwarder: ErrorForwarder) {
let _ = ERROR_FORWARDER.set(forwarder);
}
pub fn forward_control_event(level: ControlLevel, line: &str) {
if let Some(forwarder) = ERROR_FORWARDER.get() {
forwarder(level, &format!("[{}] {}", level.as_str(), scrub_text(line)));
}
}
pub(crate) fn forward_internal_error(line: &str) {
forward_control_event(ControlLevel::Error, line);
}
#[macro_export]
macro_rules! module_log {
(info, $($arg:tt)+) => {
$crate::ControlLevel::module_log_line($crate::ControlLevel::Info, format_args!($($arg)+))
};
(warn, $($arg:tt)+) => {
$crate::ControlLevel::module_log_line($crate::ControlLevel::Warn, format_args!($($arg)+))
};
(error, $($arg:tt)+) => {
$crate::ControlLevel::module_log_line($crate::ControlLevel::Error, format_args!($($arg)+))
};
}
impl ControlLevel {
#[doc(hidden)]
pub fn module_log_line(self, args: core::fmt::Arguments<'_>) {
let line = scrub_text(&args.to_string());
forward_control_event(self, &line);
match self {
ControlLevel::Info => tracing::info!("{line}"),
ControlLevel::Warn => tracing::warn!("{line}"),
ControlLevel::Error => tracing::error!("{line}"),
}
}
}
#[cfg(test)]
#[allow(clippy::disallowed_types)] mod forwarder_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
static INSTALL: std::sync::Once = std::sync::Once::new();
fn capture(_level: ControlLevel, line: &str) {
CAPTURED.lock().unwrap().push(line.to_owned());
}
fn install_sink() {
INSTALL.call_once(|| set_error_forwarder(capture));
}
struct CountingSubscriber {
events: Arc<AtomicUsize>,
messages: Arc<Mutex<Vec<String>>>,
}
struct MessageCapture {
message: Option<String>,
}
impl tracing::field::Visit for MessageCapture {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = Some(format!("{value:?}"));
}
}
}
impl tracing::Subscriber for CountingSubscriber {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
self.events.fetch_add(1, Ordering::Relaxed);
let mut capture = MessageCapture { message: None };
event.record(&mut capture);
if let Some(message) = capture.message {
self.messages.lock().unwrap().push(message);
}
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
#[test]
fn an_installed_forwarder_receives_the_line() {
install_sink();
forward_internal_error("database error mapped to internal problem: forwarder-107-boom");
assert!(
CAPTURED
.lock()
.unwrap()
.iter()
.any(|line| line.contains("forwarder-107-boom")),
"the installed forwarder should have received the diagnostic"
);
forward_internal_error(
"database error: DETAIL: Key (email)=(nick@example.com) already exists",
);
forward_internal_error("redirect to /v1/waitlist/status?token=eyJhYmNkZWZnaA.mac failed");
let captured = CAPTURED.lock().unwrap();
let joined = captured.join("\n");
assert!(!joined.contains('@'), "email reached the sink: {joined}");
assert!(
!joined.contains("token=eyJ"),
"token reached the sink: {joined}"
);
assert!(
joined.contains("[subject_hash:"),
"the pseudonym should survive for correlation: {joined}"
);
}
#[test]
fn control_events_reach_the_sink_prefixed_with_their_level() {
install_sink();
forward_control_event(
ControlLevel::Info,
"secret access forwarder-441-info store global read allowed",
);
forward_control_event(
ControlLevel::Warn,
"forwarder-441-warn serving guarded routes unprotected on an acceptance",
);
forward_control_event(
ControlLevel::Error,
"forwarder-441-error refusing guarded routes",
);
let captured = CAPTURED.lock().unwrap();
for (level, needle) in [
("info", "forwarder-441-info"),
("warn", "forwarder-441-warn"),
("error", "forwarder-441-error"),
] {
let line = captured
.iter()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no forwarded line carried {needle}"));
let prefix = format!("[{level}] ");
assert!(
line.starts_with(&prefix),
"the {needle} line should be prefixed {prefix:?}: {line}"
);
}
}
#[test]
fn the_internal_error_still_scrubs_and_now_carries_the_error_prefix() {
install_sink();
forward_internal_error(
"forwarder-441-scrub database error: DETAIL: Key (email)=(nick@example.com) \
already exists",
);
let captured = CAPTURED.lock().unwrap();
let line = captured
.iter()
.find(|line| line.contains("forwarder-441-scrub"))
.expect("the internal error should reach the sink");
assert!(line.starts_with("[error] "), "{line}");
assert!(!line.contains('@'), "email reached the sink: {line}");
assert!(
line.contains("[subject_hash:"),
"the pseudonym should survive for correlation: {line}"
);
}
#[test]
fn module_log_reaches_the_sink_and_emits_the_native_tracing_event() {
install_sink();
let token = concat!("ghs_", "ABCDEFGH", "IJKLMNOP", "QRSTUVWX", "YZ012345");
crate::module_log!(
info,
"module-log-846-info acked {} for nick@example.com",
token
);
crate::module_log!(warn, "module-log-846-warn retrying for ada@example.com");
{
let captured = CAPTURED.lock().unwrap();
for (level, needle) in [
("info", "module-log-846-info"),
("warn", "module-log-846-warn"),
] {
let line = captured
.iter()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no module_log line carried {needle}"));
let prefix = format!("[{level}] ");
assert!(
line.starts_with(&prefix),
"the {needle} line should be prefixed {prefix:?}: {line}"
);
assert!(!line.contains('@'), "email reached the sink: {line}");
assert!(!line.contains(token), "token reached the sink: {line}");
}
}
let events = Arc::new(AtomicUsize::new(0));
let messages = Arc::new(Mutex::new(Vec::new()));
let before = events.load(Ordering::Relaxed);
tracing::subscriber::with_default(
CountingSubscriber {
events: Arc::clone(&events),
messages: Arc::clone(&messages),
},
|| {
tracing::callsite::rebuild_interest_cache();
crate::module_log!(info, "module-log-846-tracing the native arm fires too");
},
);
assert_eq!(
events.load(Ordering::Relaxed),
before + 1,
"module_log! should emit one tracing event on a dispatcher"
);
let messages = messages.lock().unwrap();
assert!(
messages
.iter()
.any(|message| message.contains("module-log-846-tracing")),
"the tracing event should carry the same forwarded line: {messages:?}"
);
}
#[test]
fn forwarding_without_a_sink_is_a_noop() {
forward_control_event(
ControlLevel::Warn,
"ignored when no sink or captured when set",
);
forward_internal_error("ignored when no sink or captured when set");
}
}