1use std::borrow::Cow;
12use std::sync::OnceLock;
13
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct ScrubConfig {
21 pub enabled: bool,
28}
29
30impl Default for ScrubConfig {
31 fn default() -> Self {
34 Self { enabled: true }
35 }
36}
37
38struct Pattern {
41 regex: Regex,
42 label: &'static str,
43}
44
45const PATTERN_SOURCES: &[(&str, &str)] = &[
50 ("aws-key", r"\bAKIA[0-9A-Z]{16}\b"),
51 ("github-token", r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b"),
52 ("anthropic-key", r"\bsk-ant-[A-Za-z0-9\-_]{20,}\b"),
53 (
54 "openai-key",
55 r"\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b",
56 ),
57 ("openai-key", r"\bsk-proj-[A-Za-z0-9\-_]{20,}\b"),
58 ("slack-token", r"\bxox[baprs]-[A-Za-z0-9\-]{10,}\b"),
59 (
60 "discord-token",
61 r"\b[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}\b",
62 ),
63 (
64 "private-key",
65 r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
66 ),
67 (
68 "jwt",
69 r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
70 ),
71 (
72 "generic-bearer",
73 r"(?i)\b(bearer|authorization:)\s+[A-Za-z0-9\-._~+/]{20,}=*",
74 ),
75];
76
77#[allow(
87 clippy::expect_used,
88 reason = "hardcoded built-in security patterns must compile; a failure is a programming \
89 error that must panic loudly rather than silently skip a redaction pattern"
90)]
91fn pattern_set() -> &'static Vec<Pattern> {
92 static PATTERNS: OnceLock<Vec<Pattern>> = OnceLock::new();
93 PATTERNS.get_or_init(|| {
94 PATTERN_SOURCES
95 .iter()
96 .map(|&(label, source)| Pattern {
97 regex: Regex::new(source).unwrap_or_else(|err| {
98 panic!("built-in scrub pattern {label:?} failed to compile: {err}")
99 }),
100 label,
101 })
102 .collect()
103 })
104}
105
106#[must_use]
114pub fn scrub_secrets<'a>(text: &'a str, config: &ScrubConfig) -> Cow<'a, str> {
115 if !config.enabled {
116 return Cow::Borrowed(text);
117 }
118
119 let mut current = Cow::Borrowed(text);
120 for pattern in pattern_set() {
121 if pattern.regex.is_match(¤t) {
122 let replacement = format!("[REDACTED:{}]", pattern.label);
123 let replaced = pattern
124 .regex
125 .replace_all(¤t, replacement.as_str())
126 .into_owned();
127 current = Cow::Owned(replaced);
128 }
129 }
130 current
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn cfg() -> ScrubConfig {
138 ScrubConfig::default()
139 }
140
141 #[test]
142 fn pattern_set_compiles() {
143 let patterns = pattern_set();
145 assert_eq!(patterns.len(), PATTERN_SOURCES.len());
146 }
147
148 #[test]
149 fn disabled_config_returns_borrowed_unchanged() {
150 let text = "this has a secret AKIAABCDEFGHIJKLMNOP in it";
151 let cfg = ScrubConfig { enabled: false };
152 let result = scrub_secrets(text, &cfg);
153 assert_eq!(result, text);
154 assert!(matches!(result, Cow::Borrowed(_)));
155 }
156
157 #[test]
158 fn no_match_returns_borrowed() {
159 let text = "nothing sensitive here, just plain text";
160 let result = scrub_secrets(text, &cfg());
161 assert_eq!(result, text);
162 assert!(matches!(result, Cow::Borrowed(_)));
163 }
164
165 #[test]
168 fn aws_key_positive() {
169 let text = "key=AKIAABCDEFGHIJKLMNOP end";
170 let result = scrub_secrets(text, &cfg());
171 assert_eq!(result, "key=[REDACTED:aws-key] end");
172 }
173
174 #[test]
175 fn aws_key_near_miss() {
176 let text = "key=AKIAABCDEFGHIJKLMNO end";
178 let result = scrub_secrets(text, &cfg());
179 assert_eq!(result, text);
180 }
181
182 #[test]
185 fn github_token_positive() {
186 let token = format!("ghp_{}", "a".repeat(36));
187 let text = format!("token: {token}");
188 let result = scrub_secrets(&text, &cfg());
189 assert_eq!(result, "token: [REDACTED:github-token]");
190 }
191
192 #[test]
193 fn github_token_near_miss() {
194 let token = format!("ghp_{}", "a".repeat(35));
196 let text = format!("token: {token}");
197 let result = scrub_secrets(&text, &cfg());
198 assert_eq!(result, text);
199 }
200
201 #[test]
204 fn anthropic_key_positive() {
205 let token = format!("sk-ant-{}", "A".repeat(20));
206 let text = format!("ANTHROPIC_API_KEY={token}");
207 let result = scrub_secrets(&text, &cfg());
208 assert_eq!(result, "ANTHROPIC_API_KEY=[REDACTED:anthropic-key]");
209 }
210
211 #[test]
212 fn anthropic_key_near_miss() {
213 let token = format!("sk-ant-{}", "A".repeat(19));
214 let text = format!("ANTHROPIC_API_KEY={token}");
215 let result = scrub_secrets(&text, &cfg());
216 assert_eq!(result, text);
217 }
218
219 #[test]
222 fn openai_key_legacy_positive() {
223 let token = format!("sk-{}T3BlbkFJ{}", "a".repeat(20), "b".repeat(20));
224 let text = format!("OPENAI_API_KEY={token}");
225 let result = scrub_secrets(&text, &cfg());
226 assert_eq!(result, "OPENAI_API_KEY=[REDACTED:openai-key]");
227 }
228
229 #[test]
230 fn openai_key_legacy_near_miss() {
231 let token = format!("sk-{}X3BlbkFJ{}", "a".repeat(20), "b".repeat(20));
233 let text = format!("OPENAI_API_KEY={token}");
234 let result = scrub_secrets(&text, &cfg());
235 assert_eq!(result, text);
236 }
237
238 #[test]
241 fn openai_key_proj_positive() {
242 let token = format!("sk-proj-{}", "A".repeat(20));
243 let text = format!("OPENAI_API_KEY={token}");
244 let result = scrub_secrets(&text, &cfg());
245 assert_eq!(result, "OPENAI_API_KEY=[REDACTED:openai-key]");
246 }
247
248 #[test]
249 fn openai_key_proj_near_miss() {
250 let token = format!("sk-proj-{}", "A".repeat(19));
251 let text = format!("OPENAI_API_KEY={token}");
252 let result = scrub_secrets(&text, &cfg());
253 assert_eq!(result, text);
254 }
255
256 #[test]
259 fn slack_token_positive() {
260 let text = "SLACK_TOKEN=xoxb-1234567890-abcdefghij";
261 let result = scrub_secrets(text, &cfg());
262 assert_eq!(result, "SLACK_TOKEN=[REDACTED:slack-token]");
263 }
264
265 #[test]
266 fn slack_token_near_miss() {
267 let text = "SLACK_TOKEN=xoxb-123456789";
269 let result = scrub_secrets(text, &cfg());
270 assert_eq!(result, text);
271 }
272
273 #[test]
276 fn discord_token_positive() {
277 let token = format!("M{}.{}.{}", "A".repeat(23), "a".repeat(6), "b".repeat(27));
278 let text = format!("DISCORD_TOKEN={token}");
279 let result = scrub_secrets(&text, &cfg());
280 assert_eq!(result, "DISCORD_TOKEN=[REDACTED:discord-token]");
281 }
282
283 #[test]
284 fn discord_token_near_miss() {
285 let token = format!("X{}.{}.{}", "A".repeat(23), "a".repeat(6), "b".repeat(27));
287 let text = format!("DISCORD_TOKEN={token}");
288 let result = scrub_secrets(&text, &cfg());
289 assert_eq!(result, text);
290 }
291
292 #[test]
295 fn private_key_positive() {
296 let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\nmore lines\n-----END RSA PRIVATE KEY-----";
297 let result = scrub_secrets(text, &cfg());
298 assert_eq!(result, "[REDACTED:private-key]");
299 }
300
301 #[test]
302 fn private_key_near_miss() {
303 let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\nno end marker here";
304 let result = scrub_secrets(text, &cfg());
305 assert_eq!(result, text);
306 }
307
308 #[test]
311 fn jwt_positive() {
312 let token = format!(
313 "eyJ{}.{}.{}",
314 "a".repeat(10),
315 "b".repeat(10),
316 "c".repeat(10)
317 );
318 let text = format!("Authorization-payload: {token}");
319 let result = scrub_secrets(&text, &cfg());
320 assert!(result.contains("[REDACTED:jwt]"));
321 assert!(!result.contains(&token));
322 }
323
324 #[test]
325 fn jwt_near_miss() {
326 let token = format!("eyJ{}.{}", "a".repeat(10), "b".repeat(10));
328 let text = format!("payload: {token}");
329 let result = scrub_secrets(&text, &cfg());
330 assert_eq!(result, text);
331 }
332
333 #[test]
336 fn generic_bearer_positive() {
337 let text = format!("Authorization: Bearer {}", "a".repeat(25));
338 let result = scrub_secrets(&text, &cfg());
339 assert!(result.contains("[REDACTED:generic-bearer]"));
340 assert!(!result.to_lowercase().contains("bearer aaaa"));
341 }
342
343 #[test]
344 fn generic_bearer_near_miss() {
345 let text = format!("Bearer {}", "a".repeat(10));
347 let result = scrub_secrets(&text, &cfg());
348 assert_eq!(result, text);
349 }
350
351 #[test]
354 fn multi_secret_string() {
355 let aws = "AKIAABCDEFGHIJKLMNOP";
356 let gh_token = format!("ghp_{}", "a".repeat(36));
357 let text = format!("aws={aws} github={gh_token} done");
358 let result = scrub_secrets(&text, &cfg());
359 assert!(result.contains("[REDACTED:aws-key]"));
360 assert!(result.contains("[REDACTED:github-token]"));
361 assert!(!result.contains(aws));
362 assert!(!result.contains(&gh_token));
363 }
364
365 #[test]
366 fn idempotent() {
367 let aws = "AKIAABCDEFGHIJKLMNOP";
368 let gh_token = format!("ghp_{}", "a".repeat(36));
369 let text = format!("aws={aws} github={gh_token} bearer={}", "x".repeat(25));
370
371 let once = scrub_secrets(&text, &cfg()).into_owned();
372 let twice = scrub_secrets(&once, &cfg()).into_owned();
373 assert_eq!(once, twice);
374 }
375}