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