Skip to main content

rig_core/
redact.rs

1//! Finding and redacting secrets in text a host logs, shows or shares: API keys and tokens by
2//! their known shapes, private key blocks, and values assigned to secret-named variables.
3//!
4//! A shape matches only where a token starts, so the same letters inside a word or a base64
5//! blob are not a key. A secret with neither a known shape nor a telling name, such as a
6//! password in prose, is not found.
7//!
8//! ```
9//! use rig_core::redact;
10//!
11//! let line = "export ANTHROPIC_API_KEY=sk-ant-api03-abcdefghijklmnopqrstuvwxyz";
12//! assert!(redact::has_secret(line));
13//! assert_eq!(redact::text(line), "export ANTHROPIC_API_KEY=[REDACTED]");
14//! assert_eq!(redact::text("nothing to hide"), "nothing to hide");
15//! ```
16
17use 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
29/// What [`text`] and [`json`] put in place of a secret.
30pub const REDACTED: &str = "[REDACTED]";
31
32/// What kind of secret a [`SecretMatch`] is.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum Kind {
36    /// An OpenAI API key: `sk-`, `sk-proj-` or `sk-svcacct-`.
37    OpenAiApiKey,
38    /// An Anthropic API key: `sk-ant-`.
39    AnthropicApiKey,
40    /// A Google API key: `AIza`.
41    GoogleApiKey,
42    /// An AWS access key id: `AKIA` or `ASIA`.
43    AwsAccessKey,
44    /// A GitHub token: `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` or
45    /// `github_pat_`.
46    GitHubToken,
47    /// A GitLab personal access token: `glpat-`.
48    GitLabToken,
49    /// A Slack token: `xoxa-`, `xoxb-`, `xoxp-`, `xoxr-` or `xoxs-`.
50    SlackToken,
51    /// A Stripe secret or restricted key: `sk_live_`, `sk_test_`,
52    /// `rk_live_` or `rk_test_`.
53    StripeKey,
54    /// A Hugging Face access token: `hf_`.
55    HuggingFaceToken,
56    /// A JSON Web Token, such as an OAuth access or id token: a base64url
57    /// header and payload, each a JSON object (`eyJ…`), and a signature,
58    /// joined by `.`; the signature may be empty.
59    JsonWebToken,
60    /// A PEM private key block, `-----BEGIN … PRIVATE KEY-----` through its
61    /// end line, or through the end of the text when it has none.
62    PrivateKey,
63    /// A value assigned to a name that says it is secret, `NAME=value` or
64    /// `NAME: value`: the name in upper snake case and, or ending in,
65    /// `SECRET`, `TOKEN`, `PASSWORD`, `PASSWD`, `API_KEY`, `ACCESS_KEY`,
66    /// `SECRET_KEY` or `PRIVATE_KEY` (so `AWS_SECRET_ACCESS_KEY` and
67    /// `GITHUB_TOKEN`, not `MAX_TOKENS` or `TOKEN_COUNT`). The value runs
68    /// to its closing quote when quoted, else to whitespace, a quote, `,`,
69    /// `;` or `&` (the next assignment of a query string); one of at most
70    /// 7 characters, all digits, starting with `$` (a reference such as
71    /// `$GITHUB_TOKEN`), with characters other than printable ASCII (a
72    /// placeholder such as `‹SECRET_1›`), or [`REDACTED`] is not a secret.
73    /// Only the value is the secret.
74    Assigned,
75}
76
77impl Kind {
78    /// The kind's name, such as `OpenAI API key`.
79    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/// A secret [`find`] found: its kind, and where it is in the text, in
104/// bytes.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct SecretMatch {
107    /// What kind of secret it is.
108    pub kind: Kind,
109    /// Where it is in the text searched.
110    pub range: Range<usize>,
111}
112
113/// Every secret in `input`, in order and not overlapping.
114///
115/// ```
116/// use rig_core::redact::{self, Kind};
117///
118/// let found: Vec<_> = redact::find("id AKIA0123456789ABCDEF here").collect();
119/// assert_eq!(found.len(), 1);
120/// assert_eq!(found[0].kind, Kind::AwsAccessKey);
121/// assert_eq!(found[0].range, 3..23);
122/// ```
123pub fn find(input: &str) -> impl Iterator<Item = SecretMatch> + '_ {
124    // Bytes, not chars: every token character is ASCII, so a token starts
125    // on a char boundary, and the bytes of any other character are skipped.
126    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            // A token's tail starts no key, so skip to the token's end.
153            at += len;
154        }
155        None
156    })
157}
158
159/// Whether `input` holds a secret.
160pub fn has_secret(input: &str) -> bool {
161    find(input).next().is_some()
162}
163
164/// `input` with every secret replaced by [`REDACTED`]; borrowed when it has
165/// none.
166pub 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
182/// `json`, serialized JSON such as a JSON line, with every string in it
183/// redacted as [`json`] redacts a value: object keys are left as they are.
184/// Borrowed when it has none, and only a string with an escape in it is
185/// decoded to be searched. A string never closed is left as it is.
186///
187/// ```
188/// use rig_core::redact;
189///
190/// let line = r#"{"text":"env\nOPENAI_API_KEY=sk-proj-abcdefghijklmnop1234"}"#;
191/// assert_eq!(redact::json_text(line), r#"{"text":"env\nOPENAI_API_KEY=[REDACTED]"}"#);
192/// assert!(matches!(redact::json_text(r#"{"a":"b"}"#), std::borrow::Cow::Borrowed(_)));
193/// ```
194pub fn json_text(json: &str) -> Cow<'_, str> {
195    let mut out: Option<String> = None;
196    // How much of `json` is in `out`.
197    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        // The literal, quotes included, redacted; `None` when it holds no secret.
212        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            // No escape: the literal is its text, and REDACTED needs none.
224            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
245/// Where the JSON string whose text starts at `from` in `json` closes, and
246/// whether it has an escape; `None` when it never closes.
247fn 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
265/// Whether the text of a JSON string, `raw` as written with its escapes,
266/// may hold a secret, without decoding it: an escape decodes to a
267/// character no token holds (a control character, a quote, `\\`, `/`), so
268/// the decoded text's tokens are the runs between escapes, and none that
269/// fails [`may_be_secret`] starts one. A `\u` escape of any other
270/// character may, as may a string cut off mid-escape.
271fn 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
306/// Every string in `value` redacted as [`text`] redacts it. Object keys
307/// are left as they are.
308pub 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
321/// `result` with every secret in what its model sees (its text and JSON
322/// blocks) redacted as [`text`] and [`json`] redact them; `None` when it
323/// has none, so a clean result is never copied. A redacted text block is
324/// plain text.
325///
326/// ```
327/// use rig_core::redact;
328/// use rig_core::tool::{ToolOutput, ToolResult};
329///
330/// let printed = ToolResult::success(ToolOutput::text("GITHUB_TOKEN=abcdefgh12345678"));
331/// let redacted = redact::tool_result(&printed);
332/// let text = redacted.as_ref().and_then(|result| result.output().as_text());
333/// assert_eq!(text, Some("GITHUB_TOKEN=[REDACTED]"));
334/// assert!(redact::tool_result(&ToolResult::success(ToolOutput::text("ok"))).is_none());
335/// ```
336pub 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
362/// Whether a string in `value` holds a secret; object keys are not
363/// searched.
364fn 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
373/// The secret `rest` starts with, and its length in bytes.
374fn 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
392/// Token shapes: a prefix, the fewest characters after it, and the kind. A
393/// longer prefix comes before a shorter one it starts with.
394const 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
419/// The names, or ends of names after a `_`, that say a variable holds a
420/// secret.
421const 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
432/// Where the secret value assigned to the secret-named variable `rest`
433/// starts with is, in `rest`: see [`Kind::Assigned`]. `None` when the value
434/// is a key of a known shape, which the scan finds as its own kind.
435fn 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    // A quoted name's closing quote, then `=` or `:`, spaces around it.
449    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
474/// Whether `ch` ends an unquoted assigned value: whitespace, a quote, or a
475/// separator between assignments (`,`, `;`, `&`).
476fn ends_value(ch: char) -> bool {
477    ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | ',' | ';' | '&')
478}
479
480/// Whether `tail`, the rest of a token after a key's prefix, is long
481/// enough and has a letter, so `sk-` followed by a number or a short word
482/// is not a key.
483fn is_plausible(tail: &str, min: usize) -> bool {
484    tail.len() >= min && tail.chars().any(|ch| ch.is_ascii_alphabetic())
485}
486
487/// The length of the AWS access key id `token` starts with: `AKIA` or
488/// `ASIA` and 16 upper-case letters or digits, no more.
489fn 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
500/// The length of the JSON Web Token `rest` starts with: a header and a
501/// payload of at least 10 base64url characters each, both starting `eyJ`
502/// (`{"`), each followed by `.`, then the signature's base64url characters,
503/// if any.
504fn 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
517/// Whether a secret may start at `token`, a run of token characters: it
518/// starts as a key's prefix, a private key's header or a JSON Web Token
519/// does, or it is upper case and ends as a secret name does. A cheap test
520/// before the full one.
521fn 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
539/// Whether `byte` may be part of a key: [`is_token_char`] for bytes.
540fn is_token_byte(byte: u8) -> bool {
541    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
542}
543
544/// The length of the private key block `rest` starts with, through its end
545/// line, else through the end of `rest`.
546fn 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
561/// The length of the run of token characters `rest` starts with.
562fn token_len(rest: &str) -> usize {
563    rest.find(|ch| !is_token_char(ch)).unwrap_or(rest.len())
564}
565
566/// Whether `ch` may be part of a key.
567fn is_token_char(ch: char) -> bool {
568    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
569}
570
571/// Whether a token starts at `at` in `input`: the character before is
572/// neither a token character nor base64's `+` or `/`, so the same letters
573/// inside a word or a base64 blob (a signature, ciphertext, an image) are
574/// not read as the start of a key.
575fn 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}