Skip to main content

rivet/
redact.rs

1//! **Layer: Cross-cutting helper** (credential redaction invariant, v0.7.2 P0.3)
2//!
3//! Single chokepoint for stripping plaintext credential material out of
4//! strings that are about to land in operator-visible artifacts: logs,
5//! `summary.json` / `summary.md`, the run journal, Slack/webhook payloads,
6//! and hard-failure error messages bubbling out of any subcommand.
7//!
8//! The invariant this module backs:
9//!
10//! > A credential that the operator passed through `password`,
11//! > `*_env`, `*_file`, `credentials_file`, or as an embedded
12//! > `user:password@host` URL MUST NOT round-trip into any persisted or
13//! > emitted artifact.  When in doubt, redact.
14//!
15//! Scope:
16//! - **Embedded-URL passwords**: `scheme://user:password@host…` →
17//!   `scheme://REDACTED@host…`.  This is the only pattern Rivet
18//!   round-trips through driver/error context, so it is the single
19//!   high-value rewrite.  Patches expand here.
20//! - **Known token-shape secrets** (AWS access keys etc.) are *not*
21//!   matched on shape today — they shouldn't be in stringified error
22//!   context unless the operator passed `--source 'aws_access_key_id=AKIA…'`
23//!   by mistake.  If a leak vector is discovered, add it here, write a
24//!   regression test, and roll a patch release.
25//!
26//! What this module does NOT guarantee (documented in [`SECURITY.md`]):
27//! - Third-party driver/library output that bypasses our error wrappers.
28//! - In-memory secrets — `Zeroizing<String>` is used at the source-config
29//!   boundary, but anything copied into a `String` along the way may
30//!   linger in process memory until allocator reuse.
31//! - Secrets the operator captured *outside* Rivet (shell history, env
32//!   var dumps, `ps` snapshots) — out of scope.
33
34/// Replace `user:password@host` userinfo segments in any URL-like
35/// substring with `REDACTED@host`.
36///
37/// Conservative match:
38/// - scheme is `[A-Za-z][A-Za-z0-9+.\-]*`
39/// - followed by `://`
40/// - then a userinfo run of non-whitespace, non-`/`, non-`?`, non-`#`
41///   characters containing `:` (i.e. `user:password`)
42/// - terminated by `@`
43///
44/// A bare `user@host` (no `:`) is preserved verbatim — there's no
45/// password to redact, and stripping the username makes log lines
46/// harder to triage.  Operators wanting full userinfo redaction can
47/// continue to rely on `SourceConfig::redact_for_artifact` for the
48/// structural path.
49///
50/// Idempotent: once-redacted strings pass through unchanged.
51pub fn redact_url_passwords(s: &str) -> String {
52    // Find `scheme://userinfo@` segments.  We don't pull in a regex
53    // crate just for this one pattern — a hand-rolled walk is faster
54    // and avoids a dep that grows the binary.
55    //
56    // F-NEW-C (0.7.5 audit): the previous version copied non-matching
57    // bytes one at a time via `out.push(bytes[i] as char)`, which
58    // re-interpreted each UTF-8 byte as a Unicode code point and
59    // re-encoded it.  Every multi-byte glyph (em-dash, Cyrillic, …)
60    // became double-encoded mojibake (`—` → `â\u{80}\u{94}`) in any
61    // error message that hit the redactor.  Correct fix: copy the
62    // next UTF-8 codepoint as a whole slice of `s`, not byte-by-byte.
63    let bytes = s.as_bytes();
64    let mut out = String::with_capacity(s.len());
65    let mut i = 0;
66    while i < bytes.len() {
67        if let Some((rewritten, advance)) = try_redact_at(bytes, i) {
68            out.push_str(&rewritten);
69            i = advance;
70            continue;
71        }
72        let b = bytes[i];
73        if b.is_ascii() {
74            out.push(b as char);
75            i += 1;
76        } else {
77            // Multi-byte UTF-8 codepoint starting at `i`; continuation
78            // bytes have the form 10xxxxxx.  Copy the whole codepoint
79            // verbatim from the source string.
80            let start = i;
81            i += 1;
82            while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
83                i += 1;
84            }
85            out.push_str(&s[start..i]);
86        }
87    }
88    out
89}
90
91/// If `bytes[i..]` starts a `scheme://userinfo@` pattern with a `:` in
92/// the userinfo (a password segment), return the rewritten prefix and
93/// the new cursor position.  Otherwise return `None`.
94/// The LAST `@` from `start` up to the whitespace boundary (the log-line URL
95/// terminator), or None. The userinfo may itself contain a raw `/ ? # @ :` (a
96/// non-conforming `url:` passthrough / RIVET_STATE_URL / driver-echoed URL), so the
97/// scan must NOT stop at `/ ? #` — the real userinfo terminator is the last `@`
98/// before whitespace. Stopping earlier (an earlier bounded form) leaked a password
99/// with an internal `@` before a `/` (round-4).
100fn scan_last_at(bytes: &[u8], start: usize) -> Option<usize> {
101    let mut k = start;
102    let mut last_at = None;
103    while k < bytes.len() {
104        let b = bytes[k];
105        if b == b'@' {
106            last_at = Some(k);
107        } else if b.is_ascii_whitespace() {
108            break;
109        }
110        k += 1;
111    }
112    last_at
113}
114
115fn try_redact_at(bytes: &[u8], i: usize) -> Option<(String, usize)> {
116    // scheme: must start with an ASCII letter
117    if !bytes.get(i).is_some_and(|b| b.is_ascii_alphabetic()) {
118        return None;
119    }
120    let mut j = i + 1;
121    while j < bytes.len() {
122        let b = bytes[j];
123        if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'.' | b'-') {
124            j += 1;
125        } else {
126            break;
127        }
128    }
129    // `://`
130    if !bytes[j..].starts_with(b"://") {
131        return None;
132    }
133    let userinfo_start = j + 3;
134    // The userinfo ends at the LAST `@` before whitespace — DEFAULT-DENY. A password
135    // in a non-conforming URL (raw `url:` / RIVET_STATE_URL / driver-echoed URL, none
136    // percent-encoded) may itself contain `@ / ? #`, so any earlier bound leaks: the
137    // round-3 fix bounded at `/?#` and leaked `pa/ss`; its fail-safe still leaked a
138    // password with an internal `@` before a `/` (round-4). The last `@` before the
139    // whitespace URL-terminator is the one true userinfo boundary. Over-redacts a
140    // pathological host in a rare multi-`@` (query-`@`) URL; never leaks.
141    let at = scan_last_at(bytes, userinfo_start)?;
142    // A `:` before that `@` marks a password segment (bare `user@host` has none).
143    let has_colon = bytes[userinfo_start..at].contains(&b':');
144    if !has_colon {
145        return None;
146    }
147    // Slice out `scheme://`, replace userinfo with `REDACTED`.
148    let scheme_part = std::str::from_utf8(&bytes[i..userinfo_start]).ok()?;
149    Some((format!("{scheme_part}REDACTED"), at))
150}
151
152/// Compose every redactor.  Use this at every boundary that turns a
153/// driver/library error (or any operator-untrusted string) into a
154/// persisted or emitted artifact.
155pub fn redact_secrets(s: &str) -> String {
156    redact_url_passwords(s)
157}
158
159/// Convenience: format an `anyhow::Error` with `{:#}` and redact the
160/// result in one call.  Use at the boundary of every error-to-artifact
161/// path (`summary.error_message = ...`, `log::error!(... e ...)`).
162pub fn redact_error(e: &anyhow::Error) -> String {
163    redact_secrets(&format!("{e:#}"))
164}
165
166/// Render one log record into a redacted, operator-visible line.
167///
168/// The module scope names **logs** as a redaction target, but the `log::*`
169/// macros bypass the artifact-path redaction that is wired by hand at the
170/// error/summary call sites — a `log::warn!("…{e}", e)` whose `e` captured a
171/// `scheme://user:password@host` connect error would otherwise print the
172/// password to stderr. `main`'s `env_logger` formatter delegates here so the
173/// log **sink** itself is the chokepoint: every line, present and future,
174/// passes through [`redact_secrets`] with no reliance on each call site
175/// remembering to redact. Kept in this module (beside the other redactors) and
176/// log-crate-agnostic (`level` is a pre-rendered `&str`) so the wiring is
177/// unit-testable without capturing global stderr.
178pub fn redacted_log_line(timestamp: &str, level: &str, target: &str, message: &str) -> String {
179    redact_secrets(&format!("[{timestamp} {level} {target}] {message}"))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // ── URL-safety matrix: the recurring credential-leak class, at the infra
187    //    level (docs/url-safety-matrix.yaml). The class has regressed THREE times
188    //    (round-1 MSSQL round-trip, round-3 redact_pg_url, and the general redactor
189    //    here), each invisible to point tests. This data-driven test sweeps every
190    //    scheme × hostile-userinfo shape through the general log redactor and
191    //    asserts the secret NEVER survives. Adding a URL shape here is cheaper than
192    //    another audit round; a redaction change that reopens the leak fails CI. ──
193    #[test]
194    fn redact_secrets_url_safety_matrix() {
195        const SECRET: &str = "S3cr3tPw";
196        // Every engine scheme rivet builds/logs.
197        let schemes = ["postgresql", "mysql", "sqlserver", "mongodb"];
198        // Password bodies embedding the secret + a URL delimiter that has
199        // historically defeated a redactor (a raw '/' is in the base64 alphabet;
200        // '@'-before-'/' defeated round-3's fail-safe; ':' defeated the rfind split).
201        let bodies = [
202            SECRET.to_string(),
203            format!("{SECRET}/x"),
204            format!("{SECRET}?x"),
205            format!("{SECRET}#x"),
206            format!("{SECRET}@x"),
207            format!("{SECRET}:x"),
208            format!("pre/{SECRET}"),
209            format!("a/b?c#{SECRET}"),
210            format!("Kp@{SECRET}/x"), // '@' BEFORE '/' (round-4)
211            format!("Kp@{SECRET}?x"),
212            format!("Kp@{SECRET}#x"),
213            format!("a:b:{SECRET}"), // ':'-bearing password (round-4)
214        ];
215        for scheme in schemes {
216            for body in &bodies {
217                // A plain URL, AND a URL with a stray '@' in the query after the host
218                // (the RFC-legal ?opt=a@b shape — the round-4 stray-@ coverage gap).
219                for url in [
220                    format!("{scheme}://user:{body}@host:5432/db"),
221                    format!("{scheme}://user:{body}@host:5432/db?opt=a@b"),
222                ] {
223                    // Bare, inside an error prefix, and mid-log-line (the three real
224                    // shapes a URL reaches the sink in).
225                    for ctx in [
226                        url.clone(),
227                        format!("connect to {url} failed: timeout"),
228                        format!("[2026-07-22 ERROR src] dialing {url} then EOF"),
229                    ] {
230                        let out = redact_secrets(&ctx);
231                        assert!(
232                            !out.contains(SECRET),
233                            "SECRET leaked through redact_secrets\n  scheme={scheme} body={body}\n  in:  {ctx}\n  out: {out}"
234                        );
235                    }
236                }
237            }
238        }
239    }
240
241    // ── redact_url_passwords ───────────────────────────────────────────────
242
243    #[test]
244    fn rewrites_postgres_userinfo_with_password() {
245        let s = "connection failed to postgresql://alice:s3cret@db.prod:5432/orders: timeout";
246        let out = redact_url_passwords(s);
247        assert!(!out.contains("s3cret"), "password must be stripped: {out}");
248        assert!(
249            out.contains("postgresql://REDACTED@db.prod:5432/orders"),
250            "expected REDACTED@host, got: {out}",
251        );
252    }
253
254    #[test]
255    fn rewrites_mysql_userinfo_with_password() {
256        let s = "auth error: mysql://root:hunter2@10.0.0.5:3306/billing";
257        let out = redact_url_passwords(s);
258        assert!(!out.contains("hunter2"));
259        assert!(out.contains("mysql://REDACTED@10.0.0.5"));
260    }
261
262    #[test]
263    fn preserves_bare_user_at_host_without_password() {
264        // `user@host` has no password to strip; rewriting it would lose
265        // useful triage signal.  Pin the conservative behaviour.
266        let s = "connection: postgresql://alice@db.prod:5432/orders";
267        assert_eq!(redact_url_passwords(s), s);
268    }
269
270    #[test]
271    fn idempotent_on_already_redacted_string() {
272        let s = "postgresql://REDACTED@db.prod:5432/orders";
273        assert_eq!(redact_url_passwords(s), s);
274    }
275
276    #[test]
277    fn preserves_non_url_text_with_at_sign() {
278        // `email@example.com` is not a URL — must not be rewritten.
279        let s = "user alice@example.com reported failure";
280        assert_eq!(redact_url_passwords(s), s);
281    }
282
283    #[test]
284    fn handles_multiple_urls_in_one_string() {
285        let s = "primary postgresql://a:b@h1/d failed, retrying mysql://c:d@h2/d";
286        let out = redact_url_passwords(s);
287        assert!(!out.contains("a:b@"));
288        assert!(!out.contains("c:d@"));
289        assert!(out.contains("postgresql://REDACTED@h1/d"));
290        assert!(out.contains("mysql://REDACTED@h2/d"));
291    }
292
293    #[test]
294    fn stops_at_whitespace_in_userinfo() {
295        // Userinfo cannot contain whitespace — defensive guard against
296        // matching wild `://foo bar@…` substrings inside prose.
297        let s = "scheme://broken token@host";
298        assert_eq!(redact_url_passwords(s), s);
299    }
300
301    #[test]
302    fn preserves_strings_without_urls() {
303        let s = "export 'orders' failed: relation does not exist";
304        assert_eq!(redact_url_passwords(s), s);
305    }
306
307    // ── redact_error ───────────────────────────────────────────────────────
308
309    #[test]
310    fn redact_error_strips_password_from_anyhow_chain() {
311        let e = anyhow::anyhow!("connect failed to postgresql://alice:s3cret@db.prod/orders");
312        let out = redact_error(&e);
313        assert!(!out.contains("s3cret"));
314        assert!(out.contains("REDACTED@db.prod"));
315    }
316
317    // ── F-NEW-C: multi-byte UTF-8 must round-trip ─────────────────────────────
318
319    #[test]
320    fn preserves_em_dash_and_other_multibyte_glyphs() {
321        // Before the F-NEW-C fix, the byte-by-byte loop in
322        // `redact_url_passwords` double-encoded every non-ASCII codepoint:
323        // the em-dash `—` (UTF-8 e2 80 94) came out as `â\u{80}\u{94}`
324        // (c3 a2 c2 80 c2 94).  This test pins the round-trip so the
325        // regression cannot return silently in any error message.
326        let s = "export 'orders': --resume refused — destination prefix has _SUCCESS";
327        assert_eq!(
328            redact_url_passwords(s),
329            s,
330            "non-URL text containing an em-dash must pass through unchanged"
331        );
332
333        let s2 = "сообщение об ошибке: cannot connect";
334        assert_eq!(
335            redact_url_passwords(s2),
336            s2,
337            "Cyrillic text must pass through unchanged"
338        );
339
340        // And it must still redact correctly when the string contains
341        // BOTH multi-byte glyphs and a redactable URL.
342        let s3 = "ошибка — postgresql://u:p@host/db: dropped";
343        let out = redact_url_passwords(s3);
344        assert!(out.contains("ошибка — postgresql://REDACTED@host/db"));
345        assert!(!out.contains("u:p@"));
346    }
347
348    // ── SEC-RED: embedded `@` in password must not leak ───────────────────────
349
350    #[test]
351    fn sec_redact_url_password_with_at() {
352        // SEC-RED V8: redact_url_passwords splits userinfo at the FIRST `@`,
353        // leaking the password tail after an embedded `@`. The userinfo walk in
354        // `try_redact_at` breaks on the first `@` it sees, so the password
355        // `p@ssw0rd` is split: only `p` is treated as the password and the
356        // tail `ssw0rd` survives in the output. The userinfo terminator must be
357        // the LAST `@` before the path/query (rfind semantics, as already used
358        // by redact_pg_url in state/mod.rs).
359        let s = "connect failed to postgresql://rivet:p@ssw0rd@db.example.com:5432/orders";
360        let out = redact_url_passwords(s);
361        // No fragment of the password may survive. `ssw0rd` is the tail that
362        // leaks today.
363        assert!(
364            !out.contains("ssw0rd"),
365            "password tail after embedded @ must not leak: {out}"
366        );
367        assert!(
368            !out.contains("p@ssw0rd"),
369            "full password must not leak: {out}"
370        );
371        // Host and path must be retained, redacted to REDACTED@host.
372        assert!(
373            out.contains("postgresql://REDACTED@db.example.com:5432/orders"),
374            "expected REDACTED@host with embedded-@ password stripped, got: {out}"
375        );
376
377        // Guard: a normal password (no embedded @) still redacts correctly so
378        // this test pins the fix rather than just any change.
379        let normal = "connect failed to postgresql://rivet:s3cret@db.example.com:5432/orders";
380        let normal_out = redact_url_passwords(normal);
381        assert!(
382            !normal_out.contains("s3cret"),
383            "normal password must still be redacted: {normal_out}"
384        );
385        assert!(
386            normal_out.contains("postgresql://REDACTED@db.example.com:5432/orders"),
387            "normal password redaction unchanged: {normal_out}"
388        );
389    }
390}