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 @@ -7,6 +7,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.
- 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)
Expand Down
1 change: 1 addition & 0 deletions docs/supported_entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,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. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -586,3 +586,10 @@ recognizers:
type: predefined
enabled: false
country_code: ca

- name: DkCprRecognizer
supported_languages:
- da
type: predefined
enabled: false
country_code: dk
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from .country_specific.canada.ca_postal_code_recognizer import CaPostalCodeRecognizer
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,
Expand Down Expand Up @@ -218,6 +221,7 @@
"CreditCardRecognizer",
"CryptoRecognizer",
"DateRecognizer",
"DkCprRecognizer",
"EmailRecognizer",
"IbanRecognizer",
"IpRecognizer",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Denmark-specific recognizers."""

from .dk_cpr_recognizer import DkCprRecognizer

__all__ = [
"DkCprRecognizer",
]
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions presidio-analyzer/tests/test_dk_cpr_recognizer.py
Original file line number Diff line number Diff line change
@@ -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)