From 03046f8731071d9057a941adc17c3f895b50a29a Mon Sep 17 00:00:00 2001 From: William Malte Olsen Date: Fri, 24 Jul 2026 11:53:16 +0200 Subject: [PATCH] feat(analyzer): add Danish CPR number recognizer Add DkCprRecognizer for the 10-digit Danish personnummer (DK_CPR_NUMBER), disabled by default and tagged country_code: dk. Validation derives the full four-digit birth year from the official CPR century table so leap days are handled correctly, and applies the modulus-11 checksum as confirmation only: a passing checksum promotes the match, a failing one leaves the pattern score untouched. CPR-kontoret discontinued the mandatory modulus-11 control in 2007, so numbers issued since then are not guaranteed to satisfy it, and a hard checksum gate would drop valid numbers. --- CHANGELOG.md | 1 + docs/supported_entities.md | 1 + .../conf/default_recognizers.yaml | 7 + .../predefined_recognizers/__init__.py | 4 + .../country_specific/denmark/__init__.py | 7 + .../denmark/dk_cpr_recognizer.py | 170 ++++++++++++++++++ .../tests/test_dk_cpr_recognizer.py | 62 +++++++ 7 files changed, 252 insertions(+) create mode 100644 presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/__init__.py create mode 100644 presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/dk_cpr_recognizer.py create mode 100644 presidio-analyzer/tests/test_dk_cpr_recognizer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ab215a1b0f..feacbd905d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Analyzer #### Added +- Danish CPR number (`DK_CPR_NUMBER`) recognizer for the 10-digit personnummer, using pattern matching, context words, century-aware date validation, and a conditional modulus-11 checksum that confirms but never rejects (the checksum became optional in 2007). Disabled by default. - 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) diff --git a/docs/supported_entities.md b/docs/supported_entities.md index 9d0b1aeecb..22eef57210 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -134,6 +134,7 @@ For more information, refer to the [adding new recognizers documentation](analyz ### Sweden | FieldType | Description | Detection Method | |------------|---------------------------------------------------------------------------------------------------------|------------------------------------------| +| DK_CPR_NUMBER | The Danish CPR number (personnummer) is a unique 10-digit identifier issued to all Danish residents, encoding the date of birth and gender. | Pattern match, context, date validation and conditional modulus-11 checksum. | | SE_ORGANISATIONSNUMMER | The Swedish Organisations ID Number is a unique 10-digit number issued to all Swedish organisations. | Pattern match, context, and checksum. | | SE_PERSONNUMMER | The Swedish Personal ID Number is a unique 10/12-digit number issued to all Swedish residents. The recognizer also supports Samordningsnummer (coordination numbers) issued to individuals who are not (yet) registered residents but need a Swedish identifier (e.g., temporary workers, students). | Pattern match, context, and checksum. | diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index 84e69c68e5..b67a66f89e 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -568,3 +568,10 @@ recognizers: type: predefined enabled: false country_code: ca + + - name: DkCprRecognizer + supported_languages: + - da + type: predefined + enabled: false + country_code: dk diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index d74d50744b..ecc083fb0c 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -13,6 +13,9 @@ # Canada recognizers from .country_specific.canada.ca_sin_recognizer import CaSinRecognizer +# Denmark recognizers +from .country_specific.denmark.dk_cpr_recognizer import DkCprRecognizer + # Finland recognizers from .country_specific.finland.fi_personal_identity_code_recognizer import ( FiPersonalIdentityCodeRecognizer, @@ -214,6 +217,7 @@ "CreditCardRecognizer", "CryptoRecognizer", "DateRecognizer", + "DkCprRecognizer", "EmailRecognizer", "IbanRecognizer", "IpRecognizer", diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/__init__.py new file mode 100644 index 0000000000..0b4e487329 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/__init__.py @@ -0,0 +1,7 @@ +"""Denmark-specific recognizers.""" + +from .dk_cpr_recognizer import DkCprRecognizer + +__all__ = [ + "DkCprRecognizer", +] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/dk_cpr_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/dk_cpr_recognizer.py new file mode 100644 index 0000000000..a5f5e15492 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/denmark/dk_cpr_recognizer.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""Danish CPR number (personnummer) recognizer. + +The CPR number is a 10-digit personal identifier assigned to every Danish +resident. Its structure is ``DDMMYY-SSSS``: + +* ``DDMMYY`` is the date of birth. +* ``SSSS`` is a sequence number whose first digit, combined with the two-digit + year, encodes the birth century (see the official century table published by + CPR-kontoret in "Personnummeret i CPR-systemet"). +* The final digit was historically a modulus-11 control digit computed over all + ten digits with weights ``4 3 2 7 6 5 4 3 2 1``. + +CPR-kontoret abolished the mandatory modulus-11 check in 2007 because several +birth dates had exhausted their supply of control-digit-valid sequence numbers. +Numbers issued since then are not guaranteed to satisfy modulus-11. The +recognizer therefore treats a passing checksum as confirmation but never rejects +a candidate solely because the checksum fails, so that valid post-2007 numbers +are still detected. + +References +* CPR-kontoret, "Opbygning af CPR-nummeret" - structure and century table: + https://www.cpr.dk/cpr-systemet/opbygning-af-cpr-nummeret +* CPR-kontoret, "Personnummeret i CPR" (PDF) - structure and modulus-11 weights: + https://www.cpr.dk/media/17534/personnummeret-i-cpr.pdf +* CPR-kontoret, "Personnumre uden kontrolciffer (modulus 11 kontrol)" - CPR + numbers issued since 2007 are not guaranteed to satisfy modulus-11 and are + fully valid: + https://www.cpr.dk/cpr-systemet/personnumre-uden-kontrolciffer-modulus-11-kontrol +""" + +from __future__ import annotations + +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DkCprRecognizer(PatternRecognizer): + """Recognizes and validates Danish CPR numbers (personnummer). + + Validation pipeline: + * Normalise to ten digits. + * Validate the date component, deriving the full four-digit year from the + century table so leap days are handled correctly. + * Apply the modulus-11 checksum as confirmation only: a pass promotes the + match, a failure leaves the pattern score untouched (never invalidates). + """ + + COUNTRY_CODE = "dk" + + # Modulus-11 weights applied to the ten digits, most significant first. + _MOD11_WEIGHTS = (4, 3, 2, 7, 6, 5, 4, 3, 2, 1) + + PATTERNS = [ + Pattern( + "Danish CPR (Medium)", + r"\b\d{6}-\d{4}\b", + 0.5, + ), + Pattern( + "Danish CPR (Weak)", + r"\b\d{10}\b", + 0.3, + ), + ] + + CONTEXT = [ + "cpr", + "cpr-nummer", + "cpr nr", + "cpr-nr", + "personnummer", + "personnr", + "central person register", + "civil registration", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "da", + supported_entity: str = "DK_CPR_NUMBER", + 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, + ) + + @staticmethod + def _numeric_part(cpr: str) -> str: + """Return only the digit characters of a CPR string.""" + return "".join(filter(str.isdigit, cpr)) + + @staticmethod + def _full_year(year_two: int, seventh_digit: int) -> int: + """Derive the four-digit birth year from the century table. + + ``year_two`` is the two-digit year (``YY``); ``seventh_digit`` is the + first digit of the sequence number. + """ + if seventh_digit <= 3: + return 1900 + year_two + if seventh_digit == 4 or seventh_digit == 9: + return 2000 + year_two if year_two <= 36 else 1900 + year_two + # seventh_digit in 5..8 + return 2000 + year_two if year_two <= 57 else 1800 + year_two + + @classmethod + def _has_valid_date(cls, cpr: str) -> bool: + """Validate the date component, resolving the century for leap days.""" + try: + day = int(cpr[0:2]) + month = int(cpr[2:4]) + year_two = int(cpr[4:6]) + seventh_digit = int(cpr[6]) + except (ValueError, IndexError): + return False + + if not 1 <= month <= 12: + return False + + year = cls._full_year(year_two, seventh_digit) + days_in_month = [ + 31, + 29 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ] + return 1 <= day <= days_in_month[month - 1] + + @classmethod + def _is_mod11_valid(cls, cpr: str) -> bool: + """Modulus-11 checksum over the ten digits with the CPR weights.""" + total = sum(int(d) * w for d, w in zip(cpr, cls._MOD11_WEIGHTS)) + return total % 11 == 0 + + def validate_result(self, pattern_text: str) -> Optional[bool]: + """Validate a candidate CPR number. + + Returns ``True`` when the checksum confirms the number, ``False`` when + the structure or date is invalid, and ``None`` when the number is + structurally valid but fails the (post-2007 optional) checksum. + """ + num = self._numeric_part(pattern_text) + if len(num) != 10: + return False + + if not self._has_valid_date(num): + return False + + if self._is_mod11_valid(num): + return True + + return None diff --git a/presidio-analyzer/tests/test_dk_cpr_recognizer.py b/presidio-analyzer/tests/test_dk_cpr_recognizer.py new file mode 100644 index 0000000000..e95c2f38a6 --- /dev/null +++ b/presidio-analyzer/tests/test_dk_cpr_recognizer.py @@ -0,0 +1,62 @@ +import pytest +from presidio_analyzer.predefined_recognizers import DkCprRecognizer + +from tests import assert_result + + +@pytest.fixture(scope="module") +def recognizer(): + """Return an instance of the DkCprRecognizer.""" + return DkCprRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + """Return entities to analyze.""" + return ["DK_CPR_NUMBER"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions, expected_score", + [ + # Checksum-valid CPR numbers -> promoted to max score. + ("0101900002", 1, ((0, 10),), 1.0), + ("010190-0002", 1, ((0, 11),), 1.0), + ("Mit cpr-nummer er 010190-0002.", 1, ((18, 29),), 1.0), + # 29 Feb 1904 is a valid leap day; checksum also valid. + ("2902040008", 1, ((0, 10),), 1.0), + # Structurally valid date but checksum fails (post-2007 style): + # still detected, at the pattern score (0.3 contiguous, 0.5 hyphenated). + ("0101900000", 1, ((0, 10),), 0.3), + ("010190-0000", 1, ((0, 11),), 0.5), + # Hyphenated checksum-fail embedded in a sentence is still detected at + # the hyphenated pattern score. + ( + "CPR: 010190-0000 mangler gyldigt kontrolciffer.", + 1, + ((5, 16),), + 0.5, + ), + # Space-separated form (DDMMYY SSSS) is intentionally not supported. + ("010190 0000", 0, (), None), + # Invalid month (13), invalid day (32), and 29 Feb on a non-leap year + # (1903) are rejected regardless of checksum. + ("011390-0000", 0, (), None), + ("320190-0000", 0, (), None), + ("2902030002", 0, (), None), + # Wrong length and non-digit content do not match / are rejected. + ("123456-789", 0, (), None), + ("01019x0002", 0, (), None), + # A 10-digit run embedded in a longer digit string is not matched + # (word boundary), avoiding false positives on long numeric IDs. + ("120000101900021234", 0, (), None), + ], +) +def test_when_all_dk_cpr_then_succeed( + text, expected_len, expected_positions, expected_score, recognizer, entities +): + """Test the recognizer against valid and invalid Danish CPR numbers.""" + results = recognizer.analyze(text, entities) + assert len(results) == expected_len + for res, (st_pos, fn_pos) in zip(results, expected_positions): + assert_result(res, entities[0], st_pos, fn_pos, expected_score)