返回 CodeWhale
redact.rs
根目录 / crates / secrets / 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)
47 }
48
49 /// Maximum nesting depth the JSON redactor descends. Aligned with
50 /// serde_json's own parse limit so parsed input never truncates; anything
51 /// deeper is redacted wholesale.
52 const MAX_REDACT_JSON_DEPTH: usize = 128;
53
54 fn redact_json_secrets_at(value: &serde_json::Value, depth: usize) -> serde_json::Value {
55 if depth > MAX_REDACT_JSON_DEPTH {
56 return serde_json::Value::String(REDACTED.to_string());
57 }
58 match value {
59 serde_json::Value::Object(object) => serde_json::Value::Object(
60 object
61 .iter()
62 .map(|(key, value)| {
63 let value = if key_is_sensitive(key) {
64 serde_json::Value::String(REDACTED.to_string())
65 } else {
66 redact_json_secrets_at(value, depth + 1)
67 };
68 (key.clone(), value)
69 })
70 .collect(),
71 ),
72 serde_json::Value::Array(items) => serde_json::Value::Array(
73 items
74 .iter()
75 .map(|item| redact_json_secrets_at(item, depth + 1))
76 .collect(),
77 ),
78 serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)),
79 scalar => scalar.clone(),
80 }
81 }
82
83 /// Redact secret-bearing values from arbitrary text so it is safe to put in a
84 /// setup report, log line, error message, or test snapshot.
85 ///
86 /// Two passes, both dependency-free:
87 ///
88 /// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped
89 /// like `key = value`, `key: value`, or `key=value` whose key
90 /// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS`
91 /// credential identifier have their value replaced with [`REDACTED`]. The
92 /// spaced form (`key = value`) is matched anywhere on the line, not only
93 /// when the sensitive key owns the line's first separator — an `anyhow`
94 /// chain rendered with `{:#}` puts prose and its own `: ` separators in
95 /// front of the assignment, and that must not be a hole. Because such a
96 /// value can span several words (`authorization = Bearer <token>`),
97 /// everything from the value to the end of the line is dropped, exactly as
98 /// the whole-line form already does. Token *counts* in diagnostics
99 /// (`max tokens = 8192`) are not credentials and stay visible.
100 /// 2. **Bare tokens.** Whitespace-delimited words beginning with a known
101 /// `SECRET_TOKEN_PREFIXES` are replaced wholesale.
102 ///
103 /// The goal is defense in depth: setup state and reports are built from safe
104 /// summaries that never include secrets in the first place, and this is the
105 /// backstop for anything that echoes raw config text.
106 #[must_use]
107 pub fn redact_secrets(input: &str) -> String {
108 redact_secrets_with(input, RedactionPolicy::KeyBased)
109 }
110
111 /// How aggressively [`redact_secrets_with`] treats a sensitive-looking key.
112 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
113 pub enum RedactionPolicy {
114 /// Mask the value of every sensitive-looking key, whatever the value is.
115 /// Right for logs, previews, exports, and diagnostics: a false positive
116 /// costs nothing there and a miss leaks a credential.
117 KeyBased,
118 /// Mask a keyed value only when the value itself looks like a credential
119 /// (known prefix, JWT, bearer token, PEM block, long opaque string).
120 /// Right for text the model must be able to quote back byte-for-byte,
121 /// such as tool results that feed exact-match edits: `password:
122 /// credentials?.password`, `"password-validator": "^5.3.0"`, or
123 /// `token = make_token()` are code, not secrets (#5546).
124 CredentialShaped,
125 }
126
127 /// Redact model-bound tool output: exact configured credential values are the
128 /// caller's job; this masks only values that look like credentials so the
129 /// model keeps seeing the real bytes of ordinary code and config.
130 #[must_use]
131 pub fn redact_model_bound_secrets(input: &str) -> String {
132 redact_secrets_with(input, RedactionPolicy::CredentialShaped)
133 }
134
135 /// [`redact_secrets`] with an explicit [`RedactionPolicy`].
136 #[must_use]
137 pub fn redact_secrets_with(input: &str, policy: RedactionPolicy) -> String {
138 let mut out = String::with_capacity(input.len());
139 let mut in_private_key_block = false;
140 for line in input.split_inclusive('\n') {
141 // split_inclusive keeps the newline on the previous chunk, so we do
142 // not need to re-add separators here.
143 let body = line.strip_suffix('\n').unwrap_or(line);
144 let trimmed = body.trim();
145 if in_private_key_block {
146 if trimmed.starts_with("-----END") {
147 in_private_key_block = false;
148 out.push_str(line);
149 } else {
150 out.push_str(REDACTED);
151 if line.ends_with('\n') {
152 out.push('\n');
153 }
154 }
155 continue;
156 }
157 if is_private_key_block_start(trimmed) {
158 in_private_key_block = true;
159 out.push_str(line);
160 continue;
161 }
162 out.push_str(&redact_line(line, policy));
163 }
164 out
165 }
166
167 fn is_private_key_block_start(trimmed: &str) -> bool {
168 trimmed.starts_with("-----BEGIN") && trimmed.contains("PRIVATE KEY")
169 }
170
171 /// Redact a single line (which may include a trailing newline).
172 fn redact_line(line: &str, policy: RedactionPolicy) -> String {
173 // Preserve any trailing newline so callers keep their line structure.
174 let (body, newline) = match line.strip_suffix('\n') {
175 Some(rest) => (rest, "\n"),
176 None => (line, ""),
177 };
178
179 if let Some(redacted) = redact_keyed_assignment(body, policy) {
180 return format!("{redacted}{newline}");
181 }
182
183 // Inline-assignment / bare-token pass: mask any whitespace-delimited word
184 // carrying a sensitive keyed value or a known bare secret prefix, plus the
185 // spaced `key = value` form that `redact_keyed_assignment` above only sees
186 // when the sensitive key owns the line's first separator.
187 let mut changed = false;
188 let mut spaced = SpacedAssignment::None;
189 let mut masked: Vec<String> = Vec::new();
190 for word in body.split(' ') {
191 let trimmed = trim_word_punctuation(word);
192 if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() {
193 match policy {
194 RedactionPolicy::KeyBased => {
195 // The value may run to the end of the line, so drop the
196 // remainder rather than masking one word and leaking the
197 // rest.
198 masked.push(REDACTED.to_string());
199 changed = true;
200 break;
201 }
202 RedactionPolicy::CredentialShaped => {
203 // Only a credential-shaped value is hidden, and only that
204 // word: the rest of the line stays quotable. An auth scheme
205 // word (`Bearer`) keeps the assignment open for its token.
206 if is_auth_scheme_word(trimmed) {
207 masked.push(word.to_string());
208 continue;
209 }
210 if value_looks_like_credential(trimmed) {
211 masked.push(word.replace(trimmed, REDACTED));
212 changed = true;
213 } else {
214 masked.push(word.to_string());
215 }
216 spaced = SpacedAssignment::None;
217 continue;
218 }
219 }
220 }
221 if let Some(redacted) = redact_inline_keyed_assignment(trimmed, policy) {
222 changed = true;
223 masked.push(word.replace(trimmed, &redacted));
224 spaced = SpacedAssignment::None;
225 } else if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
226 changed = true;
227 masked.push(word.replace(trimmed, REDACTED));
228 spaced = SpacedAssignment::None;
229 } else {
230 masked.push(word.to_string());
231 spaced = spaced.advance(trimmed);
232 }
233 }
234
235 if changed {
236 format!("{}{newline}", masked.join(" "))
237 } else {
238 format!("{body}{newline}")
239 }
240 }
241
242 /// Progress through a `key <space> <sep> <space> value` assignment as the
243 /// word-level pass walks a line.
244 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
245 enum SpacedAssignment {
246 None,
247 /// The previous word was a bare sensitive key awaiting its separator.
248 SensitiveKey,
249 /// A sensitive key and its separator are both behind us.
250 AwaitingValue,
251 }
252
253 impl SpacedAssignment {
254 fn advance(self, trimmed: &str) -> Self {
255 // Runs of spaces produce empty words; they neither start nor cancel an
256 // assignment.
257 if trimmed.is_empty() {
258 return self;
259 }
260 if matches!(trimmed, "=" | ":") {
261 return if self == Self::SensitiveKey {
262 Self::AwaitingValue
263 } else {
264 Self::None
265 };
266 }
267 // `api_key=` / `api_key:` with the value in the next word. A word whose
268 // separator is *not* final was already offered to
269 // `redact_inline_keyed_assignment`, so it is not an assignment we own.
270 if let Some(key) = trimmed
271 .strip_suffix('=')
272 .or_else(|| trimmed.strip_suffix(':'))
273 {
274 return if key_is_sensitive(key) {
275 Self::AwaitingValue
276 } else {
277 Self::None
278 };
279 }
280 if key_is_sensitive(trimmed) {
281 return Self::SensitiveKey;
282 }
283 Self::None
284 }
285 }
286
287 fn trim_word_punctuation(word: &str) -> &str {
288 word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'))
289 }
290
291 /// Whether `raw`, normalized the way a config/env/JSON key is, matches a
292 /// [`SENSITIVE_KEY_HINTS`] credential identifier.
293 fn key_is_sensitive(raw: &str) -> bool {
294 let key_norm = normalize_sensitive_key(raw);
295 !key_norm.is_empty()
296 && SENSITIVE_KEY_HINTS
297 .iter()
298 .any(|hint| key_matches_sensitive_hint(&key_norm, hint))
299 }
300
301 /// Normalize the identifier boundaries commonly used by config, env, and JSON
302 /// keys without turning English plurals such as `tokens` into `token`.
303 ///
304 /// Punctuation and case transitions become `_`, so `oauth.token`,
305 /// `accessToken`, and `APIKey` share the same matching surface as
306 /// `oauth_token`, `access_token`, and `api_key`.
307 fn normalize_sensitive_key(raw: &str) -> String {
308 let mut normalized = String::with_capacity(raw.len());
309 let mut chars = raw.chars().peekable();
310 let mut previous = None;
311
312 while let Some(ch) = chars.next() {
313 if ch.is_ascii_alphanumeric() {
314 let next = chars.peek().copied();
315 let starts_case_segment = ch.is_ascii_uppercase()
316 && previous.is_some_and(|previous: char| {
317 previous.is_ascii_lowercase()
318 || previous.is_ascii_digit()
319 || (previous.is_ascii_uppercase()
320 && next.is_some_and(|next| next.is_ascii_lowercase()))
321 });
322 if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') {
323 normalized.push('_');
324 }
325 normalized.push(ch.to_ascii_lowercase());
326 } else if !normalized.is_empty() && !normalized.ends_with('_') {
327 normalized.push('_');
328 }
329 previous = Some(ch);
330 }
331
332 while normalized.ends_with('_') {
333 normalized.pop();
334 }
335 normalized
336 }
337
338 fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool {
339 if key_norm == hint {
340 return true;
341 }
342 // Compound hints already name a credential (`api_key`, `client_secret`).
343 // Substring is the right match: `openai_api_key` contains `api_key`.
344 if hint.contains('_') || hint.contains('-') {
345 return key_norm.contains(hint);
346 }
347 if hint == "token" {
348 // Camel-case normalization turns both credentials (`accessToken`) and
349 // ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented
350 // identifiers. A credential token is either the whole key, a suffix
351 // such as `access_token`, or an explicitly value-bearing `token_*`
352 // field. Metrics must stay visible in diagnostics and tool previews.
353 let is_metric_suffix = |suffix: &str| {
354 matches!(
355 suffix.split('_').next(),
356 Some(
357 "budget"
358 | "budgets"
359 | "count"
360 | "counts"
361 | "limit"
362 | "limits"
363 | "total"
364 | "totals"
365 | "usage"
366 | "used"
367 | "window"
368 | "windows"
369 )
370 )
371 };
372 if key_norm.ends_with("_token") {
373 return true;
374 }
375 if let Some(suffix) = key_norm.strip_prefix("token_") {
376 return !is_metric_suffix(suffix);
377 }
378 if let Some((_, suffix)) = key_norm.rsplit_once("_token_") {
379 return !is_metric_suffix(suffix);
380 }
381 return false;
382 }
383 // Single-word hints must be a whole identifier segment so `token`
384 // redacts `token` / `api_token` and not English `tokens`.
385 key_norm.split(['_', '-']).any(|segment| segment == hint)
386 }
387
388 fn redact_inline_keyed_assignment(word: &str, policy: RedactionPolicy) -> Option<String> {
389 let sep_idx = word.find(['=', ':'])?;
390 let (raw_key, rest) = word.split_at(sep_idx);
391 let raw_value = &rest[1..];
392 if raw_value.is_empty() {
393 return None;
394 }
395 if !key_is_sensitive(raw_key) {
396 return None;
397 }
398 match policy {
399 RedactionPolicy::KeyBased => Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED)),
400 RedactionPolicy::CredentialShaped => {
401 let (core, quote) = strip_value_quotes(raw_value);
402 if !value_looks_like_credential(core) {
403 return None;
404 }
405 Some(format!("{}{}{quote}{REDACTED}{quote}", raw_key, &rest[..1]))
406 }
407 }
408 }
409
410 /// Whether a word announces an HTTP auth scheme whose credential follows.
411 fn is_auth_scheme_word(word: &str) -> bool {
412 matches!(
413 word,
414 "Bearer" | "bearer" | "Basic" | "basic" | "Token" | "token"
415 )
416 }
417
418 /// Split a matching pair of surrounding quotes off a value, returning the
419 /// inner text and the quote to restore (empty when unquoted or unbalanced).
420 fn strip_value_quotes(value: &str) -> (&str, &str) {
421 for quote in ['"', '\''] {
422 if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
423 return (&value[1..value.len() - 1], &value[..1]);
424 }
425 }
426 // A leading quote without its partner (the word pass strips the outer
427 // punctuation of `"x",` to `"x`): treat the remainder as the value.
428 if let Some(inner) = value.strip_prefix(['"', '\'']) {
429 return (inner, "");
430 }
431 (value, "")
432 }
433
434 /// Extra bare prefixes that mark a value as a credential even though they are
435 /// too product-specific to mask as standalone words in prose.
436 const CREDENTIAL_VALUE_PREFIXES: &[&str] = &[
437 "sk-ant-",
438 "AKIA",
439 "ASIA",
440 "AIza",
441 "ghp_",
442 "gho_",
443 "ghu_",
444 "ghs_",
445 "ghr_",
446 "github_pat_",
447 "glpat-",
448 "xoxa-",
449 "xoxb-",
450 "xoxp-",
451 "xoxr-",
452 "xoxs-",
453 "npm_",
454 "ya29.",
455 ];
456
457 /// Whether a keyed value looks like credential material rather than code,
458 /// configuration, or prose.
459 ///
460 /// True for known provider prefixes, JWTs, `Bearer`/`Basic` tokens, PEM
461 /// headers, and long opaque alphanumeric runs. False for short literals,
462 /// version strings, identifiers, property/call/env references, and the
463 /// redaction placeholder itself.
464 pub(crate) fn value_looks_like_credential(value: &str) -> bool {
465 let value = value
466 .trim()
467 .trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
468 if value.is_empty() || value == REDACTED {
469 return false;
470 }
471 if looks_like_secret_token(value)
472 || CREDENTIAL_VALUE_PREFIXES
473 .iter()
474 .any(|prefix| value.len() > prefix.len() + 6 && value.starts_with(prefix))
475 {
476 return true;
477 }
478 if value.starts_with("-----BEGIN") {
479 return true;
480 }
481 if let Some((scheme, rest)) = value.split_once(' ')
482 && is_auth_scheme_word(scheme)
483 {
484 return value_looks_like_credential(rest);
485 }
486 if is_jwt_shaped(value) {
487 return true;
488 }
489 if value.len() < 16 {
490 return false;
491 }
492 if is_version_like(value) || is_reference_like(value) {
493 return false;
494 }
495 is_opaque_run(value)
496 }
497
498 fn is_jwt_shaped(value: &str) -> bool {
499 let mut parts = value.split('.');
500 match (parts.next(), parts.next(), parts.next(), parts.next()) {
501 (Some(header), Some(payload), Some(signature), None) => {
502 header.starts_with("eyJ")
503 && payload.starts_with("eyJ")
504 && !signature.is_empty()
505 && [header, payload, signature].iter().all(|part| {
506 part.chars()
507 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
508 })
509 }
510 _ => false,
511 }
512 }
513
514 fn is_version_like(value: &str) -> bool {
515 let digits = value.trim_start_matches(['^', '~', '>', '<', '=', 'v', 'V', ' ']);
516 !digits.is_empty()
517 && digits
518 .chars()
519 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
520 && digits.chars().next().is_some_and(|c| c.is_ascii_digit())
521 }
522
523 fn is_reference_like(value: &str) -> bool {
524 // Property access, calls, template/env lookups, and plain identifiers are
525 // code, not credential material.
526 value.contains("?.")
527 || value.contains('(')
528 || value.contains("${")
529 || value.contains("process.env")
530 || value.contains("os.environ")
531 || value.contains("getenv")
532 || value.contains("://")
533 || value
534 .chars()
535 .all(|c| c.is_ascii_alphabetic() || c == '_' || c == '.')
536 }
537
538 fn is_opaque_run(value: &str) -> bool {
539 value.len() >= 20
540 && value
541 .chars()
542 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
543 && value.chars().any(|c| c.is_ascii_alphabetic())
544 && value.chars().any(|c| c.is_ascii_digit())
545 }
546
547 /// If `body` is a `key <sep> value` assignment with a sensitive key, return the
548 /// line with the value redacted; otherwise `None`.
549 fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option<String> {
550 // Find the first `=` or `:` that separates a key from a value.
551 let sep_idx = body.find(['=', ':'])?;
552 let (raw_key, rest) = body.split_at(sep_idx);
553 let sep = &rest[..1];
554 let raw_value = &rest[1..];
555
556 let key_norm = raw_key
557 .trim()
558 .trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'));
559 if !key_is_sensitive(key_norm) {
560 return None;
561 }
562
563 if policy == RedactionPolicy::CredentialShaped {
564 // Replace only the value span, keep the key bytes, separator spacing,
565 // quote style, and trailing punctuation, and only when the value is
566 // credential-shaped: the model must still be able to quote the line.
567 let value_lead_ws: String = raw_value
568 .chars()
569 .take_while(|c| c.is_whitespace())
570 .collect();
571 let value_rest = raw_value.trim_start();
572 let value_core = value_rest.trim_end();
573 let trailing_ws = &value_rest[value_core.len()..];
574 let literal = value_core.trim_end_matches([',', ';']);
575 let trailer = &value_core[literal.len()..];
576 let (core, quote) = strip_value_quotes(literal);
577 if core.is_empty() || !value_looks_like_credential(core) {
578 return None;
579 }
580 return Some(format!(
581 "{raw_key}{sep}{value_lead_ws}{quote}{REDACTED}{quote}{trailer}{trailing_ws}"
582 ));
583 }
584
585 // Keep leading whitespace of the key and the original separator spacing so
586 // the redacted line reads naturally.
587 let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
588 let value_lead_ws: String = raw_value
589 .chars()
590 .take_while(|c| c.is_whitespace())
591 .collect();
592 let value_rest = raw_value.trim_start();
593 // If the value is empty, there is nothing to hide.
594 if value_rest.is_empty() {
595 return None;
596 }
597 // Preserve surrounding quotes so structured files stay parseable-looking.
598 let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
599 let replacement = if quoted {
600 format!("\"{REDACTED}\"")
601 } else {
602 REDACTED.to_string()
603 };
604 Some(format!(
605 "{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
606 raw_key.trim()
607 ))
608 }
609
610 fn looks_like_secret_token(word: &str) -> bool {
611 SECRET_TOKEN_PREFIXES
612 .iter()
613 .any(|p| word.len() > p.len() + 6 && word.starts_with(p))
614 }
615
615 lines RUST