From e84cdee3937c42bce63a499d9d4744cffe8cf674 Mon Sep 17 00:00:00 2001 From: Dom Date: Sun, 28 Jun 2026 20:24:52 +0200 Subject: [PATCH] fix(server): durcissement sanitizer PII suite revue adversariale Qwen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- agent_v0/server_v1/pii_sanitizer.py | 37 +++++++++++++++++++---------- tests/unit/test_pii_sanitizer.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/agent_v0/server_v1/pii_sanitizer.py b/agent_v0/server_v1/pii_sanitizer.py index 94910d833..8515ef631 100644 --- a/agent_v0/server_v1/pii_sanitizer.py +++ b/agent_v0/server_v1/pii_sanitizer.py @@ -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 diff --git a/tests/unit/test_pii_sanitizer.py b/tests/unit/test_pii_sanitizer.py index c470756d3..7a99eec12 100644 --- a/tests/unit/test_pii_sanitizer.py +++ b/tests/unit/test_pii_sanitizer.py @@ -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