1use std::borrow::Cow;
18use std::fmt;
19use std::ops::Range;
20
21use serde_json::Value;
22
23use crate::message::ToolResultContent;
24use crate::tool::{ToolOutput, ToolResult};
25
26#[cfg(test)]
27mod tests;
28
29pub const REDACTED: &str = "[REDACTED]";
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum Kind {
36 OpenAiApiKey,
38 AnthropicApiKey,
40 GoogleApiKey,
42 AwsAccessKey,
44 GitHubToken,
47 GitLabToken,
49 SlackToken,
51 StripeKey,
54 HuggingFaceToken,
56 JsonWebToken,
60 PrivateKey,
63 Assigned,
75}
76
77impl Kind {
78 pub fn name(self) -> &'static str {
80 match self {
81 Self::OpenAiApiKey => "OpenAI API key",
82 Self::AnthropicApiKey => "Anthropic API key",
83 Self::GoogleApiKey => "Google API key",
84 Self::AwsAccessKey => "AWS access key",
85 Self::GitHubToken => "GitHub token",
86 Self::GitLabToken => "GitLab token",
87 Self::SlackToken => "Slack token",
88 Self::StripeKey => "Stripe key",
89 Self::HuggingFaceToken => "Hugging Face token",
90 Self::JsonWebToken => "JSON Web Token",
91 Self::PrivateKey => "private key",
92 Self::Assigned => "assigned secret",
93 }
94 }
95}
96
97impl fmt::Display for Kind {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(self.name())
100 }
101}
102
103#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct SecretMatch {
107 pub kind: Kind,
109 pub range: Range<usize>,
111}
112
113pub fn find(input: &str) -> impl Iterator<Item = SecretMatch> + '_ {
124 let bytes = input.as_bytes();
127 let mut at = 0;
128 std::iter::from_fn(move || {
129 while let Some(&byte) = bytes.get(at) {
130 if !is_token_byte(byte) {
131 at += 1;
132 continue;
133 }
134 let rest = bytes.get(at..).unwrap_or_default();
135 let len = rest
136 .iter()
137 .position(|&byte| !is_token_byte(byte))
138 .unwrap_or(rest.len());
139 let token = rest.get(..len).unwrap_or_default();
140 if may_be_secret(token)
141 && starts_token(input, at)
142 && let Some(rest) = input.get(at..)
143 {
144 let found = secret_at(rest).map(|(kind, len)| (kind, 0..len));
145 let found = found.or_else(|| Some((Kind::Assigned, assigned(rest)?)));
146 if let Some((kind, range)) = found {
147 let range = at + range.start..at + range.end;
148 at = range.end;
149 return Some(SecretMatch { kind, range });
150 }
151 }
152 at += len;
154 }
155 None
156 })
157}
158
159pub fn has_secret(input: &str) -> bool {
161 find(input).next().is_some()
162}
163
164pub fn text(input: &str) -> Cow<'_, str> {
167 let mut found = find(input).peekable();
168 if found.peek().is_none() {
169 return Cow::Borrowed(input);
170 }
171 let mut out = String::with_capacity(input.len());
172 let mut from = 0;
173 for secret in found {
174 out.push_str(input.get(from..secret.range.start).unwrap_or_default());
175 out.push_str(REDACTED);
176 from = secret.range.end;
177 }
178 out.push_str(input.get(from..).unwrap_or_default());
179 Cow::Owned(out)
180}
181
182pub fn json_text(json: &str) -> Cow<'_, str> {
195 let mut out: Option<String> = None;
196 let mut copied = 0;
198 let mut at = 0;
199 while let Some(open) = json.get(at..).and_then(|rest| rest.find('"')) {
200 let open = at + open;
201 let Some((close, escaped)) = closing_quote(json, open + 1) else {
202 break;
203 };
204 at = close + 1;
205 let is_key = json
206 .get(at..)
207 .is_some_and(|rest| rest.trim_start().starts_with(':'));
208 let Some(inner) = json.get(open + 1..close).filter(|_| !is_key) else {
209 continue;
210 };
211 let redacted = if escaped {
213 if !escaped_may_hold_secret(inner.as_bytes()) {
214 continue;
215 }
216 serde_json::from_str::<String>(json.get(open..=close).unwrap_or_default())
217 .ok()
218 .and_then(|decoded| match text(&decoded) {
219 Cow::Owned(redacted) => serde_json::to_string(&redacted).ok(),
220 Cow::Borrowed(_) => None,
221 })
222 } else {
223 match text(inner) {
225 Cow::Owned(redacted) => Some(format!("\"{redacted}\"")),
226 Cow::Borrowed(_) => None,
227 }
228 };
229 if let Some(redacted) = redacted {
230 let out = out.get_or_insert_with(|| String::with_capacity(json.len()));
231 out.push_str(json.get(copied..open).unwrap_or_default());
232 out.push_str(&redacted);
233 copied = close + 1;
234 }
235 }
236 match out {
237 Some(mut out) => {
238 out.push_str(json.get(copied..).unwrap_or_default());
239 Cow::Owned(out)
240 }
241 None => Cow::Borrowed(json),
242 }
243}
244
245fn closing_quote(json: &str, from: usize) -> Option<(usize, bool)> {
248 let bytes = json.as_bytes().get(from..)?;
249 let mut escaped = false;
250 let mut at = 0;
251 loop {
252 let next = bytes
253 .get(at..)?
254 .iter()
255 .position(|&b| b == b'"' || b == b'\\')?;
256 at += next;
257 if bytes.get(at) == Some(&b'"') {
258 return Some((from + at, escaped));
259 }
260 escaped = true;
261 at += 2;
262 }
263}
264
265fn escaped_may_hold_secret(raw: &[u8]) -> bool {
272 let mut at = 0;
273 while let Some(&byte) = raw.get(at) {
274 if byte == b'\\' {
275 if raw.get(at + 1) != Some(&b'u') {
276 at += 2;
277 continue;
278 }
279 let code = raw
280 .get(at + 2..at + 6)
281 .and_then(|hex| std::str::from_utf8(hex).ok())
282 .and_then(|hex| u32::from_str_radix(hex, 16).ok());
283 if code.is_none_or(|code| code >= 0x20) {
284 return true;
285 }
286 at += 6;
287 continue;
288 }
289 if !is_token_byte(byte) {
290 at += 1;
291 continue;
292 }
293 let rest = raw.get(at..).unwrap_or_default();
294 let len = rest
295 .iter()
296 .position(|&byte| !is_token_byte(byte))
297 .unwrap_or(rest.len());
298 if may_be_secret(rest.get(..len).unwrap_or_default()) {
299 return true;
300 }
301 at += len;
302 }
303 false
304}
305
306pub fn json(value: &mut Value) {
309 match value {
310 Value::String(string) => {
311 if let Cow::Owned(redacted) = text(string) {
312 *string = redacted;
313 }
314 }
315 Value::Array(items) => items.iter_mut().for_each(json),
316 Value::Object(fields) => fields.values_mut().for_each(json),
317 Value::Null | Value::Bool(_) | Value::Number(_) => {}
318 }
319}
320
321pub fn tool_result(result: &ToolResult) -> Option<ToolResult> {
337 let content = result.output().as_content();
338 let has = |block: &ToolResultContent| match block {
339 ToolResultContent::Text(text) => has_secret(&text.text),
340 ToolResultContent::Json { value } => json_has_secret(value),
341 ToolResultContent::Image(_) => false,
342 };
343 if !content.iter().any(has) {
344 return None;
345 }
346 let redacted = content.iter().map(|block| match block {
347 ToolResultContent::Text(said) => match text(&said.text) {
348 Cow::Owned(redacted) => ToolResultContent::text(redacted),
349 Cow::Borrowed(_) => block.clone(),
350 },
351 ToolResultContent::Json { value } => {
352 let mut value = value.clone();
353 json(&mut value);
354 ToolResultContent::json(value)
355 }
356 ToolResultContent::Image(_) => block.clone(),
357 });
358 let output = ToolOutput::content(redacted.collect()).ok()?;
359 Some(result.clone().with_output(output))
360}
361
362fn json_has_secret(value: &Value) -> bool {
365 match value {
366 Value::String(string) => has_secret(string),
367 Value::Array(items) => items.iter().any(json_has_secret),
368 Value::Object(fields) => fields.values().any(json_has_secret),
369 Value::Null | Value::Bool(_) | Value::Number(_) => false,
370 }
371}
372
373fn secret_at(rest: &str) -> Option<(Kind, usize)> {
375 if rest.starts_with("-----BEGIN ") {
376 return private_key(rest).map(|len| (Kind::PrivateKey, len));
377 }
378 if let Some(len) = json_web_token(rest) {
379 return Some((Kind::JsonWebToken, len));
380 }
381 let token = rest.get(..token_len(rest))?;
382 if let Some(len) = aws_access_key(token) {
383 return Some((Kind::AwsAccessKey, len));
384 }
385 let kind = TOKENS.iter().find_map(|&(prefix, min, kind)| {
386 let tail = token.strip_prefix(prefix)?;
387 is_plausible(tail, min).then_some(kind)
388 })?;
389 Some((kind, token.len()))
390}
391
392const TOKENS: &[(&str, usize, Kind)] = &[
395 ("sk-ant-", 16, Kind::AnthropicApiKey),
396 ("sk-proj-", 16, Kind::OpenAiApiKey),
397 ("sk-svcacct-", 16, Kind::OpenAiApiKey),
398 ("sk-", 32, Kind::OpenAiApiKey),
399 ("AIza", 20, Kind::GoogleApiKey),
400 ("github_pat_", 22, Kind::GitHubToken),
401 ("ghp_", 30, Kind::GitHubToken),
402 ("gho_", 30, Kind::GitHubToken),
403 ("ghu_", 30, Kind::GitHubToken),
404 ("ghs_", 30, Kind::GitHubToken),
405 ("ghr_", 30, Kind::GitHubToken),
406 ("glpat-", 20, Kind::GitLabToken),
407 ("xoxa-", 10, Kind::SlackToken),
408 ("xoxb-", 10, Kind::SlackToken),
409 ("xoxp-", 10, Kind::SlackToken),
410 ("xoxr-", 10, Kind::SlackToken),
411 ("xoxs-", 10, Kind::SlackToken),
412 ("sk_live_", 16, Kind::StripeKey),
413 ("sk_test_", 16, Kind::StripeKey),
414 ("rk_live_", 16, Kind::StripeKey),
415 ("rk_test_", 16, Kind::StripeKey),
416 ("hf_", 30, Kind::HuggingFaceToken),
417];
418
419const SECRET_NAMES: &[&str] = &[
422 "SECRET",
423 "TOKEN",
424 "PASSWORD",
425 "PASSWD",
426 "API_KEY",
427 "ACCESS_KEY",
428 "SECRET_KEY",
429 "PRIVATE_KEY",
430];
431
432fn assigned(rest: &str) -> Option<Range<usize>> {
436 let name = rest.get(..token_len(rest))?;
437 let upper_snake = name.starts_with(|ch: char| ch.is_ascii_uppercase())
438 && name
439 .chars()
440 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_');
441 let secret_named = SECRET_NAMES.iter().any(|end| {
442 name.strip_suffix(end)
443 .is_some_and(|head| head.is_empty() || head.ends_with('_'))
444 });
445 if !upper_snake || !secret_named {
446 return None;
447 }
448 let after = rest.get(name.len()..)?;
450 let after = after.strip_prefix(['"', '\'']).unwrap_or(after);
451 let after = after.trim_start_matches([' ', '\t']);
452 let after = after.strip_prefix(['=', ':'])?;
453 let after = after.trim_start_matches([' ', '\t']);
454 let start = rest.len() - after.len();
455 let (from, value) = match after.chars().next()? {
456 quote @ ('"' | '\'') => {
457 let inner = after.get(1..)?;
458 (start + 1, inner.get(..inner.find(quote)?)?)
459 }
460 _ => (
461 start,
462 after.get(..after.find(ends_value).unwrap_or(after.len()))?,
463 ),
464 };
465 let plausible = value.len() >= 8
466 && value.chars().all(|ch| ch.is_ascii_graphic() || ch == ' ')
467 && !value.starts_with('$')
468 && !value.chars().all(|ch| ch.is_ascii_digit())
469 && value != REDACTED
470 && secret_at(value).is_none();
471 plausible.then(|| from..from + value.len())
472}
473
474fn ends_value(ch: char) -> bool {
477 ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | ',' | ';' | '&')
478}
479
480fn is_plausible(tail: &str, min: usize) -> bool {
484 tail.len() >= min && tail.chars().any(|ch| ch.is_ascii_alphabetic())
485}
486
487fn aws_access_key(token: &str) -> Option<usize> {
490 let tail = token
491 .strip_prefix("AKIA")
492 .or_else(|| token.strip_prefix("ASIA"))?;
493 let len = tail
494 .chars()
495 .take_while(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
496 .count();
497 (len == 16).then_some(4 + len)
498}
499
500fn json_web_token(rest: &str) -> Option<usize> {
505 let mut len = 0;
506 for _ in 0..2 {
507 let part = rest.get(len..)?;
508 let segment = token_len(part);
509 if !part.starts_with("eyJ") || segment < 10 || !part.get(segment..)?.starts_with('.') {
510 return None;
511 }
512 len += segment + 1;
513 }
514 Some(len + token_len(rest.get(len..)?))
515}
516
517fn may_be_secret(token: &[u8]) -> bool {
522 let first = token.first().copied().unwrap_or_default();
523 let second = token.get(1).copied().unwrap_or_default();
524 let shaped = matches!(
525 (first, second),
526 (b's' | b'r', b'k')
527 | (b'A', b'I' | b'K' | b'S')
528 | (b'g', b'h' | b'i' | b'l')
529 | (b'x', b'o')
530 | (b'h', b'f')
531 | (b'e', b'y')
532 | (b'-', b'-')
533 );
534 let named =
535 first.is_ascii_uppercase() && matches!(token.last(), Some(b'T' | b'N' | b'D' | b'Y'));
536 shaped || named
537}
538
539fn is_token_byte(byte: u8) -> bool {
541 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
542}
543
544fn private_key(rest: &str) -> Option<usize> {
547 let header = rest.get("-----BEGIN ".len()..)?;
548 let label = header.split_once("-----")?.0;
549 let is_key = label.ends_with("PRIVATE KEY")
550 && label.chars().all(|ch| ch.is_ascii_uppercase() || ch == ' ');
551 if !is_key {
552 return None;
553 }
554 let end = format!("-----END {label}-----");
555 Some(
556 rest.find(end.as_str())
557 .map_or(rest.len(), |at| at + end.len()),
558 )
559}
560
561fn token_len(rest: &str) -> usize {
563 rest.find(|ch| !is_token_char(ch)).unwrap_or(rest.len())
564}
565
566fn is_token_char(ch: char) -> bool {
568 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
569}
570
571fn starts_token(input: &str, at: usize) -> bool {
576 input
577 .get(..at)
578 .and_then(|before| before.chars().next_back())
579 .is_none_or(|ch| !is_token_char(ch) && !matches!(ch, '+' | '/'))
580}