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:
@@ -38,11 +38,15 @@ RE_NOM_NAISSANCE = re.compile(
|
|||||||
RE_NOM_BRACKET = re.compile(
|
RE_NOM_BRACKET = re.compile(
|
||||||
rf"\[((?:[{_MAJ}][\w{_MIN}'\-]*\s+){{1,3}}[{_MAJ}][\w{_MIN}'\-]*)\]"
|
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).
|
# Ordre = priorité ; group = portion à remplacer (0 = match entier).
|
||||||
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
|
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
|
||||||
(RE_NOM_NAISSANCE, "NOM", 0),
|
(RE_NOM_NAISSANCE, "NOM", 0),
|
||||||
(RE_NOM_BRACKET, "NOM", 0),
|
(RE_NOM_BRACKET, "NOM", 0),
|
||||||
|
(RE_PRENOM_NOM, "NOM", 0),
|
||||||
(RE_EMAIL, "EMAIL", 0),
|
(RE_EMAIL, "EMAIL", 0),
|
||||||
(RE_NIR, "NIR", 0),
|
(RE_NIR, "NIR", 0),
|
||||||
(RE_IPP, "IPP", 1),
|
(RE_IPP, "IPP", 1),
|
||||||
@@ -134,11 +138,26 @@ def anonymize_text(
|
|||||||
return out, entities
|
return out, entities
|
||||||
|
|
||||||
|
|
||||||
# Conteneurs de titre de fenêtre dans les events (window_focus_change, clic, saisie).
|
# Clés portant un titre de fenêtre, où qu'elles soient imbriquées dans l'event
|
||||||
_TITLE_CONTAINERS = ("window", "to", "from")
|
# (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]"
|
_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:
|
def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict:
|
||||||
"""Assainit un event capturé avant persistance (copie, ne mute pas l'original).
|
"""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, ""):
|
if ev.get(k) not in (None, ""):
|
||||||
ev[k] = _PLACEHOLDER_SAISIE
|
ev[k] = _PLACEHOLDER_SAISIE
|
||||||
|
|
||||||
# titre direct (heartbeat)
|
# tous les titres de fenêtre, où qu'ils soient imbriqués
|
||||||
if isinstance(ev.get("active_window_title"), str):
|
# (active_window_title, window/to/from.title, vision_info.window_capture.window_title…)
|
||||||
ev["active_window_title"] = anonymize_text(
|
_walk_titles(ev, mapping)
|
||||||
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]
|
|
||||||
|
|
||||||
return ev
|
return ev
|
||||||
|
|||||||
@@ -136,3 +136,39 @@ def test_sanitize_action_result_inchange():
|
|||||||
|
|
||||||
ev = {"type": "action_result", "base_shot_id": "shot_0003", "image": "x.png"}
|
ev = {"type": "action_result", "base_shot_id": "shot_0003", "image": "x.png"}
|
||||||
assert sanitize_event(ev) == ev
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user