1use std::sync::OnceLock;
38
39use regex::Regex;
40use serde::Serialize;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct RedactedMatch {
46 pub kind: &'static str,
47 pub start: usize,
50 pub len: usize,
52}
53
54#[derive(Debug, Clone, Default, Serialize)]
57pub struct RedactionResult {
58 pub text: String,
59 pub matches: Vec<RedactedMatch>,
60}
61
62impl RedactionResult {
63 pub fn was_redacted(&self) -> bool {
64 !self.matches.is_empty()
65 }
66 pub fn summary(&self) -> String {
69 if self.matches.is_empty() {
70 return String::new();
71 }
72 let mut kinds: Vec<&'static str> = self.matches.iter().map(|m| m.kind).collect();
73 kinds.sort_unstable();
74 kinds.dedup();
75 format!(
76 "redacted {} secret{}: {}",
77 self.matches.len(),
78 if self.matches.len() == 1 { "" } else { "s" },
79 kinds.join(", ")
80 )
81 }
82}
83
84pub fn redact_secrets(text: &str) -> RedactionResult {
89 let patterns = patterns();
90 let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
94 for pat in patterns {
95 for m in pat.regex.find_iter(text) {
96 spans.push((m.start(), m.end(), pat.kind));
97 }
98 }
99 if spans.is_empty() {
100 return RedactionResult {
101 text: text.to_string(),
102 matches: Vec::new(),
103 };
104 }
105 spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
106 let mut accepted: Vec<(usize, usize, &'static str)> = Vec::new();
109 let mut cursor = 0usize;
110 for (start, end, kind) in spans {
111 if start < cursor {
112 continue;
113 }
114 accepted.push((start, end, kind));
115 cursor = end;
116 }
117
118 let mut redacted = String::with_capacity(text.len());
120 let mut matches: Vec<RedactedMatch> = Vec::with_capacity(accepted.len());
121 let mut last = 0usize;
122 for (start, end, kind) in accepted {
123 redacted.push_str(&text[last..start]);
124 redacted.push_str(&format!("[REDACTED:{kind}]"));
125 matches.push(RedactedMatch {
126 kind,
127 start,
128 len: end - start,
129 });
130 last = end;
131 }
132 redacted.push_str(&text[last..]);
133 RedactionResult {
134 text: redacted,
135 matches,
136 }
137}
138
139struct SecretPattern {
140 kind: &'static str,
141 regex: Regex,
142}
143
144fn patterns() -> &'static [SecretPattern] {
145 static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
146 CELL.get_or_init(|| {
147 let mut out = Vec::new();
152 out.push(SecretPattern {
153 kind: "anthropic_oauth",
154 regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
157 });
158 out.push(SecretPattern {
159 kind: "openai_api_key",
160 regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
165 });
166 out.push(SecretPattern {
167 kind: "github_pat",
168 regex: Regex::new(
172 r"(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}",
173 )
174 .unwrap(),
175 });
176 out.push(SecretPattern {
177 kind: "slack_token",
178 regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
179 });
180 out.push(SecretPattern {
181 kind: "aws_access_key",
182 regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
185 });
186 out.push(SecretPattern {
187 kind: "jwt",
188 regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
192 });
193 out.push(SecretPattern {
194 kind: "private_key_pem",
195 regex: Regex::new(
198 r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
199 )
200 .unwrap(),
201 });
202 out.push(SecretPattern {
203 kind: "google_api_key",
204 regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
206 });
207 out.push(SecretPattern {
208 kind: "url_credentials",
209 regex: Regex::new(r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:/@]+:[^\s:/@]{4,}@").unwrap(),
217 });
218 out.push(SecretPattern {
223 kind: "generic_bearer",
224 regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
226 });
227 out.push(SecretPattern {
228 kind: "generic_api_key",
229 regex: Regex::new(r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#).unwrap(),
233 });
234 out.push(SecretPattern {
235 kind: "generic_token",
236 regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
237 });
238 out.push(SecretPattern {
239 kind: "generic_password",
240 regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
241 });
242 out
243 })
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn clean_text_round_trips_untouched() {
252 let raw = "use ripgrep before broad file reads, prefer thiserror for errors";
253 let r = redact_secrets(raw);
254 assert!(!r.was_redacted());
255 assert_eq!(r.text, raw);
256 assert!(r.summary().is_empty());
257 }
258
259 #[test]
260 fn anthropic_oauth_token_is_redacted() {
261 let raw =
262 "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
263 let r = redact_secrets(raw);
264 assert!(r.was_redacted(), "{:?}", r);
265 assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
266 assert!(!r.text.contains("sk-ant-api03"));
267 assert_eq!(r.matches.len(), 1);
268 assert_eq!(r.matches[0].kind, "anthropic_oauth");
269 }
270
271 #[test]
272 fn openai_key_is_redacted_without_shadowing_anthropic_prefix() {
273 let raw =
275 "two: sk-1234567890abcdef1234567890abcdef AND sk-ant-1234567890abcdef1234567890abcdef";
276 let r = redact_secrets(raw);
277 let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
278 assert!(kinds.contains(&"openai_api_key"));
279 assert!(kinds.contains(&"anthropic_oauth"));
280 assert!(r.text.contains("[REDACTED:openai_api_key]"));
281 assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
282 }
283
284 #[test]
285 fn url_embedded_credentials_are_redacted() {
286 let raw = "DATABASE_URL=postgres://admin:S3cr3tP4ssw0rd@db.internal:5432/prod";
287 let r = redact_secrets(raw);
288 assert!(r.was_redacted(), "{:?}", r);
289 assert!(r.text.contains("[REDACTED:url_credentials]"));
290 assert!(!r.text.contains("S3cr3tP4ssw0rd"));
291 assert!(r.text.contains("db.internal:5432/prod"));
293
294 let redis = redact_secrets("redis://default:An0therSecret123@cache:6379");
296 assert!(redis.text.contains("[REDACTED:url_credentials]"));
297 assert!(!redis.text.contains("An0therSecret123"));
298
299 let clean = redact_secrets("see https://example.com/path?x=1 for docs");
301 assert!(!clean.was_redacted(), "{:?}", clean);
302 }
303
304 #[test]
305 fn github_pat_classic_and_fine_grained_redacted() {
306 let raw = concat!(
309 "classic: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ ",
310 "fine: github_pat_11AAA_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJabcdef",
311 );
312 let r = redact_secrets(raw);
313 let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
314 assert_eq!(
315 kinds.iter().filter(|k| **k == "github_pat").count(),
316 2,
317 "two github_pat matches expected; got matches: {:?}",
318 r.matches
319 );
320 assert!(!r.text.contains("ghp_abcdef"));
321 assert!(!r.text.contains("github_pat_11AAA"));
322 }
323
324 #[test]
325 fn slack_aws_jwt_pem_google_all_redact() {
326 let raw = concat!(
327 "slack=",
331 "xoxb",
332 "-12345678-abcdefghijklmnop ",
333 "aws=AKIAIOSFODNN7EXAMPLE ",
334 "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ",
335 "google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ",
336 "pem=-----BEGIN RSA PRIVATE KEY-----\nABCDEFG\n-----END RSA PRIVATE KEY-----"
337 );
338 let r = redact_secrets(raw);
339 let kinds: Vec<&'static str> = {
340 let mut k = r.matches.iter().map(|m| m.kind).collect::<Vec<_>>();
341 k.sort_unstable();
342 k.dedup();
343 k
344 };
345 for expected in [
346 "aws_access_key",
347 "google_api_key",
348 "jwt",
349 "private_key_pem",
350 "slack_token",
351 ] {
352 assert!(
353 kinds.contains(&expected),
354 "missing kind {expected}: {kinds:?}"
355 );
356 }
357 }
358
359 #[test]
360 fn generic_assignments_match_only_with_secret_looking_value() {
361 let bad = "config: api_key = \"abcdef1234567890\" \n token : 0123456789abcdefghij1234567890\n password = hunter2hunter2";
363 let r_bad = redact_secrets(bad);
364 let kinds: Vec<_> = r_bad.matches.iter().map(|m| m.kind).collect();
365 assert!(kinds.contains(&"generic_api_key"));
366 assert!(kinds.contains(&"generic_token"));
367 assert!(kinds.contains(&"generic_password"));
368
369 let safe = "api_key = short token: 12345 password = a";
371 let r_safe = redact_secrets(safe);
372 assert!(
373 r_safe.matches.is_empty(),
374 "short values should not trip generic patterns: {r_safe:?}"
375 );
376 }
377
378 #[test]
379 fn bearer_token_in_curl_log_is_redacted() {
380 let raw = "curl -H 'Authorization: Bearer abc123def456ghi789' https://api.example.com";
381 let r = redact_secrets(raw);
382 assert!(r.was_redacted());
383 assert_eq!(r.matches[0].kind, "generic_bearer");
384 assert!(r.text.contains("[REDACTED:generic_bearer]"));
385 assert!(!r.text.contains("abc123def456ghi789"));
386 }
387
388 #[test]
389 fn overlapping_matches_keep_first_only() {
390 let raw = "Authorization: Bearer sk-1234567890abcdef1234567890abcdef1234";
394 let r = redact_secrets(raw);
395 assert_eq!(
396 r.matches.len(),
397 1,
398 "non-overlapping rule should pick one: {r:?}"
399 );
400 }
401
402 #[test]
403 fn summary_lists_unique_kinds() {
404 let raw =
405 "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
406 let r = redact_secrets(raw);
407 let summary = r.summary();
408 assert!(summary.contains("github_pat"));
409 assert!(summary.starts_with("redacted 2 secrets: github_pat"));
411 }
412
413 #[test]
414 fn match_offsets_point_into_original_text() {
415 let raw = "prefix sk-ant-api03-1234567890abcdef1234567890abcdef suffix";
416 let r = redact_secrets(raw);
417 assert_eq!(r.matches.len(), 1);
418 let m = &r.matches[0];
419 let original_match = &raw[m.start..m.start + m.len];
421 assert!(original_match.starts_with("sk-ant-api03"));
422 }
423
424 #[test]
425 fn redaction_preserves_non_secret_surroundings() {
426 let raw = "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it";
427 let r = redact_secrets(raw);
428 assert!(r.text.starts_with("# Save to .env"));
429 assert!(r.text.ends_with("# Use it"));
430 assert!(
431 r.text
432 .contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]")
433 );
434 }
435}