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}