1use hmac::{Hmac, KeyInit, Mac};
18use sha2::Sha256;
19use std::collections::BTreeMap;
20use std::sync::OnceLock;
21use tracing::field::{Field, Visit};
22
23static LOG_PSEUDONYM_KEY: OnceLock<Vec<u8>> = OnceLock::new();
26
27const PSEUDONYM_DOMAIN: &[u8] = b"cratefield/log-pseudonym/v1\x00";
31
32const UNKEYED_PSEUDONYM: &str = "000000000000";
43
44pub fn set_log_pseudonym_key(key: &[u8]) {
53 let _ = LOG_PSEUDONYM_KEY.set(key.to_vec());
54}
55
56#[must_use]
59pub fn is_secret_field(name: &str) -> bool {
60 let lowered = name.to_ascii_lowercase();
61 ["secret", "token", "key", "authorization", "password"]
62 .iter()
63 .any(|needle| lowered.contains(needle))
64}
65
66#[must_use]
68pub fn is_email_field(name: &str) -> bool {
69 let lowered = name.to_ascii_lowercase();
70 lowered.contains("email") || lowered == "subject"
71}
72
73#[must_use]
80pub fn subject_hash(value: &str) -> String {
81 pseudonym_hex(LOG_PSEUDONYM_KEY.get().map(Vec::as_slice), value)
82}
83
84fn pseudonym_hex(key: Option<&[u8]>, value: &str) -> String {
88 use std::fmt::Write as _;
89 let digest = key.and_then(|secret| {
93 Hmac::<Sha256>::new_from_slice(secret).ok().map(|mut mac| {
94 mac.update(PSEUDONYM_DOMAIN);
95 mac.update(value.as_bytes());
96 mac.finalize().into_bytes()
97 })
98 });
99 let Some(digest) = digest else {
100 return UNKEYED_PSEUDONYM.to_owned();
101 };
102 let mut hex = String::with_capacity(12);
103 for byte in digest.iter().take(6) {
104 let _ = write!(hex, "{byte:02x}");
105 }
106 hex
107}
108
109#[must_use]
114pub fn redacted_value(name: &str, value: &str) -> String {
115 if is_secret_field(name) {
116 "[redacted]".to_owned()
117 } else if is_email_field(name) && value.contains('@') {
118 format!("subject_hash:{}", subject_hash(value))
119 } else {
120 scrub_text(value)
121 }
122}
123
124const REDACTED: &str = "[redacted]";
129
130const SIGV4_SECRET_NAMES: [&str; 5] = [
137 "X-Amz-Signature=",
138 "X-Amz-Credential=",
139 "X-Amz-Security-Token=",
140 "Credential=",
141 "Signature=",
142];
143
144fn sigv4_value_end(c: char) -> bool {
149 c.is_ascii_whitespace() || c == '&' || c == ','
150}
151
152fn url_body_byte(b: u8) -> bool {
158 !matches!(
159 b,
160 b' ' | b'\t'
161 | b'\n'
162 | b'\r'
163 | b'"'
164 | b'\''
165 | b'<'
166 | b'>'
167 | b'`'
168 | b','
169 | b';'
170 | b')'
171 | b'}'
172 | b'\\'
173 | b'|'
174 )
175}
176
177fn base64url_byte(b: u8) -> bool {
178 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
179}
180
181fn email_local_byte(b: u8) -> bool {
182 b.is_ascii_alphanumeric()
183 || matches!(
184 b,
185 b'.' | b'!'
186 | b'#'
187 | b'$'
188 | b'%'
189 | b'&'
190 | b'\''
191 | b'*'
192 | b'+'
193 | b'-'
194 | b'/'
195 | b'='
196 | b'?'
197 | b'^'
198 | b'_'
199 | b'`'
200 | b'{'
201 | b'|'
202 | b'}'
203 | b'~'
204 )
205}
206
207fn email_domain_byte(b: u8) -> bool {
208 b.is_ascii_alphanumeric() || b == b'.' || b == b'-'
209}
210
211#[must_use]
245pub fn scrub_text(value: &str) -> String {
246 let pem = scrub_pem_private_keys(value);
247 let urls = scrub_urls(&pem);
248 let paths = scrub_path_queries(&urls);
249 let tokens = scrub_dotted_tokens(&paths);
250 let github = scrub_github_tokens(&tokens);
251 let bearer = scrub_bearer(&github);
252 let sigv4 = scrub_sigv4(&bearer);
253 scrub_emails(&sigv4)
254}
255
256#[must_use]
292pub fn scrub_request_url(message: &str, url: &str) -> String {
293 let Some((origin, target)) = origin_and_target(url) else {
294 return message.to_owned();
295 };
296 let reduced = message.replace(url, &origin);
297 if target.len() > 1 {
299 reduced.replace(target, REDACTED)
300 } else {
301 reduced
302 }
303}
304
305fn origin_and_target(url: &str) -> Option<(String, &str)> {
309 let colon = url.find("://")?;
310 let scheme = &url[..colon];
311 if scheme.is_empty()
312 || !scheme
313 .bytes()
314 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
315 {
316 return None;
317 }
318 let rest = &url[colon + 3..];
319 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
320 let (authority, target) = rest.split_at(end);
321 let host = authority.rsplit('@').next().unwrap_or(authority);
324 if host.is_empty() {
325 return None;
326 }
327 Some((format!("{scheme}://{host}"), target))
328}
329
330fn scrub_urls(value: &str) -> String {
333 let bytes = value.as_bytes();
334 let mut out = String::with_capacity(value.len());
335 let mut processed = 0;
336 let mut search = 0;
337 while let Some(relative) = value[search..].find("://") {
338 let colon = search + relative;
339 let mut scheme_start = colon;
340 while scheme_start > processed {
341 let b = bytes[scheme_start - 1];
342 if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.') {
343 scheme_start -= 1;
344 } else {
345 break;
346 }
347 }
348 if scheme_start == colon {
349 search = colon + 3;
351 continue;
352 }
353 let mut end = colon + 3;
354 while end < bytes.len() && url_body_byte(bytes[end]) {
355 end += 1;
356 }
357 out.push_str(&value[processed..scheme_start]);
358 out.push_str(&scrub_one_url(&value[scheme_start..end]));
359 processed = end;
360 search = end;
361 }
362 out.push_str(&value[processed..]);
363 out
364}
365
366fn scrub_one_url(url: &str) -> String {
367 let Some((scheme, rest)) = url.split_once("://") else {
368 return url.to_owned();
369 };
370 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
371 let (authority, tail) = rest.split_at(authority_end);
372 let mut out = String::with_capacity(url.len());
373 out.push_str(scheme);
374 out.push_str("://");
375 match authority.rfind('@') {
376 Some(at) => {
377 out.push_str(REDACTED);
378 out.push_str(&authority[at..]);
379 }
380 None => out.push_str(authority),
381 }
382 match tail.find('?') {
383 Some(query) => {
384 out.push_str(&tail[..query]);
385 out.push('?');
386 out.push_str(REDACTED);
387 }
388 None => out.push_str(tail),
389 }
390 out
391}
392
393fn scrub_path_queries(value: &str) -> String {
397 let mut out = String::with_capacity(value.len());
398 let mut rest = value;
399 while !rest.is_empty() {
400 let lead = rest
401 .find(|c: char| !c.is_ascii_whitespace())
402 .unwrap_or(rest.len());
403 out.push_str(&rest[..lead]);
404 let word_end = rest[lead..]
405 .find(|c: char| c.is_ascii_whitespace())
406 .map_or(rest.len(), |offset| lead + offset);
407 let word = &rest[lead..word_end];
408 match word.strip_prefix('/') {
409 Some(_) => match word.find('?') {
410 Some(query) => {
411 out.push_str(&word[..query]);
412 out.push('?');
413 out.push_str(REDACTED);
414 }
415 None => out.push_str(word),
416 },
417 None => out.push_str(word),
418 }
419 rest = &rest[word_end..];
420 }
421 out
422}
423
424fn scrub_dotted_tokens(value: &str) -> String {
427 let bytes = value.as_bytes();
428 let mut out = String::with_capacity(value.len());
429 let mut processed = 0;
430 let mut search = 0;
431 while let Some(relative) = value[search..].find("eyJ") {
432 let start = search + relative;
433 if start > processed && (base64url_byte(bytes[start - 1]) || bytes[start - 1] == b'.') {
434 search = start + 3;
436 continue;
437 }
438 let mut end = start;
439 let mut segments = 0;
440 let mut all_segments_long = true;
441 loop {
442 let segment_start = end;
443 while end < bytes.len() && base64url_byte(bytes[end]) {
444 end += 1;
445 }
446 segments += 1;
447 if end - segment_start < 8 {
448 all_segments_long = false;
449 }
450 if end + 1 < bytes.len() && bytes[end] == b'.' && base64url_byte(bytes[end + 1]) {
451 end += 1;
452 continue;
453 }
454 break;
455 }
456 if segments >= 2 && all_segments_long {
457 out.push_str(&value[processed..start]);
458 out.push_str(REDACTED);
459 processed = end;
460 }
461 search = end.max(start + 3);
462 }
463 out.push_str(&value[processed..]);
464 out
465}
466
467fn scrub_bearer(value: &str) -> String {
469 const LABEL: &str = "Bearer ";
470 let bytes = value.as_bytes();
471 let mut out = String::with_capacity(value.len());
472 let mut processed = 0;
473 let mut search = 0;
474 while let Some(relative) = value[search..].find(LABEL) {
475 let label_end = search + relative + LABEL.len();
476 let mut end = label_end;
477 while end < bytes.len()
478 && (base64url_byte(bytes[end])
479 || matches!(bytes[end], b'.' | b'+' | b'/' | b'=' | b'~'))
480 {
481 end += 1;
482 }
483 if end - label_end >= 8 {
484 out.push_str(&value[processed..label_end]);
485 out.push_str(REDACTED);
486 processed = end;
487 }
488 search = label_end.max(processed);
489 }
490 out.push_str(&value[processed..]);
491 out
492}
493
494fn scrub_sigv4(value: &str) -> String {
500 let mut out = String::with_capacity(value.len());
501 let mut rest = value;
502 loop {
503 let found = SIGV4_SECRET_NAMES
504 .iter()
505 .filter_map(|name| rest.find(name).map(|at| (at, *name)))
506 .min_by_key(|(at, _)| *at);
507 let Some((at, name)) = found else {
508 out.push_str(rest);
509 return out;
510 };
511 let value_start = at + name.len();
512 out.push_str(&rest[..value_start]);
513 let end = rest[value_start..]
514 .find(sigv4_value_end)
515 .map_or(rest.len(), |offset| value_start + offset);
516 out.push_str(REDACTED);
517 rest = &rest[end..];
518 }
519}
520
521fn scrub_github_tokens(value: &str) -> String {
536 let bytes = value.as_bytes();
537 let mut out = String::with_capacity(value.len());
538 let mut processed = 0;
539 let mut search = 0;
540 while let Some((start, body_start)) = github_token_at(bytes, search) {
541 let mut end = body_start;
542 while end < bytes.len() && token_body_byte(bytes[end]) {
543 end += 1;
544 }
545 if end - body_start >= MIN_GITHUB_TOKEN_BODY {
546 out.push_str(&value[processed..start]);
547 out.push_str(REDACTED);
548 processed = end;
549 search = end;
550 } else {
551 search = body_start;
554 }
555 }
556 out.push_str(&value[processed..]);
557 out
558}
559
560const GITHUB_TOKEN_PREFIXES: [&str; 6] = ["github_pat_", "ghs_", "ghu_", "gho_", "ghp_", "ghr_"];
563
564const MIN_GITHUB_TOKEN_BODY: usize = 20;
567
568fn token_body_byte(b: u8) -> bool {
570 b.is_ascii_alphanumeric() || b == b'_'
571}
572
573fn github_token_at(bytes: &[u8], from: usize) -> Option<(usize, usize)> {
580 let mut at = from;
581 while at < bytes.len() {
582 let starts_token = at == 0 || !bytes[at - 1].is_ascii_alphanumeric();
583 if starts_token {
584 for prefix in GITHUB_TOKEN_PREFIXES {
585 if bytes[at..].starts_with(prefix.as_bytes()) {
586 return Some((at, at + prefix.len()));
587 }
588 }
589 }
590 at += 1;
591 }
592 None
593}
594
595fn scrub_pem_private_keys(value: &str) -> String {
607 const BEGIN: &str = "-----BEGIN ";
608 const END: &str = "-----END ";
609 const PRIVATE_KEY: &str = " PRIVATE KEY-----";
610 const MAX_HEADER_LEN: usize = 128;
615 let mut out = String::with_capacity(value.len());
616 let mut processed = 0;
617 let mut search = 0;
618 while let Some(relative) = value[search..].find(BEGIN) {
619 let start = search + relative;
620 let header_end = header_line_end(value, start, MAX_HEADER_LEN);
623 if !value[start..header_end].contains(PRIVATE_KEY) {
624 search = start + BEGIN.len();
625 continue;
626 }
627 let block_end = value[header_end..]
628 .find(END)
629 .and_then(|rel| {
630 let at = header_end + rel;
631 let line_end = header_line_end(value, at, MAX_HEADER_LEN);
632 let line = &value[at..line_end];
633 if !line.contains(PRIVATE_KEY) {
634 return None;
635 }
636 Some(line.rfind("-----").map_or(line_end, |o| at + o + 5))
640 })
641 .unwrap_or(value.len());
642 out.push_str(&value[processed..start]);
643 out.push_str(REDACTED);
644 processed = block_end;
645 search = block_end;
646 }
647 out.push_str(&value[processed..]);
648 out
649}
650
651fn header_line_end(value: &str, start: usize, max_len: usize) -> usize {
657 let mut window_end = start.saturating_add(max_len).min(value.len());
658 while window_end < value.len() && !value.is_char_boundary(window_end) {
659 window_end += 1;
660 }
661 value[start..window_end]
662 .find('\n')
663 .map_or(window_end, |offset| start + offset)
664}
665
666fn scrub_emails(value: &str) -> String {
672 let bytes = value.as_bytes();
673 let mut out = String::with_capacity(value.len());
674 let mut processed = 0;
675 let mut search = 0;
676 while let Some(relative) = value[search..].find('@') {
677 let at = search + relative;
678 let mut start = at;
679 while start > processed && email_local_byte(bytes[start - 1]) {
680 start -= 1;
681 }
682 let mut end = at + 1;
683 while end < bytes.len() && email_domain_byte(bytes[end]) {
684 end += 1;
685 }
686 let local = &value[start..at];
687 let domain = &value[at + 1..end];
688 if email_shape_ok(local, domain) {
689 out.push_str(&value[processed..start]);
690 out.push_str("[subject_hash:");
691 out.push_str(&subject_hash(&value[start..end]));
692 out.push(']');
693 processed = end;
694 }
695 search = (at + 1).max(processed);
696 }
697 out.push_str(&value[processed..]);
698 out
699}
700
701fn email_shape_ok(local: &str, domain: &str) -> bool {
702 if local.is_empty() || local.len() > crate::email::MAX_LOCAL_BYTES {
703 return false;
704 }
705 if local.starts_with('.') || local.ends_with('.') {
706 return false;
707 }
708 if domain.is_empty() || !domain.contains('.') {
709 return false;
710 }
711 if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
712 return false;
713 }
714 if domain.len() > crate::email::MAX_EMAIL_BYTES {
715 return false;
716 }
717 domain
718 .split('.')
719 .all(|label| !label.is_empty() && !label.starts_with('-') && !label.ends_with('-'))
720 && domain
721 .rsplit('.')
722 .next()
723 .is_some_and(|tld| tld.len() >= 2 && tld.bytes().all(|b| b.is_ascii_alphabetic()))
724}
725
726#[derive(Debug, Default)]
730pub struct RedactingVisitor {
731 pub fields: BTreeMap<String, String>,
732}
733
734impl RedactingVisitor {
735 #[must_use]
736 pub fn new() -> Self {
737 Self::default()
738 }
739
740 fn record(&mut self, name: &str, value: &str) {
741 self.fields
742 .insert(name.to_owned(), redacted_value(name, value));
743 }
744}
745
746impl Visit for RedactingVisitor {
747 fn record_str(&mut self, field: &Field, value: &str) {
748 self.record(field.name(), value);
749 }
750
751 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
752 self.record(field.name(), &format!("{value:?}"));
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 fn hex_prefix(bytes: &[u8]) -> String {
762 use std::fmt::Write as _;
763 let mut hex = String::with_capacity(12);
764 for byte in bytes.iter().take(6) {
765 let _ = write!(hex, "{byte:02x}");
766 }
767 hex
768 }
769
770 #[test]
771 fn secret_names_are_detected_case_insensitively() {
772 for name in [
773 "authorization",
774 "Authorization",
775 "api_token",
776 "captchaToken",
777 "HARNESS_SECRET",
778 "CF_SAAS_API_TOKEN",
779 "kid_key",
780 "password",
781 ] {
782 assert!(is_secret_field(name), "{name}");
783 }
784 assert!(!is_secret_field("outcome"));
785 assert!(is_secret_field("idempotency_key"));
788 assert_eq!(redacted_value("api_token", "cf-abcdef"), "[redacted]");
791 }
792
793 #[test]
794 fn subject_hash_without_a_key_is_the_fixed_placeholder() {
795 let hash = subject_hash("nick@example.com");
799 assert_eq!(hash, UNKEYED_PSEUDONYM, "no key, no digest");
800 assert_eq!(hash.len(), 12);
801 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
802 assert!(!hash.contains('@'));
803 assert_eq!(
804 hash,
805 subject_hash("nick2@example.com"),
806 "unkeyed output must not vary with the address"
807 );
808 }
809
810 #[test]
811 fn pseudonym_hex_without_a_key_is_constant_and_not_a_bare_digest() {
812 use sha2::Digest as _;
817 for value in ["a@x.com", "b@x.com", "nick@example.com", ""] {
818 assert_eq!(pseudonym_hex(None, value), UNKEYED_PSEUDONYM, "{value}");
819 let digest: [u8; 32] = Sha256::digest(value.as_bytes()).into();
820 assert_ne!(
821 hex_prefix(&digest),
822 pseudonym_hex(None, value),
823 "the placeholder must not be a SHA-256 prefix of {value}"
824 );
825 }
826 assert_eq!(
827 pseudonym_hex(None, "a@x.com"),
828 pseudonym_hex(None, "b@x.com"),
829 "input-independent"
830 );
831 }
832
833 #[test]
834 fn pseudonym_hex_with_a_key_is_the_input_sensitive_hmac_form() {
835 let key: &[u8] = b"harness-secret-long-enough-for-this-test-0123456789";
836 let a = pseudonym_hex(Some(key), "a@x.com");
837 assert_eq!(a.len(), 12, "keyed pseudonym keeps the shape: {a}");
838 assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
839 assert_ne!(a, UNKEYED_PSEUDONYM, "keyed output is not the placeholder");
840 assert_eq!(
841 a,
842 pseudonym_hex(Some(key), "a@x.com"),
843 "stable under one key"
844 );
845 assert_ne!(
846 a,
847 pseudonym_hex(Some(key), "b@x.com"),
848 "keyed pseudonym stays input-sensitive"
849 );
850 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
853 mac.update(PSEUDONYM_DOMAIN);
854 mac.update(b"a@x.com");
855 assert_eq!(a, hex_prefix(&mac.finalize().into_bytes()));
856 }
857
858 #[test]
859 fn visitor_rules_match_redacted_value() {
860 for (name, value) in [
861 ("authorization", "Bearer super-secret-token"),
862 ("token", "captcha-value"),
863 ("email", "nick@example.com"),
864 ("subject", "nick@example.com"),
865 ("outcome", "sent"),
866 ] {
867 let stored = redacted_value(name, value);
868 if is_secret_field(name) {
869 assert_eq!(stored, "[redacted]", "{name}");
870 } else if is_email_field(name) && value.contains('@') {
871 assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
872 assert!(!stored.contains('@'));
873 } else {
874 assert_eq!(stored, value, "{name}");
875 }
876 }
877 }
878
879 #[test]
880 fn non_email_values_in_email_fields_pass_through() {
881 assert_eq!(
882 redacted_value("email_domain", "factory0.ventures"),
883 "factory0.ventures"
884 );
885 }
886
887 #[test]
891 fn scrub_text_pseudonymises_emails_inside_generic_values() {
892 let driver = "duplicate key value violates unique constraint \
893 \"subscribers_email_normalized_key\"\n\
894 DETAIL: Key (email_normalized)=(nick@example.com) already exists.";
895 let scrubbed = scrub_text(driver);
896 assert!(!scrubbed.contains('@'), "{scrubbed}");
897 assert!(!scrubbed.contains("nick"), "{scrubbed}");
898 assert!(scrubbed.contains("[subject_hash:"), "{scrubbed}");
899 assert!(
900 scrubbed.contains(&subject_hash("nick@example.com")),
901 "the pseudonym is the same one the email fields use: {scrubbed}"
902 );
903 assert!(
904 scrubbed.contains("duplicate key value violates unique constraint"),
905 "the diagnostic itself survives: {scrubbed}"
906 );
907
908 assert_eq!(
909 redacted_value("message", "mailer rejected bob@example.com"),
910 format!(
911 "mailer rejected [subject_hash:{}]",
912 subject_hash("bob@example.com")
913 )
914 );
915 }
916
917 #[test]
918 fn scrub_text_drops_query_strings_from_urls_and_paths() {
919 let message = "GET https://api.factory0.ventures/v1/email-signup/confirm?token=abc \
920 redirected to /v1/waitlist/status?token=def";
921 let scrubbed = scrub_text(message);
922 assert!(!scrubbed.contains("token=abc"), "{scrubbed}");
923 assert!(!scrubbed.contains("token=def"), "{scrubbed}");
924 assert!(
925 scrubbed.contains("https://api.factory0.ventures/v1/email-signup/confirm?[redacted]"),
926 "{scrubbed}"
927 );
928 assert!(
929 scrubbed.contains("redirected to /v1/waitlist/status?[redacted]"),
930 "{scrubbed}"
931 );
932
933 let uri = scrub_text("/ui/waitlist/status?token=01J.secret");
934 assert_eq!(uri, "/ui/waitlist/status?[redacted]");
935
936 assert_eq!(
937 scrub_text("/v1/email-signup/confirm"),
938 "/v1/email-signup/confirm",
939 "a path without a query is untouched"
940 );
941 }
942
943 #[test]
944 fn scrub_text_redacts_url_credentials() {
945 let connect = "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app";
946 let scrubbed = scrub_text(connect);
947 assert!(!scrubbed.contains("sup3r-s3cret"), "{scrubbed}");
948 assert!(!scrubbed.contains("venture:"), "{scrubbed}");
949 assert!(
950 scrubbed.contains("postgres://[redacted]@db.internal:5432/app"),
951 "{scrubbed}"
952 );
953 }
954
955 #[test]
956 fn scrub_text_redacts_signed_tokens_wherever_they_appear() {
957 use crate::ports::signer::{Kid, Payload, Signer};
958 use crate::signer::HmacSigner;
959
960 let signer = HmacSigner::new("0".repeat(crate::signer::MIN_SECRET_BYTES), None)
961 .expect("test secret is long enough");
962 let token = signer.sign(&Payload {
963 purpose: "email-signup.confirm".to_owned(),
964 subject: "01J0000000000000000000000A".to_owned(),
965 exp: None,
966 kid: Kid::Cur,
967 });
968 assert!(token.starts_with("eyJ"), "test premise: {token}");
969
970 let scrubbed = scrub_text(&format!("confirm token {token} was rejected"));
971 assert!(!scrubbed.contains(&token), "{scrubbed}");
972 assert!(!scrubbed.contains("eyJ"), "{scrubbed}");
973 assert!(scrubbed.contains(REDACTED), "{scrubbed}");
974 assert!(
975 scrubbed.contains("confirm token") && scrubbed.contains("was rejected"),
976 "surrounding words survive: {scrubbed}"
977 );
978
979 let in_url = scrub_text(&format!("https://x.example/confirm?token={token}"));
982 assert!(!in_url.contains("eyJ"), "{in_url}");
983
984 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUoifQ.c2lnbmF0dXJlLXJ1bg";
986 assert!(!scrub_text(jwt).contains("eyJ"));
987
988 assert_eq!(scrub_text("eyJhYi.短"), "eyJhYi.短");
990 }
991
992 #[test]
993 fn scrub_text_redacts_bearer_credentials() {
994 let scrubbed = scrub_text("request carried Authorization: Bearer 01Jsupersecretvalue");
995 assert!(!scrubbed.contains("01Jsupersecretvalue"), "{scrubbed}");
996 assert!(scrubbed.contains("Bearer [redacted]"), "{scrubbed}");
997 }
998
999 #[test]
1000 fn scrub_text_redacts_every_github_token_shape() {
1001 let body = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; for prefix in ["ghs_", "ghu_", "gho_", "ghp_", "ghr_", "github_pat_"] {
1004 let token = format!("{prefix}{body}");
1005 let scrubbed = scrub_text(&format!(
1006 "request failed: sent Authorization: token {token}"
1007 ));
1008 assert!(!scrubbed.contains(&token), "{prefix}: {scrubbed}");
1009 assert!(
1010 !scrubbed.contains(body),
1011 "{prefix}: token body survived: {scrubbed}"
1012 );
1013 assert!(scrubbed.contains(REDACTED), "{prefix}: {scrubbed}");
1014 assert!(
1015 scrubbed.contains("request failed") && scrubbed.contains("Authorization"),
1016 "{prefix}: surrounding words should survive: {scrubbed}"
1017 );
1018 assert_eq!(scrub_text(&scrubbed), scrubbed, "{prefix}");
1020 }
1021 assert_eq!(scrub_text(&format!("ghp_{body}")), REDACTED);
1023 let two = scrub_text(&format!("ghp_{body} and ghs_{body}"));
1024 assert!(!two.contains(body), "{two}");
1025
1026 for (glued, leading) in [
1030 (format!("prefix_ghp_{body}"), "prefix_"),
1031 (format!("x_ghs_{body}"), "x_"),
1032 (format!("KEY_github_pat_{body}"), "KEY_"),
1033 ] {
1034 let scrubbed = scrub_text(&glued);
1035 assert!(!scrubbed.contains(body), "glued: {scrubbed}");
1036 assert!(scrubbed.contains(REDACTED), "glued: {scrubbed}");
1037 assert!(
1038 scrubbed.contains(leading),
1039 "leading word survives: {scrubbed}"
1040 );
1041 assert!(
1042 !scrubbed.contains("ghp_")
1043 && !scrubbed.contains("ghs_")
1044 && !scrubbed.contains("github_pat_"),
1045 "no prefix survives: {scrubbed}"
1046 );
1047 }
1048 }
1049
1050 #[test]
1051 fn scrub_text_leaves_github_prefix_lookalikes_alone() {
1052 for value in [
1053 "ghost_town",
1055 "ghs",
1056 "ghp",
1057 "highs_levels_of_the_thing",
1059 "ghp_short",
1061 "ghs_1234",
1062 "github_pat_shortish",
1063 ] {
1064 assert_eq!(scrub_text(value), value, "over-redacted: {value}");
1065 }
1066 }
1067
1068 #[test]
1069 fn scrub_text_redacts_pem_private_key_blocks() {
1070 let block = "-----BEGIN RSA PRIVATE KEY-----\n\
1071 MIIEpAIBAAKCAQEAtqo1ZXcvbnmlkjihgfedcba0987654321\n\
1072 c2FtcGxlLXNlY3JldC1rZXktbWF0ZXJpYWwtZ29lcy1oZXJl\n\
1073 -----END RSA PRIVATE KEY-----";
1074 let message = format!("failed to load signing key: {block} (from /etc/key.pem)");
1075 let scrubbed = scrub_text(&message);
1076 assert!(!scrubbed.contains("-----BEGIN"), "{scrubbed}");
1077 assert!(!scrubbed.contains("PRIVATE KEY"), "{scrubbed}");
1078 assert!(!scrubbed.contains("c2FtcGxlLXNlY3JldC"), "{scrubbed}");
1079 assert!(scrubbed.contains(REDACTED), "{scrubbed}");
1080 assert!(
1081 scrubbed.contains("failed to load signing key")
1082 && scrubbed.contains("(from /etc/key.pem)"),
1083 "surrounding words survive: {scrubbed}"
1084 );
1085 assert_eq!(scrub_text(&scrubbed), scrubbed, "idempotent");
1086
1087 let public = "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----";
1089 assert_eq!(scrub_text(public), public);
1090
1091 let unterminated = "-----BEGIN PRIVATE KEY-----\nc2VjcmV0LWJvZHk=\nmore";
1093 let half = scrub_text(unterminated);
1094 assert!(!half.contains("c2VjcmV0LWJvZHk="), "{half}");
1095 assert!(half.contains(REDACTED), "{half}");
1096 }
1097
1098 #[test]
1099 fn scrub_text_handles_a_very_long_line_of_begin_markers() {
1100 let value = "-----BEGIN ".repeat(10_000);
1105 assert_eq!(scrub_text(&value), value);
1106 }
1107
1108 #[test]
1109 fn scrub_text_leaves_ordinary_log_values_alone() {
1110 for value in [
1111 "sent",
1112 "not_configured",
1113 "01J8Z4QWERTYUIOPASDFGH",
1114 "/v1/email-signup/confirm",
1115 "factory0.ventures",
1116 "cratefield-core@0.1.0",
1117 "database did not answer within 2 s",
1118 "[redacted]",
1119 ] {
1120 assert_eq!(scrub_text(value), value, "{value}");
1121 }
1122 }
1123
1124 #[test]
1125 fn scrub_text_is_idempotent() {
1126 let nasty = "failed for nick@example.com with Bearer abcdefgh12345 at \
1127 https://x.example/v1/confirm?token=eyJ and postgres://u:p@h/db; \
1128 X-Amz-Credential=AKID%2F20260101%2Fauto%2Fs3%2Faws4_request \
1129 Authorization: AWS4-HMAC-SHA256 Credential=AKID/20260101/auto/s3/aws4_request, \
1130 Signature=abcdef";
1131 let once = scrub_text(nasty);
1132 assert_eq!(scrub_text(&once), once, "{once}");
1133 }
1134
1135 #[test]
1140 fn scrub_request_url_removes_a_credential_path_scrub_text_cannot_see() {
1141 const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f9";
1142 let url = &format!("https://api.push.apple.com/3/device/{TOKEN}");
1143
1144 assert!(
1145 scrub_text(url).contains(TOKEN),
1146 "if scrub_text ever learns to do this, this pass can go"
1147 );
1148
1149 let message = format!("Fetch API cannot load: {url}. sending POST /3/device/{TOKEN}");
1152 let safe = scrub_request_url(&message, url);
1153 assert!(!safe.contains(TOKEN), "{safe}");
1154 assert!(!safe.contains("/3/device"), "{safe}");
1155 assert!(safe.contains("https://api.push.apple.com"), "{safe}");
1156 assert!(safe.contains("Fetch API cannot load"), "{safe}");
1158 assert!(safe.contains(REDACTED), "{safe}");
1159 }
1160
1161 #[test]
1162 fn scrub_request_url_keeps_the_port_and_never_the_userinfo() {
1163 let url = "http://user:pw@127.0.0.1:8787/3/device/tok";
1164 let safe = scrub_request_url(&format!("connect failed: {url}"), url);
1165 assert_eq!(safe, "connect failed: http://127.0.0.1:8787");
1166 }
1167
1168 #[test]
1169 fn scrub_request_url_ignores_anything_that_is_not_an_absolute_url() {
1170 for url in ["", "/3/device/tok", "api.push.apple.com", "://no-scheme/x"] {
1173 let message = "the message is not the place to find out";
1174 assert_eq!(scrub_request_url(message, url), message, "{url:?}");
1175 }
1176 }
1177
1178 #[test]
1179 fn scrub_request_url_leaves_a_root_target_alone() {
1180 let safe = scrub_request_url("GET / failed at https://x.example/", "https://x.example/");
1183 assert_eq!(safe, "GET / failed at https://x.example");
1184 }
1185
1186 #[test]
1191 fn scrub_text_redacts_sigv4_credentials() {
1192 const URL: &str = "https://examplebucket.s3.amazonaws.com/test.txt\
1195 ?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1196 &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request\
1197 &X-Amz-Date=20130524T000000Z\
1198 &X-Amz-Expires=86400\
1199 &X-Amz-SignedHeaders=host\
1200 &X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404";
1201 assert_eq!(
1202 scrub_text(URL),
1203 "https://examplebucket.s3.amazonaws.com/test.txt?[redacted]"
1204 );
1205 assert_eq!(redacted_value("uri", URL), scrub_text(URL));
1206
1207 assert_eq!(
1210 scrub_text(
1211 "X-Amz-Algorithm=AWS4-HMAC-SHA256\
1212 &X-Amz-Credential=AKID%2F20260101%2Fauto%2Fs3%2Faws4_request\
1213 &X-Amz-Date=20260101T000000Z\
1214 &X-Amz-Signature=abcd0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
1215 ),
1216 "X-Amz-Algorithm=AWS4-HMAC-SHA256\
1217 &X-Amz-Credential=[redacted]\
1218 &X-Amz-Date=20260101T000000Z\
1219 &X-Amz-Signature=[redacted]"
1220 );
1221
1222 assert_eq!(
1224 scrub_text(
1225 "Authorization: AWS4-HMAC-SHA256 \
1226 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
1227 SignedHeaders=host;x-amz-date, \
1228 Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
1229 ),
1230 "Authorization: AWS4-HMAC-SHA256 \
1231 Credential=[redacted], \
1232 SignedHeaders=host;x-amz-date, \
1233 Signature=[redacted]"
1234 );
1235
1236 assert_eq!(
1240 scrub_text(
1241 "X-Amz-Security-Token=IQoJb3JpZ2luX2VjECTEMP\
1242 &X-Amz-Signature=abcd0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab"
1243 ),
1244 "X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"
1245 );
1246 assert_eq!(
1247 scrub_text("X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"),
1248 "X-Amz-Security-Token=[redacted]&X-Amz-Signature=[redacted]"
1249 );
1250 }
1251}
1252
1253type ErrorForwarder = fn(&str);
1260
1261static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
1262
1263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1275pub enum ControlLevel {
1276 Info,
1277 Warn,
1278 Error,
1279}
1280
1281impl ControlLevel {
1282 #[must_use]
1285 pub fn as_str(self) -> &'static str {
1286 match self {
1287 ControlLevel::Info => "info",
1288 ControlLevel::Warn => "warn",
1289 ControlLevel::Error => "error",
1290 }
1291 }
1292}
1293
1294pub fn set_error_forwarder(forwarder: ErrorForwarder) {
1310 let _ = ERROR_FORWARDER.set(forwarder);
1311}
1312
1313pub fn forward_control_event(level: ControlLevel, line: &str) {
1325 if let Some(forwarder) = ERROR_FORWARDER.get() {
1326 forwarder(&format!("[{}] {}", level.as_str(), scrub_text(line)));
1327 }
1328}
1329
1330pub(crate) fn forward_internal_error(line: &str) {
1338 forward_control_event(ControlLevel::Error, line);
1339}
1340
1341#[cfg(test)]
1342#[allow(clippy::disallowed_types)] mod forwarder_tests {
1344 use super::*;
1345 use std::sync::Mutex;
1346
1347 static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
1348 static INSTALL: std::sync::Once = std::sync::Once::new();
1349
1350 fn capture(line: &str) {
1351 CAPTURED.lock().unwrap().push(line.to_owned());
1352 }
1353
1354 fn install_sink() {
1367 INSTALL.call_once(|| set_error_forwarder(capture));
1368 }
1369
1370 #[test]
1371 fn an_installed_forwarder_receives_the_line() {
1372 install_sink();
1373 forward_internal_error("database error mapped to internal problem: forwarder-107-boom");
1374 assert!(
1375 CAPTURED
1376 .lock()
1377 .unwrap()
1378 .iter()
1379 .any(|line| line.contains("forwarder-107-boom")),
1380 "the installed forwarder should have received the diagnostic"
1381 );
1382
1383 forward_internal_error(
1388 "database error: DETAIL: Key (email)=(nick@example.com) already exists",
1389 );
1390 forward_internal_error("redirect to /v1/waitlist/status?token=eyJhYmNkZWZnaA.mac failed");
1391 let captured = CAPTURED.lock().unwrap();
1392 let joined = captured.join("\n");
1393 assert!(!joined.contains('@'), "email reached the sink: {joined}");
1394 assert!(
1395 !joined.contains("token=eyJ"),
1396 "token reached the sink: {joined}"
1397 );
1398 assert!(
1399 joined.contains("[subject_hash:"),
1400 "the pseudonym should survive for correlation: {joined}"
1401 );
1402 }
1403
1404 #[test]
1405 fn control_events_reach_the_sink_prefixed_with_their_level() {
1406 install_sink();
1407 forward_control_event(
1410 ControlLevel::Info,
1411 "secret access forwarder-441-info store global read allowed",
1412 );
1413 forward_control_event(
1414 ControlLevel::Warn,
1415 "forwarder-441-warn serving guarded routes unprotected on an acceptance",
1416 );
1417 forward_control_event(
1418 ControlLevel::Error,
1419 "forwarder-441-error refusing guarded routes",
1420 );
1421 let captured = CAPTURED.lock().unwrap();
1422 for (level, needle) in [
1423 ("info", "forwarder-441-info"),
1424 ("warn", "forwarder-441-warn"),
1425 ("error", "forwarder-441-error"),
1426 ] {
1427 let line = captured
1428 .iter()
1429 .find(|line| line.contains(needle))
1430 .unwrap_or_else(|| panic!("no forwarded line carried {needle}"));
1431 let prefix = format!("[{level}] ");
1432 assert!(
1433 line.starts_with(&prefix),
1434 "the {needle} line should be prefixed {prefix:?}: {line}"
1435 );
1436 }
1437 }
1438
1439 #[test]
1440 fn the_internal_error_still_scrubs_and_now_carries_the_error_prefix() {
1441 install_sink();
1442 forward_internal_error(
1443 "forwarder-441-scrub database error: DETAIL: Key (email)=(nick@example.com) \
1444 already exists",
1445 );
1446 let captured = CAPTURED.lock().unwrap();
1447 let line = captured
1449 .iter()
1450 .find(|line| line.contains("forwarder-441-scrub"))
1451 .expect("the internal error should reach the sink");
1452 assert!(line.starts_with("[error] "), "{line}");
1453 assert!(!line.contains('@'), "email reached the sink: {line}");
1454 assert!(
1455 line.contains("[subject_hash:"),
1456 "the pseudonym should survive for correlation: {line}"
1457 );
1458 }
1459
1460 #[test]
1461 fn forwarding_without_a_sink_is_a_noop() {
1462 forward_control_event(
1467 ControlLevel::Warn,
1468 "ignored when no sink or captured when set",
1469 );
1470 forward_internal_error("ignored when no sink or captured when set");
1471 }
1472}