fix(core): exempter les hits forcés (overrides) du filtre catégorie — anti-fuite PDF (P1-2/T1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -685,7 +685,11 @@ def _filter_audit_by_disabled(audit, disabled_kinds):
|
|||||||
"""
|
"""
|
||||||
if not disabled_kinds:
|
if not disabled_kinds:
|
||||||
return audit
|
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
|
# Baseline regex
|
||||||
@@ -1331,6 +1335,10 @@ class PiiHit:
|
|||||||
original: str
|
original: str
|
||||||
placeholder: str
|
placeholder: str
|
||||||
bbox_hint: Optional[Tuple[float, float, float, float]] = None
|
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
|
@dataclass
|
||||||
class AnonResult:
|
class AnonResult:
|
||||||
@@ -1769,7 +1777,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
def _rep(m: re.Match):
|
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
|
return placeholder
|
||||||
line = rx.sub(_rep, line)
|
line = rx.sub(_rep, line)
|
||||||
# force-mask literals
|
# 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
|
if not term: continue
|
||||||
word_rx = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
|
word_rx = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
|
||||||
if word_rx.search(line):
|
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)
|
line = word_rx.sub(PLACEHOLDERS["MASK"], line)
|
||||||
# force-mask regex
|
# force-mask regex
|
||||||
for pat in (cfg.get("blacklist", {}).get("force_mask_regex", []) or []):
|
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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
def _repl_force_regex(m: re.Match, _pat=pat):
|
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"]
|
return PLACEHOLDERS["MASK"]
|
||||||
line = rx.sub(_repl_force_regex, line)
|
line = rx.sub(_repl_force_regex, line)
|
||||||
return line
|
return line
|
||||||
|
|||||||
76
tests/unit/test_core_forced_hits_exempt.py
Normal file
76
tests/unit/test_core_forced_hits_exempt.py
Normal file
@@ -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é"
|
||||||
Reference in New Issue
Block a user