Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file.
### Analyzer
#### Added
- Added `UuidRecognizer` (generic, entity type `UUID`) to detect UUIDs in the standard 8-4-4-4-12 hyphenated hexadecimal format, covering RFC 4122 versions 1-5 and RFC 9562 versions 6-8. Validates version and variant nibbles and filters the nil UUID to reduce false positives.
- Generic `VIN` recognizer (`VinRecognizer`) for Vehicle Identification Numbers using pattern match, context, and North American mod-11 check-digit validation. Invalid check digits are rejected for North American WMIs (prefix 1-5); non-NA VINs keep their base score when the check digit does not apply. Disabled by default.
- Generic `IMEI` recognizer (`ImeiRecognizer`) for International Mobile Equipment Identity numbers using a formatted pattern (`##-######-######-#` or `## ###### ###### #`), context, and Luhn checksum validation. Bare 15-digit matching is omitted to avoid collisions with other Luhn identifiers such as AMEX credit card numbers.
- South African ID number (`ZA_ID_NUMBER`) recognizer for the 13-digit national identity number, using pattern matching, context words, birth-date validation, and Luhn checksum validation. Disabled by default.
- South African recognizers for `ZA_PASSPORT`, `ZA_INCOME_TAX_NUMBER`, `ZA_DRIVER_LICENSE`, `ZA_VAT_NUMBER`, `ZA_COMPANY_REGISTRATION`, `ZA_TRAFFIC_REGISTER_NUMBER`, `ZA_LICENSE_PLATE`, `ZA_MOBILE_NUMBER`, and `ZA_TELEPHONE_NUMBER`. All disabled by default.
- Added `NoOpNlpEngine` for configurations that do not require NLP engine artifacts, enabling standalone recognizers such as `HuggingFaceNerRecognizer` to run without a spaCy or Stanza model (#2071) (Thanks @ultramancode)
Expand Down Expand Up @@ -62,6 +64,7 @@ All notable changes to this project will be documented in this file.
### General
#### Added
- Optional `countries` filter on `RecognizerRegistry.load_predefined_recognizers()` to scope predefined country-specific recognizers to a subset of locales (e.g. `countries=["us", "uk"]`). The same filter is also exposed as a top-level `supported_countries` field in the recognizer-registry YAML, mirroring `supported_languages`, and as an advisory per-recognizer `country_code:` field on every predefined country-specific entry in `default_recognizers.yaml` (cross-checked against the class attribute at load time). Country tagging works via two reconciled paths: the class-level `EntityRecognizer.COUNTRY_CODE` ClassVar (canonical for predefined recognizers) and the new `country_code` constructor kwarg on `EntityRecognizer` / `PatternRecognizer` (the path for custom recognizers without a subclass — flows through `PatternRecognizer.from_dict` so YAML `type: custom` entries can declare `country_code:` directly). Conflicting values raise `ValueError` at construction time so a predefined country recognizer can never be silently re-tagged. The resolved tag is read via the `country_code()` and `is_country_specific()` instance methods, and serialized through `to_dict()` / `from_dict()` for round-tripping. Inputs to the `countries` filter are validated up front (rejects bare strings, non-iterables, non-string elements, and blank codes). Locale-agnostic recognizers and untagged custom recognizers are always loaded regardless of the filter, preserving backwards compatibility. Adds `RecognizerRegistry.get_country_codes()` for introspection and a `WARNING` log when a requested country has no matching recognizer. See `docs/analyzer/filtering_by_country.md`. Fixes #1328.
- Canadian SIN (`CA_SIN`) recognizer for the Canadian Social Insurance Number, using regex pattern matching, context words (English and French), and Luhn checksum validation. Disabled by default.
- Published source distributions alongside wheels in the PyPI release pipeline (#1924) (Thanks @Copilot)

#### Changed
Expand Down
4 changes: 3 additions & 1 deletion docs/supported_entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ For more information, refer to the [adding new recognizers documentation](analyz
|DATE_TIME|Absolute or relative dates or periods or times smaller than a day.|Pattern match and context|
|EMAIL_ADDRESS|An email address identifies an email box to which email messages are delivered|Pattern match, context and RFC-822 validation|
|IBAN_CODE|The International Bank Account Number (IBAN) is an internationally agreed system of identifying bank accounts across national borders to facilitate the communication and processing of cross border transactions with a reduced risk of transcription errors.|Pattern match, context and checksum|
|IP_ADDRESS|An Internet Protocol (IP) address (either IPv4 or IPv6).|Pattern match and context|
|IMEI|International Mobile Equipment Identity, a 15-digit identifier for mobile devices.|Pattern match, context and checksum|
|IP_ADDRESS|An Internet Protocol (IP) address (either IPv4 or IPv6).|Pattern match, context and checksum|
|MAC_ADDRESS| A Media Access Control (MAC) address is a unique identifier assigned to network interfaces for communications on the physical network segment.|Pattern match and context|
|NRP|A person’s Nationality, religious or political group.|Custom logic and context|
|LOCATION|Name of politically or geographically defined location (cities, provinces, countries, international regions, bodies of water, mountains|Custom logic and context|
Expand All @@ -26,6 +27,7 @@ For more information, refer to the [adding new recognizers documentation](analyz
|MEDICAL_LICENSE|Common medical license numbers.|Pattern match, context and checksum|
|URL|A URL (Uniform Resource Locator), unique identifier used to locate a resource on the Internet|Pattern match, context and top level url validation|
|UUID|A Universally Unique Identifier (UUID) in the standard 8-4-4-4-12 hyphenated hexadecimal format. Covers RFC 4122 versions 1-5 and RFC 9562 versions 6-8; the nil UUID (all zeros) is excluded as a non-identifying sentinel.|Pattern match, validation of version/variant nibbles, and context|
|VIN|Vehicle Identification Number, a 17-character identifier assigned to motor vehicles per ISO 3779. Disabled by default.|Pattern match, context and checksum|

### USA

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,9 @@ recognizers:
- name: IbanRecognizer
type: predefined

- name: ImeiRecognizer
type: predefined

- name: IpRecognizer
type: predefined

Expand All @@ -460,6 +463,10 @@ recognizers:
- name: UrlRecognizer
type: predefined

- name: VinRecognizer
type: predefined
Comment thread
thatomokoena marked this conversation as resolved.
enabled: false

- name: InVoterRecognizer
type: predefined
enabled: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@
from .generic.date_recognizer import DateRecognizer
from .generic.email_recognizer import EmailRecognizer
from .generic.iban_recognizer import IbanRecognizer
from .generic.imei_recognizer import ImeiRecognizer
from .generic.ip_recognizer import IpRecognizer
from .generic.mac_recognizer import MacAddressRecognizer
from .generic.phone_recognizer import PhoneRecognizer
from .generic.url_recognizer import UrlRecognizer
from .generic.uuid_recognizer import UuidRecognizer
from .generic.vin_recognizer import VinRecognizer
Comment thread
thatomokoena marked this conversation as resolved.

# NER recognizers
from .ner.gliner_recognizer import GLiNERRecognizer
Expand Down Expand Up @@ -200,6 +202,7 @@
"EmailRecognizer",
"IpRecognizer",
"IbanRecognizer",
"ImeiRecognizer",
"MedicalLicenseRecognizer",
"UrlRecognizer",
]
Expand All @@ -218,6 +221,7 @@
"DateRecognizer",
"EmailRecognizer",
"IbanRecognizer",
"ImeiRecognizer",
"IpRecognizer",
"NhsRecognizer",
"MedicalLicenseRecognizer",
Expand All @@ -226,6 +230,7 @@
"PhoneRecognizer",
"SgFinRecognizer",
"UrlRecognizer",
"VinRecognizer",
"UsBankRecognizer",
"UsItinRecognizer",
"UsLicenseRecognizer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@
from .crypto_recognizer import CryptoRecognizer
from .email_recognizer import EmailRecognizer
from .iban_recognizer import IbanRecognizer
from .imei_recognizer import ImeiRecognizer
from .ip_recognizer import IpRecognizer
from .mac_recognizer import MacAddressRecognizer
from .phone_recognizer import PhoneRecognizer
from .url_recognizer import UrlRecognizer
from .uuid_recognizer import UuidRecognizer
from .vin_recognizer import VinRecognizer

__all__ = [
"CreditCardRecognizer",
"CryptoRecognizer",
"EmailRecognizer",
"IbanRecognizer",
"ImeiRecognizer",
"IpRecognizer",
"PhoneRecognizer",
"UrlRecognizer",
"MacAddressRecognizer",
"UuidRecognizer",
"VinRecognizer",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from typing import List, Optional, Tuple

from presidio_analyzer import EntityRecognizer, Pattern, PatternRecognizer


class ImeiRecognizer(PatternRecognizer):
"""
Recognize International Mobile Equipment Identity (IMEI) numbers using regex.

IMEI is a 15-digit identifier for mobile devices. The last digit is a Luhn
check digit derived from the preceding 14 digits; validation is performed
over the full 15-digit IMEI. Detection relies on a formatted
pattern (``##-######-######-#`` or ``## ###### ###### #``) to avoid
collisions with other 15-digit Luhn identifiers such as AMEX credit
card numbers.

ref:
- https://en.wikipedia.org/wiki/International_Mobile_Equipment_Identity
"""

PATTERNS = [
Pattern("IMEI (medium)", r"\b\d{2}([- ])\d{6}\1\d{6}\1\d\b", 0.5),
]
Comment thread
thatomokoena marked this conversation as resolved.
Comment thread
thatomokoena marked this conversation as resolved.

CONTEXT = [
"imei",
"mobile device",
"handset",
"device serial",
"phone serial",
"serial number",
]

def __init__(
self,
patterns: Optional[List[Pattern]] = None,
context: Optional[List[str]] = None,
supported_language: str = "en",
supported_entity: str = "IMEI",
replacement_pairs: Optional[List[Tuple[str, str]]] = None,
name: Optional[str] = None,
):
self.replacement_pairs = (
replacement_pairs if replacement_pairs else [("-", ""), (" ", "")]
)
patterns = patterns if patterns else self.PATTERNS
context = context if context else self.CONTEXT
super().__init__(
supported_entity=supported_entity,
patterns=patterns,
context=context,
supported_language=supported_language,
name=name,
)

def invalidate_result(self, pattern_text: str) -> bool:
"""
Check if the pattern text cannot be validated as an IMEI.

:param pattern_text: Text detected as pattern by regex
:return: True if invalidated
"""
only_digits = EntityRecognizer.sanitize_value(
pattern_text, self.replacement_pairs
)
if len(only_digits) != 15:
return True
return not self._luhn_valid(only_digits)

@staticmethod
def _luhn_valid(digits: str) -> bool:
"""Validate using the Luhn checksum."""
total = 0
for i, digit in enumerate(reversed(digits)):
n = int(digit)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from typing import List, Optional

from presidio_analyzer import Pattern, PatternRecognizer


class VinRecognizer(PatternRecognizer):
"""
Recognize Vehicle Identification Numbers (VIN) using regex.

VINs are 17-character identifiers assigned to motor vehicles per ISO 3779.
The letters I, O, and Q are excluded to avoid confusion with 1 and 0.
Position 9 is a check digit in North American VINs (FMVSS 115).

ref:
- https://en.wikipedia.org/wiki/Vehicle_identification_number
- https://www.nhtsa.gov/vin-decoder
"""

TRANSLITERATION = {
"A": 1,
"B": 2,
"C": 3,
"D": 4,
"E": 5,
"F": 6,
"G": 7,
"H": 8,
"J": 1,
"K": 2,
"L": 3,
"M": 4,
"N": 5,
"P": 7,
"R": 9,
"S": 2,
"T": 3,
"U": 4,
"V": 5,
"W": 6,
"X": 7,
"Y": 8,
"Z": 9,
}

WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]

# WMI first character 1-5 indicates North America (ISO 3780 region codes).
NA_WMI_PREFIXES = frozenset("12345")

PATTERNS = [
Pattern(
"VIN",
r"\b[A-HJ-NPR-Z0-9]{17}\b",
0.5,
),
]

CONTEXT = [
"vin",
"vehicle identification",
"vehicle identification number",
"chassis",
"chassis number",
"vehicle",
]

def __init__(
self,
patterns: Optional[List[Pattern]] = None,
context: Optional[List[str]] = None,
supported_language: str = "en",
supported_entity: str = "VIN",
name: Optional[str] = None,
):
patterns = patterns if patterns else self.PATTERNS
context = context if context else self.CONTEXT
super().__init__(
supported_entity=supported_entity,
patterns=patterns,
context=context,
supported_language=supported_language,
name=name,
)

def validate_result(self, pattern_text: str) -> Optional[bool]:
"""
Validate the North American mod-11 check digit at position 9.

For North American VINs (WMI prefix 1-5), a mismatched check digit
returns False so invalid NA VINs are filtered out. For other regions,
returns None on mismatch so the base pattern score is preserved.

:param pattern_text: Text detected as pattern by regex
:return: True if mod-11 check digit matches (any region), False if
NA-applicable and mismatched or structurally invalid, None if
non-NA and mismatched
"""
vin = pattern_text.upper()
if len(vin) != 17:
return False

total = 0
for index, char in enumerate(vin):
if char.isdigit():
value = int(char)
elif char in self.TRANSLITERATION:
value = self.TRANSLITERATION[char]
else:
return False
total += value * self.WEIGHTS[index]

remainder = total % 11
expected = "X" if remainder == 10 else str(remainder)
actual = vin[8]

if actual == expected:
return True
if vin[0] in self.NA_WMI_PREFIXES:
return False
return None
Comment thread
thatomokoena marked this conversation as resolved.
74 changes: 74 additions & 0 deletions presidio-analyzer/tests/test_imei_recognizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import pytest

from tests import assert_result_within_score_range
from presidio_analyzer.predefined_recognizers import ImeiRecognizer


@pytest.fixture(scope="module")
def recognizer():
return ImeiRecognizer()


@pytest.fixture(scope="module")
def entities():
return ["IMEI"]


@pytest.mark.parametrize(
"text, expected_len, expected_positions, expected_score_ranges",
[
# fmt: off
("49-015420-323751-8", 1, ((0, 18),), ((0.5, 0.81),)),
("49 015420 323751 8", 1, ((0, 18),), ((0.5, 0.81),)),
("handset IMEI: 49-015420-323751-8", 1, ((14, 32),), ((0.5, 0.81),)),
# Bare 15-digit strings are not matched (avoids CREDIT_CARD collisions)
("490154203237518", 0, (), ()),
("device imei 490154203237518", 0, (), ()),
# Invalid: checksum failure
("490154203237519", 0, (), ()),
("49-015420-323751-9", 0, (), ()),
# AMEX-style 15-digit Luhn number should not be detected as IMEI
("378282246310005", 0, (), ()),
# Invalid: wrong length
("49015420323751", 0, (), ()),
("4901542032375180", 0, (), ()),
# Invalid: mismatched delimiters
("49-01 5420-323751-8", 0, (), ()),
# fmt: on
],
)
def test_when_imei_in_text_then_detected(
text,
expected_len,
expected_positions,
expected_score_ranges,
recognizer,
entities,
):
results = recognizer.analyze(text, entities)
assert len(results) == expected_len
for res, (st_pos, fn_pos), (st_score, fn_score) in zip(
results, expected_positions, expected_score_ranges
):
assert_result_within_score_range(
res, entities[0], st_pos, fn_pos, st_score, fn_score
)


@pytest.mark.parametrize(
"imei, invalidated",
[
("490154203237518", False),
("49-015420-323751-8", False),
("490154203237519", True),
("123456789012345", True),
],
)
def test_invalidate_result(imei, invalidated, recognizer):
assert recognizer.invalidate_result(imei) is invalidated


def test_invalidate_result_respects_custom_replacement_pairs():
recognizer = ImeiRecognizer(replacement_pairs=[("-", ""), (".", "")])
assert recognizer.invalidate_result("49.015420.323751.8") is False
assert recognizer.invalidate_result("49.015420.323751.9") is True
Loading
Loading