Update rule_detector.py
Browse files- rule_detector.py +57 -41
rule_detector.py
CHANGED
|
@@ -1,8 +1,23 @@
|
|
| 1 |
-
"""Rule-based PII detector — HYBRID single-file edition (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
Same organisation as ``old rule/rule_detector.py`` (PIIMatch / RegexRule /
|
| 4 |
-
validators / RulebasePIIDetector with
|
| 5 |
-
|
| 6 |
|
| 7 |
OLD6 = {PHONE, IBAN, CARD_NUMBER, SWIFT, PIN, COORDINATE}
|
| 8 |
→ the precision-first OLD rules, upgraded so they still pass the
|
|
@@ -109,7 +124,9 @@ ACTIVE_RULEBASE_BLOCKING_OVERLAPS = {
|
|
| 109 |
"PIN": frozenset({"PHONE", "CVV"}),
|
| 110 |
}
|
| 111 |
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
|
| 114 |
ISO_COUNTRY_CODES = {
|
| 115 |
"AD","AE","AF","AG","AI","AL","AM","AO","AR","AS","AT","AU","AW","AX","AZ",
|
|
@@ -597,6 +614,15 @@ _EMAIL_BARE_DOUBLE_AT = re.compile(
|
|
| 597 |
r"(?<![\w.+*\-])[A-Z0-9._%+-]+@@[A-Z0-9.-]+\.[A-Z]{2,63}(?![\w-])",
|
| 598 |
re.IGNORECASE,
|
| 599 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
# ---- PHONE (OLD6 hybrid) --------------------------------------------------
|
| 602 |
_PHONE_MASK_GUARD = r"(?! ?\*{3,})(?! ?\*+\d)(?!\s*-{2,})(?!-\*)"
|
|
@@ -672,6 +698,14 @@ _IBAN_NEG_CONTEXT = re.compile(
|
|
| 672 |
r"|(?:sensor|equipment|device)(?:\s+unit)?\b[^\n\d]{0,16}"
|
| 673 |
r"|sensor\s+unit\b[^\n\d]{0,16})$"
|
| 674 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
# ---- CARD_NUMBER (OLD6) ---------------------------------------------------
|
| 677 |
_CARD_BARE = re.compile(r"(?<![\d.])\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,7}(?![\d])")
|
|
@@ -694,6 +728,15 @@ _IP_BARE = re.compile(
|
|
| 694 |
r")",
|
| 695 |
re.IGNORECASE,
|
| 696 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
|
| 698 |
# ---- URL (TUNED) ----------------------------------------------------------
|
| 699 |
_URL_BARE = re.compile(
|
|
@@ -868,49 +911,22 @@ class RulebasePIIDetector:
|
|
| 868 |
return entity_type in self.allowed_entity_types
|
| 869 |
|
| 870 |
def _build_regex_rules(self):
|
| 871 |
-
rules
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
RegexRule(entity_type="PHONE", pattern=_PHONE_BARE_INTL,
|
| 878 |
-
validator=is_phone_value, normalizer=normalize_phone),
|
| 879 |
-
RegexRule(entity_type="PHONE", pattern=_PHONE_BARE_SEP,
|
| 880 |
-
validator=is_phone_value, normalizer=normalize_phone,
|
| 881 |
-
neg_context=_PHONE_NEG_LOCAL),
|
| 882 |
-
# NATIONAL_ID (TUNED)
|
| 883 |
-
RegexRule(entity_type="NATIONAL_ID", pattern=_NATID_BARE_SSN,
|
| 884 |
-
validator=is_valid_ssn, neg_context=_NATID_NEG_CONTEXT),
|
| 885 |
-
# IBAN (OLD6)
|
| 886 |
-
RegexRule(entity_type="IBAN", pattern=_IBAN_BARE, validator=is_valid_iban,
|
| 887 |
-
normalizer=lambda v: re.sub(r"[\s-]", "", v).upper(),
|
| 888 |
-
neg_context=_IBAN_NEG_CONTEXT),
|
| 889 |
-
RegexRule(entity_type="IBAN", pattern=_IBAN_BARE_SPACED, validator=is_valid_iban,
|
| 890 |
-
normalizer=lambda v: re.sub(r"[\s-]", "", v).upper(),
|
| 891 |
-
neg_context=_IBAN_NEG_CONTEXT),
|
| 892 |
-
# CARD_NUMBER (OLD6)
|
| 893 |
-
RegexRule(entity_type="CARD_NUMBER", pattern=_CARD_BARE,
|
| 894 |
-
validator=is_valid_card_number, normalizer=strip_number_separators,
|
| 895 |
-
neg_context=_CARD_NEG_CONTEXT),
|
| 896 |
-
# IP (TUNED)
|
| 897 |
-
RegexRule(entity_type="IP", pattern=_IP_BARE, validator=is_valid_ip),
|
| 898 |
-
# URL (TUNED)
|
| 899 |
-
RegexRule(entity_type="URL", pattern=_URL_BARE, validator=is_valid_url),
|
| 900 |
-
RegexRule(entity_type="URL", pattern=_URL_BARE_PATH, validator=is_valid_url_barepath),
|
| 901 |
-
# COORDINATE (OLD6)
|
| 902 |
-
RegexRule(entity_type="COORDINATE", pattern=_COORD_BARE_DECIMAL_PAIR,
|
| 903 |
-
validator=is_valid_coord_pair_bare),
|
| 904 |
-
RegexRule(entity_type="COORDINATE", pattern=_COORD_BARE_DMS_PAIR,
|
| 905 |
-
validator=is_valid_coord_pair),
|
| 906 |
-
]
|
| 907 |
-
return [r for r in rules if self._is_enabled(r.entity_type)]
|
| 908 |
|
| 909 |
def _build_context_rules(self):
|
| 910 |
# (entity_type, pattern, validator[, neg_context]) — value captured in
|
| 911 |
# group(1); if neg_context is set, the rule is suppressed when the ~28 chars
|
| 912 |
# of left context match it (e.g. an explicit API-key cue before a PASSWORD).
|
| 913 |
specs = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
("PHONE", _PHONE_CONTEXT, is_phone_value_ctx),
|
| 915 |
("BANK_ACCOUNT", _BANK_CONTEXT, is_valid_account_number),
|
| 916 |
("CARD_NUMBER", _CARD_CONTEXT, is_valid_card_number),
|
|
|
|
| 1 |
+
"""Rule-based PII detector — HYBRID single-file edition (v3, CONTEXT-ONLY).
|
| 2 |
+
|
| 3 |
+
v3 derives from v2 (the precision-tuned 2026-06-12 set) with ONE structural
|
| 4 |
+
change requested on 2026-06-23: **every class is now detected through a positive
|
| 5 |
+
keyword context only.** All bare-regex rules are removed (``_build_regex_rules``
|
| 6 |
+
returns an empty list), and the three classes that used to be bare-only — EMAIL,
|
| 7 |
+
IBAN, IP — gain dedicated context rules (``_EMAIL_CONTEXT`` / ``_IBAN_CONTEXT`` /
|
| 8 |
+
``_IP_CONTEXT``). A span now fires only when a cue word ("email:", "IBAN", "IP
|
| 9 |
+
address", "phone", "SWIFT", …) precedes the value.
|
| 10 |
+
|
| 11 |
+
Trade-off (intended): precision is held at the v2 level (context-gating tends to
|
| 12 |
+
raise it), while recall, F1 and the number of predictions DROP — values that
|
| 13 |
+
appear with no cue (most bare EMAIL/IP/COORDINATE, the bare SSN shape, bare
|
| 14 |
+
PHONE/CARD/IBAN runs) are no longer matched. This is accepted by design.
|
| 15 |
+
|
| 16 |
+
The detection engine, validators, dedup and blocking map are unchanged from v2.
|
| 17 |
|
| 18 |
Same organisation as ``old rule/rule_detector.py`` (PIIMatch / RegexRule /
|
| 19 |
+
validators / RulebasePIIDetector with keyword-context specs, a single dedup
|
| 20 |
+
pass), but it carries the **HYBRID v2** rule set:
|
| 21 |
|
| 22 |
OLD6 = {PHONE, IBAN, CARD_NUMBER, SWIFT, PIN, COORDINATE}
|
| 23 |
→ the precision-first OLD rules, upgraded so they still pass the
|
|
|
|
| 124 |
"PIN": frozenset({"PHONE", "CVV"}),
|
| 125 |
}
|
| 126 |
|
| 127 |
+
# EMAIL/URL/IP added in v3: their context-captured value must not have hyphens
|
| 128 |
+
# or dots stripped (those are part of the address), so keep it as collapsed text.
|
| 129 |
+
CONTEXT_NORMALIZE_AS_TEXT_ENTITY_TYPES = frozenset({"PASSWORD", "TIN", "EMAIL", "URL", "IP"})
|
| 130 |
|
| 131 |
ISO_COUNTRY_CODES = {
|
| 132 |
"AD","AE","AF","AG","AI","AL","AM","AO","AR","AS","AT","AU","AW","AX","AZ",
|
|
|
|
| 614 |
r"(?<![\w.+*\-])[A-Z0-9._%+-]+@@[A-Z0-9.-]+\.[A-Z]{2,63}(?![\w-])",
|
| 615 |
re.IGNORECASE,
|
| 616 |
)
|
| 617 |
+
# v3 CONTEXT-ONLY: EMAIL must now be introduced by a cue ("email:", "thư điện tử là …").
|
| 618 |
+
# The value keeps the same shape as the bare rule; the '*' lookbehind still blocks a
|
| 619 |
+
# masked-tail leak. Validator is_valid_email keeps precision at the v2 level.
|
| 620 |
+
_EMAIL_CONTEXT = re.compile(
|
| 621 |
+
r"(?i)\b(?:e[-\s]?mail(?:\s*address)?|thư\s*điện\s*tử|địa\s*chỉ\s*(?:email|e-mail|thư)"
|
| 622 |
+
r"|hộp\s*thư(?:\s*điện\s*tử)?|gửi\s*(?:tới|đến|về)|liên\s*hệ\s*(?:qua\s*)?(?:email|mail))\b"
|
| 623 |
+
r"\s*(?:[:#\-]\s*|[^:\n@]{0,20}(?:là|is|at|tại)\s+|\s+)"
|
| 624 |
+
r"(?<![\w.+*\-])([A-Z0-9._%+\-À-ɏḀ-ỿ]+@[A-Z0-9.-]+\.[A-Z]{2,63})(?![\w-])"
|
| 625 |
+
)
|
| 626 |
|
| 627 |
# ---- PHONE (OLD6 hybrid) --------------------------------------------------
|
| 628 |
_PHONE_MASK_GUARD = r"(?! ?\*{3,})(?! ?\*+\d)(?!\s*-{2,})(?!-\*)"
|
|
|
|
| 698 |
r"|(?:sensor|equipment|device)(?:\s+unit)?\b[^\n\d]{0,16}"
|
| 699 |
r"|sensor\s+unit\b[^\n\d]{0,16})$"
|
| 700 |
)
|
| 701 |
+
# v3 CONTEXT-ONLY: IBAN must now be introduced by an "IBAN" cue. The value is
|
| 702 |
+
# captured uppercase-only (?-i:) so a trailing lowercase word can't be absorbed;
|
| 703 |
+
# both compact and single-space-grouped forms are allowed. Validator runs mod-97.
|
| 704 |
+
_IBAN_CONTEXT = re.compile(
|
| 705 |
+
r"(?i)\b(?:iban(?:\s*(?:number|no\.?|code))?|số\s*iban|mã\s*iban)\b"
|
| 706 |
+
r"\s*(?:[:#\-]\s*|[^:\n0-9]{0,16}(?:là|is)\s+|\s+)"
|
| 707 |
+
r"((?-i:[A-Z]{2}\d{2}(?:\s?[A-Z0-9]){11,34}))"
|
| 708 |
+
)
|
| 709 |
|
| 710 |
# ---- CARD_NUMBER (OLD6) ---------------------------------------------------
|
| 711 |
_CARD_BARE = re.compile(r"(?<![\d.])\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,7}(?![\d])")
|
|
|
|
| 728 |
r")",
|
| 729 |
re.IGNORECASE,
|
| 730 |
)
|
| 731 |
+
# v3 CONTEXT-ONLY: IP must now be introduced by an "IP / IP address / IPv4 / IPv6"
|
| 732 |
+
# cue. The value reuses the bare IPv4/IPv6 alternation; validator is_valid_ip uses
|
| 733 |
+
# the stdlib ipaddress parser, so a malformed run is rejected (precision held).
|
| 734 |
+
_IP_CONTEXT = re.compile(
|
| 735 |
+
r"(?i)\b(?:ip(?:\s*address|\s*addr)?|ipv4|ipv6|địa\s*chỉ\s*ip|server\s*ip|host\s*ip)\b"
|
| 736 |
+
r"\s*(?:[:#\-=]\s*|[^:\n0-9a-f]{0,16}(?:là|is|at)\s+|\s+)"
|
| 737 |
+
r"((?:\d{1,3}\.){3}\d{1,3}(?!\.\d)|[A-F0-9]{0,4}(?::[A-F0-9]{0,4}){2,7})",
|
| 738 |
+
re.IGNORECASE,
|
| 739 |
+
)
|
| 740 |
|
| 741 |
# ---- URL (TUNED) ----------------------------------------------------------
|
| 742 |
_URL_BARE = re.compile(
|
|
|
|
| 911 |
return entity_type in self.allowed_entity_types
|
| 912 |
|
| 913 |
def _build_regex_rules(self):
|
| 914 |
+
# v3 CONTEXT-ONLY: all bare-regex rules are intentionally removed. Every
|
| 915 |
+
# class is detected through a positive keyword context (see
|
| 916 |
+
# _build_context_rules). The bare patterns are still defined above (some
|
| 917 |
+
# are reused as value sub-patterns inside the context rules) but no rule
|
| 918 |
+
# scans the text without a cue anymore.
|
| 919 |
+
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
|
| 921 |
def _build_context_rules(self):
|
| 922 |
# (entity_type, pattern, validator[, neg_context]) — value captured in
|
| 923 |
# group(1); if neg_context is set, the rule is suppressed when the ~28 chars
|
| 924 |
# of left context match it (e.g. an explicit API-key cue before a PASSWORD).
|
| 925 |
specs = [
|
| 926 |
+
# v3: EMAIL / IBAN / IP context rules (these classes were bare-only in v2).
|
| 927 |
+
("EMAIL", _EMAIL_CONTEXT, is_valid_email),
|
| 928 |
+
("IBAN", _IBAN_CONTEXT, is_valid_iban),
|
| 929 |
+
("IP", _IP_CONTEXT, is_valid_ip),
|
| 930 |
("PHONE", _PHONE_CONTEXT, is_phone_value_ctx),
|
| 931 |
("BANK_ACCOUNT", _BANK_CONTEXT, is_valid_account_number),
|
| 932 |
("CARD_NUMBER", _CARD_CONTEXT, is_valid_card_number),
|