Skip to main content

redact_core/recognizers/
pattern.rs

1// Copyright 2026 Censgate LLC.
2// Licensed under the Apache License, Version 2.0. See the LICENSE file
3// in the project root for license information.
4
5use super::{validation::validate_entity, Recognizer, RecognizerResult};
6use crate::types::EntityType;
7use anyhow::Result;
8use lazy_static::lazy_static;
9use regex::Regex;
10use std::collections::HashMap;
11
12/// A built-in detection pattern expressed as data.
13///
14/// Secrets are kept in a flat table rather than as imperative registration
15/// calls so the full set can be reviewed at a glance and mirrored by an
16/// external pattern pack without touching detection logic.
17struct SecretPattern {
18    entity_type: EntityType,
19    regex: &'static str,
20    score: f32,
21}
22
23/// Built-in secret/credential detection patterns.
24///
25/// These are anchored/prefixed patterns only (e.g. `AKIA...`, `ghp_...`,
26/// `sk-ant-...`) chosen for high precision. Generic catch-alls like
27/// `api_key=...` or `password=...` are deliberately excluded here;
28/// those go through `GenericSecretRecognizer` (entropy-gated).
29const SECRET_PATTERNS: &[SecretPattern] = &[
30    SecretPattern {
31        entity_type: EntityType::PrivateKey,
32        regex: r"-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----",
33        score: 0.98,
34    },
35    SecretPattern {
36        entity_type: EntityType::JwtToken,
37        regex: r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*",
38        score: 0.90,
39    },
40    SecretPattern {
41        entity_type: EntityType::AwsAccessKey,
42        regex: r"\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[0-9A-Z]{16}\b",
43        score: 0.95,
44    },
45    SecretPattern {
46        entity_type: EntityType::AwsAccessKey,
47        // Padding `=` is not a word character, so a trailing `\b` drops the
48        // pad and can emit a truncated token. Capture the full key; the
49        // trailing delimiter is not part of the span.
50        regex: r"\b(ABSK[A-Za-z0-9+/]{109,269}={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
51        score: 0.95,
52    },
53    SecretPattern {
54        entity_type: EntityType::AwsAccessKey,
55        regex: r"\bbedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t\b",
56        score: 0.95,
57    },
58    SecretPattern {
59        entity_type: EntityType::GithubToken,
60        regex: r"\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b",
61        score: 0.95,
62    },
63    SecretPattern {
64        entity_type: EntityType::GitlabToken,
65        regex: r"\bglpat-[A-Za-z0-9_-]{20}\b",
66        score: 0.95,
67    },
68    SecretPattern {
69        entity_type: EntityType::GitlabToken,
70        regex: r"\bglpat-[0-9a-zA-Z_-]{27,300}\.[0-9a-z]{2}[0-9a-z]{7}\b",
71        score: 0.95,
72    },
73    SecretPattern {
74        entity_type: EntityType::GitlabToken,
75        regex: r"\bglcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}\b",
76        score: 0.95,
77    },
78    SecretPattern {
79        entity_type: EntityType::GitlabToken,
80        regex: r"\bglagent-[A-Za-z0-9_-]{50}\b",
81        score: 0.95,
82    },
83    SecretPattern {
84        entity_type: EntityType::GitlabToken,
85        regex: r"\bgloas-[A-Za-z0-9_-]{64}\b",
86        score: 0.95,
87    },
88    SecretPattern {
89        entity_type: EntityType::GitlabToken,
90        regex: r"\bgldt-[A-Za-z0-9_-]{20}\b",
91        score: 0.95,
92    },
93    SecretPattern {
94        entity_type: EntityType::GitlabToken,
95        regex: r"\bglft-[A-Za-z0-9_-]{20}\b",
96        score: 0.95,
97    },
98    SecretPattern {
99        entity_type: EntityType::GitlabToken,
100        regex: r"\bglptt-[0-9a-fA-F]{40}\b",
101        score: 0.95,
102    },
103    SecretPattern {
104        entity_type: EntityType::SlackToken,
105        regex: r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b",
106        score: 0.95,
107    },
108    SecretPattern {
109        entity_type: EntityType::SlackWebhook,
110        regex: r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]+/B[A-Za-z0-9_]+/[A-Za-z0-9_]{20,}",
111        score: 0.95,
112    },
113    SecretPattern {
114        entity_type: EntityType::StripeApiKey,
115        // Secret (`sk_`) and restricted (`rk_`) keys only. Publishable `pk_`
116        // keys are designed to be embedded in client-side code and are not
117        // secret, so redacting them is noise rather than protection.
118        regex: r"\b(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\b",
119        score: 0.95,
120    },
121    SecretPattern {
122        entity_type: EntityType::GoogleApiKey,
123        regex: r"\bAIza[0-9A-Za-z_-]{35}",
124        score: 0.95,
125    },
126    SecretPattern {
127        entity_type: EntityType::OpenAiApiKey,
128        // Two shapes, kept separate so neither has to be loose. Classic keys
129        // are pure alphanumeric, so requiring that rules out ordinary
130        // hyphenated identifiers (`sk-feature-branch-name`) that a combined
131        // `[A-Za-z0-9_-]{20,}` would otherwise match. Project keys do contain
132        // `-`/`_`, so they carry a longer minimum length instead.
133        regex: r"\bsk-(?:proj-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{32,})",
134        score: 0.90,
135    },
136    SecretPattern {
137        entity_type: EntityType::AnthropicApiKey,
138        regex: r"\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{24,}",
139        score: 0.95,
140    },
141    SecretPattern {
142        entity_type: EntityType::NpmToken,
143        regex: r"\bnpm_[A-Za-z0-9]{36}\b",
144        score: 0.95,
145    },
146    SecretPattern {
147        entity_type: EntityType::PyPiToken,
148        regex: r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}",
149        score: 0.95,
150    },
151    SecretPattern {
152        entity_type: EntityType::SendGridApiKey,
153        regex: r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
154        score: 0.95,
155    },
156    SecretPattern {
157        entity_type: EntityType::TwilioApiKey,
158        regex: r"\bSK[0-9a-fA-F]{32}\b",
159        score: 0.85,
160    },
161    SecretPattern {
162        entity_type: EntityType::TelegramBotToken,
163        regex: r"\b\d{8,10}:AA[A-Za-z0-9_-]{33}",
164        score: 0.95,
165    },
166    SecretPattern {
167        entity_type: EntityType::HashicorpVaultToken,
168        regex: r"\bhv[sbr]\.[A-Za-z0-9_-]{24,}",
169        score: 0.95,
170    },
171    SecretPattern {
172        entity_type: EntityType::DatabaseConnectionString,
173        // The trailing path/query are part of the connection string, so they
174        // are captured too. Without them a redaction leaves `/dbname` and any
175        // query parameters dangling after the placeholder.
176        regex: r"\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|mariadb|redis|amqp|mssql)://[^:@/\s]+:[^@/\s]+@[^\s/]+(?:/[^\s?#]*)?(?:\?[^\s#]*)?",
177        score: 0.90,
178    },
179    SecretPattern {
180        entity_type: EntityType::HuggingFaceToken,
181        regex: r"\b(?:hf_|api_org_)[A-Za-z]{34}\b",
182        score: 0.95,
183    },
184    SecretPattern {
185        entity_type: EntityType::DatabricksToken,
186        regex: r"\bdapi[0-9a-fA-F]{32}(?:-\d)?\b",
187        score: 0.95,
188    },
189    SecretPattern {
190        entity_type: EntityType::DigitalOceanToken,
191        regex: r"\bdo[por]_v1_[0-9a-fA-F]{64}\b",
192        score: 0.95,
193    },
194    SecretPattern {
195        entity_type: EntityType::NotionApiKey,
196        regex: r"\bntn_[0-9]{11}[A-Za-z0-9]{35}\b",
197        score: 0.95,
198    },
199    SecretPattern {
200        entity_type: EntityType::PerplexityApiKey,
201        regex: r"\bpplx-[A-Za-z0-9]{48}\b",
202        score: 0.95,
203    },
204    SecretPattern {
205        entity_type: EntityType::HttpBasicAuth,
206        // The rust `regex` crate has no lookaround. Padding `=` is not a word
207        // character, so a trailing `\b` misses canonically padded tokens.
208        regex: r"(?i)\bBasic\s+([A-Za-z0-9+/]+={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
209        score: 0.95,
210    },
211];
212
213/// Pattern-based recognizer using regex
214#[derive(Debug, Clone)]
215pub struct PatternRecognizer {
216    name: String,
217    patterns: HashMap<EntityType, Vec<CompiledPattern>>,
218    min_score: f32,
219}
220
221#[derive(Debug, Clone)]
222struct CompiledPattern {
223    regex: Regex,
224    score: f32,
225    context_words: Vec<String>,
226}
227
228impl PatternRecognizer {
229    /// Create a new pattern recognizer with default patterns
230    pub fn new() -> Self {
231        let mut recognizer = Self {
232            name: "PatternRecognizer".to_string(),
233            patterns: HashMap::new(),
234            min_score: 0.5,
235        };
236        recognizer.load_default_patterns();
237        recognizer
238    }
239
240    /// Create a new pattern recognizer with custom name
241    pub fn with_name(name: impl Into<String>) -> Self {
242        let mut recognizer = Self::new();
243        recognizer.name = name.into();
244        recognizer
245    }
246
247    /// Set minimum confidence score
248    pub fn with_min_score(mut self, min_score: f32) -> Self {
249        self.min_score = min_score;
250        self
251    }
252
253    /// Add a custom pattern for an entity type
254    pub fn add_pattern(
255        &mut self,
256        entity_type: EntityType,
257        pattern: &str,
258        score: f32,
259    ) -> Result<()> {
260        let regex = Regex::new(pattern)?;
261        let compiled = CompiledPattern {
262            regex,
263            score,
264            context_words: vec![],
265        };
266        self.patterns.entry(entity_type).or_default().push(compiled);
267        Ok(())
268    }
269
270    /// Add a pattern with context words for score boosting
271    pub fn add_pattern_with_context(
272        &mut self,
273        entity_type: EntityType,
274        pattern: &str,
275        score: f32,
276        context_words: Vec<String>,
277    ) -> Result<()> {
278        let regex = Regex::new(pattern)?;
279        let compiled = CompiledPattern {
280            regex,
281            score,
282            context_words,
283        };
284        self.patterns.entry(entity_type).or_default().push(compiled);
285        Ok(())
286    }
287
288    /// Load default patterns for common PII types
289    fn load_default_patterns(&mut self) {
290        // Email addresses. Explicit start/end classes instead of `\b`: CJK
291        // letters are Unicode word characters, so `\b` does not fire between
292        // 邮箱 and the local part. Group 1 is the address only.
293        let _ = self.add_pattern(
294            EntityType::EmailAddress,
295            r"(?:^|[^A-Za-z0-9._%+-])([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})(?:[^A-Za-z0-9]|$)",
296            0.8,
297        );
298
299        // Phone numbers (US/international format with separators)
300        // Requires at least one separator or parentheses to avoid matching
301        // consecutive digits in credit cards, ISBNs, etc.
302        // Matches: (555) 123-4567, 555-123-4567, 555.123.4567, 555 123 4567
303        // Does NOT match: 5551234567 (no separators - too prone to false positives)
304        let _ = self.add_pattern(
305            EntityType::PhoneNumber,
306            r"\(\d{3}\)[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]?\d{4}\b",
307            0.7,
308        );
309
310        // Credit cards: 13–19 digits, including space- or dash-separated
311        // groups (Visa 4-4-4-4, Amex 4-6-5). Luhn runs on the stripped
312        // digits. The start class excludes ASCII letters so a compact IBAN
313        // body is not taken as a card; CJK still starts a match.
314        let _ = self.add_pattern(
315            EntityType::CreditCard,
316            r"(?:^|[^0-9A-Za-z])((?:\d[ \-]?){12,18}\d)(?:[^0-9]|$)",
317            0.9,
318        );
319
320        // US SSN (simplified pattern - Rust regex doesn't support lookahead)
321        // Pattern matches XXX-XX-XXXX format
322        let _ = self.add_pattern(EntityType::UsSsn, r"\b\d{3}-\d{2}-\d{4}\b", 0.9);
323
324        // IP Address (IPv4)
325        let _ = self.add_pattern(
326            EntityType::IpAddress,
327            r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
328            0.8,
329        );
330
331        // URL
332        let _ = self.add_pattern(
333            EntityType::Url,
334            r"\b(?:https?://|www\.)[a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+(?:/[^\s]*)?\b",
335            0.7,
336        );
337
338        // Domain name (standalone, without protocol - avoid overlapping URL)
339        let _ = self.add_pattern(
340            EntityType::DomainName,
341            r"\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}\b",
342            0.7,
343        );
344
345        // GUID/UUID
346        let _ = self.add_pattern(
347            EntityType::Guid,
348            r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
349            0.9,
350        );
351
352        // MAC Address
353        let _ = self.add_pattern(
354            EntityType::MacAddress,
355            r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
356            0.9,
357        );
358
359        // UK NHS Number
360        let _ = self.add_pattern_with_context(
361            EntityType::UkNhs,
362            r"\b(?:\d{3}\s?\d{3}\s?\d{4}|\d{10})\b",
363            0.6,
364            vec![
365                "NHS".to_string(),
366                "patient".to_string(),
367                "health".to_string(),
368            ],
369        );
370
371        // UK National Insurance Number
372        let _ = self.add_pattern(
373            EntityType::UkNino,
374            r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]{1}\b",
375            0.85,
376        );
377
378        // UK Postcode
379        let _ = self.add_pattern(
380            EntityType::UkPostcode,
381            r"\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b",
382            0.75,
383        );
384
385        // UK Sort Code
386        let _ = self.add_pattern(EntityType::UkSortCode, r"\b\d{2}-\d{2}-\d{2}\b", 0.7);
387
388        // IBAN, compact or spaced in groups of 4. Checksum uses stripped
389        // alphanumerics. Group 1 is the IBAN only.
390        let _ = self.add_pattern(
391            EntityType::IbanCode,
392            r"(?:^|[^A-Z0-9])([A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30})(?:[^A-Z0-9]|$)",
393            0.75,
394        );
395
396        // Bitcoin Address
397        let _ = self.add_pattern(
398            EntityType::BtcAddress,
399            r"\b(?:bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}\b",
400            0.85,
401        );
402
403        // Ethereum Address
404        let _ = self.add_pattern(EntityType::EthAddress, r"\b0x[a-fA-F0-9]{40}\b", 0.9);
405
406        // MD5 Hash
407        let _ = self.add_pattern(EntityType::Md5Hash, r"\b[a-fA-F0-9]{32}\b", 0.6);
408
409        // SHA1 Hash
410        let _ = self.add_pattern(EntityType::Sha1Hash, r"\b[a-fA-F0-9]{40}\b", 0.6);
411
412        // SHA256 Hash
413        let _ = self.add_pattern(EntityType::Sha256Hash, r"\b[a-fA-F0-9]{64}\b", 0.6);
414
415        // US ZIP Code (5 digits or ZIP+4 format)
416        let _ = self.add_pattern(
417            EntityType::UsZipCode,
418            r"\b\d{5}(?:-\d{4})?\b",
419            0.6, // Lower confidence as could be other 5-digit numbers
420        );
421
422        // PO Box
423        let _ = self.add_pattern_with_context(
424            EntityType::PoBox,
425            r"\b(?:P\.?\s?O\.?|POST\s+OFFICE)\s*BOX\s+\d+\b",
426            0.85,
427            vec![
428                "address".to_string(),
429                "mail".to_string(),
430                "ship".to_string(),
431            ],
432        );
433
434        // ISBN (10 or 13 digit formats)
435        let _ = self.add_pattern(
436            EntityType::Isbn,
437            r"\b(?:ISBN(?:-1[03])?:?\s*)?(?:\d{9}[\dX]|\d{13})\b",
438            0.8,
439        );
440
441        // Generic Passport Number (alphanumeric, 6-9 characters)
442        let _ = self.add_pattern_with_context(
443            EntityType::PassportNumber,
444            r"\b[A-Z]{1,2}\d{6,9}\b",
445            0.7,
446            vec!["passport".to_string(), "travel".to_string()],
447        );
448
449        // Medical Record Number (various formats with MRN context)
450        let _ = self.add_pattern_with_context(
451            EntityType::MedicalRecordNumber,
452            r"\b(?:MRN|Medical\s*Record|Patient\s*ID):?\s*[A-Z0-9]{6,12}\b",
453            0.85,
454            vec![
455                "patient".to_string(),
456                "medical".to_string(),
457                "hospital".to_string(),
458            ],
459        );
460
461        // Age (with context)
462        let _ = self.add_pattern_with_context(
463            EntityType::Age,
464            r"\b(?:age|aged|years old):?\s*(\d{1,3})\b",
465            0.8,
466            vec!["years".to_string(), "old".to_string(), "age".to_string()],
467        );
468
469        // Date/Time (ISO format and common variants)
470        let _ = self.add_pattern(
471            EntityType::DateTime,
472            r"\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?)?\b",
473            0.5,
474        );
475
476        // US Driver's License (varies by state, common formats)
477        // More specific patterns to avoid false positives:
478        // - Letter prefix followed by 6-8 digits (most states)
479        // - State-specific format with dashes
480        // Base score is low (0.4) - requires context to reach min_score
481        let _ = self.add_pattern_with_context(
482            EntityType::UsDriverLicense,
483            r"\b[A-Z]\d{6,8}\b|\b[A-Z]\d{3}-\d{4}-\d{4}\b",
484            0.4,
485            vec![
486                "driver".to_string(),
487                "license".to_string(),
488                "DL".to_string(),
489                "DMV".to_string(),
490            ],
491        );
492
493        // US Passport Number (9 digits, sometimes with letter prefix)
494        // Base score is low - requires context
495        let _ = self.add_pattern_with_context(
496            EntityType::UsPassport,
497            r"\b[A-Z]?\d{9}\b",
498            0.4,
499            vec![
500                "passport".to_string(),
501                "travel".to_string(),
502                "state department".to_string(),
503            ],
504        );
505
506        // US Bank Account Number. Strong account phrases only; a single
507        // weak word (bank, routing, order) must not reach min_score.
508        // 8–19 digits so a 16–19 digit value is consumed whole.
509        let _ = self.add_pattern_with_context(
510            EntityType::UsBankNumber,
511            r"(?:^|[^0-9A-Za-z])(\d{8,19})(?:[^0-9]|$)",
512            0.3,
513            vec![
514                "account number".to_string(),
515                "acct".to_string(),
516                "account no".to_string(),
517                "kontonummer".to_string(),
518                "número de cuenta".to_string(),
519                "số tài khoản".to_string(),
520                "账号".to_string(),
521                "口座番号".to_string(),
522            ],
523        );
524
525        // UK Driver's License (DVLA format: 5 letters + 6 digits + 2 letters + 3 digits + 2 letters)
526        // Example: MORGA753116SM9IJ 35
527        let _ = self.add_pattern(
528            EntityType::UkDriverLicense,
529            r"\b[A-Z]{5}\d{6}[A-Z0-9]{2}\d[A-Z]{2}\s?\d{2}\b",
530            0.85,
531        );
532
533        // UK Passport Number (9 digits)
534        // Low base score - requires context to avoid matching random 9-digit numbers
535        let _ = self.add_pattern_with_context(
536            EntityType::UkPassportNumber,
537            r"\b\d{9}\b",
538            0.3,
539            vec![
540                "passport".to_string(),
541                "travel".to_string(),
542                "HMPO".to_string(),
543            ],
544        );
545
546        // UK Phone Number (landline: 01/02/03 prefix)
547        let _ = self.add_pattern(
548            EntityType::UkPhoneNumber,
549            r"\b(?:0[1-3]\d{2,3}\s?\d{3}\s?\d{4}|0[1-3]\d{2,3}\s?\d{6,7})\b",
550            0.75,
551        );
552
553        // UK Mobile Number (07 prefix)
554        let _ = self.add_pattern(
555            EntityType::UkMobileNumber,
556            r"\b07\d{3}\s?\d{3}\s?\d{3}\b",
557            0.8,
558        );
559
560        // UK Company Number (Companies House: 8 digits or 2 letters + 6 digits)
561        // Low base score - requires context to avoid matching random 8-digit numbers
562        let _ = self.add_pattern_with_context(
563            EntityType::UkCompanyNumber,
564            r"\b(?:\d{8}|[A-Z]{2}\d{6})\b",
565            0.3,
566            vec![
567                "company".to_string(),
568                "companies house".to_string(),
569                "registration".to_string(),
570                "CRN".to_string(),
571            ],
572        );
573
574        // Medical License Number (various formats with context)
575        let _ = self.add_pattern_with_context(
576            EntityType::MedicalLicense,
577            r"\b(?:MD|DO|NP|PA|RN|LPN)[-\s]?\d{5,10}\b",
578            0.8,
579            vec![
580                "license".to_string(),
581                "medical".to_string(),
582                "physician".to_string(),
583                "doctor".to_string(),
584                "nurse".to_string(),
585            ],
586        );
587
588        // Generic Crypto Wallet (covers various formats beyond BTC/ETH)
589        // Matches Litecoin (L/M/3), Ripple (r), etc.
590        let _ = self.add_pattern_with_context(
591            EntityType::CryptoWallet,
592            r"\b[LMr3][a-km-zA-HJ-NP-Z1-9]{25,34}\b",
593            0.75,
594            vec![
595                "wallet".to_string(),
596                "crypto".to_string(),
597                "address".to_string(),
598                "coin".to_string(),
599            ],
600        );
601
602        // Secrets and credentials - loaded from the flat data table above.
603        // Panic on compile failure: `let _ = add_pattern` previously swallowed
604        // invalid regexes (the rust `regex` crate has no lookaround).
605        for p in SECRET_PATTERNS {
606            self.add_pattern(p.entity_type.clone(), p.regex, p.score)
607                .unwrap_or_else(|e| {
608                    panic!(
609                        "SECRET_PATTERNS regex failed to compile for {:?}: {e}",
610                        p.entity_type
611                    )
612                });
613        }
614    }
615
616    /// Check context words around a match to boost confidence
617    fn check_context(&self, text: &str, start: usize, end: usize, context_words: &[String]) -> f32 {
618        if context_words.is_empty() {
619            return 0.0;
620        }
621
622        let (context_start, context_end) = context_window(text, start, end, CONTEXT_CHARS);
623        let context = text
624            .get(context_start..context_end)
625            .unwrap_or("")
626            .to_lowercase();
627
628        // Count matching context words
629        let matches = context_words
630            .iter()
631            .filter(|word| context.contains(&word.to_lowercase()))
632            .count();
633
634        // Boost score based on context matches (up to +0.3)
635        (matches as f32 / context_words.len() as f32) * 0.3
636    }
637}
638
639/// Context radius in Unicode scalar values, so non-ASCII text gets the same reach as ASCII.
640const CONTEXT_CHARS: usize = 50;
641
642/// Byte range covering `radius` chars before `start` and after `end`.
643///
644/// Both ends are char boundaries. On ASCII text this is identical to
645/// `start.saturating_sub(radius)..(end + radius).min(text.len())`.
646fn context_window(text: &str, start: usize, end: usize, radius: usize) -> (usize, usize) {
647    let start = crate::floor_char_boundary(text, start);
648    let end = crate::ceil_char_boundary(text, end.max(start));
649    let window_start = text[..start]
650        .char_indices()
651        .rev()
652        .take(radius)
653        .last()
654        .map_or(start, |(index, _)| index);
655    let window_end = text[end..]
656        .char_indices()
657        .nth(radius)
658        .map_or(text.len(), |(index, _)| end + index);
659    (window_start, window_end)
660}
661
662impl Default for PatternRecognizer {
663    fn default() -> Self {
664        Self::new()
665    }
666}
667
668impl Recognizer for PatternRecognizer {
669    fn name(&self) -> &str {
670        &self.name
671    }
672
673    fn supported_entities(&self) -> &[EntityType] {
674        lazy_static! {
675            static ref SUPPORTED: Vec<EntityType> = vec![
676                // Contact information
677                EntityType::EmailAddress,
678                EntityType::PhoneNumber,
679                EntityType::IpAddress,
680                EntityType::Url,
681                EntityType::DomainName,
682                // Financial
683                EntityType::CreditCard,
684                EntityType::IbanCode,
685                EntityType::UsBankNumber,
686                // US-specific
687                EntityType::UsSsn,
688                EntityType::UsDriverLicense,
689                EntityType::UsPassport,
690                EntityType::UsZipCode,
691                // UK-specific
692                EntityType::UkNhs,
693                EntityType::UkNino,
694                EntityType::UkPostcode,
695                EntityType::UkSortCode,
696                EntityType::UkDriverLicense,
697                EntityType::UkPassportNumber,
698                EntityType::UkPhoneNumber,
699                EntityType::UkMobileNumber,
700                EntityType::UkCompanyNumber,
701                // Healthcare
702                EntityType::MedicalLicense,
703                EntityType::MedicalRecordNumber,
704                // Generic identifiers
705                EntityType::PassportNumber,
706                EntityType::Age,
707                EntityType::Isbn,
708                EntityType::PoBox,
709                EntityType::DateTime,
710                // Crypto
711                EntityType::CryptoWallet,
712                EntityType::BtcAddress,
713                EntityType::EthAddress,
714                // Technical
715                EntityType::Guid,
716                EntityType::MacAddress,
717                EntityType::Md5Hash,
718                EntityType::Sha1Hash,
719                EntityType::Sha256Hash,
720                // Secrets and credentials
721                EntityType::PrivateKey,
722                EntityType::JwtToken,
723                EntityType::AwsAccessKey,
724                EntityType::GithubToken,
725                EntityType::GitlabToken,
726                EntityType::SlackToken,
727                EntityType::SlackWebhook,
728                EntityType::StripeApiKey,
729                EntityType::GoogleApiKey,
730                EntityType::OpenAiApiKey,
731                EntityType::AnthropicApiKey,
732                EntityType::NpmToken,
733                EntityType::PyPiToken,
734                EntityType::SendGridApiKey,
735                EntityType::TwilioApiKey,
736                EntityType::TelegramBotToken,
737                EntityType::HashicorpVaultToken,
738                EntityType::DatabaseConnectionString,
739                EntityType::HuggingFaceToken,
740                EntityType::DatabricksToken,
741                EntityType::DigitalOceanToken,
742                EntityType::NotionApiKey,
743                EntityType::PerplexityApiKey,
744                EntityType::HttpBasicAuth,
745            ];
746        }
747        &SUPPORTED
748    }
749
750    fn analyze(&self, text: &str, _language: &str) -> Result<Vec<RecognizerResult>> {
751        let _span = crate::operations_enabled()
752            .then(|| tracing::info_span!("redact.gateway.detect.patterns").entered());
753        let mut results = Vec::new();
754
755        for (entity_type, patterns) in &self.patterns {
756            for pattern in patterns {
757                for capture in pattern.regex.captures_iter(text) {
758                    // Prefer group 1 only for patterns that capture a value-only
759                    // span (HTTP Basic credentials; padded AWS Bedrock keys).
760                    // PII patterns such as AGE also have a group 1 — using it
761                    // globally would shrink those spans to the digits alone.
762                    if let Some(matched) = match entity_type {
763                        EntityType::HttpBasicAuth
764                        | EntityType::AwsAccessKey
765                        | EntityType::EmailAddress
766                        | EntityType::CreditCard
767                        | EntityType::IbanCode
768                        | EntityType::UsBankNumber => capture.get(1).or_else(|| capture.get(0)),
769                        _ => capture.get(0),
770                    } {
771                        let start = matched.start();
772                        let end = matched.end();
773                        let matched_text = matched.as_str();
774
775                        // Base score from pattern
776                        let mut score = pattern.score;
777
778                        if *entity_type == EntityType::UsBankNumber {
779                            // Require a strong account phrase. The proportional
780                            // boost is too small across the phrase list, and a
781                            // weak word must stay below min_score.
782                            if self.check_context(text, start, end, &pattern.context_words) == 0.0 {
783                                continue;
784                            }
785                            score = 0.6;
786                        } else if !pattern.context_words.is_empty() {
787                            score += self.check_context(text, start, end, &pattern.context_words);
788                            score = score.min(1.0);
789                        }
790
791                        // Apply validation (checksum, format validation)
792                        // This can reduce or zero out the score for invalid matches
793                        let validation_factor = validate_entity(entity_type, matched_text);
794                        score *= validation_factor;
795
796                        if score >= self.min_score {
797                            results.push(
798                                RecognizerResult::new(
799                                    entity_type.clone(),
800                                    start,
801                                    end,
802                                    score,
803                                    self.name(),
804                                )
805                                .with_text(text),
806                            );
807                        }
808                    }
809                }
810            }
811        }
812
813        Ok(results)
814    }
815
816    fn min_score(&self) -> f32 {
817        self.min_score
818    }
819
820    fn supports_language(&self, _language: &str) -> bool {
821        true
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn context_window_matches_legacy_byte_window_on_ascii() {
831        let text: String = (0..300).map(|i| (b'a' + (i % 26) as u8) as char).collect();
832        for start in 0..text.len() {
833            for end in start..(start + 20).min(text.len()) {
834                assert_eq!(
835                    context_window(&text, start, end, 50),
836                    (start.saturating_sub(50), (end + 50).min(text.len())),
837                    "{start}..{end}"
838                );
839            }
840        }
841    }
842
843    #[test]
844    fn context_window_counts_vietnamese_chars_not_bytes() {
845        let text = format!("bank {}841234567890", "ạ ".repeat(20));
846        let start = text.find('8').unwrap();
847        let (window_start, window_end) = context_window(&text, start, start + 12, 50);
848        assert_eq!(text[window_start..start].chars().count(), 45);
849        assert_eq!(window_end, text.len());
850        assert!(text[window_start..window_end].contains("bank"));
851    }
852
853    #[test]
854    fn check_context_does_not_panic_on_multibyte_boundaries() {
855        let recognizer = PatternRecognizer::new();
856        for text in [
857            format!("841234567890{}ó", " ".repeat(49)),
858            format!("ử{}841234567890", " ".repeat(49)),
859            format!("841234567890{}📄", " ".repeat(48)),
860            "Mã đơn hàng 20260916 đã được xử lý thành công. Tổng cộng: 1.250.000 đồng.".to_string(),
861        ] {
862            recognizer.analyze(&text, "en").expect("analyze");
863        }
864    }
865
866    #[test]
867    fn test_age_span_includes_label() {
868        let recognizer = PatternRecognizer::new();
869        let text = "age: 42";
870        let results = recognizer.analyze(text, "en").unwrap();
871        let age = results
872            .iter()
873            .find(|r| r.entity_type == EntityType::Age)
874            .expect("AGE");
875        assert_eq!(&text[age.start..age.end], "age: 42");
876    }
877
878    #[test]
879    fn pattern_recognizer_supports_any_language() {
880        let recognizer = PatternRecognizer::new();
881        for language in ["en", "es", "vi", "zh", "de", "fr"] {
882            assert!(
883                recognizer.supports_language(language),
884                "{language} should run pattern detection"
885            );
886        }
887    }
888
889    #[test]
890    fn test_email_detection() {
891        let recognizer = PatternRecognizer::new();
892        let text = "Contact me at john.doe@example.com for details";
893        let results = recognizer.analyze(text, "en").unwrap();
894
895        let email_results: Vec<_> = results
896            .iter()
897            .filter(|r| r.entity_type == EntityType::EmailAddress)
898            .collect();
899        assert_eq!(email_results.len(), 1);
900        assert_eq!(
901            email_results[0].text,
902            Some("john.doe@example.com".to_string())
903        );
904        assert!(email_results[0].score >= 0.8);
905    }
906
907    #[test]
908    fn test_phone_detection() {
909        let recognizer = PatternRecognizer::new();
910        let text = "Call me at (555) 123-4567";
911        let results = recognizer.analyze(text, "en").unwrap();
912
913        assert!(!results.is_empty());
914        let phone_result = results
915            .iter()
916            .find(|r| r.entity_type == EntityType::PhoneNumber);
917        assert!(phone_result.is_some());
918    }
919
920    #[test]
921    fn test_credit_card_detection() {
922        let recognizer = PatternRecognizer::new();
923        let text = "Card number: 4532015112830366";
924        let results = recognizer.analyze(text, "en").unwrap();
925
926        assert!(!results.is_empty());
927        let cc_result = results
928            .iter()
929            .find(|r| r.entity_type == EntityType::CreditCard);
930        assert!(cc_result.is_some());
931    }
932
933    #[test]
934    fn test_spaced_amex_4_6_5_uses_full_span() {
935        let recognizer = PatternRecognizer::new();
936        let text = "Amex 3782 822463 10005";
937        let results = recognizer.analyze(text, "es").unwrap();
938        let card = results
939            .iter()
940            .find(|r| r.entity_type == EntityType::CreditCard)
941            .expect("spaced Amex");
942        assert_eq!(card.text.as_deref(), Some("3782 822463 10005"));
943    }
944
945    #[test]
946    fn test_email_and_card_after_cjk() {
947        let recognizer = PatternRecognizer::new();
948        let text = "邮箱maria@example.com卡4532015112830366";
949        let results = recognizer.analyze(text, "zh").unwrap();
950        let email = results
951            .iter()
952            .find(|r| r.entity_type == EntityType::EmailAddress)
953            .expect("CJK-adjacent email");
954        assert_eq!(email.text.as_deref(), Some("maria@example.com"));
955        let card = results
956            .iter()
957            .find(|r| r.entity_type == EntityType::CreditCard)
958            .expect("CJK-adjacent card");
959        assert_eq!(card.text.as_deref(), Some("4532015112830366"));
960    }
961
962    #[test]
963    fn test_spaced_iban_full_span() {
964        let recognizer = PatternRecognizer::new();
965        let text = "IBAN DE89 3704 0044 0532 0130 00";
966        let results = recognizer.analyze(text, "de").unwrap();
967        let iban = results
968            .iter()
969            .find(|r| r.entity_type == EntityType::IbanCode)
970            .expect("spaced IBAN");
971        assert_eq!(iban.text.as_deref(), Some("DE89 3704 0044 0532 0130 00"));
972    }
973
974    #[test]
975    fn test_ssn_detection() {
976        let recognizer = PatternRecognizer::new();
977        let text = "SSN: 123-45-6789";
978        let results = recognizer.analyze(text, "en").unwrap();
979
980        assert!(!results.is_empty());
981        let ssn_result = results.iter().find(|r| r.entity_type == EntityType::UsSsn);
982        assert!(ssn_result.is_some());
983    }
984
985    #[test]
986    fn test_uk_nhs_with_context() {
987        let recognizer = PatternRecognizer::new();
988        // Use a valid NHS number that passes mod-11 checksum: 943 476 5919
989        // Checksum: 9*10 + 4*9 + 3*8 + 4*7 + 7*6 + 6*5 + 5*4 + 9*3 + 1*2 = 220
990        // 11 - (220 % 11) = 11 - 0 = 11 -> 0, but last digit is 9, so let's use a known valid one
991        // Valid NHS: 401 023 2137 (checksum verified)
992        let text = "NHS patient number is 401 023 2137";
993        let results = recognizer.analyze(text, "en").unwrap();
994
995        assert!(!results.is_empty());
996        let nhs_result = results.iter().find(|r| r.entity_type == EntityType::UkNhs);
997        assert!(
998            nhs_result.is_some(),
999            "Should detect NHS number with context"
1000        );
1001        // Score should be boosted due to "NHS" context
1002        if let Some(result) = nhs_result {
1003            assert!(result.score > 0.6);
1004        }
1005    }
1006
1007    #[test]
1008    fn test_uk_nino_detection() {
1009        let recognizer = PatternRecognizer::new();
1010        let text = "NINO: AB123456C";
1011        let results = recognizer.analyze(text, "en").unwrap();
1012
1013        assert!(!results.is_empty());
1014        let nino_result = results.iter().find(|r| r.entity_type == EntityType::UkNino);
1015        assert!(nino_result.is_some());
1016    }
1017
1018    #[test]
1019    fn test_multiple_entities() {
1020        let recognizer = PatternRecognizer::new();
1021        let text = "Email john@example.com, phone (555) 123-4567, SSN 123-45-6789";
1022        let results = recognizer.analyze(text, "en").unwrap();
1023
1024        assert!(results.len() >= 3);
1025        assert!(results
1026            .iter()
1027            .any(|r| r.entity_type == EntityType::EmailAddress));
1028        assert!(results
1029            .iter()
1030            .any(|r| r.entity_type == EntityType::PhoneNumber));
1031        assert!(results.iter().any(|r| r.entity_type == EntityType::UsSsn));
1032    }
1033
1034    #[test]
1035    fn test_custom_pattern() {
1036        let mut recognizer = PatternRecognizer::new();
1037        recognizer
1038            .add_pattern(
1039                EntityType::Custom("CUSTOM_ID".to_string()),
1040                r"\bCID-\d{6}\b",
1041                0.9,
1042            )
1043            .unwrap();
1044
1045        let text = "Your customer ID is CID-123456";
1046        let results = recognizer.analyze(text, "en").unwrap();
1047
1048        let custom_result = results
1049            .iter()
1050            .find(|r| matches!(r.entity_type, EntityType::Custom(_)));
1051        assert!(custom_result.is_some());
1052    }
1053
1054    #[test]
1055    fn test_min_score_filtering() {
1056        let recognizer = PatternRecognizer::new().with_min_score(0.9);
1057        let text = "Date: 2024-01-15"; // Date has score 0.5
1058        let results = recognizer.analyze(text, "en").unwrap();
1059
1060        // Date should be filtered out due to min_score
1061        let date_results = results
1062            .iter()
1063            .filter(|r| r.entity_type == EntityType::DateTime)
1064            .count();
1065        assert_eq!(date_results, 0);
1066    }
1067
1068    #[test]
1069    fn test_uk_driver_license_detection() {
1070        let recognizer = PatternRecognizer::new();
1071        let text = "UK DL: MORGA753116SM9IJ 35";
1072        let results = recognizer.analyze(text, "en").unwrap();
1073
1074        let dl_result = results
1075            .iter()
1076            .find(|r| r.entity_type == EntityType::UkDriverLicense);
1077        assert!(dl_result.is_some(), "Should detect UK driver's license");
1078    }
1079
1080    #[test]
1081    fn test_uk_mobile_detection() {
1082        let recognizer = PatternRecognizer::new();
1083        let text = "Call me on 07700 900123";
1084        let results = recognizer.analyze(text, "en").unwrap();
1085
1086        let mobile_result = results
1087            .iter()
1088            .find(|r| r.entity_type == EntityType::UkMobileNumber);
1089        assert!(mobile_result.is_some(), "Should detect UK mobile number");
1090    }
1091
1092    #[test]
1093    fn test_uk_phone_detection() {
1094        let recognizer = PatternRecognizer::new();
1095        let text = "Office: 0207 123 4567";
1096        let results = recognizer.analyze(text, "en").unwrap();
1097
1098        let phone_result = results
1099            .iter()
1100            .find(|r| r.entity_type == EntityType::UkPhoneNumber);
1101        assert!(phone_result.is_some(), "Should detect UK phone number");
1102    }
1103
1104    #[test]
1105    fn test_medical_license_detection() {
1106        let recognizer = PatternRecognizer::new();
1107        let text = "Medical license: MD-123456789";
1108        let results = recognizer.analyze(text, "en").unwrap();
1109
1110        let license_result = results
1111            .iter()
1112            .find(|r| r.entity_type == EntityType::MedicalLicense);
1113        assert!(license_result.is_some(), "Should detect medical license");
1114    }
1115
1116    #[test]
1117    fn test_supported_entities_count() {
1118        let recognizer = PatternRecognizer::new();
1119        let supported = recognizer.supported_entities();
1120        // 36 original PII + 18 Phase 1 secrets + 6 Phase 2 named types
1121        assert_eq!(
1122            supported.len(),
1123            60,
1124            "Should support 60 pattern-based entity types, got {}",
1125            supported.len()
1126        );
1127    }
1128}