-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(analyzer): add generic VIN and IMEI recognizers #2070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
thatomokoena
wants to merge
14
commits into
data-privacy-stack:main
Choose a base branch
from
thatomokoena:feature/generic-vin-imei-recognizers
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
fd53dc8
feat(analyzer): add generic VIN and IMEI recognizers
thatomokoena b0ddf80
Merge branch 'main' into feature/generic-vin-imei-recognizers
thatomokoena 7b9f290
Merge branch 'main' into feature/generic-vin-imei-recognizers
thatomokoena 88bfe96
fix(analyzer): tighten VIN and IMEI recognizer validation
thatomokoena d68f04c
fix(analyzer): use replacement_pairs in ImeiRecognizer validation
thatomokoena 9b32f4e
docs(analyzer): clarify VIN and IMEI recognizer docstrings
thatomokoena b3086be
test(analyzer): update recognizer registry count for VIN and IMEI
thatomokoena 79ec215
update doc string to match regex implementation
thatomokoena 00f1d7d
fix(analyzer): address remaining Copilot review feedback on PR #2070
thatomokoena d3556de
Merge branch 'main' into feature/generic-vin-imei-recognizers
thatomokoena 701945b
Merge branch 'main' into feature/generic-vin-imei-recognizers
omri374 78cc970
Merge branch 'main' into feature/generic-vin-imei-recognizers
thatomokoena 1311843
fix(analyzer): disable VinRecognizer by default per PR review
thatomokoena 9996c03
fix(analyzer): wrap IMEI recognizer docstring for ruff E501
thatomokoena File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
81 changes: 81 additions & 0 deletions
81
presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/imei_recognizer.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ] | ||
|
thatomokoena marked this conversation as resolved.
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 | ||
120 changes: 120 additions & 0 deletions
120
presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/vin_recognizer.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
thatomokoena marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.