1pub fn redact_secrets(input: &str) -> std::borrow::Cow<'_, str> {
18 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 (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 (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 (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 (Regex::new(r"\bAIza[0-9A-Za-z_-]{35}\b").unwrap(), "gcp_api_key"),
37 (Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b").unwrap(), "jwt"),
39 (Regex::new(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----").unwrap(), "pem_private_key"),
41 (Regex::new(r"\bhooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+\b").unwrap(), "slack_webhook"),
43 (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
58pub 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
89pub 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 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
119pub 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 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 (
146 Regex::new(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+").unwrap(),
147 "$1 [REDACTED]",
148 ),
149 (
151 Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://)[^/\s@:]+:[^/\s@]*@").unwrap(),
152 "${1}[REDACTED]@",
153 ),
154 (
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 (
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}