返回 CodeWhale
redaction.rs
根目录 / crates / workflow / src / redaction.rs
1 //! Redaction for anything that can reach a log, a span, or a durable receipt.
2 //!
3 //! Two classes of content are stripped before text is allowed to leave the
4 //! process — either onto a provider's wire or into a journal:
5 //!
6 //! 1. **Absolute paths.** `/Users/hunter/src/app`, `/home/x/...`, `C:\Users\…`
7 //! and `~/…` carry the operator's username, home directory, and machine
8 //! layout. A journal travels further than the machine that wrote it.
9 //! 2. **Repo-relative paths.** `crates/tui/src/main.rs`, `./deploy.sh`,
10 //! `../../secret/notes.md` — and their escaped spellings, `crates\/tui\/…`
11 //! and `crates\\tui\\…`. An absolute path discloses the machine; a relative
12 //! one discloses the private tree's shape, which is exactly as much as the
13 //! reader of a routing summary at another provider needs to reconstruct it.
14 //! The rule is deliberately conservative — see `looks_relative` — because
15 //! the failure it must not trade for is mangling ordinary prose or a
16 //! `provider/model` label.
17 //! 3. **Secret-shaped tokens.** Provider keys, bearer tokens, and
18 //! `SOMETHING_KEY=value` assignments. These have no business in a routing
19 //! summary and must never be persisted next to one.
20 //!
21 //! Redaction is *recorded*, not silent: [`Redaction::kinds`] names what was
22 //! removed so a receipt can disclose the fact without disclosing the content.
23
24 use std::collections::BTreeSet;
25
26 /// A redaction kind, as it appears on a disclosure. These are stable labels —
27 /// receipts persist them.
28 pub const REDACTION_ABSOLUTE_PATH: &str = "absolute_path";
29 pub const REDACTION_RELATIVE_PATH: &str = "relative_path";
30 pub const REDACTION_SECRET: &str = "secret";
31
32 /// Placeholder substituted for a removed absolute path.
33 const PATH_PLACEHOLDER: &str = "<path>";
34 /// Placeholder substituted for a removed secret-shaped token.
35 const SECRET_PLACEHOLDER: &str = "<redacted>";
36
37 /// Case-insensitive substrings that mark an identifier as secret-bearing.
38 const SECRET_NAME_MARKERS: &[&str] = &[
39 "api_key",
40 "apikey",
41 "secret",
42 "token",
43 "password",
44 "passwd",
45 "credential",
46 "authorization",
47 "auth_token",
48 "access_key",
49 "private_key",
50 "session_key",
51 ];
52
53 /// Prefixes that are themselves a credential, whatever they are attached to.
54 ///
55 /// Every entry here must be *unambiguously* a credential prefix. A prefix that
56 /// is also an ordinary English word — `bearer`, `asia` — belongs nowhere near
57 /// this list: it would redact prose and, worse, would report a `secret`
58 /// redaction kind on a receipt that removed nothing but a word. The AWS key ids
59 /// that motivated `asia`/`akia` are handled by
60 /// [`looks_like_aws_access_key`], which requires the full shape.
61 const SECRET_VALUE_PREFIXES: &[&str] = &[
62 "sk-",
63 "sk_",
64 "ghp_",
65 "gho_",
66 "ghs_",
67 "github_pat_",
68 "xoxb-",
69 "xoxp-",
70 "xapp-",
71 ];
72
73 /// HTTP authorization scheme keywords, in their canonical HTTP capitalization.
74 ///
75 /// These are *never* the secret — the secret is the token that follows them.
76 /// Redacting the keyword and stopping there is the failure this list exists to
77 /// prevent: `Authorization: Bearer <token>` would keep the token and still
78 /// claim on the receipt that a secret had been removed.
79 ///
80 /// The capitalization is stored, not normalized away, because it carries
81 /// evidence: `Bearer` is HTTP syntax and `bearer` is an English noun. See
82 /// [`is_canonical_auth_scheme`].
83 const AUTH_SCHEMES: &[&str] = &["Bearer", "Basic", "Digest", "Token"];
84
85 /// The one scheme keyword whose canonical capitalization is, by itself, enough
86 /// to treat the following token as a credential.
87 ///
88 /// The asymmetry is deliberate and is the whole of the false-positive story.
89 /// `Basic`, `Digest`, and `Token` are ordinary capitalized English words —
90 /// "Basic auth is enabled", "Token holders vote", "Digest the results" — and
91 /// arming on them would redact the next word of perfectly ordinary prose. `Bearer`
92 /// capitalized is, in a technical corpus, the HTTP scheme essentially every
93 /// time. So `Bearer qqq` loses `qqq` even though a three-letter lowercase token
94 /// looks like nothing at all, while the other three need header context first.
95 ///
96 /// The residual cost is stated plainly: `Bearer tokens are rotated weekly`
97 /// redacts `tokens`. That is a capitalized-`Bearer` sentence, which is header
98 /// syntax by shape; ordinary prose says "bearer", and lowercase never arms on
99 /// its own.
100 const SELF_EVIDENT_AUTH_SCHEME: &str = "Bearer";
101
102 /// AWS access-key-id prefixes. Matched case-sensitively and only against the
103 /// full 20-character shape, so the word `Asia` is prose and `ASIA…` is a key.
104 const AWS_KEY_ID_PREFIXES: &[&str] = &[
105 "AKIA", "ASIA", "AGPA", "AIDA", "AROA", "ANPA", "ANVA", "ASCA", "ABIA", "ACCA",
106 ];
107
108 /// Minimum length of an AWS access key id (`AKIA` + 16).
109 const AWS_KEY_ID_LEN: usize = 20;
110
111 /// Minimum length before a bare token is treated as a credential value.
112 const CREDENTIAL_VALUE_MIN_LEN: usize = 16;
113
114 /// The result of redacting one string.
115 #[derive(Debug, Clone, PartialEq, Eq)]
116 pub struct Redaction {
117 text: String,
118 kinds: BTreeSet<String>,
119 }
120
121 impl Redaction {
122 /// The redacted text.
123 #[must_use]
124 pub fn text(&self) -> &str {
125 &self.text
126 }
127
128 /// Consume the redaction, yielding the redacted text.
129 #[must_use]
130 pub fn into_text(self) -> String {
131 self.text
132 }
133
134 /// Whether anything was removed.
135 #[must_use]
136 pub fn redacted(&self) -> bool {
137 !self.kinds.is_empty()
138 }
139
140 /// Which classes of content were removed — never the content itself.
141 #[must_use]
142 pub fn kinds(&self) -> Vec<String> {
143 self.kinds.iter().cloned().collect()
144 }
145 }
146
147 /// How strongly the preceding tokens claim that the *next* token is a
148 /// credential.
149 ///
150 /// A credential is routinely written as two or three tokens
151 /// (`Authorization: Bearer <token>`), so the decision cannot be made per token.
152 /// But the evidence for "a credential follows" is not uniform, and collapsing it
153 /// to a boolean is what produces either a leak or a mangled sentence:
154 ///
155 /// - `Authorization: Bearer qqq` — the credential is `qqq`, three lowercase
156 /// letters, indistinguishable in shape from a word. Only the *context* says
157 /// it is a secret, and the context says so unambiguously.
158 /// - `authorization: bearer shares responsibility` — the same context markers,
159 /// lowercase, and the next token is an English verb. Redacting `shares` would
160 /// destroy a sentence and put a false `secret` kind on a receipt.
161 ///
162 /// So the arming carries its own strength, and the shape test is applied only
163 /// where the context is weak enough to need it.
164 #[derive(Clone, Copy, PartialEq, Eq)]
165 enum CredentialArm {
166 /// Nothing is expected; the next token is judged on its own.
167 None,
168 /// The next token is the credential only if it also *looks* like one.
169 /// Lowercase scheme words and bare `Authorization:` land here.
170 IfShaped,
171 /// The next token is the credential whatever it looks like. Reached by
172 /// canonical HTTP capitalization — `Authorization: Bearer …`, or a bare
173 /// `Bearer` — where the syntax alone settles it.
174 Certain,
175 }
176
177 impl CredentialArm {
178 const fn is_armed(self) -> bool {
179 !matches!(self, Self::None)
180 }
181 }
182
183 /// What one token resolved to, and whether it armed the *next* token.
184 struct TokenOutcome {
185 /// `None` keeps the token verbatim.
186 text: Option<String>,
187 /// Whether — and how strongly — the following token carries the credential
188 /// this one introduced. `Authorization:` and a bare `Bearer` reveal nothing
189 /// themselves; the value after them is the whole secret.
190 arm: CredentialArm,
191 }
192
193 impl TokenOutcome {
194 const fn keep() -> Self {
195 Self {
196 text: None,
197 arm: CredentialArm::None,
198 }
199 }
200
201 const fn keep_and_arm(arm: CredentialArm) -> Self {
202 Self { text: None, arm }
203 }
204
205 fn replace(text: String) -> Self {
206 Self {
207 text: Some(text),
208 arm: CredentialArm::None,
209 }
210 }
211 }
212
213 /// Redact absolute paths and secret-shaped tokens from `input`.
214 ///
215 /// Operates token-by-token over whitespace-free runs, which is enough for the
216 /// bounded, already whitespace-collapsed text this crate transmits and keeps
217 /// the rule set auditable without a regex dependency.
218 ///
219 /// One piece of state crosses token boundaries, and it has to: a credential is
220 /// routinely written as *two* tokens (`Authorization: Bearer <token>`), so a
221 /// purely per-token rule either leaks the value or redacts the English word
222 /// `bearer`. Carrying "the next token is the credential" forward — and *how
223 /// certainly*, see `CredentialArm` — is what lets this do neither.
224 #[must_use]
225 pub fn redact_for_disclosure(input: &str) -> Redaction {
226 let mut kinds = BTreeSet::new();
227 let tokens: Vec<&str> = input.split(' ').collect();
228 let mut out: Vec<String> = Vec::with_capacity(tokens.len());
229 let mut arm = CredentialArm::None;
230
231 for (index, token) in tokens.iter().enumerate() {
232 if token.is_empty() {
233 out.push(String::new());
234 continue;
235 }
236 if arm.is_armed() {
237 // `Authorization: Bearer <token>` — the scheme keyword is not the
238 // secret, so it survives and the arming carries past it. A
239 // canonically capitalized keyword also *upgrades* the arming: the
240 // header name alone left the shape in doubt, and `Bearer` settles
241 // it.
242 if is_auth_scheme(token) {
243 if is_canonical_auth_scheme(token) {
244 arm = CredentialArm::Certain;
245 }
246 out.push((*token).to_string());
247 continue;
248 }
249 // Weak arming still defers to shape, so an `authorization:` that
250 // introduces a sentence rather than a secret leaves the sentence
251 // intact — and leaves the receipt honest about having removed
252 // nothing.
253 if arm == CredentialArm::Certain || looks_like_credential_value(token) {
254 kinds.insert(REDACTION_SECRET.to_string());
255 out.push(SECRET_PLACEHOLDER.to_string());
256 arm = CredentialArm::None;
257 continue;
258 }
259 // Fall through: the token is judged on its own merits, and the
260 // arming state is replaced wholesale by `redact_token` below.
261 }
262
263 let next = tokens[index + 1..]
264 .iter()
265 .copied()
266 .find(|candidate| !candidate.is_empty());
267 let outcome = redact_token(token, next, &mut kinds);
268 arm = outcome.arm;
269 out.push(outcome.text.unwrap_or_else(|| (*token).to_string()));
270 }
271
272 Redaction {
273 text: out.join(" "),
274 kinds,
275 }
276 }
277
278 /// Redact one whitespace-free token, recording what was removed.
279 ///
280 /// `next` is the following non-empty token, used only to decide whether a bare
281 /// authorization scheme keyword is introducing a credential or is just a word.
282 fn redact_token(token: &str, next: Option<&str>, kinds: &mut BTreeSet<String>) -> TokenOutcome {
283 // `NAME=value` / `NAME:value` — a secret-bearing name redacts its value and
284 // keeps the name, which is the useful half.
285 for separator in ['=', ':'] {
286 let Some((name, value)) = token.split_once(separator) else {
287 continue;
288 };
289 let lowered = name.to_ascii_lowercase();
290 let secret_name = SECRET_NAME_MARKERS
291 .iter()
292 .any(|marker| lowered.contains(marker));
293 if secret_name {
294 // `Authorization:Bearer` carries no secret of its own; the
295 // credential is the next token. Canonical capitalization on the
296 // scheme makes that certain; `authorization:bearer` does not.
297 if is_auth_scheme(value) {
298 return TokenOutcome::keep_and_arm(if is_canonical_auth_scheme(value) {
299 CredentialArm::Certain
300 } else {
301 CredentialArm::IfShaped
302 });
303 }
304 // A bare `Authorization:` only introduces a credential when one
305 // actually follows. `authorization: needed before merge` is a
306 // sentence, and redacting `needed` would report a secret that was
307 // never there. The arming stays weak here even when a scheme word
308 // follows — the scheme token itself decides, on the next pass,
309 // whether its capitalization upgrades it.
310 if value.is_empty() {
311 return if next
312 .is_some_and(|next| is_auth_scheme(next) || looks_like_credential_value(next))
313 {
314 TokenOutcome::keep_and_arm(CredentialArm::IfShaped)
315 } else {
316 TokenOutcome::keep()
317 };
318 }
319 kinds.insert(REDACTION_SECRET.to_string());
320 return TokenOutcome::replace(format!("{name}{separator}{SECRET_PLACEHOLDER}"));
321 }
322 // A path assigned to a variable is still a path.
323 if let Some(kind) = classify_path(value) {
324 kinds.insert(kind.to_string());
325 return TokenOutcome::replace(format!("{name}{separator}{PATH_PLACEHOLDER}"));
326 }
327 }
328
329 // A bare `Bearer` in canonical HTTP capitalization introduces a credential
330 // on its own — that is what makes `Bearer qqq` lose `qqq`, which no shape
331 // test could ever do. Any other scheme keyword, and any other
332 // capitalization, needs something credential-shaped to actually follow:
333 // `bearer of bad news` and `Token holders vote` are prose.
334 if unwrap_token(token) == SELF_EVIDENT_AUTH_SCHEME {
335 return TokenOutcome::keep_and_arm(CredentialArm::Certain);
336 }
337 if is_auth_scheme(token) && next.is_some_and(looks_like_credential_value) {
338 return TokenOutcome::keep_and_arm(CredentialArm::IfShaped);
339 }
340
341 let lowered = token.to_ascii_lowercase();
342 if SECRET_VALUE_PREFIXES
343 .iter()
344 .any(|prefix| lowered.starts_with(prefix))
345 || looks_like_aws_access_key(token)
346 {
347 kinds.insert(REDACTION_SECRET.to_string());
348 return TokenOutcome::replace(SECRET_PLACEHOLDER.to_string());
349 }
350
351 if let Some(kind) = classify_path(token) {
352 kinds.insert(kind.to_string());
353 return TokenOutcome::replace(PATH_PLACEHOLDER.to_string());
354 }
355
356 TokenOutcome::keep()
357 }
358
359 /// Strip the punctuation a token picks up from surrounding prose.
360 fn unwrap_token(token: &str) -> &str {
361 token
362 .trim_start_matches(['(', '[', '"', '\'', '<'])
363 .trim_end_matches([',', '.', ';', ':', ')', ']', '"', '\'', '>', '!', '?'])
364 }
365
366 /// Whether a token is an HTTP authorization scheme keyword (and nothing else),
367 /// in any capitalization.
368 fn is_auth_scheme(token: &str) -> bool {
369 let word = unwrap_token(token);
370 !word.is_empty()
371 && word.chars().all(|ch| ch.is_ascii_alphabetic())
372 && AUTH_SCHEMES
373 .iter()
374 .any(|scheme| scheme.eq_ignore_ascii_case(word))
375 }
376
377 /// Whether a token is a scheme keyword spelled the way HTTP spells it —
378 /// `Bearer`, not `bearer` or `BEARER`.
379 ///
380 /// This is the evidence that separates syntax from prose. It is a weak signal
381 /// read honestly: capitalization is *suggestive*, so it upgrades an arming that
382 /// header context already established, and stands alone only for
383 /// [`SELF_EVIDENT_AUTH_SCHEME`].
384 fn is_canonical_auth_scheme(token: &str) -> bool {
385 AUTH_SCHEMES.contains(&unwrap_token(token))
386 }
387
388 /// Whether a token has the shape of an opaque credential value.
389 ///
390 /// Deliberately conservative: long, punctuation-free-ish, and mixing letters
391 /// with digits. Ordinary words — however long — never qualify, which is what
392 /// keeps `bearer of responsibility` out of the redactor.
393 fn looks_like_credential_value(token: &str) -> bool {
394 let value = unwrap_token(token);
395 if value.chars().count() < CREDENTIAL_VALUE_MIN_LEN {
396 return false;
397 }
398 let mut has_digit = false;
399 let mut has_alpha = false;
400 for ch in value.chars() {
401 if ch.is_ascii_digit() {
402 has_digit = true;
403 } else if ch.is_ascii_alphabetic() {
404 has_alpha = true;
405 } else if !matches!(ch, '-' | '_' | '.' | '=' | '+' | '/' | '~') {
406 return false;
407 }
408 }
409 has_digit && has_alpha
410 }
411
412 /// Whether a token is an AWS access key id.
413 ///
414 /// Requires the exact uppercase prefix *and* the full length, so `Asia` and
415 /// `ASIA` (the continent, in prose or in a shouted heading) are left alone
416 /// while `ASIA` + 16 key characters is removed.
417 fn looks_like_aws_access_key(token: &str) -> bool {
418 let value = unwrap_token(token);
419 if value.len() < AWS_KEY_ID_LEN {
420 return false;
421 }
422 if !AWS_KEY_ID_PREFIXES
423 .iter()
424 .any(|prefix| value.starts_with(prefix))
425 {
426 return false;
427 }
428 value
429 .chars()
430 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
431 }
432
433 /// Whether a token is an absolute or home-relative filesystem path.
434 ///
435 /// A lone `/` or `~` is punctuation, not a path; a Windows drive letter needs
436 /// its `:\` to count.
437 fn looks_absolute(token: &str) -> bool {
438 let trimmed = token.trim_start_matches(['(', '[', '"', '\'']);
439 if trimmed.len() < 2 {
440 return false;
441 }
442 if let Some(rest) = trimmed.strip_prefix('/') {
443 return rest.starts_with(|ch: char| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_');
444 }
445 if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
446 return true;
447 }
448 if trimmed.starts_with("\\\\") {
449 return true;
450 }
451 let mut chars = trimmed.chars();
452 matches!(
453 (chars.next(), chars.next(), chars.next()),
454 (Some(drive), Some(':'), Some('\\' | '/')) if drive.is_ascii_alphabetic()
455 )
456 }
457
458 /// Which path kind a token is, if any — the single decision both the bare-token
459 /// and the `NAME=value` rules ask, so an assignment can never be classified
460 /// differently from the same value standing alone.
461 ///
462 /// Escaped spellings are resolved first: a path that arrives inside a JSON
463 /// string is written `crates\/tui\/src\/main.rs` or `crates\\tui\\src\\main.rs`,
464 /// and reading only the literal characters would let either spelling through
465 /// while the receipt claimed nothing was removed.
466 ///
467 /// A URL is not a filesystem path and is left to the URL-bearing-input guard
468 /// that already refuses such a task, so a token carrying a scheme is declined
469 /// here rather than silently reclassified.
470 fn classify_path(token: &str) -> Option<&'static str> {
471 let raw = trim_path_punctuation(token);
472 let unescaped = unescape_path(raw);
473 let candidate = trim_path_punctuation(&unescaped);
474 if candidate.contains("://") {
475 return None;
476 }
477 // Both spellings are asked, because unescaping is lossy in one direction
478 // that matters: a UNC share is *literally* `\\host\share`, and collapsing
479 // its leading pair would demote a machine-identifying path to a relative
480 // one.
481 if looks_absolute(raw) || looks_absolute(candidate) {
482 return Some(REDACTION_ABSOLUTE_PATH);
483 }
484 if looks_relative(candidate) {
485 return Some(REDACTION_RELATIVE_PATH);
486 }
487 None
488 }
489
490 /// Strip the punctuation a path picks up from surrounding prose, including the
491 /// escaped quotes it picks up from a JSON string.
492 fn trim_path_punctuation(token: &str) -> &str {
493 token
494 .trim_start_matches(['(', '[', '{', '"', '\'', '<', '`'])
495 .trim_end_matches([
496 ',', ';', ':', '.', ')', ']', '}', '"', '\'', '>', '`', '!', '?',
497 ])
498 }
499
500 /// Resolve `\/` and `\\` to the separator they escape, and drop escaped quotes.
501 ///
502 /// Returns an owned string only because most tokens need no work; the borrowed
503 /// fast path is not worth a second code path in a function this small.
504 fn unescape_path(token: &str) -> String {
505 let mut out = String::with_capacity(token.len());
506 let mut chars = token.chars().peekable();
507 while let Some(ch) = chars.next() {
508 if ch != '\\' {
509 out.push(ch);
510 continue;
511 }
512 match chars.peek() {
513 Some('/') => {
514 out.push('/');
515 chars.next();
516 }
517 Some('\\') => {
518 out.push('\\');
519 chars.next();
520 }
521 Some('"') | Some('\'') => {
522 chars.next();
523 }
524 // A lone backslash is a separator in its own right (`C:\Users`).
525 _ => out.push('\\'),
526 }
527 }
528 out
529 }
530
531 /// Whether a token is a repo-relative filesystem path, judged conservatively.
532 ///
533 /// Two signals, and nothing else, because the cost of a false positive here is
534 /// paid in the operator's own summary: a redacted word plus a `relative_path`
535 /// kind on a receipt that removed prose.
536 ///
537 /// 1. **Explicit relative syntax** — `./x`, `../x`, and their backslash forms.
538 /// Nothing but a path is spelled that way.
539 /// 2. **A file extension on the last segment** of a multi-segment token: stem
540 /// plus 1–8 ASCII *alphabetic* characters. The alphabetic requirement is
541 /// what keeps `zai/glm-5.2` a model label rather than a file, and the
542 /// multi-segment requirement is what keeps every bare `provider/model` pair
543 /// — `deepseek/deepseek-v4-flash`, `anthropic/claude-opus-5` — intact.
544 ///
545 /// The deliberate gap is an extension-less directory (`crates/tui/src`), which
546 /// stays. Catching it would need a rule that cannot tell a directory from
547 /// `read/write/execute`, and shredding prose to hide a directory name is the
548 /// worse trade.
549 fn looks_relative(candidate: &str) -> bool {
550 if !candidate.contains(['/', '\\']) {
551 return false;
552 }
553 let explicit_prefix = ["./", "../", ".\\", "..\\"]
554 .iter()
555 .any(|prefix| candidate.starts_with(prefix));
556 if explicit_prefix {
557 return true;
558 }
559 let trimmed = candidate.trim_end_matches(['/', '\\']);
560 let segments: Vec<&str> = trimmed.split(['/', '\\']).collect();
561 if segments.len() < 2 || segments.iter().any(|segment| segment.is_empty()) {
562 return false;
563 }
564 let last = segments[segments.len() - 1];
565 let Some((stem, extension)) = last.rsplit_once('.') else {
566 return false;
567 };
568 !stem.is_empty()
569 && (1..=8).contains(&extension.chars().count())
570 && extension.chars().all(|ch| ch.is_ascii_alphabetic())
571 }
572
573 /// Whether a string contains anything this module would redact.
574 ///
575 /// Used by assertions and by durable-write guards that must fail loudly rather
576 /// than persist a path or a key.
577 #[must_use]
578 #[cfg(test)]
579 pub fn contains_redactable(input: &str) -> bool {
580 redact_for_disclosure(input).redacted()
581 }
582
583 #[cfg(test)]
584 mod tests {
585 use super::*;
586
587 #[test]
588 fn absolute_paths_are_replaced_and_recorded() {
589 let redaction = redact_for_disclosure("fix /Users/hunter/src/app/main.rs and ~/notes.md");
590
591 assert!(!redaction.text().contains("/Users/"));
592 assert!(!redaction.text().contains("~/"));
593 assert!(redaction.text().contains(PATH_PLACEHOLDER));
594 assert!(redaction.redacted());
595 assert_eq!(redaction.kinds(), vec![REDACTION_ABSOLUTE_PATH.to_string()]);
596 }
597
598 #[test]
599 fn windows_paths_and_unc_shares_count_as_absolute() {
600 for token in ["C:\\Users\\hunter\\app", "\\\\share\\team\\notes"] {
601 let redaction = redact_for_disclosure(token);
602 assert!(redaction.redacted(), "{token} must be redacted");
603 assert_eq!(redaction.text(), PATH_PLACEHOLDER);
604 }
605 }
606
607 #[test]
608 fn secret_shaped_tokens_and_assignments_are_replaced() {
609 let redaction = redact_for_disclosure("use sk-live-abc123 and ZAI_API_KEY=zzz");
610
611 assert!(!redaction.text().contains("sk-live-abc123"));
612 assert!(!redaction.text().contains("zzz"));
613 assert!(
614 redaction.text().contains("ZAI_API_KEY=<redacted>"),
615 "the name stays, the value goes: {}",
616 redaction.text()
617 );
618 assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
619 }
620
621 /// The credential in an `Authorization` header is a *separate token* from
622 /// the header name and from the scheme keyword. Redacting only the keyword
623 /// leaves the secret in the clear while the receipt claims a secret was
624 /// removed — the exact failure this covers.
625 #[test]
626 fn a_multi_token_authorization_header_loses_its_credential() {
627 let credentials = [
628 ["sk", "-live-abc123def456"].concat(),
629 ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpX", "VCJ9"].concat(),
630 ["abcdef0123456789", "abcdef"].concat(),
631 ];
632 let headers = [
633 format!("Authorization: Bearer {}", credentials[0]),
634 format!("authorization: bearer {}", credentials[1]),
635 format!("-H Authorization:Bearer {}", credentials[2]),
636 ];
637 for header in headers {
638 let redaction = redact_for_disclosure(&header);
639 let text = redaction.text();
640 assert!(redaction.redacted(), "{header} must be redacted");
641 assert!(
642 text.contains(SECRET_PLACEHOLDER),
643 "{header} must carry a placeholder: {text}"
644 );
645 for leaked in &credentials {
646 assert!(!text.contains(leaked), "{leaked} leaked through: {text}");
647 }
648 assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
649 }
650 }
651
652 /// A bare scheme keyword introduces a credential only when a
653 /// credential-shaped token actually follows it.
654 #[test]
655 fn a_bare_bearer_token_is_removed_but_the_scheme_word_survives() {
656 let redaction = redact_for_disclosure("send Bearer 9f8e7d6c5b4a3f2e1d0c9b8a and retry");
657 let text = redaction.text();
658
659 assert!(
660 text.contains("Bearer"),
661 "the scheme keyword is not a secret"
662 );
663 assert!(!text.contains("9f8e7d6c5b4a3f2e1d0c9b8a"), "{text}");
664 assert!(text.ends_with("and retry"), "{text}");
665 }
666
667 /// Ordinary words that merely *look* like credential prefixes must survive,
668 /// and must not report a `secret` redaction kind. `Asia`, `bearer`, and any
669 /// identifier containing them are prose, not keys.
670 #[test]
671 fn ordinary_words_and_identifiers_are_not_mistaken_for_secrets() {
672 for text in [
673 "ship the Asia region rollout",
674 "ASIA is a continent, not a key",
675 "the bearer of this note may enter",
676 "authorization: needed before merge",
677 "rename bearer_token_header to auth_header_name",
678 "aws_region defaults to us-east-1",
679 "pk_display is a public identifier",
680 ] {
681 let redaction = redact_for_disclosure(text);
682 assert!(!redaction.redacted(), "{text} must survive: {redaction:?}");
683 assert_eq!(redaction.text(), text);
684 }
685 }
686
687 /// The adversarial prose set. Every line here is ordinary English that the
688 /// scheme/prefix rules could plausibly mistake for credential syntax, and
689 /// every one of them must come back byte-identical with an empty `kinds`.
690 ///
691 /// A false positive is not a harmless over-redaction: it mangles the routing
692 /// summary a human reads *and* writes `secret` onto a durable receipt that
693 /// removed nothing, which makes the disclosure a lie in the safe direction.
694 #[test]
695 fn adversarial_prose_survives_the_credential_state_machine() {
696 for text in [
697 // The lowercase scheme word, in every position that could arm it.
698 "bearer shares responsibility for the rollout",
699 "the bearer of bad news is rarely thanked",
700 "bearer",
701 "each bearer token header is rewritten downstream",
702 // Capitalized scheme words that are ordinary English. These are why
703 // canonical capitalization arms only for `Bearer`.
704 "Token holders vote on the proposal",
705 "Basic auth is enabled for the staging endpoint",
706 "Digest the results before the review",
707 // Weak header context introducing a sentence, not a secret.
708 "authorization: needed before merge",
709 "authorization: bearer shares responsibility",
710 // Identifiers and prefixes that resemble key material.
711 "variables like aws_region and pk_display stay readable",
712 "aws_ prefixed variables are documented in the runbook",
713 // `pk_` is a *public* key prefix and carries nothing; it is
714 // deliberately absent from SECRET_VALUE_PREFIXES. (`sk_` is not
715 // listed here because it genuinely is a secret prefix and redacting
716 // it is correct.)
717 "pk_ and pub_ are conventions, not values",
718 "asia and akia are four letter strings",
719 "the variables were renamed in the same commit",
720 // Long lowercase words are still words: shape alone must not fire.
721 "internationalization is spelled with eighteen letters",
722 ] {
723 let redaction = redact_for_disclosure(text);
724 assert!(
725 !redaction.redacted(),
726 "{text:?} is prose and must survive untouched: {redaction:?}"
727 );
728 assert_eq!(redaction.text(), text);
729 assert!(
730 redaction.kinds().is_empty(),
731 "{text:?} must not claim a redaction it did not make"
732 );
733 }
734 }
735
736 /// The adversarial credential set. A real credential is often short,
737 /// lowercase, punctuated, or otherwise shapeless — `Bearer qqq` is the
738 /// canonical example, and no shape test could ever catch it. Context has to.
739 #[test]
740 fn adversarial_credentials_lose_the_whole_value() {
741 for (text, leaked) in [
742 // The short, shapeless credential. This is the leak the evidence
743 // model exists to close.
744 ("Bearer qqq", "qqq"),
745 ("Authorization: Bearer qqq", "qqq"),
746 ("authorization: Bearer qqq", "qqq"),
747 // Punctuated and quoted header forms.
748 ("Authorization: Bearer qqq.", "qqq"),
749 ("-H \"Authorization: Bearer qqq\"", "qqq"),
750 ("Authorization:Bearer qqq", "qqq"),
751 // The scheme keyword may not absorb the redaction and leave the
752 // value behind.
753 ("send Bearer hunter2 now", "hunter2"),
754 (
755 "curl -H Authorization: Bearer sk-live-0000 -X POST",
756 "sk-live-0000",
757 ),
758 ] {
759 let redaction = redact_for_disclosure(text);
760 let redacted_text = redaction.text();
761 assert!(
762 redaction.redacted(),
763 "{text:?} carries a credential and must be redacted"
764 );
765 assert!(
766 !redacted_text.split(' ').any(|token| token == leaked
767 || token.trim_end_matches(['.', ',', '"', '\'']) == leaked),
768 "{leaked:?} leaked through {text:?}: {redacted_text}"
769 );
770 assert!(
771 redacted_text.contains(SECRET_PLACEHOLDER),
772 "{text:?} must carry a placeholder: {redacted_text}"
773 );
774 assert!(
775 redaction.kinds().contains(&REDACTION_SECRET.to_string()),
776 "{text:?} must disclose the secret kind"
777 );
778 // The scheme keyword is not the secret and must still be readable,
779 // so a reader can tell *what* was removed.
780 assert!(
781 redacted_text.to_ascii_lowercase().contains("bearer"),
782 "the scheme keyword must survive: {redacted_text}"
783 );
784 }
785 }
786
787 /// The one documented false positive of the capitalization rule, pinned so
788 /// it stays deliberate rather than becoming a surprise. Capitalized `Bearer`
789 /// followed by a word is treated as header syntax; ordinary prose spells it
790 /// lowercase, which the test above covers.
791 #[test]
792 fn capitalized_bearer_arms_even_in_prose_and_that_is_the_known_cost() {
793 let redaction = redact_for_disclosure("Bearer tokens are rotated weekly");
794 assert_eq!(redaction.text(), "Bearer <redacted> are rotated weekly");
795
796 // The lowercase spelling — what prose actually uses — is untouched.
797 let prose = redact_for_disclosure("bearer tokens are rotated weekly");
798 assert!(!prose.redacted());
799 }
800
801 /// The real AWS shape still goes, so dropping the bare `asia`/`akia`
802 /// prefixes did not trade a false positive for a false negative.
803 #[test]
804 fn full_aws_access_key_ids_are_still_removed() {
805 for key in [
806 ["AKIA", "IOSFODNN7EXAMPLE"].concat(),
807 ["ASIA", "IOSFODNN7EXAMPLE"].concat(),
808 ] {
809 let redaction = redact_for_disclosure(&format!("creds {key} rotated"));
810 assert!(!redaction.text().contains(&key), "{}", redaction.text());
811 assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
812 }
813 }
814
815 #[test]
816 fn ordinary_prose_is_left_alone() {
817 let redaction = redact_for_disclosure("refactor the parser and add a regression test");
818 assert!(!redaction.redacted());
819 assert_eq!(
820 redaction.text(),
821 "refactor the parser and add a regression test"
822 );
823 assert!(redaction.kinds().is_empty());
824 }
825
826 /// A repo-relative path discloses the private tree's shape to whatever
827 /// provider the routing summary reaches, and is persisted next to it. Every
828 /// spelling one arrives in — bare, quoted, JSON-escaped with `\/` or `\\`,
829 /// explicitly relative, assigned to a name, trailing prose punctuation —
830 /// must lose the path *and* say so on the receipt.
831 #[test]
832 fn repo_relative_paths_are_redacted_in_every_spelling_and_disclosed() {
833 for token in [
834 "crates/tui/src/main.rs",
835 "src/lib.rs",
836 "web/lib/deploy-preflight.test.ts",
837 ".github/workflows/web.yml",
838 "crates\\tui\\src\\main.rs",
839 "crates\\/tui\\/src\\/main.rs",
840 "\\\"crates/tui/src/main.rs\\\"",
841 "\"crates/tui/src/main.rs\"",
842 "(crates/tui/src/main.rs)",
843 "./deploy.sh",
844 "../../secret/notes.md",
845 "..\\secret\\notes.md",
846 ] {
847 let redaction = redact_for_disclosure(token);
848 assert!(redaction.redacted(), "{token} must be redacted");
849 assert!(
850 !redaction.text().contains("main.rs")
851 && !redaction.text().contains("notes.md")
852 && !redaction.text().contains("deploy"),
853 "{token} leaked: {}",
854 redaction.text()
855 );
856 assert!(
857 redaction
858 .kinds()
859 .contains(&REDACTION_RELATIVE_PATH.to_string()),
860 "{token} must disclose the relative_path kind: {:?}",
861 redaction.kinds()
862 );
863 }
864
865 // In a sentence, and as a value: the name survives, the path does not.
866 let sentence = redact_for_disclosure("patch crates/tui/src/main.rs, then path=src/lib.rs");
867 assert_eq!(
868 sentence.text(),
869 "patch <path> then path=<path>",
870 "prose keeps its shape around the placeholder"
871 );
872 assert_eq!(
873 sentence.kinds(),
874 vec![REDACTION_RELATIVE_PATH.to_string()],
875 "one kind, honestly reported"
876 );
877 }
878
879 /// The other half of the same rule: it must not shred ordinary prose,
880 /// `provider/model` labels, or bare punctuation, because a false positive
881 /// here costs the operator their own summary *and* puts a redaction kind on
882 /// a receipt that removed nothing.
883 #[test]
884 fn prose_labels_and_bare_punctuation_are_not_paths() {
885 for token in [
886 // Provider/model labels — the exact shape a Fleet receipt carries.
887 "deepseek/deepseek-v4-flash",
888 "zai/glm-5.2",
889 "anthropic/claude-opus-5",
890 "workspace/glm-pair",
891 // Prose that happens to carry separators.
892 "a/b",
893 "and/or",
894 "read/write/execute",
895 "TODO/FIXME",
896 "provider/model/reasoning",
897 // Bare punctuation and non-paths.
898 "/",
899 "~",
900 "5:30",
901 "v0.9.2",
902 // A URL is not a filesystem path; the URL-bearing-input guard owns
903 // it, and reclassifying it here would be a silent behavior change.
904 "https://example.test/a/b.rs",
905 ] {
906 let redaction = redact_for_disclosure(token);
907 assert!(!redaction.redacted(), "{token} must not be redacted");
908 assert_eq!(redaction.text(), token, "{token} must survive verbatim");
909 }
910 }
911
912 /// The documented gap, pinned so it stays a decision rather than a
913 /// surprise: an extension-less directory survives, because no rule can
914 /// separate it from `read/write/execute` without shredding prose.
915 #[test]
916 fn an_extension_less_directory_is_the_known_residual() {
917 let redaction = redact_for_disclosure("look in crates/tui/src");
918 assert!(!redaction.redacted());
919 }
920
921 #[test]
922 fn contains_redactable_matches_the_redactor() {
923 assert!(contains_redactable("/Users/hunter"));
924 assert!(contains_redactable("token=abc"));
925 assert!(!contains_redactable("land a fix in the workflow crate"));
926 }
927 }
928
928 lines RUST