Skip to content

Commit e069216

Browse files
watsosDA-Steve1951CopilotSharonHart
authored
feat: Add UuidRecognizer for detecting UUIDs (v1-v8) (#2175)
* feat: Add UuidRecognizer for detecting UUIDs (v1-v5, v7) * docs: add UUID to supported entities table * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: filter empty UUID segments and export UuidRecognizer from generic package * test: add UUID v2 (DCE Security) coverage * feat: add UUID v6 and v8 support (RFC 9562) * docs: clarify nil UUID is excluded, not a supported special case * test: assert expected_positions/expected_score_ranges lengths match expected_len * test: assert recognizer growth+presence instead of exact count; add UUID v3 coverage --------- Co-authored-by: Steve Watson <steve@disciplinedactions.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sharon Hart <sharonh.dev@gmail.com>
1 parent 84a4f66 commit e069216

8 files changed

Lines changed: 214 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
66

77
### Analyzer
88
#### Added
9+
- 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.
910
- 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.
1011
- 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.
1112
- 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)

docs/supported_entities.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ For more information, refer to the [adding new recognizers documentation](analyz
2525
|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|
2626
|MEDICAL_LICENSE|Common medical license numbers.|Pattern match, context and checksum|
2727
|URL|A URL (Uniform Resource Locator), unique identifier used to locate a resource on the Internet|Pattern match, context and top level url validation|
28+
|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|
2829

2930
### USA
3031

presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,9 @@ recognizers:
451451
- name: MacAddressRecognizer
452452
type: predefined
453453

454+
- name: UuidRecognizer
455+
type: predefined
456+
454457
- name: PhoneRecognizer
455458
type: predefined
456459

presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
from .generic.mac_recognizer import MacAddressRecognizer
173173
from .generic.phone_recognizer import PhoneRecognizer
174174
from .generic.url_recognizer import UrlRecognizer
175+
from .generic.uuid_recognizer import UuidRecognizer
175176

176177
# NER recognizers
177178
from .ner.gliner_recognizer import GLiNERRecognizer
@@ -221,6 +222,7 @@
221222
"NhsRecognizer",
222223
"MedicalLicenseRecognizer",
223224
"MacAddressRecognizer",
225+
"UuidRecognizer",
224226
"PhoneRecognizer",
225227
"SgFinRecognizer",
226228
"UrlRecognizer",

presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .mac_recognizer import MacAddressRecognizer
99
from .phone_recognizer import PhoneRecognizer
1010
from .url_recognizer import UrlRecognizer
11+
from .uuid_recognizer import UuidRecognizer
1112

1213
__all__ = [
1314
"CreditCardRecognizer",
@@ -18,4 +19,5 @@
1819
"PhoneRecognizer",
1920
"UrlRecognizer",
2021
"MacAddressRecognizer",
22+
"UuidRecognizer",
2123
]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from typing import List, Optional
2+
3+
from presidio_analyzer import Pattern, PatternRecognizer
4+
5+
6+
class UuidRecognizer(PatternRecognizer):
7+
"""
8+
Recognize UUID (Universally Unique Identifier) using regex.
9+
10+
Supports the standard 8-4-4-4-12 hyphenated hexadecimal format for
11+
RFC 4122 UUID versions 1-5 and RFC 9562 versions 6-8.
12+
13+
Note: the nil UUID (00000000-0000-0000-0000-000000000000) is explicitly
14+
excluded as a non-identifying sentinel value.
15+
16+
ref:
17+
- https://datatracker.ietf.org/doc/html/rfc4122
18+
- https://datatracker.ietf.org/doc/html/rfc9562
19+
"""
20+
21+
PATTERNS = [
22+
Pattern(
23+
"UUID (hyphenated)",
24+
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",
25+
0.5,
26+
),
27+
]
28+
29+
CONTEXT = ["uuid", "guid", "unique identifier"]
30+
31+
# Valid version nibble values: 1-8 (versions 1-5 per RFC 4122;
32+
# versions 6, 7, 8 per RFC 9562)
33+
VALID_VERSIONS = {"1", "2", "3", "4", "5", "6", "7", "8"}
34+
35+
# Valid variant bits (RFC 4122 variant): first hex digit of the
36+
# 4th group must be 8, 9, a, or b
37+
VALID_VARIANT_PREFIXES = {"8", "9", "a", "b"}
38+
39+
NIL_UUID = "00000000-0000-0000-0000-000000000000"
40+
41+
def __init__(
42+
self,
43+
patterns: Optional[List[Pattern]] = None,
44+
context: Optional[List[str]] = None,
45+
supported_language: str = "en",
46+
supported_entity: str = "UUID",
47+
name: Optional[str] = None,
48+
):
49+
patterns = patterns if patterns else self.PATTERNS
50+
context = context if context else self.CONTEXT
51+
super().__init__(
52+
supported_entity=supported_entity,
53+
patterns=patterns,
54+
context=context,
55+
supported_language=supported_language,
56+
name=name,
57+
)
58+
59+
def invalidate_result(self, pattern_text: str) -> bool:
60+
"""
61+
Check if the pattern text is a valid UUID format.
62+
63+
Validates the RFC 4122/9562 version nibble and variant bits to
64+
reduce false positives from arbitrary hex-hyphen strings, and
65+
filters out the nil UUID (all zeros), which is a well-known
66+
sentinel value rather than an identifying value.
67+
68+
:param pattern_text: Text detected as pattern by regex
69+
:return: True if invalidated (invalid or non-identifying UUID)
70+
"""
71+
if pattern_text.lower() == self.NIL_UUID:
72+
return True
73+
74+
groups = pattern_text.split("-")
75+
if len(groups) != 5 or any(not g for g in groups):
76+
return True
77+
78+
version_nibble = groups[2][0].lower()
79+
if version_nibble not in self.VALID_VERSIONS:
80+
return True
81+
82+
variant_nibble = groups[3][0].lower()
83+
if variant_nibble not in self.VALID_VARIANT_PREFIXES:
84+
return True
85+
86+
return False

presidio-analyzer/tests/test_recognizer_registry.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,15 @@ def mock_recognizer_registry():
5151

5252
def test_when_get_recognizers_then_all_recognizers_returned(mock_recognizer_registry):
5353
registry = mock_recognizer_registry
54+
count_before_loading = len(registry.get_recognizers(language="en", all_fields=True))
5455
registry.load_predefined_recognizers()
5556
recognizers = registry.get_recognizers(language="en", all_fields=True)
5657

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

6064

6165
def test_when_get_recognizers_then_return_all_fields(mock_recognizer_registry):
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import pytest
2+
from presidio_analyzer.predefined_recognizers import UuidRecognizer
3+
4+
from tests import assert_result_within_score_range
5+
6+
7+
@pytest.fixture(scope="module")
8+
def recognizer():
9+
"""Return a UuidRecognizer instance for testing."""
10+
return UuidRecognizer()
11+
12+
13+
@pytest.fixture(scope="module")
14+
def entities():
15+
"""Return the entity list this recognizer supports."""
16+
return ["UUID"]
17+
18+
19+
@pytest.mark.parametrize(
20+
"text, expected_len, expected_positions, expected_score_ranges",
21+
[
22+
# fmt: off
23+
# Version 4 (random) - most common
24+
("Request ID: 550e8400-e29b-41d4-a716-446655440000",
25+
1, ((12, 48),), ((0.5, 0.5),)),
26+
("User UUID: 6fa459ea-ee8a-3ca4-894e-db77e160355e",
27+
1, ((11, 47),), ((0.5, 0.5),)),
28+
29+
# Version 1 (time-based)
30+
("Trace: f47ac10b-58cc-1372-8567-0e02b2c3d479",
31+
1, ((7, 43),), ((0.5, 0.5),)),
32+
33+
# Version 2 (DCE Security, RFC 4122)
34+
("DCE UUID: 550e8400-e29b-21d4-a716-446655440000",
35+
1, ((10, 46),), ((0.5, 0.5),)),
36+
37+
# Version 3 (MD5 name-based, RFC 4122)
38+
("6ba7b810-9dad-31d1-80b4-00c04fd430c8",
39+
1, ((0, 36),), ((0.5, 0.5),)),
40+
41+
# Version 5 (SHA-1 name-based)
42+
("Object id 74738ff5-5367-5958-9aee-98fffdcd1876 created",
43+
1, ((10, 46),), ((0.5, 0.5),)),
44+
45+
# Version 6 (reordered time-based, RFC 9562)
46+
("Sortable ID: 1ec9414c-232a-6b00-b3c8-9e6bdeced846",
47+
1, ((13, 49),), ((0.5, 0.5),)),
48+
49+
# Version 7 (timestamp-based, RFC 9562)
50+
("New record: 018f4f8e-9a3b-7c3d-8e9f-1a2b3c4d5e6f",
51+
1, ((12, 48),), ((0.5, 0.5),)),
52+
53+
# Version 8 (vendor/implementation-specific, RFC 9562)
54+
("Custom UUID: 550e8400-e29b-81d4-a716-446655440000",
55+
1, ((13, 49),), ((0.5, 0.5),)),
56+
57+
# Uppercase
58+
("GUID: 550E8400-E29B-41D4-A716-446655440000",
59+
1, ((6, 42),), ((0.5, 0.5),)),
60+
61+
# With context keywords
62+
("unique identifier: 550e8400-e29b-41d4-a716-446655440000",
63+
1, ((19, 55),), ((0.5, "max"),)),
64+
("The guid is 6fa459ea-ee8a-3ca4-894e-db77e160355e",
65+
1, ((12, 48),), ((0.5, "max"),)),
66+
67+
# Multiple UUIDs
68+
(
69+
"IDs: 550e8400-e29b-41d4-a716-446655440000 and "
70+
"f47ac10b-58cc-1372-8567-0e02b2c3d479",
71+
2,
72+
((5, 41), (46, 82)),
73+
((0.5, 0.5), (0.5, 0.5)),
74+
),
75+
76+
# Invalid cases - should not match
77+
("Not a UUID: 550e8400-e29b-41d4-a716",
78+
0, (), ()), # Too short
79+
("Invalid: zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
80+
0, (), ()), # Invalid hex
81+
("Nil UUID: 00000000-0000-0000-0000-000000000000",
82+
0, (), ()), # Nil UUID filtered
83+
("Bad version: 550e8400-e29b-01d4-a716-446655440000",
84+
0, (), ()), # Invalid version nibble (0)
85+
("Bad version: 550e8400-e29b-91d4-a716-446655440000",
86+
0, (), ()), # Invalid version nibble (9)
87+
("Bad variant: 550e8400-e29b-41d4-1716-446655440000",
88+
0, (), ()), # Invalid variant nibble
89+
# fmt: on
90+
],
91+
)
92+
def test_when_uuids_then_succeed(
93+
text,
94+
expected_len,
95+
expected_positions,
96+
expected_score_ranges,
97+
recognizer,
98+
entities,
99+
max_score,
100+
):
101+
"""Verify UuidRecognizer detects valid UUIDs and rejects invalid ones."""
102+
results = recognizer.analyze(text, entities)
103+
assert len(results) == expected_len
104+
assert len(expected_positions) == expected_len
105+
assert len(expected_score_ranges) == expected_len
106+
for res, (st_pos, fn_pos), (st_score, fn_score) in zip(
107+
results, expected_positions, expected_score_ranges
108+
):
109+
if fn_score == "max":
110+
fn_score = max_score
111+
assert_result_within_score_range(
112+
res, entities[0], st_pos, fn_pos, st_score, fn_score
113+
)

0 commit comments

Comments
 (0)