Skip to main content

faucet_cli/secrets/
registry.rs

1//! Process-global registry of resolved secret values + a redaction scrubber.
2//!
3//! Interpolation resolves secrets on raw config strings, so by the time the
4//! config is a typed structure a secret value is an ordinary `String`. Rather
5//! than tag fields, we track the resolved *values* and scrub any occurrence
6//! from output the CLI emits (the [`RedactingWriter`]).
7
8use std::borrow::Cow;
9use std::collections::HashSet;
10use std::io::{self, Write};
11use std::sync::{OnceLock, RwLock};
12
13/// Values shorter than this are not registered — masking 1–3 char strings
14/// would over-redact unrelated output.
15const MIN_REDACT_LEN: usize = 4;
16
17fn registry() -> &'static RwLock<HashSet<String>> {
18    static REG: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
19    REG.get_or_init(|| RwLock::new(HashSet::new()))
20}
21
22/// Register a resolved secret value so it is scrubbed from future output.
23pub fn register(secret: &str) {
24    if secret.len() >= MIN_REDACT_LEN {
25        registry()
26            .write()
27            .expect("secret registry lock poisoned")
28            .insert(secret.to_owned());
29    }
30}
31
32/// Replace every registered secret value in `input` with `***`.
33pub fn redact(input: &str) -> Cow<'_, str> {
34    let reg = registry().read().expect("secret registry lock poisoned");
35    if reg.is_empty() {
36        return Cow::Borrowed(input);
37    }
38    // Process **longest secret first**: a secret that is a substring of another
39    // (e.g. `abcd` inside `abcdXYZW`) must be replaced *after* the longer one,
40    // otherwise replacing the shorter one first destroys the longer match and
41    // leaves its extra tail (`XYZW`) exposed. `HashSet` iteration order is
42    // randomized, so without this sort redaction is nondeterministic.
43    let mut secrets: Vec<&str> = reg.iter().map(String::as_str).collect();
44    secrets.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
45    let mut out: Option<String> = None;
46    for secret in secrets {
47        let current = out.as_deref().unwrap_or(input);
48        if current.contains(secret) {
49            out = Some(current.replace(secret, "***"));
50        }
51    }
52    match out {
53        Some(s) => Cow::Owned(s),
54        None => Cow::Borrowed(input),
55    }
56}
57
58/// Longest registered secret in bytes (0 if none). Sizes the [`RedactingWriter`]
59/// hold-back window so a secret split across two `write()` calls is still caught.
60fn max_secret_len() -> usize {
61    registry()
62        .read()
63        .expect("secret registry lock poisoned")
64        .iter()
65        .map(String::len)
66        .max()
67        .unwrap_or(0)
68}
69
70/// An `io::Write` adapter that runs [`redact`] over every chunk before
71/// forwarding it to the inner writer. Wrapping the tracing subscriber's
72/// writer in this scrubs secret values out of *all* CLI log/diagnostic output
73/// at the I/O boundary, regardless of which field carried the value.
74pub struct RedactingWriter<W: Write> {
75    inner: W,
76    /// Trailing bytes withheld from the previous `write` — the window that might
77    /// be the *start* of a secret completing in a later write. Bounded by the
78    /// longest registered secret; flushed on `flush`/drop.
79    pending: Vec<u8>,
80}
81
82impl<W: Write> RedactingWriter<W> {
83    pub fn new(inner: W) -> Self {
84        Self {
85            inner,
86            pending: Vec::new(),
87        }
88    }
89}
90
91impl<W: Write> Write for RedactingWriter<W> {
92    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
93        self.pending.extend_from_slice(buf);
94        // Withhold the last `max_secret_len - 1` bytes so a secret straddling
95        // this write and the next is scrubbed once the two are joined. Everything
96        // before that window is safe to emit: any *complete* secret in it has
97        // already been masked, and an *incomplete* prefix can only sit in the
98        // withheld tail.
99        let keep = max_secret_len().saturating_sub(1);
100        if self.pending.len() > keep {
101            // `into_owned` drops the borrow of `self.pending` so we can mutate it.
102            let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
103            let mut split = scrubbed.len().saturating_sub(keep);
104            while split > 0 && !scrubbed.is_char_boundary(split) {
105                split -= 1;
106            }
107            // Snapped to a char boundary above, so the byte split is also a char
108            // boundary — emit the prefix, retain the suffix.
109            let bytes = scrubbed.as_bytes();
110            self.inner.write_all(&bytes[..split])?;
111            self.pending.clear();
112            self.pending.extend_from_slice(&bytes[split..]);
113        }
114        // Report the original length consumed — the tracing fmt layer treats a
115        // short write as an error, and the withheld bytes are an internal detail.
116        Ok(buf.len())
117    }
118
119    fn flush(&mut self) -> io::Result<()> {
120        if !self.pending.is_empty() {
121            let scrubbed = redact(&String::from_utf8_lossy(&self.pending)).into_owned();
122            self.inner.write_all(scrubbed.as_bytes())?;
123            self.pending.clear();
124        }
125        self.inner.flush()
126    }
127}
128
129impl<W: Write> Drop for RedactingWriter<W> {
130    fn drop(&mut self) {
131        // Emit any withheld tail so the final bytes of a stream (e.g. a log event
132        // formatted then dropped without an explicit flush) are never lost.
133        let _ = self.flush();
134    }
135}
136
137/// `MakeWriter` that produces a [`RedactingWriter`] over stderr, for the
138/// tracing fmt subscriber. Only needed when the `observability` feature wires
139/// a subscriber (the sole place the CLI formats tracing output).
140#[cfg(feature = "observability")]
141pub struct RedactingMakeWriter;
142
143#[cfg(feature = "observability")]
144impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter {
145    type Writer = RedactingWriter<std::io::Stderr>;
146    fn make_writer(&'a self) -> Self::Writer {
147        RedactingWriter::new(std::io::stderr())
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use serial_test::serial;
155
156    fn clear() {
157        registry().write().unwrap().clear();
158    }
159
160    #[test]
161    #[serial]
162    fn redacts_registered_value() {
163        clear();
164        register("supersecrettoken");
165        assert_eq!(
166            redact("Authorization: supersecrettoken"),
167            "Authorization: ***"
168        );
169    }
170
171    #[test]
172    #[serial]
173    fn leaves_unregistered_text_untouched() {
174        clear();
175        register("supersecrettoken");
176        assert_eq!(redact("nothing to see"), "nothing to see");
177    }
178
179    #[test]
180    #[serial]
181    fn does_not_register_short_values() {
182        clear();
183        register("abc"); // < MIN_REDACT_LEN
184        assert_eq!(redact("abc def"), "abc def");
185    }
186
187    #[test]
188    #[serial]
189    fn redact_handles_overlapping_secrets_longest_first() {
190        clear();
191        // A shorter secret that is a prefix of a longer one. Redacting the
192        // shorter first (unordered iteration) leaves the longer secret's tail
193        // ("XYZW") exposed; longest-first redaction must mask the whole thing.
194        register("abcd");
195        register("abcdXYZW");
196        let out = redact("value=abcdXYZW end");
197        assert!(
198            !out.contains("XYZW"),
199            "longer secret partially leaked: {out}"
200        );
201        assert_eq!(out, "value=*** end");
202    }
203
204    #[test]
205    #[serial]
206    fn writer_scrubs_secret_split_across_writes() {
207        clear();
208        register("supersecretvalue");
209        let mut buf: Vec<u8> = Vec::new();
210        {
211            let mut w = RedactingWriter::new(&mut buf);
212            // The secret straddles two separate write() calls.
213            w.write_all(b"token=supersec").unwrap();
214            w.write_all(b"retvalue done").unwrap();
215            w.flush().unwrap();
216        }
217        let out = String::from_utf8(buf).unwrap();
218        assert!(
219            !out.contains("supersecretvalue"),
220            "secret leaked across write boundary: {out}"
221        );
222        assert_eq!(out, "token=*** done");
223    }
224
225    #[test]
226    #[serial]
227    fn writer_scrubs_secret_on_write() {
228        clear();
229        let secret = "hunter2pass";
230        register(secret);
231        let mut buf: Vec<u8> = Vec::new();
232        {
233            let mut w = RedactingWriter::new(&mut buf);
234            write!(w, "token={secret} done").unwrap();
235            w.flush().unwrap();
236        }
237        assert_eq!(String::from_utf8(buf).unwrap(), "token=*** done");
238    }
239}