diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index e89d278b4..acdd45f55 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -27,7 +27,7 @@ from fastapi import BackgroundTasks, Depends, FastAPI, File, HTTPException, Requ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from .pii_sanitizer import sanitize_event +from .pii_sanitizer import sanitize_event, sanitize_log_entries from .replay_failure_logger import log_replay_failure from .replay_verifier import ReplayVerifier, VerificationResult from .replay_learner import ReplayLearner @@ -7263,7 +7263,10 @@ async def agents_logs(request: AgentLogsRequest): # Bloque les postes révoqués/désinstallés + met à jour last_seen_at. _guard_agent_registry_access(machine_id, endpoint="agents/logs") - received = agent_logs_store.append(machine_id, request.logs) + # Assainissement PII côté serveur avant persistance (couche 1 regex, sans NER). + # Un mapping partagé sur le batch garantit la cohérence des tokens ([NOM_1]…). + safe_logs = sanitize_log_entries(request.logs) + received = agent_logs_store.append(machine_id, safe_logs) return {"status": "ok", "received": received, "machine_id": machine_id} diff --git a/agent_v0/server_v1/pii_sanitizer.py b/agent_v0/server_v1/pii_sanitizer.py index c85207efb..404e2ee48 100644 --- a/agent_v0/server_v1/pii_sanitizer.py +++ b/agent_v0/server_v1/pii_sanitizer.py @@ -203,6 +203,40 @@ def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict: return ev +def sanitize_log_entries( + entries: List[Dict], *, mapping: Optional[Dict] = None +) -> List[Dict]: + """Assainit un batch de log-entries reçues d'un client Léa avant persistance. + + Pour chaque entrée, renvoie une **copie** où les champs texte porteurs de PII + sont passés par `anonymize_text` : + - `message` (str) : assaini par `anonymize_text`. + - `logger` (str) : assaini de la même façon (peut porter un chemin patient). + - `ts` et `level` : préservés à l'identique, jamais touchés. + + Un `mapping` partagé est utilisé pour **toutes** les entrées du batch afin de + garantir la cohérence des tokens (même PII → même token). Si `mapping` est + None, un mapping local est créé et partagé entre toutes les entrées du batch. + + Tolère les valeurs absentes, None ou non-str sans lever d'exception. + N'utilise que `anonymize_text` — aucune regex supplémentaire. + """ + if not entries: + return [] + if mapping is None: + mapping = {} + + result: List[Dict] = [] + for entry in entries: + item = copy.copy(entry) # copie superficielle suffit (valeurs scalaires) + for field in ("message", "logger"): + v = item.get(field) + if isinstance(v, str): + item[field] = anonymize_text(v, mapping=mapping)[0] + result.append(item) + return result + + # Clés d'un workflow core portant du texte potentiellement PII : cible OCR # (`by_text`), noms d'écrans/labels dérivés des titres. Le contenu saisi est # déjà neutralisé à la source (sanitize_event → [SAISIE]). diff --git a/tests/unit/test_sanitize_log_entries.py b/tests/unit/test_sanitize_log_entries.py new file mode 100644 index 000000000..1d8043bc6 --- /dev/null +++ b/tests/unit/test_sanitize_log_entries.py @@ -0,0 +1,163 @@ +"""Tests TDD de sanitize_log_entries — assainissement PII des logs Léa reçus côté serveur. + +Branche feat/push-log-dgx. N'importe QUE pii_sanitizer (pas api_stream, DETTE-013). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_ROOT = str(Path(__file__).resolve().parents[2]) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +# --------------------------------------------------------------------------- +# 1. message avec PII → brut absent, tokens présents +# --------------------------------------------------------------------------- + +def test_message_pii_tokenise(): + """Un nom clinique + numéro long disparaissent ; des tokens [...] les remplacent. + + Couche 1 (regex, sans NER) : détecte le format « Prénom NOM » (RE_PRENOM_NOM) + et l'IPP structuré (RE_IPP). Le format inverse « NOM Prénom » relève de la + couche 2 NER — hors scope ici. + """ + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + entries = [ + { + "ts": "2026-06-30T10:00:00Z", + "level": "INFO", + "logger": "lea.replay", + "message": "Ouverture dossier Catherine MOREL IPP: 295841", + } + ] + result = sanitize_log_entries(entries) + + assert len(result) == 1 + msg = result[0]["message"] + assert "MOREL" not in msg, f"NOM toujours présent : {msg!r}" + assert "Catherine" not in msg, f"Prénom toujours présent : {msg!r}" + assert "295841" not in msg, f"IPP toujours présent : {msg!r}" + assert "[" in msg, f"Aucun token dans : {msg!r}" + + +# --------------------------------------------------------------------------- +# 2. ts / level préservés à l'identique +# --------------------------------------------------------------------------- + +def test_ts_level_preserves(): + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + entries = [ + {"ts": "2026-06-30T10:00:00Z", "level": "WARNING", + "logger": "lea.core", "message": "simple message sans pii"} + ] + result = sanitize_log_entries(entries) + + assert result[0]["ts"] == "2026-06-30T10:00:00Z" + assert result[0]["level"] == "WARNING" + + +# --------------------------------------------------------------------------- +# 3. liste vide → liste vide +# --------------------------------------------------------------------------- + +def test_liste_vide(): + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + assert sanitize_log_entries([]) == [] + + +# --------------------------------------------------------------------------- +# 4. entrée sans clé `message` → pas de crash, entrée conservée +# --------------------------------------------------------------------------- + +def test_entree_sans_message(): + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + entries = [{"ts": "2026-06-30T10:00:01Z", "level": "DEBUG", "logger": "lea.init"}] + result = sanitize_log_entries(entries) + + assert len(result) == 1 + assert "message" not in result[0] # champ absent → reste absent + + +# --------------------------------------------------------------------------- +# 5. cohérence : même PII dans 2 entrées → même token (mapping partagé) +# --------------------------------------------------------------------------- + +def test_coherence_mapping_partage(): + """La même PII dans deux messages du batch reçoit le même token.""" + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + entries = [ + {"ts": "T1", "level": "INFO", "logger": "l", "message": "IPP: 295841 reçu"}, + {"ts": "T2", "level": "INFO", "logger": "l", "message": "Relance IPP: 295841"}, + ] + result = sanitize_log_entries(entries) + + msg1 = result[0]["message"] + msg2 = result[1]["message"] + + # le brut est absent des deux + assert "295841" not in msg1 + assert "295841" not in msg2 + + # le token est identique (mapping partagé) + import re + tokens1 = re.findall(r"\[IPP_\d+\]", msg1) + tokens2 = re.findall(r"\[IPP_\d+\]", msg2) + assert tokens1, f"Pas de token IPP dans msg1 : {msg1!r}" + assert tokens2, f"Pas de token IPP dans msg2 : {msg2!r}" + assert tokens1[0] == tokens2[0], ( + f"Tokens différents pour la même PII : {tokens1[0]} vs {tokens2[0]}" + ) + + +# --------------------------------------------------------------------------- +# 6. `message` non-str → skip proprement, pas de crash +# --------------------------------------------------------------------------- + +def test_message_non_str(): + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + entries = [ + {"ts": "T1", "level": "INFO", "logger": "l", "message": None}, + {"ts": "T2", "level": "INFO", "logger": "l", "message": 42}, + {"ts": "T3", "level": "INFO", "logger": "l", "message": ["liste"]}, + ] + result = sanitize_log_entries(entries) + + assert len(result) == 3 + # les valeurs non-str sont préservées telles quelles + assert result[0]["message"] is None + assert result[1]["message"] == 42 + assert result[2]["message"] == ["liste"] + + +# --------------------------------------------------------------------------- +# 7. champ `logger` str est aussi assaini si porteur de PII +# --------------------------------------------------------------------------- + +def test_logger_pii_tokenise(): + """Si le champ logger contient de la PII (ex. chemin patient), il est assaini.""" + from agent_v0.server_v1.pii_sanitizer import sanitize_log_entries + + entries = [ + { + "ts": "T1", + "level": "INFO", + "logger": "lea.patient.MOREL_Catherine", + "message": "step start", + } + ] + result = sanitize_log_entries(entries) + logger_out = result[0]["logger"] + # Le NOM doit être tokenisé (RE_PRENOM_NOM captera « Catherine MOREL » … + # mais « MOREL_Catherine » n'est pas le format clinique standard — le test + # vérifie surtout qu'il n'y a pas de crash et que le champ est traité.) + # On ne fixe pas d'assertion sur la valeur : juste pas de crash. + assert isinstance(logger_out, str)