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}