Skip to main content

mur_common/
redact.rs

1//! One redaction chokepoint, shared by every writer that puts text on disk.
2//!
3//! This lived in `mur-agent-runtime::hooks::b0_helpers` and was reachable only
4//! from the runtime's own telemetry writer. B0 rule 9 is named "telemetry sink
5//! redaction", which reads like a guarantee about everything MUR writes — it
6//! was not. The CLI hook pipeline's capture queue
7//! (`mur-core::inject::queue`) went to disk unredacted, and on a real install
8//! accumulated 934 MB of verbatim command lines including API keys (#979).
9//!
10//! It sits in `mur-common` because both `mur-agent-runtime` and `mur-core`
11//! write text, and neither may depend on the other for it.
12//!
13//! `mur-common::skill::scan::secrets` DETECTS secrets and reports findings;
14//! this module REPLACES them. The two are deliberately separate: a scanner
15//! that silently rewrote its input would be a surprising scanner.
16
17pub fn redact_secrets(input: &str) -> std::borrow::Cow<'_, str> {
18    // TODO(M1): collapse into mur-common::skill::scan::secrets
19    use regex_lite::Regex;
20    use std::borrow::Cow;
21    use std::sync::OnceLock;
22
23    static REDACT_PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
24    let patterns = REDACT_PATTERNS.get_or_init(|| {
25        vec![
26            // OpenAI / Anthropic API keys
27            (Regex::new(r"\bsk-[a-zA-Z0-9]{20,}\b").unwrap(), "openai_key"),
28            (Regex::new(r"\bsk-ant-[a-zA-Z0-9-]{20,}\b").unwrap(), "anthropic_key"),
29            // AWS access keys
30            (Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap(), "aws_access_key"),
31            (Regex::new(r"\baws_secret_access_key\s*[:=]\s*[A-Za-z0-9/+=]{40}\b").unwrap(), "aws_secret_key"),
32            // GitHub PAT
33            (Regex::new(r"\bghp_[A-Za-z0-9]{36}\b").unwrap(), "github_pat"),
34            (Regex::new(r"\bghs_[A-Za-z0-9]{36}\b").unwrap(), "github_app_token"),
35            // GCP service account / API key
36            (Regex::new(r"\bAIza[0-9A-Za-z_-]{35}\b").unwrap(), "gcp_api_key"),
37            // JWT (3 base64url segments separated by dots)
38            (Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b").unwrap(), "jwt"),
39            // PEM private key
40            (Regex::new(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----").unwrap(), "pem_private_key"),
41            // Slack webhook
42            (Regex::new(r"\bhooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+\b").unwrap(), "slack_webhook"),
43            // Generic .env-style assignment with high-entropy value
44            (Regex::new(r"(?i)\b(api_key|api_secret|secret_key|access_token|password|token)\s*[:=]\s*[A-Za-z0-9_\-./+=]{20,}\b").unwrap(), "env_assignment"),
45        ]
46    });
47
48    let mut out: Cow<'_, str> = Cow::Borrowed(input);
49    for (rx, label) in patterns {
50        if rx.is_match(&out) {
51            let replacement = format!("[REDACTED:{label}]");
52            out = Cow::Owned(rx.replace_all(&out, replacement.as_str()).into_owned());
53        }
54    }
55    out
56}
57
58/// Replace home-directory-style absolute paths with `~/`. Catches
59/// macOS `/Users/<user>/`, Linux `/home/<user>/`, and Windows
60/// `C:\Users\<user>\` so error messages don't leak the OS user
61/// account name in telemetry. Conservative: only the username
62/// portion is collapsed; the trailing path is preserved so
63/// debugging context survives.
64pub fn redact_home_path(input: &str) -> std::borrow::Cow<'_, str> {
65    use regex_lite::Regex;
66    use std::borrow::Cow;
67    use std::sync::OnceLock;
68
69    static RE_UNIX: OnceLock<Regex> = OnceLock::new();
70    static RE_MAC: OnceLock<Regex> = OnceLock::new();
71    static RE_WIN: OnceLock<Regex> = OnceLock::new();
72
73    let unix = RE_UNIX.get_or_init(|| Regex::new(r"/home/[^/\s]+/").unwrap());
74    let mac = RE_MAC.get_or_init(|| Regex::new(r"/Users/[^/\s]+/").unwrap());
75    let win = RE_WIN.get_or_init(|| Regex::new(r"(?i)[A-Z]:\\Users\\[^\\\s]+\\").unwrap());
76
77    let mut out: Cow<'_, str> = Cow::Borrowed(input);
78    for rx in [unix, mac] {
79        if rx.is_match(&out) {
80            out = Cow::Owned(rx.replace_all(&out, "~/").into_owned());
81        }
82    }
83    if win.is_match(&out) {
84        out = Cow::Owned(win.replace_all(&out, "~\\").into_owned());
85    }
86    out
87}
88
89/// Redact every string leaf of a JSON value in place.
90///
91/// The shape the telemetry writer already used, moved here so both writers
92/// share it. Walking the tree rather than regexing the serialised line is the
93/// safe form: a replacement lands inside a JSON string and cannot break the
94/// structure around it.
95pub fn redact_value(value: &mut serde_json::Value) {
96    match value {
97        serde_json::Value::String(s) => {
98            let stage1 = redact_secrets(s);
99            let stage2 = redact_home_path(&stage1);
100            // Only allocate-and-replace if something actually changed.
101            if stage2 != *s {
102                *s = stage2.into_owned();
103            }
104        }
105        serde_json::Value::Array(items) => {
106            for item in items {
107                redact_value(item);
108            }
109        }
110        serde_json::Value::Object(map) => {
111            for v in map.values_mut() {
112                redact_value(v);
113            }
114        }
115        serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => {}
116    }
117}
118
119/// Mask credential *fields* whatever the value's length or shape.
120///
121/// [`redact_secrets`] recognises secrets by their shape (a provider prefix, a
122/// JWT, a 20+ char `.env` value). A short password has no shape, so
123/// `password=hunter2` passes it untouched. This pass keys on the field name
124/// instead: a value attached to a credential-looking key, a `Bearer` token, a
125/// credential CLI flag, or URL userinfo is replaced outright.
126///
127/// Deliberately NOT folded into [`redact_secrets`]: that function feeds every
128/// telemetry and capture writer, and key-name masking over-redacts ordinary
129/// log text (`tokens: 1200`). Writers whose output is permanent and small —
130/// a signed HITL decision label — call both.
131pub fn redact_credentials(input: &str) -> std::borrow::Cow<'_, str> {
132    use regex_lite::Regex;
133    use std::borrow::Cow;
134    use std::sync::OnceLock;
135
136    /// Key-name stems that mark a credential field. A stem matches inside a
137    /// longer key (`x_api_key`, `GITHUB_TOKEN`), which over-redacts on purpose.
138    const STEMS: &str = r"(?:api[_-]?key|token|secret|password|passwd|pwd|authorization|private[_-]?key|credentials?|cookie)";
139
140    static PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
141    let patterns = PATTERNS.get_or_init(|| {
142        vec![
143            // `Bearer abc` / `Basic abc` — before the key rule, so
144            // `Authorization: Bearer abc` loses the token, not just "Bearer".
145            (
146                Regex::new(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+").unwrap(),
147                "$1 [REDACTED]",
148            ),
149            // `scheme://user:pass@host`
150            (
151                Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://)[^/\s@:]+:[^/\s@]*@").unwrap(),
152                "${1}[REDACTED]@",
153            ),
154            // `--password hunter2`, `-token=abc`
155            (
156                Regex::new(&format!(r"(?i)(--?[A-Za-z0-9_-]*{STEMS}[A-Za-z0-9_-]*)(\s+|=)[^\s-]\S*"))
157                    .unwrap(),
158                "$1$2[REDACTED]",
159            ),
160            // `password=hunter2`, `"token": "abc"`, `API_KEY: abc`
161            (
162                Regex::new(&format!(
163                    r#"(?i)([A-Za-z0-9_-]*{STEMS}[A-Za-z0-9_-]*["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)"#
164                ))
165                .unwrap(),
166                "${1}[REDACTED]",
167            ),
168        ]
169    });
170
171    let mut out: Cow<'_, str> = Cow::Borrowed(input);
172    for (rx, rep) in patterns {
173        if rx.is_match(&out) {
174            out = Cow::Owned(rx.replace_all(&out, *rep).into_owned());
175        }
176    }
177    out
178}
179
180#[cfg(test)]
181mod credential_tests {
182    use super::redact_credentials;
183
184    #[track_caller]
185    fn masked(input: &str, secret: &str) {
186        let out = redact_credentials(input);
187        assert!(!out.contains(secret), "{secret:?} leaked: {out:?}");
188        assert!(out.contains("[REDACTED]"), "nothing masked: {out:?}");
189    }
190
191    #[test]
192    fn short_values_on_credential_keys_are_masked() {
193        masked("password=hunter2", "hunter2");
194        masked("PWD: x1", "x1");
195        masked("token: abc", "abc");
196        masked(r#"{"api_key": "k9"}"#, "k9");
197        masked("export GITHUB_TOKEN='s3 cr3t'", "s3 cr3t");
198        masked("db_password = pa55;", "pa55");
199    }
200
201    #[test]
202    fn auth_headers_and_flags_are_masked() {
203        masked("curl -H 'Authorization: Bearer abc.def'", "abc.def");
204        masked("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz");
205        masked("mysql --password hunter2 db", "hunter2");
206        masked("tool -api-key=zz9", "zz9");
207    }
208
209    #[test]
210    fn url_userinfo_is_masked() {
211        masked("git clone https://bob:pa55@example.com/r.git", "pa55");
212        masked("postgres://admin:hunter2@db:5432/x", "hunter2");
213    }
214
215    #[test]
216    fn ordinary_text_is_borrowed() {
217        for s in [
218            "cargo test -p mur-core",
219            "~/proj/Cargo.toml",
220            "https://example.com/a",
221        ] {
222            assert!(
223                matches!(redact_credentials(s), std::borrow::Cow::Borrowed(_)),
224                "{s}"
225            );
226        }
227    }
228}