Skip to main content

mermaid_cli/utils/
redact.rs

1//! Secret redaction for persisted/recorded text (Cause 7).
2//!
3//! Anything we write to disk or replay logs — the `--record` JSONL stream,
4//! durable memory files, compaction summaries, surfaced upstream errors — can
5//! incidentally carry a credential the model just read (`read_file .env`, an
6//! API error echoing a key, a pasted token). [`redact_secrets`] scrubs the
7//! common secret shapes before that text is persisted.
8//!
9//! Deliberately high-precision (known key prefixes, `Authorization: Bearer`,
10//! and `SECRET_NAME=value` where the name matches the same patterns as the
11//! env-scrubber in `providers::tool::exec`). Ordinary prose, code, and tool
12//! output pass through untouched — over-redaction would make the record/replay
13//! and memory features useless.
14
15use std::sync::LazyLock;
16
17use regex::Regex;
18
19/// `(pattern, replacement)` pairs. `replacement` may reference capture groups
20/// with `${1}` so a labelled secret keeps its label (`API_KEY=[REDACTED]`)
21/// while the value is scrubbed.
22static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
23    let p = |re: &str| Regex::new(re).expect("static redaction regex must compile");
24    vec![
25        // NAME=VALUE / NAME: VALUE where NAME looks credential-bearing. Mirrors
26        // the `is_secret_env_name` denylist patterns in `providers::tool::exec`.
27        // The value must be >= 6 chars so we don't scrub `token_count = 42`
28        // (a credential-named identifier in arithmetic/config), and we consume an
29        // optional closing quote so `KEY: 'value'` doesn't leave a dangling `'`.
30        (
31            p(
32                r#"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"']{6,}["']?"#,
33            ),
34            "${1}=[REDACTED]",
35        ),
36        // Authorization: Bearer <token>
37        (
38            p(r#"(?i)\b(Bearer)\s+[A-Za-z0-9._\-]{8,}"#),
39            "${1} [REDACTED]",
40        ),
41        // Provider key prefixes (high-precision shapes).
42        (p(r#"\bsk-(?:ant-)?[A-Za-z0-9._\-]{12,}"#), "[REDACTED]"), // OpenAI / Anthropic
43        (p(r#"\bAKIA[0-9A-Z]{16}\b"#), "[REDACTED]"),               // AWS access key id
44        (p(r#"\bgh[pousr]_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"),     // GitHub tokens
45        (p(r#"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"#), "[REDACTED]"),   // Slack
46        (p(r#"\bAIza[0-9A-Za-z._\-]{20,}\b"#), "[REDACTED]"),       // Google API key
47        (p(r#"\bgsk_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"),           // Groq
48        (p(r#"\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b"#), "[REDACTED]"), // Stripe
49        // Generic JWT (header.payload.signature, base64url segments).
50        (
51            p(r#"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"#),
52            "[REDACTED]",
53        ),
54        // PEM private-key header (the armored block that follows is the key).
55        (p(r#"-----BEGIN [A-Z ]*PRIVATE KEY-----"#), "[REDACTED]"),
56        // URL-embedded credentials: scheme://user:password@host → scrub password.
57        (p(r#"(://[^/:@\s]+:)[^/@\s]{3,}@"#), "${1}[REDACTED]@"),
58    ]
59});
60
61/// Minimum String-value length under a credential-named JSON key before we
62/// redact it, so a benign short value like `{"key":"id"}` isn't scrubbed.
63const MIN_CREDENTIAL_VALUE_LEN: usize = 6;
64
65/// Bare JSON object key names that designate a credential but contain no longer
66/// token like `API_KEY`/`SECRET` (those are caught by [`CRED_KEY_TOKEN`]).
67/// Matched case-insensitively against the *whole* key so a value can't hide
68/// behind a `{"key": …}` / `{"authorization": …}` field.
69const BARE_CREDENTIAL_KEYS: &[&str] = &[
70    "key",
71    "authorization",
72    "auth",
73    "password",
74    "token",
75    "secret",
76    "apikey",
77];
78
79/// Strong credential token any part of a JSON key name may contain (`api_key`,
80/// `aws_secret_access_key`, `refresh_token`, …). Mirrors the `NAME[:=]VALUE`
81/// name pattern in [`SECRET_PATTERNS`].
82static CRED_KEY_TOKEN: LazyLock<Regex> = LazyLock::new(|| {
83    Regex::new(
84        r"(?i)(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)",
85    )
86    .expect("static redaction regex must compile")
87});
88
89/// True when a JSON object key *names* a credential — either it contains a
90/// strong token ([`CRED_KEY_TOKEN`]) or it's one of the bare field names
91/// ([`BARE_CREDENTIAL_KEYS`]). Used by [`redact_json`] so a credential carried
92/// as a structured field with an opaque value is still scrubbed.
93fn key_names_credential(key: &str) -> bool {
94    CRED_KEY_TOKEN.is_match(key)
95        || BARE_CREDENTIAL_KEYS
96            .iter()
97            .any(|bare| key.eq_ignore_ascii_case(bare))
98}
99
100/// Replace credential-shaped substrings of `input` with `[REDACTED]`.
101pub fn redact_secrets(input: &str) -> String {
102    let mut out = std::borrow::Cow::Borrowed(input);
103    for (re, repl) in SECRET_PATTERNS.iter() {
104        if re.is_match(&out) {
105            out = std::borrow::Cow::Owned(re.replace_all(&out, *repl).into_owned());
106        }
107    }
108    out.into_owned()
109}
110
111/// Walk a JSON value and redact every string leaf in place. The recorder uses
112/// this to scrub whole structured payloads at the single write choke point.
113pub fn redact_json(value: &mut serde_json::Value) {
114    match value {
115        serde_json::Value::String(s) => {
116            let red = redact_secrets(s);
117            if &red != s {
118                *s = red;
119            }
120        },
121        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
122        serde_json::Value::Object(map) => {
123            for (key, val) in map.iter_mut() {
124                // Key-name-aware redaction (root cause): a credential carried as a
125                // structured field with an opaque value — `{"api_key":"gsk_…"}`,
126                // `{"key":"<token>"}` — matches no value-shape pattern, and the
127                // `NAME[:=]VALUE` regex can't span two JSON nodes. So when the KEY
128                // names a credential, scrub a non-trivial String value outright,
129                // regardless of its shape. Other keys recurse via `redact_secrets`.
130                if key_names_credential(key)
131                    && let serde_json::Value::String(s) = val
132                    && s.len() >= MIN_CREDENTIAL_VALUE_LEN
133                {
134                    *s = "[REDACTED]".to_string();
135                    continue;
136                }
137                redact_json(val);
138            }
139        },
140        _ => {},
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn redacts_named_secrets() {
150        assert_eq!(
151            redact_secrets("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
152            "OPENAI_API_KEY=[REDACTED]"
153        );
154        assert_eq!(
155            redact_secrets("export AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG'"),
156            "export AWS_SECRET_ACCESS_KEY=[REDACTED]"
157        );
158        assert_eq!(redact_secrets("password = hunter2"), "password=[REDACTED]");
159        // CLI-arg form — the new #93 usage context (MCP server args / stderr).
160        assert_eq!(
161            redact_secrets("--api-key=sk-abcdefghijklmnop1234"),
162            "--api-key=[REDACTED]"
163        );
164    }
165
166    #[test]
167    fn redacts_known_prefixes_and_bearer() {
168        assert_eq!(
169            redact_secrets("Authorization: Bearer abcdef123456ghijkl"),
170            "Authorization: Bearer [REDACTED]"
171        );
172        assert_eq!(
173            redact_secrets("key sk-ant-api03-abcdefghijklmnop"),
174            "key [REDACTED]"
175        );
176        assert_eq!(
177            redact_secrets("AKIAIOSFODNN7EXAMPLE here"),
178            "[REDACTED] here"
179        );
180        assert_eq!(
181            redact_secrets("token ghp_0123456789abcdefghijABCDEFGHIJ012345"),
182            "token [REDACTED]"
183        );
184    }
185
186    #[test]
187    fn leaves_ordinary_text_untouched() {
188        // No false positives on normal prose / code / hashes that aren't keys.
189        for ok in [
190            "the quick brown fox",
191            "let token_count = 42;",
192            "commit a614aa9f deploys the fix",
193            "see src/providers/tool/exec.rs:855",
194        ] {
195            assert_eq!(redact_secrets(ok), ok, "should not redact: {ok}");
196        }
197    }
198
199    #[test]
200    fn redacts_new_value_prefixes() {
201        assert_eq!(
202            redact_secrets("groq gsk_abcdefghijklmnopqrstuvwxyz0123"),
203            "groq [REDACTED]"
204        );
205        assert_eq!(
206            redact_secrets("stripe sk_live_abcdefghijklmnop1234"),
207            "stripe [REDACTED]"
208        );
209        // JWT: three base64url segments.
210        assert_eq!(
211            redact_secrets(
212                "token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM"
213            ),
214            "token [REDACTED]"
215        );
216        // PEM private-key header.
217        assert_eq!(
218            redact_secrets("-----BEGIN RSA PRIVATE KEY-----"),
219            "[REDACTED]"
220        );
221        // URL-embedded credentials: only the password portion is scrubbed.
222        assert_eq!(
223            redact_secrets("postgres://admin:s3cr3tpass@db.example.com/app"),
224            "postgres://admin:[REDACTED]@db.example.com/app"
225        );
226    }
227
228    #[test]
229    fn redact_json_scrubs_credential_keyed_values_regardless_of_shape() {
230        // The Groq key's opaque value matches no prefix, but the `api_key` key
231        // name forces redaction (the root-cause fix).
232        let mut v = serde_json::json!({
233            "api_key": "gsk_opaquevalue123456",
234            "authorization": "OpaqueBearerLikeValue",
235            "key": "id",
236            "name": "hello",
237            "nested": { "Secret-Token": "another-opaque-credential" },
238        });
239        redact_json(&mut v);
240        assert_eq!(v["api_key"], "[REDACTED]");
241        assert_eq!(v["authorization"], "[REDACTED]");
242        // Short benign value under a credential key is left alone.
243        assert_eq!(v["key"], "id");
244        // Non-credential key with benign value is untouched.
245        assert_eq!(v["name"], "hello");
246        // Recurses into nested objects, matching credential keys there too.
247        assert_eq!(v["nested"]["Secret-Token"], "[REDACTED]");
248    }
249
250    #[test]
251    fn redact_json_scrubs_string_leaves() {
252        let mut v = serde_json::json!({
253            "text": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
254            "count": 7,
255            "nested": ["safe", "Bearer abcdef123456ghijkl"],
256        });
257        redact_json(&mut v);
258        assert_eq!(v["text"], "OPENAI_API_KEY=[REDACTED]");
259        assert_eq!(v["count"], 7);
260        assert_eq!(v["nested"][0], "safe");
261        assert_eq!(v["nested"][1], "Bearer [REDACTED]");
262    }
263}