klieo-pii-patterns 3.4.0

Shared PII detection patterns (regex source strings) consumed by klieo-ops and klieo-ops-evidence-verify.
Documentation
#![deny(missing_docs)]
#![deny(rust_2018_idioms)]
#![deny(rustdoc::broken_intra_doc_links)]

//! # klieo-pii-patterns
//!
//! Shared catalogue of regex source strings + their labels, consumed by
//! `klieo-ops::redactor::DefaultRedactor` and
//! `klieo-ops-evidence-verify::checks::check_redactions`. Single source of
//! truth — patterns added here are picked up by both consumers
//! automatically.
//!
//! Consumers compile each entry with `regex::RegexBuilder::case_insensitive(true)`.
//!
//! ## Ordering invariant
//!
//! `PATTERNS` is iterated in declaration order by `DefaultRedactor` so the
//! `[REDACTED:<LABEL>]` token produced by one pass cannot collide with a
//! subsequent pattern. Specifically: `BIC` must precede `IBAN` because the
//! IBAN replacement token contains characters that the BIC pattern would
//! otherwise re-match. Do not reorder without verifying redactor tests.

/// Catalogue entry: `(label, regex_source)`.
///
/// `label` is the upper-case identifier used in the `[REDACTED:<LABEL>]`
/// replacement token. `regex_source` is intended to be compiled with
/// `case_insensitive(true)` by consumers.
///
/// The BIC pattern pins case-sensitivity with an inline `(?-i:…)` group so
/// it stays case-sensitive even under a case-insensitive builder. A BIC is
/// pure uppercase letters; without the pin, `[A-Z]{6}…` matches any 8- or
/// 11-letter word once the builder folds case. IBAN is left case-insensitive
/// on purpose — its `\d{2}` guard prevents word false-positives, and
/// lowercase IBANs should still be caught.
pub type PatternEntry = (&'static str, &'static str);

/// PII pattern catalogue. Iteration order is load-bearing — see the
/// module-level "Ordering invariant" section.
pub const PATTERNS: &[PatternEntry] = &[
    ("BIC", r"(?-i:\b[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?\b)"),
    ("IBAN", r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"),
    (
        "EMAIL",
        r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
    ),
    (
        "JWT",
        r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b",
    ),
    ("IPV4", r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
    (
        "PEM",
        r"-----BEGIN (?:[A-Z ]+)-----[\s\S]+?-----END (?:[A-Z ]+)-----",
    ),
    // German Steueridentifikationsnummer — require an explicit prefix to
    // avoid over-matching any 11-digit number (claim ids, internal counters,
    // phone numbers). Pre-Phase-B over-broad pattern `\b\d{11}\b` is
    // replaced with this context-anchored form.
    (
        "TAXID_DE",
        r"(?:steuer[-_ ]?id|steueridentifikationsnummer|tax[-_ ]?id)[:\s=]*\d{11}",
    ),
];

/// Pattern labels exported for downstream verifier code that wants to
/// reference a subset of labels by name (e.g. evidence-verifier bit-4
/// emits `"unredacted <label>"` messages).
pub mod labels {
    /// IBAN — International Bank Account Number.
    pub const IBAN: &str = "IBAN";
    /// EMAIL — RFC 5321-ish email address.
    pub const EMAIL: &str = "EMAIL";
}

#[cfg(test)]
mod tests {
    use super::*;
    use regex::RegexBuilder;

    #[test]
    fn every_pattern_compiles_case_insensitively() {
        for (label, pat) in PATTERNS {
            RegexBuilder::new(pat)
                .case_insensitive(true)
                .build()
                .unwrap_or_else(|e| panic!("{label} pattern failed to compile: {e}"));
        }
    }

    #[test]
    fn bic_precedes_iban_in_order() {
        let positions: std::collections::HashMap<&str, usize> = PATTERNS
            .iter()
            .enumerate()
            .map(|(i, (label, _))| (*label, i))
            .collect();
        assert!(positions["BIC"] < positions["IBAN"]);
    }

    #[test]
    fn taxid_de_requires_prefix() {
        // bare 11-digit number must NOT match the new TAXID_DE pattern.
        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "TAXID_DE").unwrap().1)
            .case_insensitive(true)
            .build()
            .unwrap();
        assert!(
            !re.is_match("12345678901"),
            "bare 11-digit string must not match"
        );
        assert!(re.is_match("steuer-id: 12345678901"));
        assert!(re.is_match("Steueridentifikationsnummer: 12345678901"));
    }

    #[test]
    fn iban_pattern_rejects_near_misses() {
        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "IBAN").unwrap().1)
            .case_insensitive(true)
            .build()
            .unwrap();
        // Too short to be a valid IBAN-shaped string.
        assert!(
            !re.is_match("DE12"),
            "short string must not match IBAN pattern"
        );
        // Plain number without country code prefix.
        assert!(
            !re.is_match("12345678901234"),
            "bare numeric string must not match IBAN"
        );
        // Positive: a well-formed IBAN must match.
        assert!(
            re.is_match("DE89370400440532013000"),
            "valid IBAN must match"
        );
    }

    #[test]
    fn email_pattern_rejects_near_misses() {
        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "EMAIL").unwrap().1)
            .case_insensitive(true)
            .build()
            .unwrap();
        assert!(
            !re.is_match("not-an-email"),
            "bare word must not match EMAIL"
        );
        assert!(
            !re.is_match("missing-at-sign.com"),
            "missing @ must not match EMAIL"
        );
        // Positive: a valid email must match.
        assert!(re.is_match("user@example.com"), "valid email must match");
    }

    #[test]
    fn bic_pattern_rejects_near_misses() {
        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "BIC").unwrap().1)
            .case_insensitive(true)
            .build()
            .unwrap();
        // Too short — fewer than 8 chars.
        assert!(!re.is_match("ABCD12"), "5-char string must not match BIC");
        // Positive: a well-formed 8-char BIC must match.
        assert!(re.is_match("DEUTDEDB"), "valid 8-char BIC must match");
    }

    #[test]
    fn bic_stays_case_sensitive_under_insensitive_builder() {
        // Consumers (e.g. klieo-ops DefaultRedactor) compile with
        // case_insensitive(true). The inline (?-i:) in the BIC source must
        // keep it case-sensitive so ordinary 8/11-letter words are NOT
        // redacted as bank codes.
        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "BIC").unwrap().1)
            .case_insensitive(true)
            .build()
            .unwrap();
        for word in ["individuals", "potentially", "decision", "employee"] {
            assert!(
                !re.is_match(word),
                "ordinary word `{word}` must not match BIC"
            );
        }
        assert!(re.is_match("DEUTDEFF"), "uppercase BIC must still match");
        assert!(
            !re.is_match("deutdeff"),
            "lowercase BIC-shaped token must not match"
        );
    }

    #[test]
    fn jwt_pattern_rejects_near_misses() {
        let re = RegexBuilder::new(PATTERNS.iter().find(|(l, _)| *l == "JWT").unwrap().1)
            .case_insensitive(true)
            .build()
            .unwrap();
        // Does not start with eyJ.
        assert!(
            !re.is_match("header.payload.sig"),
            "non-base64url JWT prefix must not match"
        );
        // Only two segments (missing signature).
        assert!(
            !re.is_match("eyJhbGc.eyJzdWI"),
            "two-segment token must not match JWT pattern"
        );
        // Positive: three-segment eyJ token must match.
        assert!(
            re.is_match("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.abc123"),
            "valid three-segment JWT must match"
        );
    }
}