scrubber_rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,636 @@
1
+ //! The detector registry.
2
+ //!
3
+ //! A *detector* is a public key the user enables (`:email`, `:credit_card`).
4
+ //! A *rule* is one compiled regex. Most detectors are a single rule; a few
5
+ //! (`:api_key`, `:phone`, `:password_pair`) are a family of rules that all
6
+ //! report the same type, because expressing them as one alternation would make
7
+ //! capture-group bookkeeping unreadable.
8
+
9
+ pub mod checksum;
10
+ pub mod upi;
11
+ pub mod validate;
12
+
13
+ pub use validate::{Candidate, Validator};
14
+
15
+ use crate::replace::MaskKind;
16
+
17
+ /// One compiled-at-startup pattern.
18
+ pub struct Rule {
19
+ /// Detector key this rule reports as, e.g. `"credit_card"`.
20
+ pub kind: &'static str,
21
+ /// The Rust `regex` source.
22
+ pub pattern: &'static str,
23
+ /// Literal substrings, any one of which must be present for this rule to
24
+ /// have a chance of matching. Empty means "always run this rule".
25
+ /// Used to build the stage-1 Aho-Corasick prefilter.
26
+ pub anchors: &'static [&'static str],
27
+ /// Which capture group to redact. 0 is the whole match; `password_pair` and
28
+ /// `url_credentials` redact only group 1 so the surrounding key stays
29
+ /// readable in logs.
30
+ pub capture: usize,
31
+ pub validator: Option<Validator>,
32
+ /// Lower wins when two matches overlap.
33
+ pub priority: u8,
34
+ pub mask: MaskKind,
35
+ }
36
+
37
+ /// Plain rule: redact the whole match, no post-validation.
38
+ const fn rule(
39
+ kind: &'static str,
40
+ pattern: &'static str,
41
+ anchors: &'static [&'static str],
42
+ priority: u8,
43
+ mask: MaskKind,
44
+ ) -> Rule {
45
+ Rule {
46
+ kind,
47
+ pattern,
48
+ anchors,
49
+ capture: 0,
50
+ validator: None,
51
+ priority,
52
+ mask,
53
+ }
54
+ }
55
+
56
+ /// Rule with a checksum or context validator.
57
+ const fn checked(
58
+ kind: &'static str,
59
+ pattern: &'static str,
60
+ anchors: &'static [&'static str],
61
+ priority: u8,
62
+ mask: MaskKind,
63
+ validator: Validator,
64
+ ) -> Rule {
65
+ Rule {
66
+ kind,
67
+ pattern,
68
+ anchors,
69
+ capture: 0,
70
+ validator: Some(validator),
71
+ priority,
72
+ mask,
73
+ }
74
+ }
75
+
76
+ /// Rule that redacts only capture group 1, leaving the surrounding key visible.
77
+ const fn captured(
78
+ kind: &'static str,
79
+ pattern: &'static str,
80
+ anchors: &'static [&'static str],
81
+ priority: u8,
82
+ ) -> Rule {
83
+ Rule {
84
+ kind,
85
+ pattern,
86
+ anchors,
87
+ capture: 1,
88
+ validator: None,
89
+ priority,
90
+ mask: MaskKind::Full,
91
+ }
92
+ }
93
+
94
+ /// Capture-group rule with a validator that sees the captured span.
95
+ const fn captured_checked(
96
+ kind: &'static str,
97
+ pattern: &'static str,
98
+ anchors: &'static [&'static str],
99
+ priority: u8,
100
+ validator: Validator,
101
+ ) -> Rule {
102
+ Rule {
103
+ kind,
104
+ pattern,
105
+ anchors,
106
+ capture: 1,
107
+ validator: Some(validator),
108
+ priority,
109
+ mask: MaskKind::Full,
110
+ }
111
+ }
112
+
113
+ // Priorities. Secrets beat structured IDs beat loose numeric shapes, so that
114
+ // when spans overlap the more specific reading wins (behaviour contract S2).
115
+ // A provider-specific secret rule and the generic `key = value` rule usually
116
+ // match the same span. Ranking the specific one first means you get
117
+ // `[API_KEY]` rather than `[PASSWORD_PAIR]` — same redaction, better forensics.
118
+ const P_PRIVATE_KEY: u8 = 1;
119
+ const P_AWS: u8 = 2;
120
+ const P_API_KEY: u8 = 3;
121
+ const P_JWT: u8 = 4;
122
+ const P_URL_CREDS: u8 = 5;
123
+ const P_PASSWORD: u8 = 6;
124
+ pub const P_CUSTOM: u8 = 8;
125
+ const P_CARD: u8 = 20;
126
+ const P_IBAN: u8 = 21;
127
+ const P_AADHAAR: u8 = 22;
128
+ const P_SSN: u8 = 23;
129
+ const P_PAN: u8 = 24;
130
+ const P_UPI: u8 = 25;
131
+ const P_EMAIL: u8 = 26;
132
+ const P_MAC: u8 = 40;
133
+ const P_IPV6: u8 = 41;
134
+ const P_IPV4: u8 = 42;
135
+ const P_PHONE_IN: u8 = 50;
136
+ const P_PHONE: u8 = 51;
137
+
138
+ /// Every rule in the library, grouped by the detector key that enables it.
139
+ pub static REGISTRY: &[(&str, &[Rule])] = &[
140
+ (
141
+ "email",
142
+ &[checked(
143
+ "email",
144
+ r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?\.[A-Za-z]{2,24}\b",
145
+ &[],
146
+ P_EMAIL,
147
+ MaskKind::Email,
148
+ validate::email,
149
+ )],
150
+ ),
151
+ (
152
+ "phone",
153
+ &[
154
+ // +country (space/dot/dash) NNN NNN NNNN
155
+ rule(
156
+ "phone",
157
+ r"\+[1-9]\d{0,2}[ .\-]?\(?\d{3}\)?[ .\-]?\d{3}[ .\-]?\d{4}\b",
158
+ &[],
159
+ P_PHONE,
160
+ MaskKind::Tail4,
161
+ ),
162
+ // Bare E.164
163
+ rule("phone", r"\+[1-9]\d{7,14}\b", &[], P_PHONE, MaskKind::Tail4),
164
+ // NNN-NNN-NNNN / NNN.NNN.NNNN / NNN NNN NNNN
165
+ checked(
166
+ "phone",
167
+ r"\b\d{3}[ .\-]\d{3}[ .\-]\d{4}\b",
168
+ &[],
169
+ P_PHONE,
170
+ MaskKind::Tail4,
171
+ validate::word_start,
172
+ ),
173
+ // (NNN) NNN-NNNN
174
+ rule(
175
+ "phone",
176
+ r"\(\d{3}\)[ .\-]?\d{3}[ .\-]?\d{4}\b",
177
+ &[],
178
+ P_PHONE,
179
+ MaskKind::Tail4,
180
+ ),
181
+ ],
182
+ ),
183
+ (
184
+ "phone_in",
185
+ &[
186
+ rule(
187
+ "phone_in",
188
+ r"\+91[ .\-]?[6-9]\d{9}\b",
189
+ &["+91"],
190
+ P_PHONE_IN,
191
+ MaskKind::Tail4,
192
+ ),
193
+ checked(
194
+ "phone_in",
195
+ r"\b0?[6-9]\d{9}\b",
196
+ &[],
197
+ P_PHONE_IN,
198
+ MaskKind::Tail4,
199
+ validate::word_start,
200
+ ),
201
+ ],
202
+ ),
203
+ (
204
+ "credit_card",
205
+ &[checked(
206
+ "credit_card",
207
+ r"\b\d(?:[ \-]?\d){12,18}\b",
208
+ &[],
209
+ P_CARD,
210
+ MaskKind::Tail4,
211
+ validate::credit_card,
212
+ )],
213
+ ),
214
+ (
215
+ "aadhaar",
216
+ &[checked(
217
+ "aadhaar",
218
+ r"\b[2-9]\d{3}[ \-]?\d{4}[ \-]?\d{4}\b",
219
+ &[],
220
+ P_AADHAAR,
221
+ MaskKind::Tail4,
222
+ validate::aadhaar,
223
+ )],
224
+ ),
225
+ (
226
+ "pan",
227
+ &[checked(
228
+ "pan",
229
+ r"\b[A-Z]{5}[0-9]{4}[A-Z]\b",
230
+ &[],
231
+ P_PAN,
232
+ MaskKind::Tail4,
233
+ validate::pan,
234
+ )],
235
+ ),
236
+ (
237
+ "upi",
238
+ &[checked(
239
+ "upi",
240
+ r"\b[A-Za-z0-9][A-Za-z0-9.\-_]{1,60}@[A-Za-z]{2,64}\b",
241
+ &[],
242
+ P_UPI,
243
+ MaskKind::Full,
244
+ validate::upi_vpa,
245
+ )],
246
+ ),
247
+ (
248
+ "ssn",
249
+ &[
250
+ checked(
251
+ "ssn",
252
+ r"\b\d{3}-\d{2}-\d{4}\b",
253
+ &[],
254
+ P_SSN,
255
+ MaskKind::Tail4,
256
+ validate::ssn,
257
+ ),
258
+ checked(
259
+ "ssn",
260
+ r"\b\d{3} \d{2} \d{4}\b",
261
+ &[],
262
+ P_SSN,
263
+ MaskKind::Tail4,
264
+ validate::ssn,
265
+ ),
266
+ ],
267
+ ),
268
+ (
269
+ "iban",
270
+ &[checked(
271
+ "iban",
272
+ r"\b[A-Z]{2}\d{2}(?:[ \-]?[A-Z0-9]){11,30}\b",
273
+ &[],
274
+ P_IBAN,
275
+ MaskKind::Tail4,
276
+ validate::iban,
277
+ )],
278
+ ),
279
+ (
280
+ "ip",
281
+ &[checked(
282
+ "ip",
283
+ r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
284
+ &[],
285
+ P_IPV4,
286
+ MaskKind::Tail4,
287
+ validate::ipv4,
288
+ )],
289
+ ),
290
+ (
291
+ "ipv6",
292
+ &[checked(
293
+ "ipv6",
294
+ r"(?:[0-9A-Fa-f]{0,4}:){2,7}(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|[0-9A-Fa-f]{0,4})",
295
+ &[],
296
+ P_IPV6,
297
+ MaskKind::Full,
298
+ validate::ipv6,
299
+ )],
300
+ ),
301
+ (
302
+ "mac",
303
+ &[
304
+ rule(
305
+ "mac",
306
+ r"\b[0-9A-Fa-f]{2}(?:[:\-][0-9A-Fa-f]{2}){5}\b",
307
+ &[],
308
+ P_MAC,
309
+ MaskKind::Tail4,
310
+ ),
311
+ // Cisco three-group form.
312
+ rule(
313
+ "mac",
314
+ r"\b[0-9A-Fa-f]{4}(?:\.[0-9A-Fa-f]{4}){2}\b",
315
+ &[],
316
+ P_MAC,
317
+ MaskKind::Tail4,
318
+ ),
319
+ ],
320
+ ),
321
+ (
322
+ "jwt",
323
+ &[rule(
324
+ "jwt",
325
+ r"\beyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]*",
326
+ &["eyJ"],
327
+ P_JWT,
328
+ MaskKind::Full,
329
+ )],
330
+ ),
331
+ (
332
+ "aws_key",
333
+ &[rule(
334
+ "aws_key",
335
+ r"\bA(?:KIA|SIA|IDA|ROA|GPA|NPA|NVA|PKA|IPA)[0-9A-Z]{16}\b",
336
+ &[
337
+ "AKIA", "ASIA", "AIDA", "AROA", "AGPA", "ANPA", "ANVA", "APKA", "AIPA",
338
+ ],
339
+ P_AWS,
340
+ MaskKind::Full,
341
+ )],
342
+ ),
343
+ (
344
+ "private_key",
345
+ &[rule(
346
+ "private_key",
347
+ r"(?s)-----BEGIN[ A-Z0-9]*PRIVATE KEY(?: BLOCK)?-----.*?-----END[ A-Z0-9]*PRIVATE KEY(?: BLOCK)?-----",
348
+ &["-----BEGIN"],
349
+ P_PRIVATE_KEY,
350
+ MaskKind::Full,
351
+ )],
352
+ ),
353
+ ("api_key", API_KEY_RULES),
354
+ (
355
+ "password_pair",
356
+ &[
357
+ captured_checked(
358
+ "password_pair",
359
+ r#"(?i)(?:password|passwd|pwd|secret|api[_\-]?key|apikey|access[_\-]?token|auth[_\-]?token|token)["']?\s*[:=]\s*"([^"\n]{1,256})""#,
360
+ PASSWORD_ANCHORS,
361
+ P_PASSWORD,
362
+ validate::not_redaction_token,
363
+ ),
364
+ captured_checked(
365
+ "password_pair",
366
+ r#"(?i)(?:password|passwd|pwd|secret|api[_\-]?key|apikey|access[_\-]?token|auth[_\-]?token|token)["']?\s*[:=]\s*'([^'\n]{1,256})'"#,
367
+ PASSWORD_ANCHORS,
368
+ P_PASSWORD,
369
+ validate::not_redaction_token,
370
+ ),
371
+ captured_checked(
372
+ "password_pair",
373
+ r#"(?i)(?:password|passwd|pwd|secret|api[_\-]?key|apikey|access[_\-]?token|auth[_\-]?token|token)["']?\s*[:=]\s*([^\s,;&"'}\]]{1,256})"#,
374
+ PASSWORD_ANCHORS,
375
+ P_PASSWORD + 1,
376
+ validate::not_redaction_token,
377
+ ),
378
+ ],
379
+ ),
380
+ (
381
+ "url_credentials",
382
+ &[captured(
383
+ "url_credentials",
384
+ r"[A-Za-z][A-Za-z0-9+.\-]*://[^\s/:@]+:([^\s/@]+)@",
385
+ &["://"],
386
+ P_URL_CREDS,
387
+ )],
388
+ ),
389
+ ];
390
+
391
+ const PASSWORD_ANCHORS: &[&str] = &[
392
+ "password", "passwd", "pwd", "secret", "apikey", "api_key", "api-key", "token",
393
+ ];
394
+
395
+ /// A curated subset of gitleaks-style provider rules. Every entry here is a
396
+ /// vendor-documented, structurally unambiguous credential format — no
397
+ /// entropy heuristics, because those false-positive on hashes and UUIDs.
398
+ static API_KEY_RULES: &[Rule] = &[
399
+ // GitHub classic PAT / OAuth / user-to-server / server-to-server / refresh
400
+ rule(
401
+ "api_key",
402
+ r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b",
403
+ &["ghp_", "gho_", "ghu_", "ghs_", "ghr_"],
404
+ P_API_KEY,
405
+ MaskKind::Full,
406
+ ),
407
+ // GitHub fine-grained PAT
408
+ rule(
409
+ "api_key",
410
+ r"\bgithub_pat_[A-Za-z0-9_]{60,120}\b",
411
+ &["github_pat_"],
412
+ P_API_KEY,
413
+ MaskKind::Full,
414
+ ),
415
+ // GitLab PAT
416
+ rule(
417
+ "api_key",
418
+ r"\bglpat-[A-Za-z0-9_\-]{20,64}\b",
419
+ &["glpat-"],
420
+ P_API_KEY,
421
+ MaskKind::Full,
422
+ ),
423
+ // Slack tokens
424
+ rule(
425
+ "api_key",
426
+ r"\bxox[baprse]-[A-Za-z0-9\-]{10,72}\b",
427
+ &["xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-", "xoxe-"],
428
+ P_API_KEY,
429
+ MaskKind::Full,
430
+ ),
431
+ // Slack incoming webhook
432
+ rule(
433
+ "api_key",
434
+ r"https://hooks\.slack\.com/services/[A-Za-z0-9_/\-]{20,}",
435
+ &["hooks.slack.com"],
436
+ P_API_KEY,
437
+ MaskKind::Full,
438
+ ),
439
+ // Stripe secret / restricted / publishable
440
+ rule(
441
+ "api_key",
442
+ r"\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,247}\b",
443
+ &[
444
+ "sk_live_", "sk_test_", "rk_live_", "rk_test_", "pk_live_", "pk_test_",
445
+ ],
446
+ P_API_KEY,
447
+ MaskKind::Full,
448
+ ),
449
+ // Anthropic
450
+ rule(
451
+ "api_key",
452
+ r"\bsk-ant-[A-Za-z0-9_\-]{20,120}\b",
453
+ &["sk-ant-"],
454
+ P_API_KEY,
455
+ MaskKind::Full,
456
+ ),
457
+ // OpenAI (classic and project-scoped)
458
+ rule(
459
+ "api_key",
460
+ r"\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,160}\b",
461
+ &["sk-"],
462
+ P_API_KEY,
463
+ MaskKind::Full,
464
+ ),
465
+ // Google API key
466
+ rule(
467
+ "api_key",
468
+ r"\bAIza[0-9A-Za-z_\-]{35}\b",
469
+ &["AIza"],
470
+ P_API_KEY,
471
+ MaskKind::Full,
472
+ ),
473
+ // SendGrid
474
+ rule(
475
+ "api_key",
476
+ r"\bSG\.[A-Za-z0-9_\-]{16,32}\.[A-Za-z0-9_\-]{16,64}\b",
477
+ &["SG."],
478
+ P_API_KEY,
479
+ MaskKind::Full,
480
+ ),
481
+ // Twilio API key / account SID
482
+ rule(
483
+ "api_key",
484
+ r"\b(?:SK|AC)[0-9a-fA-F]{32}\b",
485
+ &[],
486
+ P_API_KEY,
487
+ MaskKind::Full,
488
+ ),
489
+ // npm
490
+ rule(
491
+ "api_key",
492
+ r"\bnpm_[A-Za-z0-9]{36}\b",
493
+ &["npm_"],
494
+ P_API_KEY,
495
+ MaskKind::Full,
496
+ ),
497
+ // PyPI upload token
498
+ rule(
499
+ "api_key",
500
+ r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_\-]{50,}",
501
+ &["pypi-AgEIcHlwaS5vcmc"],
502
+ P_API_KEY,
503
+ MaskKind::Full,
504
+ ),
505
+ // Shopify access tokens
506
+ rule(
507
+ "api_key",
508
+ r"\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b",
509
+ &["shpat_", "shpca_", "shppa_", "shpss_"],
510
+ P_API_KEY,
511
+ MaskKind::Full,
512
+ ),
513
+ // Square
514
+ rule(
515
+ "api_key",
516
+ r"\bsq0(?:atp|csp|idp)-[A-Za-z0-9_\-]{22,64}\b",
517
+ &["sq0atp-", "sq0csp-", "sq0idp-"],
518
+ P_API_KEY,
519
+ MaskKind::Full,
520
+ ),
521
+ // Mailgun
522
+ rule(
523
+ "api_key",
524
+ r"\bkey-[0-9a-f]{32}\b",
525
+ &["key-"],
526
+ P_API_KEY,
527
+ MaskKind::Full,
528
+ ),
529
+ // DigitalOcean
530
+ rule(
531
+ "api_key",
532
+ r"\bdop_v1_[a-f0-9]{64}\b",
533
+ &["dop_v1_"],
534
+ P_API_KEY,
535
+ MaskKind::Full,
536
+ ),
537
+ // Telegram bot token
538
+ rule(
539
+ "api_key",
540
+ r"\b\d{8,10}:AA[A-Za-z0-9_\-]{33}\b",
541
+ &[":AA"],
542
+ P_API_KEY,
543
+ MaskKind::Full,
544
+ ),
545
+ // AWS secret access key, which only has shape in context
546
+ captured(
547
+ "api_key",
548
+ r#"(?i)aws[_\-]?(?:secret[_\-]?)?access[_\-]?key["']?\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})"#,
549
+ &["aws"],
550
+ P_API_KEY,
551
+ ),
552
+ ];
553
+
554
+ /// Detector keys enabled by default: everything except the region packs.
555
+ pub const DEFAULTS: &[&str] = &[
556
+ "email",
557
+ "phone",
558
+ "credit_card",
559
+ "ssn",
560
+ "iban",
561
+ "ip",
562
+ "ipv6",
563
+ "mac",
564
+ "jwt",
565
+ "aws_key",
566
+ "api_key",
567
+ "private_key",
568
+ "password_pair",
569
+ "url_credentials",
570
+ ];
571
+
572
+ /// The opt-in India pack (DPDP Act shaped).
573
+ pub const INDIA: &[&str] = &["phone_in", "aadhaar", "pan", "upi"];
574
+
575
+ /// Rules for a detector key, or `None` if the key is unknown.
576
+ pub fn rules_for(key: &str) -> Option<&'static [Rule]> {
577
+ REGISTRY
578
+ .iter()
579
+ .find(|(k, _)| *k == key)
580
+ .map(|(_, rules)| *rules)
581
+ }
582
+
583
+ /// Every detector key, for error messages and `Scrubber.detectors`.
584
+ pub fn all_keys() -> Vec<&'static str> {
585
+ REGISTRY.iter().map(|(k, _)| *k).collect()
586
+ }
587
+
588
+ #[cfg(test)]
589
+ mod tests {
590
+ use super::*;
591
+
592
+ #[test]
593
+ fn every_registry_pattern_compiles() {
594
+ for (key, rules) in REGISTRY {
595
+ for (i, r) in rules.iter().enumerate() {
596
+ regex::Regex::new(r.pattern)
597
+ .unwrap_or_else(|e| panic!("{key} rule {i} failed to compile: {e}"));
598
+ }
599
+ }
600
+ }
601
+
602
+ #[test]
603
+ fn capture_groups_exist_where_declared() {
604
+ for (key, rules) in REGISTRY {
605
+ for (i, r) in rules.iter().enumerate() {
606
+ let re = regex::Regex::new(r.pattern).unwrap();
607
+ assert!(
608
+ re.captures_len() > r.capture,
609
+ "{key} rule {i} declares capture {} but has {} groups",
610
+ r.capture,
611
+ re.captures_len() - 1
612
+ );
613
+ }
614
+ }
615
+ }
616
+
617
+ #[test]
618
+ fn defaults_and_india_are_registered_and_disjoint() {
619
+ for key in DEFAULTS.iter().chain(INDIA.iter()) {
620
+ assert!(rules_for(key).is_some(), "{key} is not in the registry");
621
+ }
622
+ for key in INDIA {
623
+ assert!(!DEFAULTS.contains(key), "{key} must be opt-in");
624
+ }
625
+ assert_eq!(DEFAULTS.len() + INDIA.len(), REGISTRY.len());
626
+ }
627
+
628
+ #[test]
629
+ fn rule_kinds_match_their_registry_key() {
630
+ for (key, rules) in REGISTRY {
631
+ for r in rules.iter() {
632
+ assert_eq!(r.kind, *key);
633
+ }
634
+ }
635
+ }
636
+ }