From 2a3aab117dd770a918143a5725badc2b4a5abfdc Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Fri, 26 Jun 2026 10:10:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20coordination=20quarantaine=20r?= =?UTF-8?q?=C3=A9siduelle=20NIR/TEL=20d=C3=A9coch=C3=A9s=20(P1-2/F-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- anonymizer_core_refactored_onnx.py | 114 +++++++++++++++++++++++-- tests/unit/test_q1_quarantine.py | 132 +++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 7 deletions(-) diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index 320b219..68484fd 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -865,6 +865,88 @@ def validate_nir(nir_raw: str) -> bool: return False return key_int == (97 - (body_int % 97)) + +# === Plan 1b (P1-2/F-4) — patterns de rescan résiduel gatés par catégorie ==== +# Le rescan M5 (cf. process_pdf) re-scanne le texte masqué à la recherche de PII +# résiduelles ; toute occurrence (seuil 0) met le document en quarantaine full. +# Quand une catégorie est volontairement décochée (laissée en clair), elle ne +# doit PAS déclencher la quarantaine — ni directement, ni via le pattern résiduel +# d'une AUTRE catégorie. Piège connu : un NIR laissé en clair (`1 85 05 74 123 +# 456 78`) fait matcher le pattern résiduel TEL sur son bloc central de chiffres +# (le `0` de `05…` amorce l'ancre `(?:\+33|0)`). On résout ce couplage en +# pré-masquant les blocs de type NIR AVANT d'appliquer les patterns, mais +# UNIQUEMENT quand NIR est décoché. Quand rien n'est désactivé, le pré-masquage +# est l'identité et la liste de patterns est byte-for-byte celle d'avant. + +# Pattern d'un bloc « type NIR » : 13 à 15 chiffres groupés (espaces/points/ +# tirets optionnels), tel qu'écrit dans un document (`1 85 05 74 123 456 78`). +# Utilisé uniquement pour neutraliser un NIR laissé EN CLAIR avant le rescan TEL. +_RE_NIR_LIKE_SPAN = re.compile( + r"\b\d(?:[\s.\-]?\d){12,14}\b" +) + + +def _residual_premask_text(text: str, disabled_kinds: Optional[Set[str]] = None) -> str: + """Neutralise les blocs « type NIR » du texte AVANT le rescan résiduel, + uniquement si la catégorie NIR est désactivée (laissée en clair). + + But : empêcher qu'un NIR en clair ne fasse matcher le pattern résiduel TEL + (ou IBAN) sur son bloc central de chiffres et ne déclenche une quarantaine + injustifiée. Quand NIR n'est PAS désactivé, retourne le texte inchangé + (identité) → comportement byte-for-byte préservé. + """ + disabled = disabled_kinds or set() + if "NIR" not in disabled: + return text + # Remplace chaque bloc type-NIR par des espaces de même longueur : aucune + # frontière de chiffres n'est créée/détruite, et le pattern TEL ne peut plus + # s'amorcer sur ces chiffres laissés en clair. + return _RE_NIR_LIKE_SPAN.sub(lambda m: " " * len(m.group(0)), text) + + +def _build_residual_patterns( + disabled_kinds: Optional[Set[str]] = None, +) -> List[Tuple["re.Pattern", str]]: + """Construit la liste `(regex compilée, label)` des patterns de rescan + résiduel, en retirant les catégories décochées. + + `disabled_kinds` contient des noms de CATÉGORIE (les 7 toggles : "NIR", + "TEL", "NOM", …), pas des kinds bruts. + + Règles : + - EMAIL et IBAN : toujours inclus. + - NIR : inclus seulement si "NIR" non désactivé. + - TEL : inclus seulement si "TEL" non désactivé. L'exclusion des blocs + type-NIR du pattern TEL est gérée en amont par `_residual_premask_text` + (appliqué au texte au call-site), pas dans le pattern lui-même → quand + NIR est activé, le pattern TEL est strictement identique à avant. + + Non-régression : `_build_residual_patterns(set())` produit EXACTEMENT la + liste historique (NIR, EMAIL, IBAN, TEL, dans cet ordre). + + NB : les littéraux EMAIL/IBAN/TEL ci-dessous sont des filets résiduels + INDÉPENDANTS et volontairement plus LARGES que les masqueurs canoniques + (`RE_EMAIL`/`RE_IBAN`/`RE_TEL`). Ils doivent rester littéraux ici : les + basculer sur les constantes canoniques changerait le comportement résiduel + et casserait la non-régression byte-for-byte du chemin par défaut. + """ + disabled = disabled_kinds or set() + patterns: List[Tuple["re.Pattern", str]] = [] + if "NIR" not in disabled: + patterns.append( + (re.compile(RE_NIR.pattern if hasattr(RE_NIR, "pattern") else r"\b\d{15}\b"), "NIR") + ) + patterns.append((re.compile(r"\b[\w.%+-]+@[\w.-]+\.\w{2,}\b"), "EMAIL")) + patterns.append( + (re.compile(r"\b(?:FR\d{2})?\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2,3}\b"), "IBAN") + ) + if "TEL" not in disabled: + patterns.append( + (re.compile(r"\b(?:\+33|0)[\s.\-]?\d[\s.\-]?(?:\d[\s.\-]?){8}\b"), "TEL") + ) + return patterns + + # Mots médicaux/techniques/courants qui ne sont pas des noms de personnes. # Source de vérité externalisée dans data/stopwords_manuels.txt + BDPM/edsnlp. _MEDICAL_STOP_WORDS_FALLBACK = { @@ -5534,15 +5616,22 @@ def process_pdf( # initiales, whitelist). Si PII résiduelles > seuil, on NE LIVRE PAS — quarantaine full. # Inconditionnel : toujours exécuté même si quarantine_mgr absent (Codex review). if SEUIL_RESCAN_RESIDUEL is not None: - _residual_pii_patterns = [ - (re.compile(RE_NIR.pattern if hasattr(RE_NIR, 'pattern') else r"\b\d{15}\b"), "NIR"), - (re.compile(r"\b[\w.%+-]+@[\w.-]+\.\w{2,}\b"), "EMAIL"), - (re.compile(r"\b(?:FR\d{2})?\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2,3}\b"), "IBAN"), - (re.compile(r"\b(?:\+33|0)[\s.\-]?\d[\s.\-]?(?:\d[\s.\-]?){8}\b"), "TEL"), - ] + # Plan 1b (P1-2/F-4) — patterns gatés par catégorie décochée. + # disabled_kinds contient des noms de CATÉGORIE (les 7 toggles). + _rescan_disabled = cfg.get("disabled_kinds") or set() + _residual_pii_patterns = _build_residual_patterns(_rescan_disabled) + # Pré-masquage SCOPÉ AU SEUL SCAN TEL : quand NIR est décoché, neutralise + # les blocs type-NIR laissés EN CLAIR uniquement pour le pattern TEL + # (sinon TEL s'amorce sur le bloc central de chiffres du NIR → quarantaine + # injustifiée). EMAIL/IBAN/NIR scannent le texte ORIGINAL : sinon le + # pré-masquage effacerait les groupes de chiffres d'un IBAN en clair et + # affaiblirait silencieusement le filet IBAN (toujours actif). Identité + # quand NIR n'est pas décoché → comportement byte-for-byte préservé. + _tel_scan_text = _residual_premask_text(final_text, _rescan_disabled) residual_count = 0 for pat, _label in _residual_pii_patterns: - residual_count += len(pat.findall(final_text)) + _scan_text = _tel_scan_text if _label == "TEL" else final_text + residual_count += len(pat.findall(_scan_text)) # F4 — filet de rescan élargi aux noms INSEE en MAJUSCULES. # OPT-IN : désactivé par défaut. Sur le corpus audit_30, INSEE contient @@ -5551,9 +5640,13 @@ def process_pdf( # les documents en quarantaine. À utiliser quand on tolère le sur- # masquage et qu'on veut zéro fuite (ex: profil "paranoid"). # Pour activer : passer cfg["rescan"]["check_insee_names"] = True. + # Plan 1b (P1-2/F-4) : ce filet vise des NOMS → désactivé si la catégorie + # NOM est décochée (sinon un nom laissé en clair déclencherait quarantaine). _check_insee = False if isinstance(cfg, dict): _check_insee = bool((cfg.get("rescan", {}) or {}).get("check_insee_names", False)) + if "NOM" in _rescan_disabled: + _check_insee = False if _check_insee: _placeholder_bare = {p.strip("[]") for p in PLACEHOLDERS.values()} _wl_terms = [] @@ -5573,6 +5666,13 @@ def process_pdf( residual_count += 1 log.warning("Residual INSEE name detected: %s (in %s)", token, pdf_path.name) + # Plan 1b (P1-2/F-4) — le filet résiduel reste STRICT (seuil 0) + # inconditionnellement : toute fuite EMAIL/IBAN/NIR/TEL met TOUJOURS le + # document en quarantaine. La contamination croisée d'une catégorie + # décochée (ses spans en clair matchant un pattern résiduel actif) sera + # traitée span-précisément en Task 3 (gating texte : pré-masquage des + # spans des catégories décochées AVANT le rescan), pas par un seuil + # relâché qui affaiblirait globalement le filet. if residual_count > SEUIL_RESCAN_RESIDUEL: if quarantine_mgr is not None: quarantine_mgr.flag( diff --git a/tests/unit/test_q1_quarantine.py b/tests/unit/test_q1_quarantine.py index 10f29cc..f55de17 100644 --- a/tests/unit/test_q1_quarantine.py +++ b/tests/unit/test_q1_quarantine.py @@ -182,6 +182,138 @@ class TestRescanQuarantine: assert mgr.has_full_quarantine("doc_leak") +# === Tests F4 : patterns résiduels gated par catégorie désactivée === + +class TestResidualPatternsGating: + """F-4 (P1-2) — `_build_residual_patterns(disabled)` : une catégorie + décochée ne doit pas déclencher la quarantaine résiduelle, ni directement, + ni via le pattern résiduel d'une autre catégorie (piège NIR ⇄ TEL).""" + + def _labels(self, patterns): + return {label for _pat, label in patterns} + + @staticmethod + def _residual_count(text, disabled): + """Reproduit EXACTEMENT le calcul du call-site (process_pdf) : + seul le scan TEL voit le texte pré-masqué ; EMAIL/IBAN/NIR voient + le texte ORIGINAL.""" + from anonymizer_core_refactored_onnx import ( + _build_residual_patterns, + _residual_premask_text, + ) + + patterns = _build_residual_patterns(disabled) + tel_text = _residual_premask_text(text, disabled) + total = 0 + for pat, label in patterns: + scan = tel_text if label == "TEL" else text + total += len(pat.findall(scan)) + return total + + def test_default_set_includes_all_labels(self) -> None: + """Aucune catégorie désactivée → NIR, EMAIL, IBAN, TEL tous présents.""" + from anonymizer_core_refactored_onnx import _build_residual_patterns + + labels = self._labels(_build_residual_patterns(set())) + assert {"NIR", "EMAIL", "IBAN", "TEL"}.issubset(labels) + + def test_nir_disabled_drops_nir_keeps_others(self) -> None: + """NIR décoché → NIR absent, EMAIL/IBAN toujours présents.""" + from anonymizer_core_refactored_onnx import _build_residual_patterns + + labels = self._labels(_build_residual_patterns({"NIR"})) + assert "NIR" not in labels + assert "EMAIL" in labels + assert "IBAN" in labels + + def test_tel_disabled_drops_tel(self) -> None: + """TEL décoché → TEL absent.""" + from anonymizer_core_refactored_onnx import _build_residual_patterns + + labels = self._labels(_build_residual_patterns({"TEL"})) + assert "TEL" not in labels + + def test_nir_disabled_tel_does_not_match_nir_in_clear(self) -> None: + """Piège F-4 : NIR décoché laissé en clair → le pré-masquage SCOPÉ-TEL + empêche le pattern TEL de matcher le bloc central de chiffres du NIR. + Le NIR-pattern est retiré du set et EMAIL/IBAN ne matchent pas des + chiffres nus → décompte résiduel global == 0 pour ce NIR en clair.""" + from anonymizer_core_refactored_onnx import ( + _build_residual_patterns, + _residual_premask_text, + ) + + nir_en_clair = "1 85 05 74 123 456 78" + disabled = {"NIR"} + + # Le pattern TEL appliqué au texte pré-masqué → 0 match. + patterns = _build_residual_patterns(disabled) + tel_pat = next(pat for pat, label in patterns if label == "TEL") + premasked = _residual_premask_text(nir_en_clair, disabled) + assert tel_pat.findall(premasked) == [] + + # Décompte résiduel global (logique call-site, TEL-scopé) == 0. + total = self._residual_count(nir_en_clair, disabled) + assert total == 0, ( + f"NIR décoché ne doit pas déclencher la quarantaine, " + f"or {total} match(s) résiduel(s) sur {nir_en_clair!r}" + ) + + def test_nir_disabled_clear_iban_still_matches(self) -> None: + """Fix 1 (régression) : le pré-masquage NIR est SCOPÉ au seul scan TEL. + Un IBAN en clair, avec NIR décoché, DOIT toujours déclencher le filet + IBAN résiduel — le pré-masquage ne doit PAS effacer ses groupes de + chiffres (sinon le backstop IBAN, toujours actif, serait affaibli).""" + from anonymizer_core_refactored_onnx import _build_residual_patterns + + iban_clair = "FR76 3000 1007 9412 3456 7890 185" + disabled = {"NIR"} + + # Le pattern IBAN (scanné sur le texte ORIGINAL) matche toujours. + patterns = _build_residual_patterns(disabled) + iban_pat = next(pat for pat, label in patterns if label == "IBAN") + assert iban_pat.findall(iban_clair), "le filet IBAN doit rester actif" + + # Décompte résiduel global (logique call-site) ≥ 1. + total = self._residual_count(iban_clair, disabled) + assert total >= 1, ( + f"IBAN en clair doit déclencher la quarantaine même NIR décoché, " + f"or {total} match(s)" + ) + + def test_residual_threshold_is_strict_zero_regardless_of_disabled(self) -> None: + """Fix 2 (régression) : le seuil résiduel reste STRICT (0) + inconditionnellement. Un EMAIL en clair → 1 résidu, et 1 > 0 ⇒ + quarantaine, même avec des catégories décochées (pas de relâchement + à 1 qui laisserait passer une fuite EMAIL/IBAN).""" + from anonymizer_core_refactored_onnx import SEUIL_RESCAN_RESIDUEL + + assert SEUIL_RESCAN_RESIDUEL == 0 + + email_clair = "a@b.fr" + # Une catégorie est décochée mais le seuil effectif reste 0. + for disabled in (set(), {"NIR"}, {"NIR", "TEL"}): + total = self._residual_count(email_clair, disabled) + assert total == 1, (disabled, total) + # 1 résidu > seuil strict (0) ⇒ quarantaine déclenchée. + assert total > SEUIL_RESCAN_RESIDUEL + + def test_nir_enabled_tel_behavior_unchanged(self) -> None: + """Non-régression : NIR activé → le pré-masquage est l'identité et + un vrai téléphone est toujours détecté par le pattern TEL.""" + from anonymizer_core_refactored_onnx import ( + _build_residual_patterns, + _residual_premask_text, + ) + + tel = "06 12 34 56 78" + patterns = _build_residual_patterns(set()) + text = _residual_premask_text(tel, set()) + assert text == tel # identité quand rien n'est désactivé + tel_pat = next(pat for pat, label in patterns if label == "TEL") + assert tel_pat.findall(tel), "un vrai téléphone doit rester détecté" + + # === Tests A : INDEX.md et errors.log =========================== class TestQuarantineArtifacts: