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 merge_and_redact(text, collect_spans(text, patterns()))
90}
91
92pub fn scrub_for_export(text: &str) -> RedactionResult {
97 let mut spans = collect_spans(text, patterns());
98 spans.extend(collect_pii_spans(text));
99 merge_and_redact(text, spans)
100}
101
102fn collect_spans(text: &str, patterns: &[SecretPattern]) -> Vec<(usize, usize, &'static str)> {
105 let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
106 for pat in patterns {
107 for m in pat.regex.find_iter(text) {
108 spans.push((m.start(), m.end(), pat.kind));
109 }
110 }
111 spans
112}
113
114fn merge_and_redact(text: &str, mut spans: Vec<(usize, usize, &'static str)>) -> RedactionResult {
117 if spans.is_empty() {
118 return RedactionResult {
119 text: text.to_string(),
120 matches: Vec::new(),
121 };
122 }
123 spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
124 let mut accepted: Vec<(usize, usize, &'static str)> = Vec::new();
125 let mut cursor = 0usize;
126 for (start, end, kind) in spans {
127 if start < cursor {
128 continue;
129 }
130 accepted.push((start, end, kind));
131 cursor = end;
132 }
133
134 let mut redacted = String::with_capacity(text.len());
135 let mut matches: Vec<RedactedMatch> = Vec::with_capacity(accepted.len());
136 let mut last = 0usize;
137 for (start, end, kind) in accepted {
138 redacted.push_str(&text[last..start]);
139 redacted.push_str(&format!("[REDACTED:{kind}]"));
140 matches.push(RedactedMatch {
141 kind,
142 start,
143 len: end - start,
144 });
145 last = end;
146 }
147 redacted.push_str(&text[last..]);
148 RedactionResult {
149 text: redacted,
150 matches,
151 }
152}
153
154fn collect_pii_spans(text: &str) -> Vec<(usize, usize, &'static str)> {
158 let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
159 for pat in pii_patterns() {
160 for m in pat.regex.find_iter(text) {
161 if pat.kind == "credit_card" {
162 let digits: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
163 if !(13..=19).contains(&digits.len()) || !luhn_valid(&digits) {
164 continue;
165 }
166 }
167 spans.push((m.start(), m.end(), pat.kind));
168 }
169 }
170 spans
171}
172
173fn luhn_valid(digits: &str) -> bool {
175 if digits.is_empty() {
176 return false;
177 }
178 let mut sum = 0u32;
179 let mut double = false;
180 for c in digits.chars().rev() {
181 let mut d = match c.to_digit(10) {
182 Some(d) => d,
183 None => return false,
184 };
185 if double {
186 d *= 2;
187 if d > 9 {
188 d -= 9;
189 }
190 }
191 sum += d;
192 double = !double;
193 }
194 sum % 10 == 0
195}
196
197fn pii_patterns() -> &'static [SecretPattern] {
199 static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
200 CELL.get_or_init(|| {
201 vec![
202 SecretPattern {
203 kind: "email",
204 regex: Regex::new(r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b").unwrap(),
205 },
206 SecretPattern {
207 kind: "ssn",
208 regex: Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
211 },
212 SecretPattern {
213 kind: "phone",
214 regex: Regex::new(
218 r"\b(?:\+?\d{1,3}[ .\-]?)?(?:\(\d{3}\)|\d{3})[ .\-]\d{3}[ .\-]\d{4}\b",
219 )
220 .unwrap(),
221 },
222 SecretPattern {
223 kind: "credit_card",
224 regex: Regex::new(r"\b\d(?:[ \-]?\d){12,18}\b").unwrap(),
227 },
228 ]
229 })
230}
231
232struct SecretPattern {
233 kind: &'static str,
234 regex: Regex,
235}
236
237fn patterns() -> &'static [SecretPattern] {
238 static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
239 CELL.get_or_init(|| {
240 vec![
245 SecretPattern {
246 kind: "anthropic_oauth",
247 regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
250 },
251 SecretPattern {
252 kind: "openai_api_key",
253 regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
258 },
259 SecretPattern {
260 kind: "github_pat",
261 regex: Regex::new(
265 r"(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}",
266 )
267 .unwrap(),
268 },
269 SecretPattern {
270 kind: "slack_token",
271 regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
272 },
273 SecretPattern {
274 kind: "aws_access_key",
275 regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
278 },
279 SecretPattern {
280 kind: "jwt",
281 regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
285 },
286 SecretPattern {
287 kind: "private_key_pem",
288 regex: Regex::new(
291 r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
292 )
293 .unwrap(),
294 },
295 SecretPattern {
296 kind: "google_api_key",
297 regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
299 },
300 SecretPattern {
301 kind: "url_credentials",
302 regex: Regex::new(r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:/@]+:[^\s:/@]{4,}@").unwrap(),
310 },
311 SecretPattern {
316 kind: "generic_bearer",
317 regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
319 },
320 SecretPattern {
321 kind: "generic_api_key",
322 regex: Regex::new(r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#).unwrap(),
326 },
327 SecretPattern {
328 kind: "generic_token",
329 regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
330 },
331 SecretPattern {
332 kind: "generic_password",
333 regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
334 },
335 ]
336 })
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn clean_text_round_trips_untouched() {
345 let raw = "use ripgrep before broad file reads, prefer thiserror for errors";
346 let r = redact_secrets(raw);
347 assert!(!r.was_redacted());
348 assert_eq!(r.text, raw);
349 assert!(r.summary().is_empty());
350 }
351
352 #[test]
354 fn scrub_for_export_redacts_pii_and_credentials() {
355 let raw = "contact alice@example.com or 415-555-0142; ssn 123-45-6789; \
356 key sk-ant-AbCdEfGhIjKlMnOpQrStUvWx0123456789";
357 let r = scrub_for_export(raw);
358 let kinds: std::collections::BTreeSet<&str> = r.matches.iter().map(|m| m.kind).collect();
359 assert!(kinds.contains("email"), "{r:?}");
360 assert!(kinds.contains("phone"), "{r:?}");
361 assert!(kinds.contains("ssn"), "{r:?}");
362 assert!(kinds.contains("anthropic_oauth"), "{r:?}");
363 assert!(!r.text.contains("alice@example.com"));
364 assert!(!r.text.contains("123-45-6789"));
365 assert!(!r.text.contains("sk-ant-"));
366 }
367
368 #[test]
369 fn scrub_for_export_luhn_gates_credit_cards() {
370 let good = scrub_for_export("card 4242 4242 4242 4242 on file");
372 assert!(
373 good.matches.iter().any(|m| m.kind == "credit_card"),
374 "valid card must scrub: {good:?}"
375 );
376 let bad = scrub_for_export("trace 1234567890123456 step");
378 assert!(
379 !bad.matches.iter().any(|m| m.kind == "credit_card"),
380 "non-Luhn digit run must NOT scrub: {bad:?}"
381 );
382 }
383
384 #[test]
385 fn scrub_for_export_leaves_technical_text_alone() {
386 let raw = "build with cargo 1.79; commit a1b2c3d4; port 8787; ulid 01K8YMJ448514TP6CPQ";
388 let r = scrub_for_export(raw);
389 assert!(!r.was_redacted(), "false positive: {r:?}");
390 assert_eq!(r.text, raw);
391 }
392
393 #[test]
394 fn luhn_check() {
395 assert!(luhn_valid("4242424242424242"));
396 assert!(!luhn_valid("4242424242424241"));
397 assert!(!luhn_valid(""));
398 }
399
400 #[test]
401 fn anthropic_oauth_token_is_redacted() {
402 let raw =
403 "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
404 let r = redact_secrets(raw);
405 assert!(r.was_redacted(), "{:?}", r);
406 assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
407 assert!(!r.text.contains("sk-ant-api03"));
408 assert_eq!(r.matches.len(), 1);
409 assert_eq!(r.matches[0].kind, "anthropic_oauth");
410 }
411
412 #[test]
413 fn openai_key_is_redacted_without_shadowing_anthropic_prefix() {
414 let raw =
416 "two: sk-1234567890abcdef1234567890abcdef AND sk-ant-1234567890abcdef1234567890abcdef";
417 let r = redact_secrets(raw);
418 let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
419 assert!(kinds.contains(&"openai_api_key"));
420 assert!(kinds.contains(&"anthropic_oauth"));
421 assert!(r.text.contains("[REDACTED:openai_api_key]"));
422 assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
423 }
424
425 #[test]
426 fn url_embedded_credentials_are_redacted() {
427 let raw = "DATABASE_URL=postgres://admin:S3cr3tP4ssw0rd@db.internal:5432/prod";
428 let r = redact_secrets(raw);
429 assert!(r.was_redacted(), "{:?}", r);
430 assert!(r.text.contains("[REDACTED:url_credentials]"));
431 assert!(!r.text.contains("S3cr3tP4ssw0rd"));
432 assert!(r.text.contains("db.internal:5432/prod"));
434
435 let redis = redact_secrets("redis://default:An0therSecret123@cache:6379");
437 assert!(redis.text.contains("[REDACTED:url_credentials]"));
438 assert!(!redis.text.contains("An0therSecret123"));
439
440 let clean = redact_secrets("see https://example.com/path?x=1 for docs");
442 assert!(!clean.was_redacted(), "{:?}", clean);
443 }
444
445 #[test]
446 fn github_pat_classic_and_fine_grained_redacted() {
447 let raw = concat!(
450 "classic: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ ",
451 "fine: github_pat_11AAA_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJabcdef",
452 );
453 let r = redact_secrets(raw);
454 let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
455 assert_eq!(
456 kinds.iter().filter(|k| **k == "github_pat").count(),
457 2,
458 "two github_pat matches expected; got matches: {:?}",
459 r.matches
460 );
461 assert!(!r.text.contains("ghp_abcdef"));
462 assert!(!r.text.contains("github_pat_11AAA"));
463 }
464
465 #[test]
466 fn slack_aws_jwt_pem_google_all_redact() {
467 let raw = concat!(
468 "slack=",
472 "xoxb",
473 "-12345678-abcdefghijklmnop ",
474 "aws=AKIAIOSFODNN7EXAMPLE ",
475 "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ",
476 "google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ",
477 "pem=-----BEGIN RSA PRIVATE KEY-----\nABCDEFG\n-----END RSA PRIVATE KEY-----"
478 );
479 let r = redact_secrets(raw);
480 let kinds: Vec<&'static str> = {
481 let mut k = r.matches.iter().map(|m| m.kind).collect::<Vec<_>>();
482 k.sort_unstable();
483 k.dedup();
484 k
485 };
486 for expected in [
487 "aws_access_key",
488 "google_api_key",
489 "jwt",
490 "private_key_pem",
491 "slack_token",
492 ] {
493 assert!(
494 kinds.contains(&expected),
495 "missing kind {expected}: {kinds:?}"
496 );
497 }
498 }
499
500 #[test]
501 fn generic_assignments_match_only_with_secret_looking_value() {
502 let bad = "config: api_key = \"abcdef1234567890\" \n token : 0123456789abcdefghij1234567890\n password = hunter2hunter2";
504 let r_bad = redact_secrets(bad);
505 let kinds: Vec<_> = r_bad.matches.iter().map(|m| m.kind).collect();
506 assert!(kinds.contains(&"generic_api_key"));
507 assert!(kinds.contains(&"generic_token"));
508 assert!(kinds.contains(&"generic_password"));
509
510 let safe = "api_key = short token: 12345 password = a";
512 let r_safe = redact_secrets(safe);
513 assert!(
514 r_safe.matches.is_empty(),
515 "short values should not trip generic patterns: {r_safe:?}"
516 );
517 }
518
519 #[test]
520 fn bearer_token_in_curl_log_is_redacted() {
521 let raw = "curl -H 'Authorization: Bearer abc123def456ghi789' https://api.example.com";
522 let r = redact_secrets(raw);
523 assert!(r.was_redacted());
524 assert_eq!(r.matches[0].kind, "generic_bearer");
525 assert!(r.text.contains("[REDACTED:generic_bearer]"));
526 assert!(!r.text.contains("abc123def456ghi789"));
527 }
528
529 #[test]
530 fn overlapping_matches_keep_first_only() {
531 let raw = "Authorization: Bearer sk-1234567890abcdef1234567890abcdef1234";
535 let r = redact_secrets(raw);
536 assert_eq!(
537 r.matches.len(),
538 1,
539 "non-overlapping rule should pick one: {r:?}"
540 );
541 }
542
543 #[test]
544 fn summary_lists_unique_kinds() {
545 let raw =
546 "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
547 let r = redact_secrets(raw);
548 let summary = r.summary();
549 assert!(summary.contains("github_pat"));
550 assert!(summary.starts_with("redacted 2 secrets: github_pat"));
552 }
553
554 #[test]
555 fn match_offsets_point_into_original_text() {
556 let raw = "prefix sk-ant-api03-1234567890abcdef1234567890abcdef suffix";
557 let r = redact_secrets(raw);
558 assert_eq!(r.matches.len(), 1);
559 let m = &r.matches[0];
560 let original_match = &raw[m.start..m.start + m.len];
562 assert!(original_match.starts_with("sk-ant-api03"));
563 }
564
565 #[test]
566 fn redaction_preserves_non_secret_surroundings() {
567 let raw = "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it";
568 let r = redact_secrets(raw);
569 assert!(r.text.starts_with("# Save to .env"));
570 assert!(r.text.ends_with("# Use it"));
571 assert!(
572 r.text
573 .contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]")
574 );
575 }
576}