fix(server): durcissement sanitizer PII suite revue adversariale Qwen

- FN-1/2/3 : ajout RE_PRENOM_NOM (« Prénom NOM » inversé sans parens/crochets,
  ex. « Alix DATTIN ») ; 2e mot tout-majuscules -> 0 FP sur « Mozilla Firefox ».
- FN-4 (majeur, 228 events) : sanitize_event scanne désormais les titres
  RÉCURSIVEMENT (vision_info.window_capture.window_title et tout titre imbriqué),
  au lieu de 3 clés top-level hardcodées.
2 correctifs issus de la revue croisée Qwen. 11 tests verts, 0 FP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-28 20:24:52 +02:00
parent 30d8f65e9a
commit e84cdee393
2 changed files with 60 additions and 13 deletions

View File

@@ -38,11 +38,15 @@ RE_NOM_NAISSANCE = re.compile(
RE_NOM_BRACKET = re.compile(
rf"\[((?:[{_MAJ}][\w{_MIN}'\-]*\s+){{1,3}}[{_MAJ}][\w{_MIN}'\-]*)\]"
)
# « Prénom NOM » inversé, sans parenthèses ni crochets (ex. « Alix DATTIN »).
# 2e mot tout en MAJUSCULES → faible risque de FP (« Mozilla Firefox » ne matche pas).
RE_PRENOM_NOM = re.compile(rf"\b[{_MAJ}][{_MIN}]+\s+[{_MAJ}][{_MAJ}\-']+\b")
# Ordre = priorité ; group = portion à remplacer (0 = match entier).
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
(RE_NOM_NAISSANCE, "NOM", 0),
(RE_NOM_BRACKET, "NOM", 0),
(RE_PRENOM_NOM, "NOM", 0),
(RE_EMAIL, "EMAIL", 0),
(RE_NIR, "NIR", 0),
(RE_IPP, "IPP", 1),
@@ -134,11 +138,26 @@ def anonymize_text(
return out, entities
# Conteneurs de titre de fenêtre dans les events (window_focus_change, clic, saisie).
_TITLE_CONTAINERS = ("window", "to", "from")
# Clés portant un titre de fenêtre, où qu'elles soient imbriquées dans l'event
# (top-level `active_window_title`, `window/to/from.title`, et surtout
# `vision_info.window_capture.window_title` — blind spot signalé par Qwen).
_TITLE_KEYS = ("title", "window_title", "active_window_title")
_PLACEHOLDER_SAISIE = "[SAISIE]"
def _walk_titles(obj, mapping: Dict) -> None:
"""Parcourt récursivement l'event et assainit toute valeur de titre de fenêtre."""
if isinstance(obj, dict):
for k, v in obj.items():
if k in _TITLE_KEYS and isinstance(v, str):
obj[k] = anonymize_text(v, mapping=mapping)[0]
else:
_walk_titles(v, mapping)
elif isinstance(obj, list):
for item in obj:
_walk_titles(item, mapping)
def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict:
"""Assainit un event capturé avant persistance (copie, ne mute pas l'original).
@@ -159,16 +178,8 @@ def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict:
if ev.get(k) not in (None, ""):
ev[k] = _PLACEHOLDER_SAISIE
# titre direct (heartbeat)
if isinstance(ev.get("active_window_title"), str):
ev["active_window_title"] = anonymize_text(
ev["active_window_title"], mapping=mapping
)[0]
# titres imbriqués (window / to / from)
for key in _TITLE_CONTAINERS:
sub = ev.get(key)
if isinstance(sub, dict) and isinstance(sub.get("title"), str):
sub["title"] = anonymize_text(sub["title"], mapping=mapping)[0]
# tous les titres de fenêtre, où qu'ils soient imbriqués
# (active_window_title, window/to/from.title, vision_info.window_capture.window_title…)
_walk_titles(ev, mapping)
return ev

View File

@@ -136,3 +136,39 @@ def test_sanitize_action_result_inchange():
ev = {"type": "action_result", "base_shot_id": "shot_0003", "image": "x.png"}
assert sanitize_event(ev) == ev
def test_prenom_nom_inverse():
"""FN-1/2/3 (Qwen) : « Prénom NOM » inversé (sans parens/crochets)."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
m: dict = {}
for s, leak in [("Alix DATTIN - Mozilla Firefox", "DATTIN"),
("Agathe RONDOT - PACS CIM ARES", "RONDOT"),
("Marie FLANDINETTE - Mozilla Firefox", "FLANDINETTE")]:
out, _ = anonymize_text(s, mapping=m)
assert leak not in out, out
assert "[NOM_" in out
# pas de faux positif sur les logiciels (2e mot non capitalisé tout en majuscules)
out, ents = anonymize_text("Mozilla Firefox - Expert Sante - Consultation")
assert out == "Mozilla Firefox - Expert Sante - Consultation"
assert ents == []
def test_sanitize_event_titre_imbrique_vision_info():
"""FN-4 (Qwen) : titre PII imbriqué dans vision_info.window_capture (228 events)."""
from agent_v0.server_v1.pii_sanitizer import sanitize_event
titre = "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Firefox"
ev = {
"type": "mouse_click",
"window": {"title": titre, "app_name": "firefox.exe"},
"vision_info": {"window_capture": {"window_title": titre, "app_name": "firefox.exe"}},
}
out = sanitize_event(ev)
wc = out["vision_info"]["window_capture"]["window_title"]
assert "168246" not in wc and "VIOLA" not in wc, wc
assert "[IPP_1]" in wc
# cohérence : même titre dans window et vision_info -> même token
assert out["window"]["title"] == wc