feat(security): détection dialogues système Windows + fail-closed

Nouveau module system_dialog_guard.py :
- Détection UAC, CredUI, SmartScreen, Defender, Driver install
- Multi-signal (ClassName UIA, process, title FR/EN, parent_path)
- Faux positifs validés (OSIRIS, OBSIUS, MEDSPHERE, Chrome, Excel)

Intégration dans executor.py et policy.py :
- 6 points de décision (avant click/type/key_combo, VLM, policy)
- Pause supervisée au lieu de clic aveugle
- Fail-closed en cas d'exception (P0-D audit)
- Notification systray + remontée serveur

Fix mock test policy engine pour compat _system_dialog_pause=None.
39 + 5 tests unitaires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-14 16:48:00 +02:00
parent c77844fa9a
commit aee64f54b1
6 changed files with 1227 additions and 0 deletions

View File

@@ -122,6 +122,7 @@ class TestPolicyEngine:
def _make_engine(self):
from agent_v0.agent_v1.core.policy import PolicyEngine
executor = MagicMock()
executor._system_dialog_pause = None
return PolicyEngine(executor), executor
def test_premier_essai_popup_fermee_retry(self):

View File

@@ -0,0 +1,391 @@
# tests/unit/test_system_dialog_guard.py
"""
Tests du garde-fou sécurité : détection des dialogues système Windows critiques.
Objectif : garantir que Léa REFUSE de cliquer automatiquement sur un
UAC / CredUI / SmartScreen (vecteur d'attaque ransomware).
Philosophie :
- Faux positif tolérable (pause pour rien).
- Faux négatif catastrophique (clic UAC).
- Les tests privilégient la sécurité : tout dialogue suspect DOIT matcher.
Cf. agent_v0/agent_v1/core/system_dialog_guard.py
"""
from __future__ import annotations
import pytest
from agent_v0.agent_v1.core.system_dialog_guard import (
SystemDialogCategory,
SystemDialogDetection,
is_system_dialog,
)
# =============================================================================
# UAC (Contrôle de compte d'utilisateur) — le danger le plus grave
# =============================================================================
class TestUACDetection:
"""Un UAC qui n'est PAS détecté = vecteur d'attaque ransomware."""
def test_uac_via_class_name_exact(self):
"""ClassName UIA du Consent.exe."""
d = is_system_dialog(
uia_snapshot={"class_name": "$$$Secure UAP Dummy Window Class$$$"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
assert d.matched_signal == "class_name"
def test_uac_via_class_name_case_variation(self):
"""Robustesse à la casse (UIA peut varier)."""
d = is_system_dialog(
uia_snapshot={"class_name": "$$$SECURE UAP DUMMY WINDOW CLASS$$$"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_class_name_fuzzy_secure_uap(self):
"""Détection souple si Microsoft ajoute un suffixe."""
d = is_system_dialog(
uia_snapshot={"class_name": "Secure UAP Dummy"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_process_consent_exe(self):
"""Le process consent.exe signe un UAC, peu importe la classe."""
d = is_system_dialog(uia_snapshot={"process_name": "consent.exe"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_process_consent_exe_with_path(self):
"""Chemin complet doit être normalisé."""
d = is_system_dialog(
uia_snapshot={"process_name": r"C:\Windows\System32\consent.exe"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_app_name_in_window_info(self):
"""psutil.name() (app_name côté agent) doit être reconnu."""
d = is_system_dialog(
window_info={"title": "Administrateur", "app_name": "consent.exe"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_title_fr(self):
"""Titre français officiel."""
d = is_system_dialog(
window_info={"title": "Contrôle de compte d'utilisateur"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_title_en(self):
"""Titre anglais."""
d = is_system_dialog(window_info={"title": "User Account Control"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_title_question_fr(self):
"""Phrase caractéristique du prompt UAC."""
d = is_system_dialog(
window_info={
"title": (
"Voulez-vous autoriser cette application à apporter "
"des modifications à votre appareil ?"
)
}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_title_question_en(self):
d = is_system_dialog(
window_info={
"title": "Do you want to allow this app to make changes to your device?"
}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
def test_uac_via_parent_path_button_focused(self):
"""Cas critique : le focus est sur le bouton 'Oui' de l'UAC.
Le ClassName du Button sera "Button", mais le parent est bien
le Consent.exe. La détection DOIT matcher sur parent_path.
"""
d = is_system_dialog(
uia_snapshot={
"class_name": "Button",
"name": "Oui",
"control_type": "Button",
"parent_path": [
{"class_name": "$$$Secure UAP Dummy Window Class$$$", "name": ""},
],
}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.UAC
assert "parent" in d.matched_signal.lower() or d.matched_signal == "parent_class_name"
# =============================================================================
# CredUI (prompt mot de passe Windows)
# =============================================================================
class TestCredUIDetection:
"""Les prompts de mot de passe ne doivent JAMAIS recevoir de frappe auto."""
def test_credui_via_class_name(self):
d = is_system_dialog(
uia_snapshot={"class_name": "Credential Dialog Xaml Host"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
def test_credui_variant_no_space(self):
"""Variante sans espaces."""
d = is_system_dialog(
uia_snapshot={"class_name": "CredentialDialogXamlHost"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
def test_credui_via_process_credentialuibroker(self):
d = is_system_dialog(
uia_snapshot={"process_name": "CredentialUIBroker.exe"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
def test_credui_via_process_credui(self):
d = is_system_dialog(uia_snapshot={"process_name": "credui.exe"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
def test_credui_via_title_fr(self):
d = is_system_dialog(window_info={"title": "Sécurité Windows"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
def test_credui_via_title_en(self):
d = is_system_dialog(window_info={"title": "Windows Security"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
def test_credui_via_title_enter_creds(self):
d = is_system_dialog(
window_info={"title": "Connectez-vous à votre compte"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.CREDUI
# =============================================================================
# SmartScreen
# =============================================================================
class TestSmartScreenDetection:
def test_smartscreen_via_process(self):
d = is_system_dialog(uia_snapshot={"process_name": "smartscreen.exe"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.SMARTSCREEN
def test_smartscreen_via_title_fr(self):
d = is_system_dialog(
window_info={"title": "Windows a protégé votre ordinateur"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.SMARTSCREEN
def test_smartscreen_via_title_en(self):
d = is_system_dialog(
window_info={"title": "Windows protected your PC"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.SMARTSCREEN
def test_smartscreen_unknown_publisher(self):
d = is_system_dialog(
window_info={"title": "Éditeur inconnu — Voulez-vous continuer ?"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.SMARTSCREEN
# =============================================================================
# Autres dialogues système
# =============================================================================
class TestOtherSystemDialogs:
def test_defender_via_process(self):
d = is_system_dialog(uia_snapshot={"process_name": "MsMpEng.exe"})
assert d.is_system_dialog
assert d.category == SystemDialogCategory.DEFENDER
def test_defender_threat_detected(self):
d = is_system_dialog(
window_info={"title": "Menace détectée — Windows Defender"}
)
assert d.is_system_dialog
# Le regex "windows defender" gagne (listé en premier dans les patterns)
assert d.category == SystemDialogCategory.DEFENDER
def test_driver_install(self):
d = is_system_dialog(
window_info={"title": "Installer ce pilote logiciel ?"}
)
assert d.is_system_dialog
assert d.category == SystemDialogCategory.DRIVER
# =============================================================================
# FAUX POSITIFS À ÉVITER — les apps métier doivent passer
# =============================================================================
class TestBusinessAppsPassThrough:
"""Vérifier que les apps métier (OSIRIS, OBSIUS, MEDSPHERE, etc.)
ne sont jamais confondues avec des dialogues système.
Un faux positif ici = workflow métier cassé.
"""
def test_osiris_main_window(self):
d = is_system_dialog(
window_info={"title": "OSIRIS - Patient Dupont Jean", "app_name": "osiris.exe"}
)
assert not d.is_system_dialog
def test_osiris_confirmation_dialog(self):
"""Un dialogue #32770 OSIRIS métier (confirmation sauvegarde) DOIT passer."""
d = is_system_dialog(
uia_snapshot={
"class_name": "#32770",
"name": "Confirmation",
"process_name": "osiris.exe",
},
window_info={"title": "Confirmation", "app_name": "osiris.exe"},
)
assert not d.is_system_dialog
def test_obsius_main_window(self):
d = is_system_dialog(
window_info={"title": "OBSIUS - Consultation", "app_name": "obsius.exe"}
)
assert not d.is_system_dialog
def test_medsphere(self):
d = is_system_dialog(
window_info={"title": "MEDSPHERE v4.2", "app_name": "medsphere.exe"}
)
assert not d.is_system_dialog
def test_chrome_with_security_word_in_page(self):
"""Chrome avec 'sécurité' dans le titre de page = OK si app_name=chrome.exe.
NOTE : on accepte ici un faux positif théorique car 'Sécurité Windows'
est matché par le regex titre. Un navigateur affichant cette exacte
phrase en titre est possible. Compte tenu de l'asymétrie (clic UAC =
catastrophe), on ACCEPTE cette pause supervisée de fait.
"""
# Cas neutre : titre Chrome sans 'Sécurité Windows'
d = is_system_dialog(
window_info={
"title": "Google Chrome - Recherche Google",
"app_name": "chrome.exe",
}
)
assert not d.is_system_dialog
def test_excel_file(self):
d = is_system_dialog(
window_info={"title": "Classeur1 - Excel", "app_name": "EXCEL.EXE"}
)
assert not d.is_system_dialog
def test_notepad(self):
d = is_system_dialog(
window_info={"title": "Sans titre - Bloc-notes", "app_name": "notepad.exe"}
)
assert not d.is_system_dialog
# =============================================================================
# Cas limites — robustesse
# =============================================================================
class TestEdgeCases:
def test_no_input_returns_false(self):
"""Aucune info disponible = pas de blocage (fail-open)."""
d = is_system_dialog()
assert not d.is_system_dialog
def test_empty_strings(self):
d = is_system_dialog(
uia_snapshot={"class_name": "", "process_name": "", "name": ""},
window_info={"title": "", "app_name": ""},
)
assert not d.is_system_dialog
def test_none_values(self):
"""Les champs à None ne doivent pas planter."""
d = is_system_dialog(
uia_snapshot={"class_name": None, "process_name": None},
window_info={"title": None, "app_name": None},
)
assert not d.is_system_dialog
def test_detection_to_dict_serializable(self):
d = is_system_dialog(
window_info={"title": "User Account Control"}
)
data = d.to_dict()
assert data["is_system_dialog"] is True
assert data["category"] == SystemDialogCategory.UAC
assert data["reason"]
def test_unicode_title_fr_accents(self):
"""Les accents ne cassent pas la détection."""
d = is_system_dialog(
window_info={"title": "Contrôle de compte d'utilisateur"}
)
assert d.is_system_dialog
def test_whitespace_title(self):
d = is_system_dialog(
window_info={"title": " Windows Security "}
)
assert d.is_system_dialog
# =============================================================================
# Intégration avec detect_current_system_dialog
# =============================================================================
def test_detect_current_system_dialog_no_exception_linux():
"""Sur Linux, UIA indispo mais l'appel ne doit jamais planter."""
from agent_v0.agent_v1.core.system_dialog_guard import detect_current_system_dialog
detection = detect_current_system_dialog()
# On ne valide pas le résultat (dépend de la fenêtre active) —
# juste qu'il n'y a pas d'exception.
assert isinstance(detection, SystemDialogDetection)
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,148 @@
"""
Tests du Fix P0-D : le garde-fou de dialogue système doit fail-closed.
Avant : si la détection lève une exception, on laissait passer (fail-open),
ce qui contredisait le principe "faux positif tolérable, faux négatif
catastrophique" — un UAC non détecté à cause d'un bug = vecteur ransomware.
Après : exception → pause supervisée + log critical + notification utilisateur
+ flag `_system_dialog_pause` positionné avec category="unknown_check_failed".
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@pytest.fixture
def executor():
"""Crée un ActionExecutorV1 sans déclencher l'init mss/pynput lourd.
On instancie via __new__ + on injecte les attributs minimaux nécessaires
au test du garde-fou.
"""
from agent_v0.agent_v1.core.executor import ActionExecutorV1
exe = ActionExecutorV1.__new__(ActionExecutorV1)
exe._system_dialog_pause = None
exe._notification_manager = None # le @property notifier renverra _Noop
return exe
class TestUACGuardFailClosedP0D:
"""Fix P0-D : exception dans la détection → pause supervisée."""
def test_exception_triggers_pause(self, executor, monkeypatch):
"""Si detect_current_system_dialog lève, on pause au lieu de laisser passer."""
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
def _boom(*_a, **_kw):
raise RuntimeError("UIA backend down")
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
# Avant : pas de pause
assert executor._system_dialog_pause is None
# Appel : doit retourner True (= "STOP") au lieu de False
result = executor._check_and_pause_on_system_dialog(
context="test_p0d_unit"
)
assert result is True, (
"fail-closed : exception → True (le caller doit stopper) "
"et NON False comme avant le fix"
)
def test_exception_sets_pause_state(self, executor, monkeypatch):
"""Vérifie que _system_dialog_pause contient les bonnes infos."""
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
def _boom(*_a, **_kw):
raise ValueError("XPath error")
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
executor._check_and_pause_on_system_dialog(context="popup_handler")
pause = executor._system_dialog_pause
assert pause is not None, "Le flag de pause doit être positionné"
assert pause["category"] == "unknown_check_failed"
assert pause["matched_signal"] == "exception"
assert pause["matched_value"] == "ValueError"
assert "XPath error" in pause["reason"]
assert pause["context"] == "popup_handler"
def test_no_exception_no_dialog_returns_false(self, executor, monkeypatch):
"""Si pas de dialogue système et pas d'exception → False (laisse passer)."""
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
class _FakeDetection:
is_system_dialog = False
category = None
matched_signal = None
matched_value = None
reason = None
monkeypatch.setattr(
guard_mod,
"detect_current_system_dialog",
lambda *a, **k: _FakeDetection(),
)
result = executor._check_and_pause_on_system_dialog(context="ok")
assert result is False
assert executor._system_dialog_pause is None
def test_dialog_detected_returns_true(self, executor, monkeypatch):
"""Si UAC détecté légitimement → True + pause (comportement existant)."""
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
class _UACDetection:
is_system_dialog = True
category = "uac_consent"
matched_signal = "class_name"
matched_value = "$$$Secure UAP Dummy Window Class$$$"
reason = "UAC consent prompt detected"
monkeypatch.setattr(
guard_mod,
"detect_current_system_dialog",
lambda *a, **k: _UACDetection(),
)
result = executor._check_and_pause_on_system_dialog(context="uac_test")
assert result is True
assert executor._system_dialog_pause is not None
assert executor._system_dialog_pause["category"] == "uac_consent"
def test_exception_notifies_user(self, executor, monkeypatch):
"""L'exception doit déclencher une notification utilisateur."""
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
def _boom(*_a, **_kw):
raise OSError("UIA com error")
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
# Spy sur la notification
notifications = []
class _SpyNotifier:
def notify(self, title=None, message=None, **kwargs):
notifications.append((title, message))
executor._notification_manager = _SpyNotifier()
executor._check_and_pause_on_system_dialog(context="spy_test")
assert len(notifications) >= 1, (
"Une notification doit être envoyée à l'utilisateur"
)
title, message = notifications[0]
assert "sécurité" in title.lower() or "lea" in title.lower()
assert "garde-fou" in message.lower() or "pause" in message.lower()