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