Skip to main content

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// public:
27/// Replace any password-like value with a fixed placeholder. Use this
28/// in summary strings and error messages — never the real password,
29/// not even truncated.
30pub const fn redact_password() -> &'static str {
31    "<password>"
32}
33
34// public:
35/// Render a short, privacy-preserving fingerprint of a token. The
36/// returned string includes the first 8 chars of `sha256(token)` —
37/// just enough for an operator to correlate two log lines about the
38/// same token without disclosing it. Never reverses to the original.
39///
40/// Used for: session-cookie tokens, password-reset tokens, future
41/// API keys.
42pub fn redact_token(token: &str) -> String {
43    let digest = Sha256::digest(token.as_bytes());
44    let hex = URL_SAFE_NO_PAD.encode(digest);
45    // 8 chars of the b64-encoded sha256 — ~48 bits of fingerprint.
46    // Plenty for correlation; not reversible.
47    let prefix: String = hex.chars().take(8).collect();
48    format!("<token:…{prefix}>")
49}
50
51// public:
52/// Replace an MFA secret with a fixed placeholder. MFA secrets are
53/// always stored encrypted at rest; this helper exists so a stray
54/// log statement during development can't accidentally write the
55/// plaintext.
56pub const fn redact_mfa_secret() -> &'static str {
57    "<mfa-secret>"
58}
59
60// public:
61/// Replace a backup code with a fixed placeholder. Codes are
62/// short-lived single-use; like passwords, the right log line is
63/// "redacted" with no fingerprint.
64pub const fn redact_backup_code() -> &'static str {
65    "<backup-code>"
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn redact_password_returns_fixed_placeholder() {
74        assert_eq!(redact_password(), "<password>");
75    }
76
77    #[test]
78    fn redact_token_is_deterministic() {
79        let a = redact_token("abc123");
80        let b = redact_token("abc123");
81        assert_eq!(a, b);
82    }
83
84    #[test]
85    fn redact_token_changes_per_token() {
86        assert_ne!(redact_token("aaa"), redact_token("bbb"));
87    }
88
89    #[test]
90    fn redact_token_reveals_no_substring_of_input() {
91        // Property: the redacted form must not contain any 4-char
92        // substring of the input. Catches accidental "show last N
93        // chars" regressions.
94        let plaintext = "secretSESSIONcookie";
95        let r = redact_token(plaintext);
96        for win in plaintext.as_bytes().windows(4) {
97            let needle = std::str::from_utf8(win).unwrap();
98            assert!(!r.contains(needle), "redaction leaked {needle:?}: {r}");
99        }
100    }
101
102    #[test]
103    fn redact_token_format_is_recognizable() {
104        let r = redact_token("anything");
105        assert!(r.starts_with("<token:…"));
106        assert!(r.ends_with('>'));
107        // 8-char fingerprint between the marker and closing >.
108        assert_eq!(r.len(), "<token:…".len() + 8 + ">".len());
109    }
110
111    #[test]
112    fn redact_mfa_secret_is_constant() {
113        assert_eq!(redact_mfa_secret(), "<mfa-secret>");
114    }
115
116    #[test]
117    fn redact_backup_code_is_constant() {
118        assert_eq!(redact_backup_code(), "<backup-code>");
119    }
120}