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    ]
48});
49
50/// Replace credential-shaped substrings of `input` with `[REDACTED]`.
51pub fn redact_secrets(input: &str) -> String {
52    let mut out = std::borrow::Cow::Borrowed(input);
53    for (re, repl) in SECRET_PATTERNS.iter() {
54        if re.is_match(&out) {
55            out = std::borrow::Cow::Owned(re.replace_all(&out, *repl).into_owned());
56        }
57    }
58    out.into_owned()
59}
60
61/// Walk a JSON value and redact every string leaf in place. The recorder uses
62/// this to scrub whole structured payloads at the single write choke point.
63pub fn redact_json(value: &mut serde_json::Value) {
64    match value {
65        serde_json::Value::String(s) => {
66            let red = redact_secrets(s);
67            if &red != s {
68                *s = red;
69            }
70        },
71        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
72        serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
73        _ => {},
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn redacts_named_secrets() {
83        assert_eq!(
84            redact_secrets("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
85            "OPENAI_API_KEY=[REDACTED]"
86        );
87        assert_eq!(
88            redact_secrets("export AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG'"),
89            "export AWS_SECRET_ACCESS_KEY=[REDACTED]"
90        );
91        assert_eq!(redact_secrets("password = hunter2"), "password=[REDACTED]");
92        // CLI-arg form — the new #93 usage context (MCP server args / stderr).
93        assert_eq!(
94            redact_secrets("--api-key=sk-abcdefghijklmnop1234"),
95            "--api-key=[REDACTED]"
96        );
97    }
98
99    #[test]
100    fn redacts_known_prefixes_and_bearer() {
101        assert_eq!(
102            redact_secrets("Authorization: Bearer abcdef123456ghijkl"),
103            "Authorization: Bearer [REDACTED]"
104        );
105        assert_eq!(
106            redact_secrets("key sk-ant-api03-abcdefghijklmnop"),
107            "key [REDACTED]"
108        );
109        assert_eq!(
110            redact_secrets("AKIAIOSFODNN7EXAMPLE here"),
111            "[REDACTED] here"
112        );
113        assert_eq!(
114            redact_secrets("token ghp_0123456789abcdefghijABCDEFGHIJ012345"),
115            "token [REDACTED]"
116        );
117    }
118
119    #[test]
120    fn leaves_ordinary_text_untouched() {
121        // No false positives on normal prose / code / hashes that aren't keys.
122        for ok in [
123            "the quick brown fox",
124            "let token_count = 42;",
125            "commit a614aa9f deploys the fix",
126            "see src/providers/tool/exec.rs:855",
127        ] {
128            assert_eq!(redact_secrets(ok), ok, "should not redact: {ok}");
129        }
130    }
131
132    #[test]
133    fn redact_json_scrubs_string_leaves() {
134        let mut v = serde_json::json!({
135            "text": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
136            "count": 7,
137            "nested": ["safe", "Bearer abcdef123456ghijkl"],
138        });
139        redact_json(&mut v);
140        assert_eq!(v["text"], "OPENAI_API_KEY=[REDACTED]");
141        assert_eq!(v["count"], 7);
142        assert_eq!(v["nested"][0], "safe");
143        assert_eq!(v["nested"][1], "Bearer [REDACTED]");
144    }
145}