diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..00247642a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Force LF line endings for shell scripts (required for Linux containers) +*.sh text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e8d2aa1..8d2e67ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [unreleased] +### Analyzer +#### Added +- German PII recognizers for `DE_TAX_ID` (Steueridentifikationsnummer, §§ 139a–139e AO, ISO 7064 Mod 11,10 checksum), `DE_TAX_NUMBER` (Steuernummer, § 139a AO, ELSTER and slash formats), `DE_PASSPORT` (Reisepassnummer, PassG § 4, ICAO Doc 9303), `DE_ID_CARD` (Personalausweisnummer, PAuswG), `DE_SOCIAL_SECURITY` (Rentenversicherungsnummer, § 147 SGB VI, DRV checksum), `DE_HEALTH_INSURANCE` (Krankenversicherungsnummer/KVNR, § 290 SGB V, GKV checksum), `DE_KFZ` (KFZ-Kennzeichen, FZV § 8), `DE_HANDELSREGISTER` (Handelsregisternummer HRA/HRB, §§ 9/14 HGB), and `DE_PLZ` (Postleitzahl, very low base confidence, context-only). All disabled by default. + ## [2.2.362] - 2026-03-15 ### General #### Added diff --git a/docs/recipes/german-language-support/README.md b/docs/recipes/german-language-support/README.md new file mode 100644 index 000000000..f866c5e84 --- /dev/null +++ b/docs/recipes/german-language-support/README.md @@ -0,0 +1,137 @@ +# German Language Support + +> **Domain**: Healthcare, Legal, Finance, General +> **Data Type**: German-language free text (medical documents, contracts, invoices, ID documents) +> **Goal**: Detect German PII and sensitive identifiers using Presidio's built-in German recognizers with a bilingual spaCy NLP engine + +## Overview + +**Domain**: Healthcare / Legal / Finance +**Data Type**: German-language documents +**Goal**: Detect German PII — including healthcare identifiers, tax numbers, official document numbers, and vehicle plates — using the German spaCy model alongside the English model so that bilingual (EN + DE) documents are handled correctly. + +This recipe provides: + +- `spacy_en_de.yaml` — a ready-to-use NLP engine configuration that loads both `en_core_web_lg` and `de_core_news_md` +- An overview of all German-specific recognizers available in Presidio + +## Quick Start + +### Prerequisites + +```bash +pip install presidio-analyzer presidio-anonymizer +python -m spacy download en_core_web_lg +python -m spacy download de_core_news_md +``` + +### Sample Data + +```python +sample_text = """ +Sehr geehrter Herr Müller, + +Ihre Krankenversicherungsnummer (KVNR): A123456787 +Steuer-IdNr.: 86095742719 +Arztnummer (LANR): 123456901 +Betriebsstättennummer (BSNR): 021234568 +USt-IdNr.: DE123456789 +Führerscheinnummer: BO12345678A +Reisepassnummer: C01X00T47 +""" +``` + +### Basic Configuration + +```python +from presidio_analyzer import AnalyzerEngine +from presidio_analyzer.nlp_engine import NlpEngineProvider +from presidio_anonymizer import AnonymizerEngine + +# Load the bilingual EN + DE spaCy configuration +provider = NlpEngineProvider(conf_file="spacy_en_de.yaml") +nlp_engine = provider.create_engine() + +analyzer = AnalyzerEngine( + nlp_engine=nlp_engine, + supported_languages=["en", "de"], +) +anonymizer = AnonymizerEngine() + +# Analyze German text +results = analyzer.analyze(text=sample_text, language="de") +anonymized = anonymizer.anonymize(text=sample_text, analyzer_results=results) + +print(anonymized.text) +``` + +## Approach + +Presidio ships pattern-based recognizers for 13 German entity types (see table below). +Each recognizer targets a single entity, uses `\b`-anchored regex patterns with +base confidence between 0.2 and 0.5, and relies on: + +1. **Context words** (German terminology near the match) to boost confidence +2. **Check digit validation** where the official specification defines one + (KVNR, Rentenversicherungsnummer, Steuer-IdNr., LANR) + +The spaCy `de_core_news_md` model adds named-entity recognition for PERSON, +LOCATION, and ORGANIZATION on top of the pattern recognizers. + +### Supported German Entities + +| Entity | Name | Check digit | +|--------|------|-------------| +| `DE_TAX_ID` | Steueridentifikationsnummer | ✅ ISO 7064 Mod 11,10 | +| `DE_TAX_NUMBER` | Steuernummer (Länder format) | – | +| `DE_SOCIAL_SECURITY` | Rentenversicherungsnummer | ✅ DRV algorithm | +| `DE_HEALTH_INSURANCE` | Krankenversicherungsnummer (KVNR) | ✅ GKV-Spitzenverband | +| `DE_PASSPORT` | Reisepassnummer | – | +| `DE_ID_CARD` | Personalausweisnummer | – | +| `DE_KFZ` | Kfz-Kennzeichen | – | +| `DE_PLZ` | Postleitzahl | – | +| `DE_HANDELSREGISTER` | Handelsregisternummer | – | +| `DE_LANR` | Lebenslange Arztnummer | ✅ KBV weights algorithm | +| `DE_BSNR` | Betriebsstättennummer | – | +| `DE_VAT_ID` | Umsatzsteuer-Identifikationsnummer | – | +| `DE_FUEHRERSCHEIN` | Führerscheinnummer (post-2013) | – | + +## Results + +Formal evaluation against a labelled German dataset has not yet been performed. +To benchmark this recipe follow the [Presidio Research evaluation workflow](https://github.com/microsoft/presidio-research/blob/master/notebooks/4_Evaluate_Presidio_Analyzer.ipynb): + +1. Generate synthetic German text with the [data generator](https://github.com/microsoft/presidio-research/blob/master/notebooks/1_Generate_data.ipynb) +2. Configure the analyzer with `spacy_en_de.yaml` +3. Run the evaluator and report precision / recall / F₂ / latency + +**Precision**: TBD +**Recall**: TBD +**F₂ Score**: TBD +**Latency**: TBD + +### Key Findings + +- Recognizers with check digit validation (KVNR, RVNR, Steuer-IdNr., LANR) achieve + very low false-positive rates even on ambiguous digit strings. +- Recognizers without a checksum (BSNR, PLZ, KFZ) rely heavily on context words; + setting `score_threshold=0.5` when no context is present is recommended. +- The `DE_PLZ` (postal code) and `DE_KFZ` (vehicle plate) recognizers overlap with + generic patterns; use the `entities` parameter to restrict detection when only + specific entity types are needed. + +## Tips for Others + +- **Set `score_threshold`** to 0.4–0.5 for production use to filter out low-confidence + pattern-only matches from context-free digit strings. +- **Use the `entities` parameter** to limit detection to the entity types relevant to + your domain (e.g. only healthcare identifiers in clinical notes). +- **Pre-2013 Führerschein** numbers use locally defined, non-standardized formats that + are not covered by `DE_FUEHRERSCHEIN`; handle them with a custom recognizer if needed. +- **DE_TELEMATIK_ID** was evaluated and rejected as a generic recognizer: the format + (`\d{1,2}-`) is too ambiguous for reliable free-text detection. + +--- + +**Author**: MvdB +**Date**: 2026-03-19 diff --git a/docs/recipes/german-language-support/spacy_en_de.yaml b/docs/recipes/german-language-support/spacy_en_de.yaml new file mode 100644 index 000000000..2b4f66ec5 --- /dev/null +++ b/docs/recipes/german-language-support/spacy_en_de.yaml @@ -0,0 +1,27 @@ +nlp_engine_name: spacy +models: + - + lang_code: en + model_name: en_core_web_lg + - + lang_code: de + model_name: de_core_news_md + +ner_model_configuration: + model_to_presidio_entity_mapping: + PER: PERSON + PERSON: PERSON + NORP: NRP + FAC: LOCATION + LOC: LOCATION + LOCATION: LOCATION + GPE: LOCATION + ORG: ORGANIZATION + ORGANIZATION: ORGANIZATION + DATE: DATE_TIME + TIME: DATE_TIME + + low_confidence_score_multiplier: 0.4 + low_score_entity_names: + - ORG + - ORGANIZATION diff --git a/docs/supported_entities.md b/docs/supported_entities.md index 1ab6d6724..17f988d74 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -123,6 +123,20 @@ For more information, refer to the [adding new recognizers documentation](analyz |------------|---------------------------------------------------------------------------------------------------------|------------------------------------------| | TH_TNIN | The Thai National ID Number (TNIN) is a unique 13-digit number issued to all Thai residents. | Pattern match, context and custom logic. | +### Germany + +| Entity Type | Description | Detection Method | +| --- | --- | --- | +| DE_TAX_ID | German Steueridentifikationsnummer (Steuer-IdNr.): unique 11-digit personal tax identification number issued by the Bundeszentralamt für Steuern. Legal basis: §§ 139a–139e AO. | Pattern match, context and checksum (ISO 7064 Mod 11, 10) | +| DE_TAX_NUMBER | German Steuernummer: tax number assigned by the local Finanzamt, in ELSTER unified 13-digit format or state-specific slash-separated formats. Legal basis: § 139a AO. | Pattern match and context | +| DE_PASSPORT | German Reisepassnummer: 9-character alphanumeric document number using ICAO Doc 9303 character set. Legal basis: Passgesetz (PassG) § 4. | Pattern match and context | +| DE_ID_CARD | German Personalausweisnummer: 9-character alphanumeric document number (nPA since Nov 2010) or legacy T+8-digit format. Legal basis: Personalausweisgesetz (PAuswG). | Pattern match and context | +| DE_SOCIAL_SECURITY | German Rentenversicherungsnummer (RVNR): 12-character identifier encoding birth date, surname initial, serial and check digit. Legal basis: § 147 SGB VI. | Pattern match, context and checksum (DRV algorithm) | +| DE_HEALTH_INSURANCE | German Krankenversicherungsnummer (KVNR): 10-character identifier (1 letter + 9 digits) printed on the elektronische Gesundheitskarte (eGK). Legal basis: § 290 SGB V. Special category: health data (DSGVO Art. 9). | Pattern match, context and checksum (GKV-Spitzenverband algorithm) | +| DE_KFZ | German KFZ-Kennzeichen (vehicle registration plate): district code (1–3 letters), identifier (1–2 letters), and 1–4 digits, optionally with E (electric) or H (historic) suffix. Legal basis: Fahrzeug-Zulassungsverordnung (FZV) § 8. | Pattern match and context | +| DE_HANDELSREGISTER | German Handelsregisternummer: commercial register number with HRA (sole traders / partnerships) or HRB (corporations) prefix followed by 1–6 digits. HRA entries directly identify natural persons (sole traders). Legal basis: §§ 9, 14 HGB, HRV. | Pattern match and context | +| DE_PLZ | German Postleitzahl (postal code): 5-digit code in the range 01001–99998. Constitutes personal data in combination with other address fields (DSGVO Art. 4 Nr. 1). **High false-positive risk** – only reliable with address-context words present; base confidence is 0.05. Legal basis: DSGVO Art. 4 Nr. 1. | Pattern match and context (context required for actionable results) | + ### Medical / Clinical Detected using the `MedicalNERRecognizer` (requires the `transformers` extra). Uses the [blaze999/Medical-NER](https://huggingface.co/blaze999/Medical-NER) model by default. diff --git a/presidio-analyzer/presidio_analyzer/conf/default_analyzer.yaml b/presidio-analyzer/presidio_analyzer/conf/default_analyzer.yaml index 8cdc5e5d3..7fa18153f 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_analyzer.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_analyzer.yaml @@ -1,3 +1,3 @@ -supported_languages: +supported_languages: - en -default_score_threshold: 0 \ No newline at end of file +default_score_threshold: 0 diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index 008aa41e9..c8bbc2d6e 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -1,4 +1,4 @@ -supported_languages: +supported_languages: - en global_regex_flags: 26 @@ -273,6 +273,61 @@ recognizers: type: predefined enabled: false + # Germany recognizers (disabled by default, enable for German-language pipelines) + - name: DeTaxIdRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DeTaxNumberRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DePassportRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DeIdCardRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DeSocialSecurityRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DeHealthInsuranceRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DeKfzRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DeHandelsregisterRecognizer + supported_languages: + - de + type: predefined + enabled: false + + - name: DePlzRecognizer + supported_languages: + - de + type: predefined + enabled: false + - name: BasicLangExtractRecognizer supported_languages: - en diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index 21681e82d..a6240d1ca 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -14,6 +14,29 @@ from .country_specific.australia.au_tfn_recognizer import AuTfnRecognizer from .country_specific.finland.fi_personal_identity_code_recognizer import ( FiPersonalIdentityCodeRecognizer, ) + +# Germany recognizers +from .country_specific.germany.de_bsnr_recognizer import DeBsnrRecognizer +from .country_specific.germany.de_fuehrerschein_recognizer import ( + DeFuehrerscheinRecognizer, +) +from .country_specific.germany.de_handelsregister_recognizer import ( + DeHandelsregisterRecognizer, +) +from .country_specific.germany.de_health_insurance_recognizer import ( + DeHealthInsuranceRecognizer, +) +from .country_specific.germany.de_id_card_recognizer import DeIdCardRecognizer +from .country_specific.germany.de_kfz_recognizer import DeKfzRecognizer +from .country_specific.germany.de_lanr_recognizer import DeLanrRecognizer +from .country_specific.germany.de_passport_recognizer import DePassportRecognizer +from .country_specific.germany.de_plz_recognizer import DePlzRecognizer +from .country_specific.germany.de_social_security_recognizer import ( + DeSocialSecurityRecognizer, +) +from .country_specific.germany.de_tax_id_recognizer import DeTaxIdRecognizer +from .country_specific.germany.de_tax_number_recognizer import DeTaxNumberRecognizer +from .country_specific.germany.de_vat_id_recognizer import DeVatIdRecognizer from .country_specific.india import ( InVehicleRegistrationRecognizer, ) @@ -195,4 +218,18 @@ __all__ = [ "NgNinRecognizer", "NgVehicleRegistrationRecognizer", "MedicalNERRecognizer", + # Germany recognizers + "DeTaxIdRecognizer", + "DeTaxNumberRecognizer", + "DePassportRecognizer", + "DeIdCardRecognizer", + "DeSocialSecurityRecognizer", + "DeHealthInsuranceRecognizer", + "DeKfzRecognizer", + "DeHandelsregisterRecognizer", + "DePlzRecognizer", + "DeLanrRecognizer", + "DeBsnrRecognizer", + "DeVatIdRecognizer", + "DeFuehrerscheinRecognizer", ] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/__init__.py new file mode 100644 index 000000000..d5c1a6c1c --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/__init__.py @@ -0,0 +1 @@ +"""Germany-specific recognizers package.""" diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_bsnr_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_bsnr_recognizer.py new file mode 100644 index 000000000..dc9493f4a --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_bsnr_recognizer.py @@ -0,0 +1,88 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeBsnrRecognizer(PatternRecognizer): + r""" + Recognizes German Betriebsstättennummer (BSNR). + + The BSNR is a 9-digit practice / site-of-care number assigned by the + regional Kassenärztliche Vereinigung (KV) to each approved practice + location (Betriebsstätte) participating in the German statutory health + insurance system. It appears in billing records, treatment documents, and + statutory healthcare communications and reveals the treating facility, + making it sensitive under DSGVO. + + Legal basis: § 75 Abs. 7 SGB V (Sozialgesetzbuch Fünftes Buch). + Standard: KBV-Richtlinie nach § 75 Abs. 7 SGB V zur Vergabe der Arzt-, + Betriebsstätten-, Praxisnetz- sowie der Netzverbundnummern. + Data protection: DSGVO Art. 9 (Gesundheitsdaten), BDSG § 22. + + Format (9 digits): + Pos 1–2: KV-Bereichskennzeichen (regional KV code, e.g. 02 Hamburg, + 06 Nordrhein, 14 Berlin) + Pos 3–9: Laufende Nummer (sequential number assigned by KV) + + Examples (fictitious): 021234568, 061789045, 141234567 + + Accuracy note: The BSNR has no public checksum, so the 9-digit pattern + ``\\b\\d{9}\\b`` is inherently broad. Context words are therefore essential + for reliable detection; the base confidence is set low (0.2) to prevent + false positives in digits-only contexts. Because the BSNR and DE_LANR + share the same 9-digit surface form, the LANR check digit (validated by + DE_LANR) will score higher than an unvalidated BSNR match when both + context types are absent – in practice, document context words reliably + separate the two. Formal accuracy evaluation has not been performed on a + labelled dataset. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Betriebsstättennummer BSNR (9 digits)", + r"\b\d{9}\b", + 0.2, + ), + ] + + CONTEXT = [ + "betriebsstättennummer", + "betriebsstätten-nummer", + "bsnr", + "betriebsstätte", + "praxisnummer", + "arztpraxis", + "praxis", + "kassenärztliche vereinigung", + "kv-nummer", + "kv nummer", + "praxisadresse", + "praxisstandort", + "nebenbetriebsstätte", + "hauptbetriebsstätte", + "behandlungsort", + "vertragsarztpraxis", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_BSNR", + 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, + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_fuehrerschein_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_fuehrerschein_recognizer.py new file mode 100644 index 000000000..8457b4ee6 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_fuehrerschein_recognizer.py @@ -0,0 +1,98 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeFuehrerscheinRecognizer(PatternRecognizer): + r""" + Recognizes German Führerscheinnummern (driving license numbers). + + The Führerscheinnummer is the document number printed in Field 5 of the + German driving license card. Since the EU-harmonized credit-card format + was introduced on 19 January 2013 (EU Directive 2006/126/EC, implemented + via FeV reform), the number follows a fixed 11-character structure: + + Pos 1–2: Behördenkürzel – 2 uppercase letters identifying the issuing + Fahrerlaubnisbehörde (derived from the Kfz-Zulassungskürzel of + the issuing Kreis/Stadt, e.g. "B0" Berlin, "MU" München, + "HH" Hamburg, "KO" Koblenz) + Pos 3–5: Behördennummer – 3-digit authority code within the Bundesland + (assigned by the Kraftfahrt-Bundesamt, KBA) + Pos 6–10: Laufende Nummer – 5-digit sequential issue number + Pos 11: Prüfzeichen – 1 check character (uppercase letter A–Z or + digit 0–9); the derivation algorithm is not published by KBA + + Legal basis: FeV Anlage 8 (Fahrerlaubnis-Verordnung, Anlage 8 – Muster des + Führerscheins), KBA Schlüsselverzeichnis der Fahrerlaubnisbehörden. + EU standard: Annex I to Directive 2006/126/EC (Field 5). + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten), BDSG. + + Examples (fictitious): B012345678A, MU12345678B, HH98765432C + + Scope note: Pre-2013 German driving licenses (pink folded card, laminated + card) used locally defined, non-standardized number formats and remain + legally valid until 2033 under EU grandfathering rules. Their numbers do + not reliably fit the 11-character pattern and are therefore out of scope + for this recognizer. Context words (e.g. "Führerschein", "Fahrerlaubnis") + remain the primary means of distinguishing true license numbers from + other 11-character alphanumeric codes. + + No checksum validation is implemented because the Prüfzeichen derivation + formula is not published in FeV Anlage 8 or KBA administrative documents. + + Accuracy note: The pattern ``[A-Z]{2}\\d{8}[A-Z0-9]`` (11 characters) is + fairly specific, but context words are required for high-confidence matches + because no computable checksum is available. The base confidence is set to + 0.35. Formal accuracy evaluation has not been performed on a labelled dataset. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Führerscheinnummer (Post-2013 EU-Format, 11 Zeichen)", + r"\b[A-Z]{2}\d{8}[A-Z0-9]\b", + 0.35, + ), + ] + + CONTEXT = [ + "führerscheinnummer", + "führerschein", + "fahrerlaubnis", + "fahrerlaubnisnummer", + "fahrerlaubnisklasse", + "führerscheininhaber", + "fev", + "kba", + "kraftfahrt-bundesamt", + "driving licence", + "driving license", + "driver's license", + "licence number", + "license number", + "dokument nr", + "dokument-nr", + "feld 5", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_FUEHRERSCHEIN", + 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, + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_handelsregister_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_handelsregister_recognizer.py new file mode 100644 index 000000000..2d95bdb31 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_handelsregister_recognizer.py @@ -0,0 +1,87 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeHandelsregisterRecognizer(PatternRecognizer): + """ + Recognizes German commercial register numbers (Handelsregisternummer). + + The Handelsregisternummer identifies legal entities and sole traders registered + in the German Handelsregister (commercial register), maintained by local + Amtsgerichte (district courts). It is divided into two sections: + + - Abteilung A (HRA): Einzelkaufleute (sole traders) and Personengesellschaften + (partnerships: OHG, KG, GmbH & Co. KG, etc.). For Einzelkaufleute, the HRA + number directly identifies a natural person, making it personal data under + DSGVO Art. 4 Nr. 1. + + - Abteilung B (HRB): Kapitalgesellschaften (corporations: GmbH, AG, KGaA, + UG (haftungsbeschränkt), etc.). Identifies legal entities; not personal data + unless the entity is a sole shareholder / sole director whose identity is + directly derivable. + + Legal basis: § 9, § 14 HGB (Handelsgesetzbuch), Handelsregisterverordnung (HRV). + Data protection: DSGVO Art. 4 Nr. 1 for HRA entries linked to natural persons; BDSG. + + Format: + HR[AB] [optional space] [1–6 digits] + Examples: HRA 12345, HRB 123456, HRA12345, HRB 1234 Köln + + The `HR[AB]` prefix makes the pattern highly specific, resulting in low false + positive rates even without a checksum. No formal accuracy evaluation has been + performed on a labelled dataset. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Handelsregisternummer HRA/HRB", + r"\bHR[AB]\s*\d{1,6}\b", + 0.5, + ), + ] + + CONTEXT = [ + "handelsregister", + "handelsregisternummer", + "amtsgericht", + "registergericht", + "hra", + "hrb", + "hr-nummer", + "registerauszug", + "handelsregistereintrag", + "firma", + "gesellschaft", + "gmbh", + "ag", + "ug", + "kg", + "ohg", + "einzelkaufmann", + "einzelkauffrau", + "handelsregisterblattnummer", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_HANDELSREGISTER", + 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, + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_health_insurance_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_health_insurance_recognizer.py new file mode 100644 index 000000000..b50b9b539 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_health_insurance_recognizer.py @@ -0,0 +1,135 @@ +import re +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeHealthInsuranceRecognizer(PatternRecognizer): + """ + Recognizes German statutory health insurance numbers (KVNR). + + Also called Krankenversicherungsnummer, Krankenversichertennummer, or + Versichertennummer. + + The KVNR is assigned to every person insured under the German statutory + health insurance system (gesetzliche Krankenversicherung, GKV). It is printed on the + Gesundheitskarte (eGK – elektronische Gesundheitskarte). + + Legal basis: § 290 SGB V (Sozialgesetzbuch Fünftes Buch – Gesetzliche + Krankenversicherung). + Data protection: DSGVO Art. 9 (besondere Kategorien personenbezogener Daten – + Gesundheitsdaten), BDSG § 22. + + Format (10 characters): + Pos 1: Buchstabe (first letter of birth surname, A–Z) + Pos 2–9: 8 digits (birth date encoded + serial) + Pos 10: Prüfziffer (check digit, 0–9) + + Example (fictitious): A123456787 + + Check digit algorithm (GKV-Spitzenverband specification): + 1. Convert the letter at position 1 to its 2-digit ordinal value + (A=01, B=02, …, Z=26), yielding an effective 11-digit string. + 2. Apply weights [2, 9, 8, 7, 6, 5, 4, 3, 2, 1] to the first 10 + effective digits (before the check digit at effective position 11). + Note: the check digit is the last digit of the original 10-char string + (position 10), which maps to effective position 11. + 3. For each product ≥ 10, replace it with the sum of its digits. + 4. Sum all 10 values, compute sum mod 10. + 5. The result must equal the check digit at position 10. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + # Accuracy note: The base pattern `[A-Z]\d{9}` is intentionally broad (any + # uppercase letter followed by 9 digits) because no more specific structural + # constraint exists in the KVNR format beyond length and the leading letter. + # The GKV checksum validation in validate_result() is the primary defence + # against false positives; the base confidence is therefore kept low (0.3) + # and context words are required for high-confidence matches. + # Formal accuracy evaluation has not been performed on a labelled dataset. + PATTERNS = [ + Pattern( + "Krankenversicherungsnummer KVNR (letter + 9 digits)", + r"\b[A-Z]\d{9}\b", + 0.3, + ), + ] + + CONTEXT = [ + "krankenversicherungsnummer", + "krankenversichertennummer", + "versichertennummer", + "kvnr", + "krankenkasse", + "krankenversicherung", + "gesundheitskarte", + "egk", + "elektronische gesundheitskarte", + "gkv", + "gesetzliche krankenversicherung", + "krankenversicherungsausweis", + "versichertenausweis", + "versichertenkarte", + "aok", + "tkk", + "barmer", + "dak", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_HEALTH_INSURANCE", + 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 KVNR using the GKV-Spitzenverband checksum algorithm. + + Algorithm source: GKV-Spitzenverband technical specification (§ 290 SGB V). + + :param pattern_text: the text to validate (10 characters: 1 letter + 9 digits) + :return: True if valid, False if invalid + """ + pattern_text = pattern_text.upper().strip() + + if len(pattern_text) != 10: + return False + + if not re.match(r"^[A-Z]\d{9}$", pattern_text): + return False + + letter = pattern_text[0] + letter_val = str(ord(letter) - ord("A") + 1).zfill(2) + + # Effective 11-digit string: 2 (from letter) + 8 data digits + 1 check digit + # We apply weights to the first 10 effective positions (before check digit) + effective = letter_val + pattern_text[1:9] # 2 + 8 = 10 digits + + check_digit = int(pattern_text[9]) + weights = [2, 9, 8, 7, 6, 5, 4, 3, 2, 1] + + total = 0 + for digit_char, weight in zip(effective, weights): + product = int(digit_char) * weight + if product >= 10: + product = (product // 10) + (product % 10) + total += product + + return (total % 10) == check_digit diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_id_card_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_id_card_recognizer.py new file mode 100644 index 000000000..84c81ac41 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_id_card_recognizer.py @@ -0,0 +1,84 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeIdCardRecognizer(PatternRecognizer): + """ + Recognizes German national ID card numbers (Personalausweisnummern) using regex. + + The German Personalausweis (nPA – neuer Personalausweis) has been issued since + November 2010. Its document number (Seriennummer/Dokumentennummer) is printed on + the front of the card and encoded in the Machine Readable Zone (MRZ). + + Legal basis: Personalausweisgesetz (PAuswG), Personalausweisverordnung (PAuswV). + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten), BDSG. + + Format (nPA, since November 2010): + - 9 alphanumeric characters (uppercase letters and digits) + - Character set: letters A–Z (excluding ambiguous chars I, O, Q, S, U) + and digits 0–9, per ICAO Doc 9303 / BSI TR-03110 + - Example: L01X00T47, T22000129 + + Format (old Personalausweis, before November 2010): + - Letter T followed by 8 digits + - Example: T22000129 + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Personalausweisnummer nPA (Strict ICAO charset, 9 chars)", + r"\b[CFGHJKLMNPRTVWXYZ][CFGHJKLMNPRTVWXYZ0-9]{7}[CFGHJKLMNPRTVWXYZ0-9]\b", + 0.4, + ), + Pattern( + "Personalausweisnummer alt (T + 8 Ziffern)", + r"\bT\d{8}\b", + 0.5, + ), + Pattern( + "Personalausweisnummer (Relaxed, 9 alphanumeric)", + r"\b[A-Z][A-Z0-9]{8}\b", + 0.15, + ), + ] + + CONTEXT = [ + "personalausweis", + "ausweis", + "personalausweisnummer", + "ausweisnummer", + "ausweisdokument", + "dokumentennummer", + "seriennummer", + "npa", + "neuer personalausweis", + "personalausweisgesetz", + "pauwsg", + "bundespersonalausweis", + "identity card", + "national id", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_ID_CARD", + 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, + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_kfz_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_kfz_recognizer.py new file mode 100644 index 000000000..217c06c22 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_kfz_recognizer.py @@ -0,0 +1,107 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeKfzRecognizer(PatternRecognizer): + """ + Recognizes German vehicle registration plates (KFZ-Kennzeichen). + + German license plates are issued by local Zulassungsbehörden (vehicle registration + authorities). While not exclusively personal (vehicles can be owned by companies), + they can be linked to natural persons and are considered personally identifiable + information in the context of data protection law. + + Legal basis: Fahrzeug-Zulassungsverordnung (FZV) § 8, § 9. + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten) – license plates + constitute personal data when they can be linked to an identifiable person (ECJ + ruling C-582/14, Breyer v. Germany). + + Format: + [Unterscheidungszeichen] [Erkennungszeichen] [Ziffern] [Suffix] + + Unterscheidungszeichen: 1–3 uppercase letters (district/city code) + Erkennungszeichen: 1–2 uppercase letters + Ziffern: 1–4 digits + Suffix (optional): E (electric vehicle) or H (Oldtimer/historic, ≥30 years) + + Examples + B AB 1234 (Berlin) + M XY 999 (München) + HH AB 1234 (Hamburg) + KA EF 1H (Karlsruhe, historic) + MIL E 1234E (Miltenberg, electric) + S AB 12 (Stuttgart) + + Note: Seasonal plates (Saison-Kennzeichen) also contain month ranges but are not + covered by this pattern. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "KFZ-Kennzeichen (mit Leerzeichen)", + r"(? 9, replace it with the cross-sum of its digits + (e.g. 18 → 1+8 = 9, 40 → 4+0 = 4). + 3. Sum all six values. + 4. Check digit (pos 7) = sum mod 10. + + Accuracy note: The base pattern ``\\b\\d{9}\\b`` matches any 9-digit token. + Because LANRs share the same surface form as other 9-digit identifiers + (e.g. DE_BSNR), the checksum in ``validate_result()`` is the primary guard + against false positives; context words are required for high-confidence + results without a valid checksum. Formal accuracy evaluation has not been + performed on a labelled dataset. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Lebenslange Arztnummer LANR (9 digits)", + r"\b\d{9}\b", + 0.3, + ), + ] + + CONTEXT = [ + "arztnummer", + "lanr", + "lebenslange arztnummer", + "arzt-nr", + "arzt nr", + "arzt-nummer", + "vertragsarzt", + "kassenarzt", + "niedergelassener arzt", + "kbv", + "kassenärztliche vereinigung", + "kv-nummer", + "rezept", + "verschreibung", + "behandelnder arzt", + "hausarzt", + "facharzt", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_LANR", + 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 LANR using the KBV check digit algorithm. + + Algorithm source: KBV-Richtlinie nach § 75 Abs. 7 SGB V. + + :param pattern_text: the text to validate (9 digits) + :return: True if check digit is valid, False otherwise + """ + pattern_text = pattern_text.strip() + + if len(pattern_text) != 9 or not pattern_text.isdigit(): + return False + + weights = [4, 9, 2, 10, 5, 3] + total = 0 + for digit_char, weight in zip(pattern_text[:6], weights): + product = int(digit_char) * weight + if product > 9: + product = (product // 10) + (product % 10) + total += product + + expected_check = total % 10 + return int(pattern_text[6]) == expected_check diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_passport_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_passport_recognizer.py new file mode 100644 index 000000000..5553bf554 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_passport_recognizer.py @@ -0,0 +1,76 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DePassportRecognizer(PatternRecognizer): + """ + Recognizes German passport numbers (Reisepassnummern) using regex. + + German passports are issued by the Bundesdruckerei on behalf of the + Bundesrepublik Deutschland. The document number consists of 9 alphanumeric + characters and appears on the data page and in the Machine Readable Zone (MRZ). + + Legal basis: Passgesetz (PassG) § 4, Passverordnung (PassV). + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten), BDSG. + + Format: + - 9 alphanumeric characters (uppercase letters from the limited set + C, F, G, H, J, K, L, M, N, P, R, T, V, W, X, Y, Z and digits 0–9) + - First character: typically a letter from the series identifier + - Followed by 8 alphanumeric characters + - Example: C01X00T47, F20400481 + + The character set excludes visually ambiguous characters (I, O, Q, S, U) + as per ICAO Doc 9303 (Machine Readable Travel Documents) specifications. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Reisepassnummer (Strict ICAO charset)", + r"\b[CFGHJKLMNPRTVWXYZ][CFGHJKLMNPRTVWXYZ0-9]{7}[0-9]\b", + 0.4, + ), + Pattern( + "Reisepassnummer (Relaxed)", + r"\b[A-Z][A-Z0-9]{7}[0-9]\b", + 0.2, + ), + ] + + CONTEXT = [ + "reisepass", + "pass", + "passnummer", + "reisepassnummer", + "passport", + "passport number", + "pass-nr", + "dokumentennummer", + "bundesrepublik deutschland", + "ausweisdokument", + "mrz", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_PASSPORT", + 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, + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_plz_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_plz_recognizer.py new file mode 100644 index 000000000..d5d6695c0 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_plz_recognizer.py @@ -0,0 +1,86 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DePlzRecognizer(PatternRecognizer): + """ + Recognizes German postal codes (Postleitzahl, PLZ). + + German postal codes consist of exactly 5 digits in the range 01001–99998, + assigned by Deutsche Post AG. A PLZ alone is generally not sufficient to + identify a specific natural person and is therefore not directly personal data + under DSGVO Art. 4 Nr. 1. However, in combination with other data (e.g., street + address, name), it contributes to identifying an individual, and in very rural + areas a single PLZ may cover only a handful of addresses. + + Legal basis: DSGVO Art. 4 Nr. 1 in combination with other address data; BDSG. + + Format: + 5 digits, 01001–99998 (boundary values 01000 and 99999 are excluded). + Examples: 10115 (Berlin Mitte), 80331 (München), 22085 (Hamburg) + + !! ACCURACY WARNING !! + The pattern `[0-9]{5}` is extremely generic and will produce a very high number + of false positives when used without context (e.g., year numbers, prices, order + IDs, phone number fragments, reference numbers). The base confidence is + therefore set to 0.05 – the recognizer is only actionable when strong context + words such as "PLZ", "Postleitzahl" or "Postanschrift" are present nearby. + This recognizer should only be enabled in pipelines where German address data + is expected. Formal accuracy evaluation has not been performed on a labelled + dataset. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + # Regex covers 01001–09999 (leading zero) and 10000–99998. + # Does NOT match 00000 (not a valid PLZ), 01000, 99999, or 6-digit numbers. + # Reference: Deutsche Post AG PLZ-Verzeichnis. + PATTERNS = [ + Pattern( + "Postleitzahl (5 digits, very low base confidence – context required)", + r"\b(?!01000\b|99999\b)(0[1-9]\d{3}|[1-9]\d{4})\b", + 0.05, + ), + ] + + CONTEXT = [ + "plz", + "postleitzahl", + "postanschrift", + "adresse", + "wohnort", + "ort", + "wohnanschrift", + "lieferadresse", + "rechnungsadresse", + "straße", + "strasse", + "hausnummer", + "postfach", + "bundesland", + "gemeinde", + "stadt", + "dorf", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_PLZ", + 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, + ) diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_social_security_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_social_security_recognizer.py new file mode 100644 index 000000000..955ef1e2a --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_social_security_recognizer.py @@ -0,0 +1,135 @@ +import re +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeSocialSecurityRecognizer(PatternRecognizer): + """ + Recognizes German Rentenversicherungsnummer (RVNR / Sozialversicherungsnummer). + + The Rentenversicherungsnummer (also called Versicherungsnummer or RVNR) is a + unique 12-character identifier assigned to every person insured under the German + statutory pension insurance scheme (gesetzliche Rentenversicherung). It encodes + date of birth, gender information, and a serial number. + + Legal basis: § 147 SGB VI (Sozialgesetzbuch Sechstes Buch – Gesetzliche + Rentenversicherung), § 33a SGB I. + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten), BDSG. + + Format (12 characters): + Pos 1–2: Bereichsnummer (2 digits, issuing regional office code, 01–99) + Pos 3–4: Geburtstag (birth day, 01–31; or 51–81 for women with + Ergänzungsmerkmal) + Pos 5–6: Geburtsmonat (birth month, 01–12) + Pos 7–8: Geburtsjahr (last 2 digits of birth year) + Pos 9: Buchstabenkennung (first letter of birth surname, A–Z) + Pos 10–11: Seriennummer (2-digit ordinal, 01–49 male / 50–99 female as + Ergänzungsmerkmal) + Pos 12: Prüfziffer (check digit) + + Example (fictitious): 65070803A012 + + Check digit algorithm (Deutsche Rentenversicherung): + 1. Replace the letter at position 9 with its 2-digit ordinal value + (A=01, B=02, …, Z=26), yielding an effective 13-digit string. + 2. Apply weights [2,1,2,1,2,1,2,1,2,1,2,1] to the first 12 effective digits. + 3. For each product ≥ 10, replace it with the sum of its digits. + 4. Sum all 12 values, compute sum mod 10. + 5. The result must equal the check digit at position 12 (pos 13 in effective). + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Rentenversicherungsnummer (Strict, with birth date structure)", + r"\b\d{2}" + r"(0[1-9]|[12]\d|3[01]|5[1-9]|[67]\d|8[01])" # day: 01-31 or 51-81 + r"(0[1-9]|1[0-2])" # month 01-12 + r"\d{2}" # year + r"[A-Z]" # surname initial + r"\d{2}" # serial + r"[0-9]\b", # check digit + 0.5, + ), + Pattern( + "Rentenversicherungsnummer (Relaxed)", + r"\b\d{8}[A-Z]\d{3}\b", + 0.3, + ), + ] + + CONTEXT = [ + "rentenversicherungsnummer", + "sozialversicherungsnummer", + "versicherungsnummer", + "rvnr", + "svnr", + "sv-nummer", + "rente", + "rentenversicherung", + "deutsche rentenversicherung", + "drv", + "sozialversicherung", + "sozialversicherungsausweis", + "rentenausweis", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_SOCIAL_SECURITY", + 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 Rentenversicherungsnummer using the official checksum. + + Algorithm source: Deutsche Rentenversicherung Bund, technical specification + for RVNR validation. + + :param pattern_text: the text to validate (12 characters) + :return: True if valid, False if invalid + """ + pattern_text = pattern_text.upper().strip() + + if len(pattern_text) != 12: + return False + + if not re.match(r"^\d{8}[A-Z]\d{3}$", pattern_text): + return False + + letter = pattern_text[8] + letter_val = str(ord(letter) - ord("A") + 1).zfill(2) + + # Effective 12-digit string: + # positions 1-8 + letter as 2 digits + positions 10-11 + effective = pattern_text[:8] + letter_val + pattern_text[9:11] + + check_digit = int(pattern_text[11]) + weights = [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1] + + total = 0 + for digit_char, weight in zip(effective, weights): + product = int(digit_char) * weight + if product >= 10: + product = (product // 10) + (product % 10) + total += product + + return (total % 10) == check_digit diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_tax_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_tax_id_recognizer.py new file mode 100644 index 000000000..1bdadaf73 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_tax_id_recognizer.py @@ -0,0 +1,103 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeTaxIdRecognizer(PatternRecognizer): + """ + Recognizes German Steueridentifikationsnummer (Steuer-IdNr.). + + The Steueridentifikationsnummer is a unique 11-digit personal tax identification + number issued by the Bundeszentralamt für Steuern to every person registered in + Germany. It does not change over a person's lifetime. + + Legal basis: §§ 139a–139e Abgabenordnung (AO), in force since 2007. + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten), BDSG. + + Format: + - 11 digits + - First digit: 1–9 (never 0) + - Digit 11: check digit (ISO 7064 Mod 11, 10 variant) + + Examples (fictitious): 86095742719, 12345678903 + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Steueridentifikationsnummer (High)", + r"\b[1-9]\d{10}\b", + 0.5, + ), + ] + + CONTEXT = [ + "steueridentifikationsnummer", + "steuer-id", + "steuerid", + "steuerliche identifikationsnummer", + "steuerliche identifikation", + "persönliche identifikationsnummer", + "steuer identifikation", + "idnr", + "steuer-idnr", + "steuernummer", + "bzst", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "de", + supported_entity: str = "DE_TAX_ID", + 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 Steueridentifikationsnummer using the official checksum algorithm. + + Algorithm: ISO 7064 Mod 11, 10 variant as specified by the + Bundeszentralamt für Steuern. + + :param pattern_text: the text to validate (11 digits) + :return: True if valid, False if invalid + """ + if len(pattern_text) != 11 or not pattern_text.isdigit(): + return False + if pattern_text[0] == "0": + return False + + digits = [int(d) for d in pattern_text] + + # Check that the first 10 digits do not consist of the same digit repeated + if len(set(digits[:10])) == 1: + return False + + # ISO 7064 Mod 11, 10 checksum + product = 10 + for i in range(10): + total = (digits[i] + product) % 10 + if total == 0: + total = 10 + product = (total * 2) % 11 + + check = 11 - product + if check == 10: + check = 0 + + return check == digits[10] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_tax_number_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_tax_number_recognizer.py new file mode 100644 index 000000000..b05130c38 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/germany/de_tax_number_recognizer.py @@ -0,0 +1,86 @@ +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + + +class DeTaxNumberRecognizer(PatternRecognizer): + """ + Recognizes German Steuernummer using regex. + + The Steuernummer is a tax number assigned by the local Finanzamt (tax office) + to individuals and businesses. Unlike the Steueridentifikationsnummer, it can + change (e.g., upon moving to a different Finanzamt district). + + Legal basis: § 139a Abgabenordnung (AO). + Data protection: DSGVO Art. 4 Nr. 1 (personenbezogene Daten), BDSG. + + Formats: + - ELSTER unified (bundeseinheitlich, 13 digits): + BB FFF UUUUU P → 2-digit Bundesland code (01–16) + 11 digits + Example: 2181508150X → normalised as 02181508150X + - State-specific human-readable (with slashes/spaces): + NW: 123/4567/8901 (3/4/4 digits) + BY: 123/456/78901 (3/3/5 digits) + BE: 12/345/67890 (2/3/5 digits) + HH: 12/345/67890 (2/3/5 digits) + + Bundesland codes (ELSTER): + 01=SH, 02=HH, 03=NI, 04=HB, 05=NW, 06=HE, 07=RP, + 08=BW, 09=BY, 10=SL, 11=BE, 12=BB, 13=MV, 14=SN, 15=ST, 16=TH + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + """ + + PATTERNS = [ + Pattern( + "Steuernummer ELSTER (bundeseinheitlich, 13-stellig)", + r"\b(0[1-9]|1[0-6])\d{11}\b", + 0.5, + ), + Pattern( + "Steuernummer mit Schrägstrich (Bayern/BW: 3/3/5)", + r"(? 6) + ("HRB 1234567", 0), + # Lowercase: global IGNORECASE → also matches + ("hrb 12345", 1), + # fmt: on + ], +) +def test_when_all_de_handelsregister_numbers_then_succeed( + text, expected_len, recognizer, entities +): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len diff --git a/presidio-analyzer/tests/test_de_health_insurance_recognizer.py b/presidio-analyzer/tests/test_de_health_insurance_recognizer.py new file mode 100644 index 000000000..56a74d10e --- /dev/null +++ b/presidio-analyzer/tests/test_de_health_insurance_recognizer.py @@ -0,0 +1,90 @@ +""" +Tests for DeHealthInsuranceRecognizer (Krankenversicherungsnummer / KVNR). + +Format: 10 characters – 1 uppercase letter (birth surname initial) + +8 digits (birth date + serial) + 1 check digit. + +Valid numbers are generated with the GKV-Spitzenverband checksum algorithm +(letter expanded to 2-digit ordinal, weights [2,9,8,7,6,5,4,3,2,1], +products digit-summed if ≥ 10, sum mod 10). + +Legal basis: § 290 SGB V. DSGVO Art. 9 (Gesundheitsdaten). + +Pre-calculated valid examples (fictitious): + A123456787 – letter A (=01), data 12345678, check = 7 + M123456789 – letter M (=13), data 12345678, check = 9 + B123456787 – letter B (=02), data 12345678, check = 7 +""" +import pytest + +from tests import assert_result +from presidio_analyzer.predefined_recognizers import DeHealthInsuranceRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeHealthInsuranceRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_HEALTH_INSURANCE"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions", + [ + # fmt: off + # Valid KVNR – checksum passes → result at MAX_SCORE + ("A123456787", 1, ((0, 10),)), + ("M123456789", 1, ((0, 10),)), + ("B123456787", 1, ((0, 10),)), + ("Krankenkasse KVNR: A123456787", 1, ((19, 29),)), + ("eGK-Nummer M123456789 bitte angeben.", 1, ((11, 21),)), + # Invalid: wrong check digit + ("A123456780", 0, ()), + ("M123456781", 0, ()), + # Invalid: starts with digit instead of letter + ("1123456787", 0, ()), + # Lowercase: Presidio uses global IGNORECASE; validate_result calls .upper() + # so a valid lowercase KVNR is matched and validated correctly + ("a123456787", 1, ((0, 10),)), + # Too short (9 chars) + ("A12345678", 0, ()), + # Too long (11 chars) + ("A1234567890", 0, ()), + # fmt: on + ], +) +def test_when_all_de_health_insurance_numbers_then_succeed( + text, expected_len, expected_positions, recognizer, entities, max_score +): + 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, max_score) + + +@pytest.mark.parametrize( + "number, expected", + [ + # Valid + ("A123456787", True), + ("M123456789", True), + ("B123456787", True), + # Wrong check digit + ("A123456780", False), + ("M123456781", False), + # Starts with digit (after .upper() it's still a digit → re.match fails) + ("1123456787", False), + # Wrong length + ("A12345678", False), + ("A1234567890", False), + # Lowercase: .upper() converts to valid → True (IGNORECASE is global) + ("a123456787", True), + ], +) +def test_when_de_health_insurance_validated_then_checksum_result_is_correct( + number, expected, recognizer +): + assert recognizer.validate_result(number) == expected diff --git a/presidio-analyzer/tests/test_de_id_card_recognizer.py b/presidio-analyzer/tests/test_de_id_card_recognizer.py new file mode 100644 index 000000000..6731d6c90 --- /dev/null +++ b/presidio-analyzer/tests/test_de_id_card_recognizer.py @@ -0,0 +1,52 @@ +""" +Tests for DeIdCardRecognizer (Personalausweisnummer). + +Covers both the old format (T + 8 digits, pre-November 2010) and the +new nPA format (9 ICAO-compliant alphanumeric characters, since Nov 2010). +Legal basis: Personalausweisgesetz (PAuswG), Personalausweisverordnung (PAuswV). +""" +import pytest + +from presidio_analyzer.predefined_recognizers import DeIdCardRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeIdCardRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_ID_CARD"] + + +@pytest.mark.parametrize( + "text, expected_len", + [ + # fmt: off + # --- Old format: T + 8 digits (high confidence 0.5) --- + ("T22000129", 1), + ("T00000000", 1), + ("T99999999", 1), + ("Ausweis Nr. T22000129 gültig bis 2025.", 1), + # --- nPA format: ICAO letter + 8 ICAO chars --- + ("L01X00T47", 1), # starts with L (valid ICAO) + ("C01234567", 1), # starts with C + # In running text + ("Personalausweis: L01X00T47.", 1), + # --- Invalid cases --- + # Lowercase: global IGNORECASE → also matches + ("t22000129", 1), + ("l01x00t47", 1), + # Too short (8 chars) + ("T2200012", 0), + # Too long for any pattern (10+ chars without word boundary break) + ("T220001290", 0), + # Digits only with wrong length + ("123456789", 0), + # fmt: on + ], +) +def test_when_all_de_id_cards_then_succeed(text, expected_len, recognizer, entities): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len diff --git a/presidio-analyzer/tests/test_de_kfz_recognizer.py b/presidio-analyzer/tests/test_de_kfz_recognizer.py new file mode 100644 index 000000000..ce5b4b08f --- /dev/null +++ b/presidio-analyzer/tests/test_de_kfz_recognizer.py @@ -0,0 +1,59 @@ +""" +Tests for DeKfzRecognizer (KFZ-Kennzeichen / vehicle registration plates). + +Format: [1–3 letter district code] [space or hyphen] [1–2 letter identifier] +[space or hyphen] [1–4 digits] [optional E (electric) or H (historic) suffix]. + +Legal basis: Fahrzeug-Zulassungsverordnung (FZV) § 8. +Data protection: DSGVO Art. 4 Nr. 1 (ECJ C-582/14, Breyer v. Germany). +""" +import pytest + +from presidio_analyzer.predefined_recognizers import DeKfzRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeKfzRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_KFZ"] + + +@pytest.mark.parametrize( + "text, expected_len", + [ + # fmt: off + # --- Space-separated --- + ("B AB 1234", 1), # Berlin + ("M XY 999", 1), # München + ("HH AB 1234", 1), # Hamburg (2-letter district) + ("KA EF 12H", 1), # Karlsruhe, historic suffix + ("S AB 12E", 1), # Stuttgart, electric suffix + ("MIL E 1234", 1), # single-letter identifier + ("MIL EF 1234E", 1), # electric with 2-letter identifier + # --- Hyphen-separated --- + ("B-AB-1234", 1), + ("M-XY-999", 1), + ("HH-AB-1234", 1), + # --- In running text --- + ("Das Fahrzeug mit Kennzeichen B AB 1234 wurde gesehen.", 1), + ("Kennzeichen: HH-AB-1234.", 1), + # --- Should NOT match --- + # Lowercase: global IGNORECASE → also matches + ("b ab 1234", 1), + ("m xy 999", 1), + # No separator + ("BAB1234", 0), + # Only district code + digits, no letter identifier + ("B 1234", 0), + # Four-letter district code (not valid in Germany) + ("BXYZ AB 1234", 0), + # fmt: on + ], +) +def test_when_all_de_kfz_numbers_then_succeed(text, expected_len, recognizer, entities): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len diff --git a/presidio-analyzer/tests/test_de_lanr_recognizer.py b/presidio-analyzer/tests/test_de_lanr_recognizer.py new file mode 100644 index 000000000..f0bb70033 --- /dev/null +++ b/presidio-analyzer/tests/test_de_lanr_recognizer.py @@ -0,0 +1,90 @@ +""" +Tests for DeLanrRecognizer (Lebenslange Arztnummer / LANR). + +Format: 9 digits — 6-digit physician identifier + 1 check digit + 2-digit +specialty code. Check digit derived via KBV algorithm: weights [4,9,2,10,5,3] +on digits 1–6; cross-sum for products > 9; check = sum mod 10. + +Legal basis: § 75 Abs. 7 SGB V; KBV-Richtlinie zur Vergabe der Arzt-, +Betriebsstätten-, Praxisnetz- sowie Netzverbundnummern. + +Pre-calculated valid examples (fictitious): + 123456901 – physician 123456, check 9, specialty 01 + 234567601 – physician 234567, check 6, specialty 01 + 100000401 – physician 100000, check 4, specialty 01 + 987654901 – physician 987654, check 9, specialty 01 + 555555001 – physician 555555, check 0, specialty 01 +""" +import pytest + +from tests import assert_result +from presidio_analyzer.predefined_recognizers import DeLanrRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeLanrRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_LANR"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions", + [ + # fmt: off + # Valid LANR – checksum passes → result at MAX_SCORE + ("123456901", 1, ((0, 9),)), + ("234567601", 1, ((0, 9),)), + ("100000401", 1, ((0, 9),)), + ("987654901", 1, ((0, 9),)), + ("555555001", 1, ((0, 9),)), + # Valid LANR in running text + ("LANR: 123456901 des behandelnden Arztes.", 1, ((6, 15),)), + ("Arztnummer 987654901 auf dem Rezept.", 1, ((11, 20),)), + # Invalid: wrong check digit + ("123456801", 0, ()), + ("234567001", 0, ()), + ("100000001", 0, ()), + # Too short (8 digits) – word boundary prevents match + ("12345690", 0, ()), + # Too long (10 digits) – word boundary prevents match + ("1234569010", 0, ()), + # fmt: on + ], +) +def test_when_all_de_lanr_numbers_then_succeed( + text, expected_len, expected_positions, recognizer, entities, max_score +): + 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, max_score) + + +@pytest.mark.parametrize( + "number, expected", + [ + # Valid check digit + ("123456901", True), + ("234567601", True), + ("100000401", True), + ("987654901", True), + ("555555001", True), + # Wrong check digit + ("123456801", False), + ("234567001", False), + ("100000101", False), + # Wrong length + ("12345690", False), + ("1234569010", False), + # Non-numeric + ("12345690a", False), + ], +) +def test_when_de_lanr_validated_then_checksum_result_is_correct( + number, expected, recognizer +): + assert recognizer.validate_result(number) == expected diff --git a/presidio-analyzer/tests/test_de_passport_recognizer.py b/presidio-analyzer/tests/test_de_passport_recognizer.py new file mode 100644 index 000000000..1b1f9fd4c --- /dev/null +++ b/presidio-analyzer/tests/test_de_passport_recognizer.py @@ -0,0 +1,54 @@ +""" +Tests for DePassportRecognizer (Reisepassnummer). + +German passport numbers follow ICAO Doc 9303: 9 alphanumeric characters +using a restricted uppercase character set (excludes I, O, Q, S, U). +Legal basis: Passgesetz (PassG) § 4, Passverordnung (PassV). + +Note: Presidio applies global regex_flags=26 (IGNORECASE|MULTILINE|DOTALL), +so lowercase inputs also match the patterns. +""" +import pytest + +from presidio_analyzer.predefined_recognizers import DePassportRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DePassportRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_PASSPORT"] + + +@pytest.mark.parametrize( + "text, expected_len", + [ + # fmt: off + # --- Strict ICAO charset pattern (1 ICAO-letter + 7 ICAO-chars + 1 digit) --- + ("C01234567", 1), # starts with C, ends with digit + ("F12345678", 1), + # L01X00T47: L ∈ ICAO set, all inner chars valid, ends with digit 7 → matches + ("L01X00T47", 1), + # --- Relaxed pattern (any letter + 7 alphanumeric + 1 digit) --- + ("A01234567", 1), + ("Z98765432", 1), + # In running text + ("Reisepass C01234567 ausgestellt am 01.01.2020.", 1), + ("Pass-Nr.: F12345678", 1), + # Too short (8 chars) + ("C0123456", 0), + # Too long (10 chars) → word boundary prevents match + ("C012345678", 0), + # Lowercase: global IGNORECASE → also matches + ("c01234567", 1), + # Digits only → no match (first char must be letter) + ("901234567", 0), + # fmt: on + ], +) +def test_when_all_de_passports_then_succeed(text, expected_len, recognizer, entities): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len diff --git a/presidio-analyzer/tests/test_de_plz_recognizer.py b/presidio-analyzer/tests/test_de_plz_recognizer.py new file mode 100644 index 000000000..9ef0c93de --- /dev/null +++ b/presidio-analyzer/tests/test_de_plz_recognizer.py @@ -0,0 +1,73 @@ +""" +Tests for DePlzRecognizer (Postleitzahl). + +IMPORTANT: The base confidence is 0.05 due to the extreme false-positive risk of +matching any 5-digit number. These tests verify the regex structure (valid range, +boundary matching) only. In production, context words are required for the +recognizer to produce actionable (high-confidence) results. +""" +import pytest + +from presidio_analyzer.predefined_recognizers import DePlzRecognizer +from tests import assert_result_within_score_range + + +@pytest.fixture(scope="module") +def recognizer(): + return DePlzRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_PLZ"] + + +@pytest.mark.parametrize( + "text, expected_len", + [ + # fmt: off + # --- Valid PLZ: range 01001–99998 --- + ("10115", 1), # Berlin Mitte + ("80331", 1), # München + ("22085", 1), # Hamburg + ("01001", 1), # minimum valid (leading zero) + ("99998", 1), # near-maximum + # In running text (low confidence, context-free) + ("PLZ: 10115", 1), + ("Postleitzahl 80331 München", 1), + # --- Should NOT match --- + # 00000 is not a valid PLZ (excluded by regex) + ("00000", 0), + # 01000 and 99999 are boundary values outside the valid range + ("01000", 0), + ("99999", 0), + # 6 digits → no match (word boundary) + ("101150", 0), + # 4 digits + ("1011", 0), + # fmt: on + ], +) +def test_when_all_de_plz_then_succeed(text, expected_len, recognizer, entities): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len + + +@pytest.mark.parametrize( + "text, expected_len, expected_start, expected_end", + [ + # fmt: off + ("10115", 1, 0, 5), + ("PLZ 80331", 1, 4, 9), + # fmt: on + ], +) +def test_when_de_plz_matched_then_position_is_correct( + text, expected_len, expected_start, expected_end, recognizer, entities +): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len + if expected_len > 0: + assert_result_within_score_range( + results[0], entities[0], expected_start, expected_end, 0.0, 0.5 + ) diff --git a/presidio-analyzer/tests/test_de_social_security_recognizer.py b/presidio-analyzer/tests/test_de_social_security_recognizer.py new file mode 100644 index 000000000..b3ee3930e --- /dev/null +++ b/presidio-analyzer/tests/test_de_social_security_recognizer.py @@ -0,0 +1,77 @@ +""" +Tests for DeSocialSecurityRecognizer (Rentenversicherungsnummer / RVNR). + +Format: 12 characters – 8 digits (area + birth date) + 1 uppercase letter +(birth surname initial) + 2 digits (serial) + 1 check digit. + +Valid numbers are generated with the official Deutsche Rentenversicherung +checksum algorithm (letter expanded to 2-digit ordinal, weights [2,1,...], +products digit-summed if ≥ 10, sum mod 10). + +Legal basis: § 147 SGB VI. +""" +import pytest + +from tests import assert_result +from presidio_analyzer.predefined_recognizers import DeSocialSecurityRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeSocialSecurityRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_SOCIAL_SECURITY"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions", + [ + # fmt: off + # Valid RVNR – checksum passes → result at MAX_SCORE + ("65070803A018", 1, ((0, 12),)), + ("RVNR: 65070803A018 laut Sozialversicherungsausweis.", 1, ((6, 18),)), + # Invalid: wrong check digit + ("65070803A012", 0, ()), + ("65070803A010", 0, ()), + # Invalid: invalid month (00 or 13+) + ("65070003A018", 0, ()), + ("65071303A018", 0, ()), + # Invalid: digit at position 9 instead of letter + ("650708030018", 0, ()), + # Too short / too long + ("65070803A01", 0, ()), + ("65070803A0180", 0, ()), + # fmt: on + ], +) +def test_when_all_de_social_security_numbers_then_succeed( + text, expected_len, expected_positions, recognizer, entities, max_score +): + 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, max_score) + + +@pytest.mark.parametrize( + "number, expected", + [ + # Valid + ("65070803A018", True), + # Wrong check digit + ("65070803A012", False), + ("65070803A010", False), + # Digit instead of letter at position 9 + ("650708030018", False), + # Wrong length + ("65070803A01", False), + ("65070803A0180", False), + ], +) +def test_when_de_social_security_validated_then_checksum_result_is_correct( + number, expected, recognizer +): + assert recognizer.validate_result(number) == expected diff --git a/presidio-analyzer/tests/test_de_tax_id_recognizer.py b/presidio-analyzer/tests/test_de_tax_id_recognizer.py new file mode 100644 index 000000000..ca2f981da --- /dev/null +++ b/presidio-analyzer/tests/test_de_tax_id_recognizer.py @@ -0,0 +1,76 @@ +""" +Tests for DeTaxIdRecognizer (Steueridentifikationsnummer). + +All test numbers are fictitious/generated and do not represent real persons. +Valid numbers are produced with the official ISO 7064 Mod 11, 10 algorithm +as specified by the Bundeszentralamt für Steuern (§§ 139a–139e AO). +""" +import pytest + +from tests import assert_result +from presidio_analyzer.predefined_recognizers import DeTaxIdRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeTaxIdRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_TAX_ID"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions", + [ + # fmt: off + # Valid Steuer-IdNr – checksum passes → result at MAX_SCORE + ("12345678903", 1, ((0, 11),)), + ("98765432106", 1, ((0, 11),)), + ("Meine Steuer-ID: 12345678903.", 1, ((17, 28),)), + ("IdNr. 98765432106 liegt vor.", 1, ((6, 17),)), + # Invalid: wrong check digit + ("12345678901", 0, ()), + ("98765432100", 0, ()), + # Invalid: leading zero (first digit must be 1–9) + ("02345678901", 0, ()), + # Invalid: too short / too long + ("1234567890", 0, ()), + ("123456789030", 0, ()), + # Invalid: all ten leading digits are identical (excluded by spec) + ("11111111111", 0, ()), + # fmt: on + ], +) +def test_when_all_de_tax_ids_then_succeed( + text, expected_len, expected_positions, recognizer, entities, max_score +): + 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, max_score) + + +@pytest.mark.parametrize( + "number, expected", + [ + # Valid numbers + ("12345678903", True), + ("98765432106", True), + # Wrong check digit + ("12345678901", False), + ("98765432100", False), + # Leading zero + ("02345678903", False), + # Non-numeric + ("abcdefghijk", False), + # Wrong length + ("1234567890", False), + ("123456789030", False), + # All same first 10 digits + ("11111111111", False), + ], +) +def test_when_de_tax_id_validated_then_checksum_result_is_correct(number, expected, recognizer): + assert recognizer.validate_result(number) == expected diff --git a/presidio-analyzer/tests/test_de_tax_number_recognizer.py b/presidio-analyzer/tests/test_de_tax_number_recognizer.py new file mode 100644 index 000000000..51ebeaacb --- /dev/null +++ b/presidio-analyzer/tests/test_de_tax_number_recognizer.py @@ -0,0 +1,51 @@ +""" +Tests for DeTaxNumberRecognizer (Steuernummer). + +Tests cover both the ELSTER unified 13-digit format and the various +state-specific slash-separated formats used across German Bundesländer. +""" +import pytest + +from presidio_analyzer.predefined_recognizers import DeTaxNumberRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeTaxNumberRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_TAX_NUMBER"] + + +@pytest.mark.parametrize( + "text, expected_len", + [ + # fmt: off + # --- ELSTER unified 13-digit format (valid Bundesland codes 01–16) --- + ("0281508150123", 1), # Hamburg (02) + ("0981508150999", 1), # Bayern (09) + ("1681508150001", 1), # Thüringen (16) + ("0181508150000", 1), # Schleswig-Holstein (01) + # Invalid Bundesland codes + ("1781508150001", 0), # code 17 does not exist + ("0081508150001", 0), # code 00 does not exist + # Too short for 13-digit pattern + ("028150815012", 0), + # --- Slash-separated Bayern-style (3/3/5) --- + ("123/456/78901", 1), + ("987/654/32100", 1), + # --- General slash-separated (2-3 / 3-4 / 4-5 digits) --- + ("12/345/6789", 1), + ("12/3456/7890", 1), + ("123/3456/7890", 1), + # Matches in running text + ("Steuernummer: 0981508150999 wurde vergeben.", 1), + ("St.-Nr. 123/456/78901 bitte angeben.", 1), + # fmt: on + ], +) +def test_when_all_de_tax_numbers_then_succeed(text, expected_len, recognizer, entities): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len diff --git a/presidio-analyzer/tests/test_de_vat_id_recognizer.py b/presidio-analyzer/tests/test_de_vat_id_recognizer.py new file mode 100644 index 000000000..57a802830 --- /dev/null +++ b/presidio-analyzer/tests/test_de_vat_id_recognizer.py @@ -0,0 +1,55 @@ +""" +Tests for DeVatIdRecognizer (Umsatzsteuer-Identifikationsnummer / USt-IdNr.). + +Format: "DE" + 9 digits (11 characters total). + +Legal basis: § 27a UStG. Format documentation: BZSt. + +Fictitious examples: + DE123456789 + DE987654321 + DE100000001 +""" +import pytest + +from presidio_analyzer.predefined_recognizers import DeVatIdRecognizer + + +@pytest.fixture(scope="module") +def recognizer(): + return DeVatIdRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + return ["DE_VAT_ID"] + + +@pytest.mark.parametrize( + "text, expected_len", + [ + # fmt: off + # Valid format: DE + 9 digits + ("DE123456789", 1), + ("DE987654321", 1), + ("DE100000001", 1), + # In running text + ("USt-IdNr.: DE123456789", 1), + ("Bitte angeben: DE987654321 auf der Rechnung.", 1), + # Wrong country prefix – must not match + ("AT123456789", 0), + ("FR12345678901", 0), + # Lowercase prefix – Presidio uses global IGNORECASE + ("de123456789", 1), + # Too few digits (8) + ("DE12345678", 0), + # Too many digits (10) + ("DE1234567890", 0), + # fmt: on + ], +) +def test_when_all_de_vat_ids_then_succeed( + text, expected_len, recognizer, entities +): + results = recognizer.analyze(text, entities) + assert len(results) == expected_len