| 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 |