Skip to main content

klieo_pii_patterns/
lib.rs

1#![deny(missing_docs)]
2#![deny(rust_2018_idioms)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! # klieo-pii-patterns
6//!
7//! Shared catalogue of regex source strings + their labels, consumed by
8//! `klieo-ops::redactor::DefaultRedactor` and
9//! `klieo-ops-evidence-verify::checks::check_redactions`. Single source of
10//! truth — patterns added here are picked up by both consumers
11//! automatically.
12//!
13//! Consumers compile each entry with `regex::RegexBuilder::case_insensitive(true)`.
14//!
15//! ## Ordering invariant
16//!
17//! `PATTERNS` is iterated in declaration order by `DefaultRedactor` so the
18//! `[REDACTED:<LABEL>]` token produced by one pass cannot collide with a
19//! subsequent pattern. Specifically: `BIC` must precede `IBAN` because the
20//! IBAN replacement token contains characters that the BIC pattern would
21//! otherwise re-match. Do not reorder without verifying redactor tests.
22
23/// Catalogue entry: `(label, regex_source)`.
24///
25/// `label` is the upper-case identifier used in the `[REDACTED:<LABEL>]`
26/// replacement token. `regex_source` is intended to be compiled with
27/// `case_insensitive(true)` by consumers.
28///
29/// The BIC pattern pins case-sensitivity with an inline `(?-i:…)` group so
30/// it stays case-sensitive even under a case-insensitive builder. A BIC is
31/// pure uppercase letters; without the pin, `[A-Z]{6}…` matches any 8- or
32/// 11-letter word once the builder folds case. IBAN is left case-insensitive
33/// on purpose — its `\d{2}` guard prevents word false-positives, and
34/// lowercase IBANs should still be caught.
35pub type PatternEntry = (&'static str, &'static str);
36
37/// PII pattern catalogue. Iteration order is load-bearing — see the
38/// module-level "Ordering invariant" section.
39pub const PATTERNS: &[PatternEntry] = &[
40    ("BIC", r"(?-i:\b[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?\b)"),
41    ("IBAN", r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"),
42    (
43        "EMAIL",
44        r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
45    ),
46    (
47        "JWT",
48        r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b",
49    ),
50    ("IPV4", r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
51    (
52        "PEM",
53        r"-----BEGIN (?:[A-Z ]+)-----[\s\S]+?-----END (?:[A-Z ]+)-----",
54    ),
55    // German Steueridentifikationsnummer — require an explicit prefix to
56    // avoid over-matching any 11-digit number (claim ids, internal counters,
57    // phone numbers). Pre-Phase-B over-broad pattern `\b\d{11}\b` is
58    // replaced with this context-anchored form.
59    (
60        "TAXID_DE",
61        r"(?:steuer[-_ ]?id|steueridentifikationsnummer|tax[-_ ]?id)[:\s=]*\d{11}",
62    ),
63];
64
65/// Pattern labels exported for downstream verifier code that wants to
66/// reference a subset of labels by name (e.g. evidence-verifier bit-4
67/// emits `"unredacted <label>"` messages).
68pub mod labels {
69    /// IBAN — International Bank Account Number.
70    pub const IBAN: &str = "IBAN";
71    /// EMAIL — RFC 5321-ish email address.
72    pub const EMAIL: &str = "EMAIL";
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use regex::RegexBuilder;
79
80    #[test]
81    fn every_pattern_compiles_case_insensitively() {
82        for (label, pat) in PATTERNS {
83            RegexBuilder::new(pat)
84                .case_insensitive(true)
85                .build()
86                .unwrap_or_else(|e| panic!("{label} pattern failed to compile: {e}"));
87        }
88    }
89
90    #[test]
91    fn bic_precedes_iban_in_order() {
92        let positions: std::collections::HashMap<&str, usize> = PATTERNS
93            .iter()
94            .enumerate()
95            .map(|(i, (label, _))| (*label, i))
96            .collect();
97        assert!(positions["BIC"] < positions["IBAN"]);
98    }
99
100    #[test]
101    fn taxid_de_requires_prefix() {
102        // bare 11-digit number must NOT match the new TAXID_DE pattern.
103        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "TAXID_DE").unwrap().1)
104            .case_insensitive(true)
105            .build()
106            .unwrap();
107        assert!(
108            !re.is_match("12345678901"),
109            "bare 11-digit string must not match"
110        );
111        assert!(re.is_match("steuer-id: 12345678901"));
112        assert!(re.is_match("Steueridentifikationsnummer: 12345678901"));
113    }
114
115    #[test]
116    fn iban_pattern_rejects_near_misses() {
117        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "IBAN").unwrap().1)
118            .case_insensitive(true)
119            .build()
120            .unwrap();
121        // Too short to be a valid IBAN-shaped string.
122        assert!(
123            !re.is_match("DE12"),
124            "short string must not match IBAN pattern"
125        );
126        // Plain number without country code prefix.
127        assert!(
128            !re.is_match("12345678901234"),
129            "bare numeric string must not match IBAN"
130        );
131        // Positive: a well-formed IBAN must match.
132        assert!(
133            re.is_match("DE89370400440532013000"),
134            "valid IBAN must match"
135        );
136    }
137
138    #[test]
139    fn email_pattern_rejects_near_misses() {
140        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "EMAIL").unwrap().1)
141            .case_insensitive(true)
142            .build()
143            .unwrap();
144        assert!(
145            !re.is_match("not-an-email"),
146            "bare word must not match EMAIL"
147        );
148        assert!(
149            !re.is_match("missing-at-sign.com"),
150            "missing @ must not match EMAIL"
151        );
152        // Positive: a valid email must match.
153        assert!(re.is_match("user@example.com"), "valid email must match");
154    }
155
156    #[test]
157    fn bic_pattern_rejects_near_misses() {
158        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "BIC").unwrap().1)
159            .case_insensitive(true)
160            .build()
161            .unwrap();
162        // Too short — fewer than 8 chars.
163        assert!(!re.is_match("ABCD12"), "5-char string must not match BIC");
164        // Positive: a well-formed 8-char BIC must match.
165        assert!(re.is_match("DEUTDEDB"), "valid 8-char BIC must match");
166    }
167
168    #[test]
169    fn bic_stays_case_sensitive_under_insensitive_builder() {
170        // Consumers (e.g. klieo-ops DefaultRedactor) compile with
171        // case_insensitive(true). The inline (?-i:) in the BIC source must
172        // keep it case-sensitive so ordinary 8/11-letter words are NOT
173        // redacted as bank codes.
174        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "BIC").unwrap().1)
175            .case_insensitive(true)
176            .build()
177            .unwrap();
178        for word in ["individuals", "potentially", "decision", "employee"] {
179            assert!(
180                !re.is_match(word),
181                "ordinary word `{word}` must not match BIC"
182            );
183        }
184        assert!(re.is_match("DEUTDEFF"), "uppercase BIC must still match");
185        assert!(
186            !re.is_match("deutdeff"),
187            "lowercase BIC-shaped token must not match"
188        );
189    }
190
191    #[test]
192    fn jwt_pattern_rejects_near_misses() {
193        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "JWT").unwrap().1)
194            .case_insensitive(true)
195            .build()
196            .unwrap();
197        // Does not start with eyJ.
198        assert!(
199            !re.is_match("header.payload.sig"),
200            "non-base64url JWT prefix must not match"
201        );
202        // Only two segments (missing signature).
203        assert!(
204            !re.is_match("eyJhbGc.eyJzdWI"),
205            "two-segment token must not match JWT pattern"
206        );
207        // Positive: three-segment eyJ token must match.
208        assert!(
209            re.is_match("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.abc123"),
210            "valid three-segment JWT must match"
211        );
212    }
213}