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/// 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}