rustio_admin/admin/redact.rs
1//! Sanitisation helpers for log lines, audit summaries, and error
2//! messages.
3//!
4//! Doctrine 11: never log secrets. Recovery flows route every secret-
5//! adjacent string through one of these helpers before it reaches the
6//! audit trail or any log target. The functions return either a
7//! fixed placeholder string (for genuinely opaque secrets like
8//! passwords and MFA secrets) or a short fingerprint (for tokens that
9//! benefit from being correlatable in support traffic without leaking
10//! the full value).
11//!
12//! Adopted by:
13//!
14//! - `audit::record` summary text generation
15//! - the upcoming `Mailer` debug logging path
16//! - any handler that needs to format a status string mentioning a
17//! token or password
18//!
19//! If you find yourself wanting to log a secret directly, ask
20//! whether the log line is more useful than the risk of disclosure.
21//! In every case the framework has shipped so far, the answer is no.
22
23use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
24use sha2::{Digest, Sha256};
25
26/// Replace any password-like value with a fixed placeholder. Use this
27/// in summary strings and error messages — never the real password,
28/// not even truncated.
29pub const fn redact_password() -> &'static str {
30 "<password>"
31}
32
33/// Render a short, privacy-preserving fingerprint of a token. The
34/// returned string includes the first 8 chars of `sha256(token)` —
35/// just enough for an operator to correlate two log lines about the
36/// same token without disclosing it. Never reverses to the original.
37///
38/// Used for: session-cookie tokens, password-reset tokens, future
39/// API keys.
40pub fn redact_token(token: &str) -> String {
41 let digest = Sha256::digest(token.as_bytes());
42 let hex = URL_SAFE_NO_PAD.encode(digest);
43 // 8 chars of the b64-encoded sha256 — ~48 bits of fingerprint.
44 // Plenty for correlation; not reversible.
45 let prefix: String = hex.chars().take(8).collect();
46 format!("<token:…{prefix}>")
47}
48
49/// Replace an MFA secret with a fixed placeholder. MFA secrets are
50/// always stored encrypted at rest; this helper exists so a stray
51/// log statement during development can't accidentally write the
52/// plaintext.
53pub const fn redact_mfa_secret() -> &'static str {
54 "<mfa-secret>"
55}
56
57/// Replace a backup code with a fixed placeholder. Codes are
58/// short-lived single-use; like passwords, the right log line is
59/// "redacted" with no fingerprint.
60pub const fn redact_backup_code() -> &'static str {
61 "<backup-code>"
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn redact_password_returns_fixed_placeholder() {
70 assert_eq!(redact_password(), "<password>");
71 }
72
73 #[test]
74 fn redact_token_is_deterministic() {
75 let a = redact_token("abc123");
76 let b = redact_token("abc123");
77 assert_eq!(a, b);
78 }
79
80 #[test]
81 fn redact_token_changes_per_token() {
82 assert_ne!(redact_token("aaa"), redact_token("bbb"));
83 }
84
85 #[test]
86 fn redact_token_reveals_no_substring_of_input() {
87 // Property: the redacted form must not contain any 4-char
88 // substring of the input. Catches accidental "show last N
89 // chars" regressions.
90 let plaintext = "secretSESSIONcookie";
91 let r = redact_token(plaintext);
92 for win in plaintext.as_bytes().windows(4) {
93 let needle = std::str::from_utf8(win).unwrap();
94 assert!(!r.contains(needle), "redaction leaked {needle:?}: {r}");
95 }
96 }
97
98 #[test]
99 fn redact_token_format_is_recognizable() {
100 let r = redact_token("anything");
101 assert!(r.starts_with("<token:…"));
102 assert!(r.ends_with('>'));
103 // 8-char fingerprint between the marker and closing >.
104 assert_eq!(r.len(), "<token:…".len() + 8 + ">".len());
105 }
106
107 #[test]
108 fn redact_mfa_secret_is_constant() {
109 assert_eq!(redact_mfa_secret(), "<mfa-secret>");
110 }
111
112 #[test]
113 fn redact_backup_code_is_constant() {
114 assert_eq!(redact_backup_code(), "<backup-code>");
115 }
116}