From 4357a58d7d757590172899567dea498ad33f3816 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Sat, 27 Jun 2026 11:38:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(core):=20exempter=20les=20hits=20forc=C3=A9?= =?UTF-8?q?s=20(overrides)=20du=20filtre=20cat=C3=A9gorie=20=E2=80=94=20an?= =?UTF-8?q?ti-fuite=20PDF=20(P1-2/T1)?= 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 | 16 +++-- tests/unit/test_core_forced_hits_exempt.py | 76 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_core_forced_hits_exempt.py diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index fd22cf5..709fc27 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -685,7 +685,11 @@ def _filter_audit_by_disabled(audit, disabled_kinds): """ if not disabled_kinds: return audit - return [h for h in audit if _category_of(h.kind) not in disabled_kinds] + # P1-2/T1 (anti-fuite PDF) : un hit FORCÉ (override utilisateur / blacklist + # force-mask) est TOUJOURS conservé → toujours gravé, quel que soit le toggle + # de catégorie. getattr défensif au cas où un PiiHit serait construit ailleurs + # sans le champ (le default du dataclass couvre déjà ce cas). + return [h for h in audit if getattr(h, "forced", False) or _category_of(h.kind) not in disabled_kinds] # Baseline regex @@ -1331,6 +1335,10 @@ class PiiHit: original: str placeholder: str bbox_hint: Optional[Tuple[float, float, float, float]] = None + # P1-2/T1 (anti-fuite PDF) : un hit FORCÉ (override utilisateur / blacklist + # force-mask) est TOUJOURS gravé, jamais retiré par un toggle de catégorie. + # Default False ⇒ no-op strict pour tous les call sites existants. + forced: bool = False @dataclass class AnonResult: @@ -1769,7 +1777,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st except Exception: continue def _rep(m: re.Match): - audit.append(PiiHit(page_idx, name, m.group(0), placeholder)) + audit.append(PiiHit(page_idx, name, m.group(0), placeholder, forced=True)) return placeholder line = rx.sub(_rep, line) # force-mask literals @@ -1777,7 +1785,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st if not term: continue word_rx = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE) if word_rx.search(line): - audit.append(PiiHit(page_idx, "force_term", term, PLACEHOLDERS["MASK"])) + audit.append(PiiHit(page_idx, "force_term", term, PLACEHOLDERS["MASK"], forced=True)) line = word_rx.sub(PLACEHOLDERS["MASK"], line) # force-mask regex for pat in (cfg.get("blacklist", {}).get("force_mask_regex", []) or []): @@ -1786,7 +1794,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st except Exception: continue def _repl_force_regex(m: re.Match, _pat=pat): - audit.append(PiiHit(page_idx, "force_regex", m.group(0), PLACEHOLDERS["MASK"])) + audit.append(PiiHit(page_idx, "force_regex", m.group(0), PLACEHOLDERS["MASK"], forced=True)) return PLACEHOLDERS["MASK"] line = rx.sub(_repl_force_regex, line) return line diff --git a/tests/unit/test_core_forced_hits_exempt.py b/tests/unit/test_core_forced_hits_exempt.py new file mode 100644 index 0000000..93356fc --- /dev/null +++ b/tests/unit/test_core_forced_hits_exempt.py @@ -0,0 +1,76 @@ +"""Anti-fuite PDF : un masque FORCÉ (override utilisateur / blacklist force-mask) +ne doit JAMAIS être retiré de l'audit par un toggle de catégorie. + +Contexte (Plan 1b, P1-2/T1) : +- `_apply_overrides` masque le TEXTE inline ET ajoute un `PiiHit` dont le `kind` + est contrôlé par l'utilisateur (`name` de l'override). Cet appel est + inconditionnel (pas gaté par `disabled_kinds`). +- `_filter_audit_by_disabled` retire ensuite de l'audit les hits dont la + catégorie est désactivée, AVANT la gravure PDF. +- BUG : si un utilisateur nomme un override avec une catégorie toggleable + (ex. `name="NOM"`) et désactive cette catégorie, le texte reste masqué mais + le hit est retiré de l'audit → la gravure PDF laisse la valeur EN CLAIR. + +Correctif attendu : marquer les hits forcés (`forced=True`) et les exempter du +filtre catégorie. Un terme explicitement forcé est TOUJOURS gravé. +""" +import re + +import anonymizer_core_refactored_onnx as core + + +def test_forced_override_hit_survives_category_filter(): + """Un override nommé "NOM" produit un hit FORCÉ qui survit au filtre {"NOM"}.""" + cfg = { + "regex_overrides": [ + {"pattern": r"\bDupont\b", "placeholder": "[NOM]", "name": "NOM"}, + ], + } + audit: list = [] + line = "Patient Dupont vu ce jour." + + masked = core._apply_overrides(line, audit, 0, cfg) + + # Le texte est bien masqué (comportement inline inchangé). + assert "Dupont" not in masked + assert "[NOM]" in masked + + # Un hit a été produit, de catégorie NOM, et marqué forcé. + assert len(audit) == 1 + forced_hit = audit[0] + assert forced_hit.kind == "NOM" + assert core._category_of(forced_hit.kind) == "NOM" + assert getattr(forced_hit, "forced", False) is True + + # Cœur du correctif : avec NOM désactivé, le hit FORCÉ reste dans l'audit + # (donc serait gravé dans le PDF) → pas de fuite. + filtered = core._filter_audit_by_disabled(list(audit), {"NOM"}) + assert forced_hit in filtered, "le hit forcé a été retiré → fuite PDF" + + +def test_genuine_nom_hit_still_dropped_by_filter(): + """Le correctif ne sur-exempte pas : un vrai hit NOM (non forcé) est bien retiré.""" + genuine = core.PiiHit(0, "NOM", "Martin", "[NOM]") + # Par défaut un PiiHit n'est PAS forcé. + assert getattr(genuine, "forced", False) is False + + filtered = core._filter_audit_by_disabled([genuine], {"NOM"}) + assert genuine not in filtered, "un hit NOM non forcé doit être retiré quand NOM est désactivé" + + +def test_forced_blacklist_terms_marked_forced(): + """Les force_mask_terms / force_mask_regex sont aussi marqués forcés.""" + cfg = { + "blacklist": { + "force_mask_terms": ["CHUXX"], + "force_mask_regex": [r"SIGLE-\d+"], + }, + } + audit: list = [] + line = "Etablissement CHUXX, code SIGLE-42." + + core._apply_overrides(line, audit, 0, cfg) + + assert len(audit) == 2 + for h in audit: + assert getattr(h, "forced", False) is True, f"{h.kind} non marqué forcé"