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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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.
- 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
1 change: 1 addition & 0 deletions docs/supported_entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ For more information, refer to the [adding new recognizers documentation](analyz
|PHONE_NUMBER|A telephone number. The `PhoneRecognizer` can be extended programmatically for country-specific detection by configuring `supported_regions` and `supported_entity` (e.g. Philippines: `PhoneRecognizer(supported_regions=["PH"], supported_entity="PH_MOBILE_NUMBER")`, Turkey: `PhoneRecognizer(supported_regions=["TR"], supported_entity="TR_PHONE_NUMBER")`)|Custom logic, pattern match and context|
|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|

### USA

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

- name: UuidRecognizer
type: predefined

- name: PhoneRecognizer
type: predefined

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
from .generic.mac_recognizer import MacAddressRecognizer
from .generic.phone_recognizer import PhoneRecognizer
from .generic.url_recognizer import UrlRecognizer
from .generic.uuid_recognizer import UuidRecognizer

# NER recognizers
from .ner.gliner_recognizer import GLiNERRecognizer
Expand Down Expand Up @@ -220,6 +221,7 @@
"NhsRecognizer",
"MedicalLicenseRecognizer",
"MacAddressRecognizer",
"UuidRecognizer",
"PhoneRecognizer",
"SgFinRecognizer",
"UrlRecognizer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .mac_recognizer import MacAddressRecognizer
from .phone_recognizer import PhoneRecognizer
from .url_recognizer import UrlRecognizer
from .uuid_recognizer import UuidRecognizer

__all__ = [
"CreditCardRecognizer",
Expand All @@ -18,4 +19,5 @@
"PhoneRecognizer",
"UrlRecognizer",
"MacAddressRecognizer",
"UuidRecognizer",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from typing import List, Optional

from presidio_analyzer import Pattern, PatternRecognizer


class UuidRecognizer(PatternRecognizer):
"""
Recognize UUID (Universally Unique Identifier) using regex.

Supports the standard 8-4-4-4-12 hyphenated hexadecimal format for
RFC 4122 UUID versions 1-5 and RFC 9562 versions 6-8.

Comment thread
watsos marked this conversation as resolved.
Note: the nil UUID (00000000-0000-0000-0000-000000000000) is explicitly
excluded as a non-identifying sentinel value.

ref:
- https://datatracker.ietf.org/doc/html/rfc4122
- https://datatracker.ietf.org/doc/html/rfc9562
"""

PATTERNS = [
Pattern(
"UUID (hyphenated)",
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",
0.5,
),
]

CONTEXT = ["uuid", "guid", "unique identifier"]

# Valid version nibble values: 1-8 (versions 1-5 per RFC 4122;
# versions 6, 7, 8 per RFC 9562)
VALID_VERSIONS = {"1", "2", "3", "4", "5", "6", "7", "8"}

# Valid variant bits (RFC 4122 variant): first hex digit of the
# 4th group must be 8, 9, a, or b
VALID_VARIANT_PREFIXES = {"8", "9", "a", "b"}

NIL_UUID = "00000000-0000-0000-0000-000000000000"

def __init__(
self,
patterns: Optional[List[Pattern]] = None,
context: Optional[List[str]] = None,
supported_language: str = "en",
supported_entity: str = "UUID",
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 invalidate_result(self, pattern_text: str) -> bool:
"""
Check if the pattern text is a valid UUID format.

Validates the RFC 4122/9562 version nibble and variant bits to
reduce false positives from arbitrary hex-hyphen strings, and
filters out the nil UUID (all zeros), which is a well-known
sentinel value rather than an identifying value.

:param pattern_text: Text detected as pattern by regex
:return: True if invalidated (invalid or non-identifying UUID)
"""
if pattern_text.lower() == self.NIL_UUID:
return True

groups = pattern_text.split("-")
if len(groups) != 5 or any(not g for g in groups):
return True

version_nibble = groups[2][0].lower()
if version_nibble not in self.VALID_VERSIONS:
return True

variant_nibble = groups[3][0].lower()
if variant_nibble not in self.VALID_VARIANT_PREFIXES:
return True

return False
8 changes: 6 additions & 2 deletions presidio-analyzer/tests/test_recognizer_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,15 @@ def mock_recognizer_registry():

def test_when_get_recognizers_then_all_recognizers_returned(mock_recognizer_registry):
registry = mock_recognizer_registry
count_before_loading = len(registry.get_recognizers(language="en", all_fields=True))
registry.load_predefined_recognizers()
recognizers = registry.get_recognizers(language="en", all_fields=True)

# 1 custom recognizer in english + 28 predefined - 11 disabled
assert len(recognizers) == 1 + 28 - 11
# Loading predefined recognizers should add EN recognizers, and the new
# UuidRecognizer should be among them. Avoid asserting an exact count,
# since that count changes whenever a recognizer is added or removed.
assert len(recognizers) > count_before_loading
assert any(type(rec).__name__ == "UuidRecognizer" for rec in recognizers)


def test_when_get_recognizers_then_return_all_fields(mock_recognizer_registry):
Expand Down
113 changes: 113 additions & 0 deletions presidio-analyzer/tests/test_uuid_recognizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import pytest
from presidio_analyzer.predefined_recognizers import UuidRecognizer

from tests import assert_result_within_score_range


@pytest.fixture(scope="module")
def recognizer():
"""Return a UuidRecognizer instance for testing."""
return UuidRecognizer()


@pytest.fixture(scope="module")
def entities():
"""Return the entity list this recognizer supports."""
return ["UUID"]


@pytest.mark.parametrize(
"text, expected_len, expected_positions, expected_score_ranges",
[
# fmt: off
# Version 4 (random) - most common
("Request ID: 550e8400-e29b-41d4-a716-446655440000",
1, ((12, 48),), ((0.5, 0.5),)),
("User UUID: 6fa459ea-ee8a-3ca4-894e-db77e160355e",
1, ((11, 47),), ((0.5, 0.5),)),

# Version 1 (time-based)
("Trace: f47ac10b-58cc-1372-8567-0e02b2c3d479",
1, ((7, 43),), ((0.5, 0.5),)),

# Version 2 (DCE Security, RFC 4122)
("DCE UUID: 550e8400-e29b-21d4-a716-446655440000",
1, ((10, 46),), ((0.5, 0.5),)),

# Version 3 (MD5 name-based, RFC 4122)
("6ba7b810-9dad-31d1-80b4-00c04fd430c8",
1, ((0, 36),), ((0.5, 0.5),)),

# Version 5 (SHA-1 name-based)
("Object id 74738ff5-5367-5958-9aee-98fffdcd1876 created",
1, ((10, 46),), ((0.5, 0.5),)),

# Version 6 (reordered time-based, RFC 9562)
("Sortable ID: 1ec9414c-232a-6b00-b3c8-9e6bdeced846",
1, ((13, 49),), ((0.5, 0.5),)),

# Version 7 (timestamp-based, RFC 9562)
("New record: 018f4f8e-9a3b-7c3d-8e9f-1a2b3c4d5e6f",
1, ((12, 48),), ((0.5, 0.5),)),

# Version 8 (vendor/implementation-specific, RFC 9562)
("Custom UUID: 550e8400-e29b-81d4-a716-446655440000",
1, ((13, 49),), ((0.5, 0.5),)),

# Uppercase
("GUID: 550E8400-E29B-41D4-A716-446655440000",
1, ((6, 42),), ((0.5, 0.5),)),

# With context keywords
("unique identifier: 550e8400-e29b-41d4-a716-446655440000",
1, ((19, 55),), ((0.5, "max"),)),
("The guid is 6fa459ea-ee8a-3ca4-894e-db77e160355e",
1, ((12, 48),), ((0.5, "max"),)),

# Multiple UUIDs
(
"IDs: 550e8400-e29b-41d4-a716-446655440000 and "
"f47ac10b-58cc-1372-8567-0e02b2c3d479",
2,
((5, 41), (46, 82)),
((0.5, 0.5), (0.5, 0.5)),
),

# Invalid cases - should not match
("Not a UUID: 550e8400-e29b-41d4-a716",
0, (), ()), # Too short
("Invalid: zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
0, (), ()), # Invalid hex
("Nil UUID: 00000000-0000-0000-0000-000000000000",
0, (), ()), # Nil UUID filtered
("Bad version: 550e8400-e29b-01d4-a716-446655440000",
0, (), ()), # Invalid version nibble (0)
("Bad version: 550e8400-e29b-91d4-a716-446655440000",
0, (), ()), # Invalid version nibble (9)
("Bad variant: 550e8400-e29b-41d4-1716-446655440000",
0, (), ()), # Invalid variant nibble
# fmt: on
],
)
def test_when_uuids_then_succeed(
text,
expected_len,
expected_positions,
expected_score_ranges,
recognizer,
entities,
max_score,
):
"""Verify UuidRecognizer detects valid UUIDs and rejects invalid ones."""
results = recognizer.analyze(text, entities)
assert len(results) == expected_len
assert len(expected_positions) == expected_len
assert len(expected_score_ranges) == expected_len
for res, (st_pos, fn_pos), (st_score, fn_score) in zip(
results, expected_positions, expected_score_ranges
):
if fn_score == "max":
fn_score = max_score
assert_result_within_score_range(
res, entities[0], st_pos, fn_pos, st_score, fn_score
)