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:
@@ -20,6 +20,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Forcer l'import de config AVANT pynput/mss pour garantir que le
|
||||
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
|
||||
@@ -88,6 +89,11 @@ class ActionExecutorV1:
|
||||
self._api_token = os.environ.get("RPA_API_TOKEN", "")
|
||||
# Gestionnaire de notifications toast (pour les messages utilisateur)
|
||||
self._notification_manager = None
|
||||
# Drapeau sécurité : positionné quand on détecte un dialogue système
|
||||
# (UAC, CredUI, SmartScreen…). Lu par le caller pour signaler une
|
||||
# pause supervisée au serveur (`paused_need_help`).
|
||||
# Cf. core/system_dialog_guard.py
|
||||
self._system_dialog_pause: Optional[Dict[str, Any]] = None
|
||||
# Log de la resolution physique pour le diagnostic DPI
|
||||
self._log_screen_info()
|
||||
|
||||
@@ -537,6 +543,11 @@ class ActionExecutorV1:
|
||||
"visual_resolved": False,
|
||||
}
|
||||
|
||||
# Réinitialiser le drapeau dialogue système à chaque action
|
||||
# (sinon une détection lors d'une action précédente ferait bail-out
|
||||
# immédiat sur toutes les suivantes).
|
||||
self._system_dialog_pause = None
|
||||
|
||||
# ── Bloc conditionnel : skip si le dialogue n'est pas apparu ──
|
||||
# Les actions marquées conditional_on_window ne s'exécutent que
|
||||
# si la fenêtre attendue est effectivement présente. Sinon → skip.
|
||||
@@ -594,6 +605,23 @@ class ActionExecutorV1:
|
||||
f"{int(action.get('y_pct', 0) * height)})"
|
||||
)
|
||||
|
||||
# ── SÉCURITÉ : check proactif AVANT toute action ──
|
||||
# Si un UAC / CredUI / SmartScreen est déjà à l'écran (apparu
|
||||
# spontanément entre deux actions), on pause IMMÉDIATEMENT
|
||||
# sans rien tenter. Clic / type / key_combo : tous bloqués.
|
||||
# Cf. core/system_dialog_guard.py
|
||||
if action_type in ("click", "type", "key_combo", "double_click", "right_click"):
|
||||
if self._check_and_pause_on_system_dialog(context=f"pre_action_{action_type}"):
|
||||
pause_info = self._system_dialog_pause or {}
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
# Resolution visuelle des coordonnees si demande
|
||||
x_pct = action.get("x_pct", 0.0)
|
||||
y_pct = action.get("y_pct", 0.0)
|
||||
@@ -737,6 +765,27 @@ class ActionExecutorV1:
|
||||
popup_coords = observation.get("popup_coords")
|
||||
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
|
||||
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
|
||||
|
||||
# ── SÉCURITÉ : refuser de cliquer sur un dialogue système ──
|
||||
# Avant de suivre les coordonnées du serveur (VLM-based,
|
||||
# donc faillible) ou de rappeler le VLM local, on
|
||||
# vérifie que la popup n'est PAS un UAC/CredUI/SmartScreen.
|
||||
if self._check_and_pause_on_system_dialog(
|
||||
context="observer_popup"
|
||||
):
|
||||
# Dialogue système → on remonte la pause au replay.
|
||||
# On renvoie le résultat immédiatement pour que le
|
||||
# serveur passe en paused_need_help.
|
||||
pause_info = self._system_dialog_pause or {}
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
if popup_coords:
|
||||
real_x = int(popup_coords["x_pct"] * width)
|
||||
real_y = int(popup_coords["y_pct"] * height)
|
||||
@@ -745,7 +794,20 @@ class ActionExecutorV1:
|
||||
print(f" [OBSERVER] Popup fermée — reprise du flow normal")
|
||||
else:
|
||||
# Pas de coordonnées → fallback sur handle_popup_vlm classique
|
||||
# (qui re-vérifie aussi system_dialog en interne)
|
||||
self._handle_popup_vlm()
|
||||
# Si _handle_popup_vlm a détecté un dialogue système,
|
||||
# on remonte la pause au replay.
|
||||
if self._system_dialog_pause:
|
||||
pause_info = self._system_dialog_pause
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
elif obs_state == "unexpected":
|
||||
# État inattendu (pas la bonne page/écran)
|
||||
@@ -840,6 +902,24 @@ class ActionExecutorV1:
|
||||
f"({policy_decision.reason})"
|
||||
)
|
||||
|
||||
# ── SÉCURITÉ : si Policy a détecté un dialogue système
|
||||
# pendant son _try_close_popup, on remonte la pause au
|
||||
# serveur SANS tenter aucune action supplémentaire.
|
||||
if self._system_dialog_pause:
|
||||
pause_info = self._system_dialog_pause
|
||||
logger.critical(
|
||||
f"[POLICY] Dialogue système détecté par popup handler "
|
||||
f"({pause_info.get('category')}) — pause supervisée"
|
||||
)
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
if policy_decision.decision == Decision.RETRY:
|
||||
resolved2 = self._resolve_target_visual(
|
||||
server_url, target_spec, x_pct, y_pct, width, height
|
||||
@@ -1771,6 +1851,9 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
"target_spec": result.get("target_spec"),
|
||||
# Correction humaine (mode apprentissage supervisé)
|
||||
"correction": result.get("correction"),
|
||||
# Sécurité : dialogue système critique détecté (UAC, CredUI, SmartScreen)
|
||||
"system_dialog": result.get("system_dialog"),
|
||||
"needs_human": result.get("needs_human"),
|
||||
}
|
||||
try:
|
||||
resp2 = requests.post(
|
||||
@@ -1796,6 +1879,129 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# Garde-fou sécurité : dialogues système Windows (UAC, CredUI, SmartScreen)
|
||||
# =========================================================================
|
||||
|
||||
def _check_and_pause_on_system_dialog(self, context: str = "") -> bool:
|
||||
"""Détecter un dialogue système critique et positionner la pause.
|
||||
|
||||
Si un dialogue UAC, CredUI, SmartScreen (etc.) est actif, on :
|
||||
- N'appelle JAMAIS le VLM sur l'image (évite de lui faire suggérer "Oui")
|
||||
- Ne clique JAMAIS automatiquement
|
||||
- Positionne `self._system_dialog_pause` pour que le caller signale
|
||||
une pause supervisée au serveur
|
||||
- Notifie l'utilisateur via systray
|
||||
- Log l'événement pour audit
|
||||
|
||||
Args:
|
||||
context: Chaîne d'origine pour les logs (ex: "handle_popup_vlm",
|
||||
"observer_popup_click").
|
||||
|
||||
Returns:
|
||||
True si un dialogue système a été détecté (le caller doit
|
||||
stopper toute action automatique). False sinon.
|
||||
"""
|
||||
try:
|
||||
from .system_dialog_guard import detect_current_system_dialog
|
||||
detection = detect_current_system_dialog()
|
||||
except Exception as e:
|
||||
# Fix P0-D : fail-closed (principe "faux positif tolérable,
|
||||
# faux négatif catastrophique"). Si la détection échoue, on ne
|
||||
# peut PAS affirmer que l'écran est sûr — on pause par précaution
|
||||
# et on demande à l'humain. Un UAC non détecté à cause d'un bug
|
||||
# de détection = vecteur d'attaque ransomware.
|
||||
logger.critical(
|
||||
f"[SYS-DIALOG] Erreur détection dialogue système "
|
||||
f"(context={context}) : {e} — PAUSE SUPERVISÉE par précaution "
|
||||
f"(fail-closed : impossible de garantir l'absence de dialogue "
|
||||
f"système critique)"
|
||||
)
|
||||
print(
|
||||
f" [SÉCURITÉ] Vérification du garde-fou système a échoué "
|
||||
f"— pause supervisée par précaution ({type(e).__name__})"
|
||||
)
|
||||
# Positionner le flag de pause avec une catégorie dédiée pour que
|
||||
# le caller (execute_replay_action) remonte "paused_need_help".
|
||||
self._system_dialog_pause = {
|
||||
"category": "unknown_check_failed",
|
||||
"matched_signal": "exception",
|
||||
"matched_value": type(e).__name__,
|
||||
"reason": f"system_dialog_guard détection exception: {e}",
|
||||
"context": context,
|
||||
}
|
||||
# Notification utilisateur best-effort.
|
||||
try:
|
||||
notifier = self.notifier
|
||||
msg = (
|
||||
"Vérification du garde-fou système a échoué — "
|
||||
"pause supervisée par précaution. Léa ne clique pas."
|
||||
)
|
||||
if hasattr(notifier, "notify"):
|
||||
notifier.notify(
|
||||
title="Léa — sécurité",
|
||||
message=msg,
|
||||
timeout=10,
|
||||
)
|
||||
elif hasattr(notifier, "error"):
|
||||
notifier.error(msg)
|
||||
except Exception as notify_err:
|
||||
logger.debug(f"[SYS-DIALOG] Notification échouée : {notify_err}")
|
||||
return True
|
||||
|
||||
if not detection.is_system_dialog:
|
||||
return False
|
||||
|
||||
# Audit log : TOUJOURS tracer, même si la pause est redondante.
|
||||
logger.critical(
|
||||
f"[SYS-DIALOG] REFUS D'INTERACTION — {detection.category} "
|
||||
f"détecté via {detection.matched_signal}='{detection.matched_value}' "
|
||||
f"(context={context}). Pause supervisée demandée."
|
||||
)
|
||||
print(
|
||||
f" [SÉCURITÉ] Dialogue système détecté : {detection.category} "
|
||||
f"— Léa NE CLIQUE PAS, intervention humaine requise"
|
||||
)
|
||||
|
||||
# Positionner le flag pour le caller (execute_replay_action)
|
||||
self._system_dialog_pause = {
|
||||
"category": detection.category,
|
||||
"matched_signal": detection.matched_signal,
|
||||
"matched_value": detection.matched_value,
|
||||
"reason": detection.reason,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
# Notification systray (best-effort, ne jamais planter dessus)
|
||||
try:
|
||||
cat_fr = {
|
||||
"uac_consent": "élévation de privilèges (UAC)",
|
||||
"windows_credential_prompt": "demande de mot de passe Windows",
|
||||
"smartscreen": "alerte SmartScreen",
|
||||
"windows_defender": "alerte Windows Defender",
|
||||
"driver_install": "installation de pilote",
|
||||
"security_toast": "notification de sécurité",
|
||||
"unknown_system_dialog": "dialogue système inconnu",
|
||||
}.get(detection.category, detection.category)
|
||||
msg = (
|
||||
f"Dialogue système détecté ({cat_fr}) — "
|
||||
f"intervention humaine requise. Léa ne clique pas."
|
||||
)
|
||||
# On essaie d'abord un formateur explicite ; sinon fallback error
|
||||
notifier = self.notifier
|
||||
if hasattr(notifier, "notify"):
|
||||
notifier.notify(
|
||||
title="Léa — sécurité",
|
||||
message=msg,
|
||||
timeout=10,
|
||||
)
|
||||
elif hasattr(notifier, "error"):
|
||||
notifier.error(msg)
|
||||
except Exception as e:
|
||||
logger.debug(f"[SYS-DIALOG] Notification échouée : {e}")
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# Gestion intelligente des popups imprévues (VLM)
|
||||
# =========================================================================
|
||||
@@ -1817,9 +2023,22 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
Une seule tentative par action (pas de boucle infinie).
|
||||
|
||||
**SÉCURITÉ** : avant toute interaction, on détecte les dialogues
|
||||
système Windows critiques (UAC, CredUI, SmartScreen). Si un tel
|
||||
dialogue est actif → pause supervisée immédiate, pas de VLM, pas
|
||||
de clic automatique. Cf. system_dialog_guard.py.
|
||||
|
||||
Returns:
|
||||
True si une popup a été gérée (fermée), False sinon.
|
||||
False aussi en cas de dialogue système → le caller doit traiter
|
||||
`self._system_dialog_pause` pour signaler la pause au serveur.
|
||||
"""
|
||||
# ── SÉCURITÉ : refus absolu de cliquer sur un dialogue système ──
|
||||
# Un UAC / CredUI / SmartScreen ne doit JAMAIS recevoir de clic
|
||||
# automatique. On détecte AVANT le VLM (coût minimal ~20ms UIA).
|
||||
if self._check_and_pause_on_system_dialog(context="handle_popup_vlm"):
|
||||
return False
|
||||
|
||||
# Capturer le screenshot actuel (résolution native pour template matching)
|
||||
screenshot_b64 = self._capture_screenshot_b64(max_width=0, quality=75)
|
||||
if not screenshot_b64:
|
||||
|
||||
@@ -85,6 +85,10 @@ class PolicyEngine:
|
||||
2. Si retry déjà fait → demander à l'acteur gemma4
|
||||
3. Selon gemma4 : SKIP, ABORT, ou SUPERVISE
|
||||
|
||||
**SÉCURITÉ** : si, pendant l'étape 1, le handler popup détecte un
|
||||
dialogue système Windows (UAC, CredUI, SmartScreen…), on bascule
|
||||
immédiatement en SUPERVISE. Cf. system_dialog_guard.py.
|
||||
|
||||
Args:
|
||||
action: L'action qui a échoué
|
||||
target_spec: La cible non trouvée
|
||||
@@ -96,6 +100,22 @@ class PolicyEngine:
|
||||
# ── Étape 1 : Tentative de fermeture popup (premier essai) ──
|
||||
if retry_count == 0:
|
||||
popup_handled = self._try_close_popup()
|
||||
|
||||
# Si le popup handler a détecté un dialogue système, on
|
||||
# bascule immédiatement en SUPERVISE — pas de retry, pas de
|
||||
# gemma4 : on rend la main à l'humain.
|
||||
if getattr(self._executor, "_system_dialog_pause", None):
|
||||
sd = self._executor._system_dialog_pause
|
||||
return PolicyDecision(
|
||||
decision=Decision.SUPERVISE,
|
||||
reason=(
|
||||
f"Dialogue système détecté ({sd.get('category', '?')}) — "
|
||||
f"refus d'interaction automatique"
|
||||
),
|
||||
action_taken="system_dialog_blocked",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
if popup_handled:
|
||||
return PolicyDecision(
|
||||
decision=Decision.RETRY,
|
||||
|
||||
448
agent_v0/agent_v1/core/system_dialog_guard.py
Normal file
448
agent_v0/agent_v1/core/system_dialog_guard.py
Normal file
@@ -0,0 +1,448 @@
|
||||
# agent_v1/core/system_dialog_guard.py
|
||||
"""
|
||||
Garde-fou sécurité : détection des dialogues système Windows critiques.
|
||||
|
||||
==============================================================================
|
||||
POURQUOI ?
|
||||
==============================================================================
|
||||
|
||||
Pendant un replay, si un dialogue UAC, CredUI (mot de passe Windows),
|
||||
SmartScreen ou une notification de sécurité Windows apparaît, Léa pourrait
|
||||
demander au VLM "quel bouton cliquer" et recevoir "Oui" en réponse.
|
||||
|
||||
→ **Léa cliquerait OUI sur une élévation UAC** → vecteur d'attaque ransomware.
|
||||
|
||||
Ce module fournit la détection de ces dialogues pour que l'exécuteur
|
||||
**ne clique JAMAIS dessus automatiquement**. La décision est renvoyée à
|
||||
l'humain (pause supervisée).
|
||||
|
||||
==============================================================================
|
||||
PRINCIPE
|
||||
==============================================================================
|
||||
|
||||
- **Faux positif tolérable** : on préfère pauser pour rien plutôt que cliquer
|
||||
sur un UAC.
|
||||
- **Faux négatif catastrophique** : mieux vaut être trop prudent.
|
||||
- **Multi-signal** : titre, ClassName UIA, nom de processus, parent_path.
|
||||
Un seul signal suffit à bloquer.
|
||||
- **Compatible Citrix** : les dialogues UAC d'un client Citrix apparaissent
|
||||
aussi dans la VM distante — la détection par classe UIA fonctionne.
|
||||
|
||||
==============================================================================
|
||||
PATTERNS DE DÉTECTION (ordre de criticité décroissant)
|
||||
==============================================================================
|
||||
|
||||
1. UAC Consent (élévation de privilèges)
|
||||
- ClassName : `$$$Secure UAP Dummy Window Class$$$`
|
||||
- Process : `consent.exe`
|
||||
- Titre : "Contrôle de compte d'utilisateur", "User Account Control"
|
||||
|
||||
2. CredUI (prompt mot de passe Windows)
|
||||
- ClassName : `Credential Dialog Xaml Host`
|
||||
- Process : `credentialuibroker.exe`, `credui.exe`
|
||||
- Titre : "Sécurité Windows", "Windows Security"
|
||||
|
||||
3. SmartScreen (protection contre applications inconnues)
|
||||
- Process : `smartscreen.exe`
|
||||
- Titre : "Windows a protégé votre ordinateur", "Windows protected your PC"
|
||||
|
||||
4. Windows Defender / Security Center
|
||||
- Process : `securityhealthhost.exe`, `msmpeng.exe`
|
||||
- Titre : "Sécurité Windows", "Windows Defender"
|
||||
|
||||
5. Signatures pilotes / driver install
|
||||
- Titre : "Installer ce pilote", "Driver signature"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Catégories de dialogues système (pour logging + messages)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SystemDialogCategory:
|
||||
"""Catégories de dialogues système à bloquer absolument."""
|
||||
UAC = "uac_consent" # Élévation de privilèges
|
||||
CREDUI = "windows_credential_prompt" # Prompt de mot de passe
|
||||
SMARTSCREEN = "smartscreen" # Protection SmartScreen
|
||||
DEFENDER = "windows_defender" # Alerte Windows Defender
|
||||
DRIVER = "driver_install" # Installation pilote signé
|
||||
SECURITY_TOAST = "security_toast" # Toast de sécurité Windows
|
||||
UNKNOWN_DIALOG = "unknown_system_dialog" # Dialogue #32770 sans app connue
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemDialogDetection:
|
||||
"""Résultat d'une analyse de dialogue système."""
|
||||
is_system_dialog: bool
|
||||
category: str = "" # Valeur de SystemDialogCategory
|
||||
matched_signal: str = "" # Ex: "class_name=Consent.exe"
|
||||
matched_value: str = "" # La valeur qui a matché
|
||||
reason: str = "" # Explication lisible
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"is_system_dialog": self.is_system_dialog,
|
||||
"category": self.category,
|
||||
"matched_signal": self.matched_signal,
|
||||
"matched_value": self.matched_value,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signatures de détection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# ClassName UIA (casse préservée — Windows exposées telle quelle par UIA).
|
||||
# Utilisées telles quelles puis en minuscules pour matcher avec souplesse.
|
||||
_CLASS_NAMES_SYSTEM = {
|
||||
# UAC Consent
|
||||
"$$$Secure UAP Dummy Window Class$$$": SystemDialogCategory.UAC,
|
||||
"Credential Dialog Xaml Host": SystemDialogCategory.CREDUI,
|
||||
# Windows Credential UI ancien nom
|
||||
"CredentialDialogXamlHost": SystemDialogCategory.CREDUI,
|
||||
}
|
||||
|
||||
# Nom de processus (comparaison insensible à la casse, .exe normalisé)
|
||||
_PROCESS_NAMES_SYSTEM = {
|
||||
"consent.exe": SystemDialogCategory.UAC,
|
||||
"credentialuibroker.exe": SystemDialogCategory.CREDUI,
|
||||
"credui.exe": SystemDialogCategory.CREDUI,
|
||||
"credwiz.exe": SystemDialogCategory.CREDUI,
|
||||
"smartscreen.exe": SystemDialogCategory.SMARTSCREEN,
|
||||
"securityhealthhost.exe": SystemDialogCategory.DEFENDER,
|
||||
"securityhealthui.exe": SystemDialogCategory.DEFENDER,
|
||||
"securityhealthsystray.exe": SystemDialogCategory.DEFENDER,
|
||||
"msmpeng.exe": SystemDialogCategory.DEFENDER,
|
||||
"windowsdefender.exe": SystemDialogCategory.DEFENDER,
|
||||
"msiexec.exe": SystemDialogCategory.DRIVER, # prompts pilotes signés
|
||||
"drvinst.exe": SystemDialogCategory.DRIVER,
|
||||
}
|
||||
|
||||
# Motifs titre (insensibles à la casse, regex avec word boundaries)
|
||||
# On ne matche pas les titres génériques trop larges pour limiter les faux
|
||||
# positifs sur OSIRIS/OBSIUS/MEDSPHERE.
|
||||
_TITLE_PATTERNS_SYSTEM: Tuple[Tuple[re.Pattern, str], ...] = (
|
||||
# UAC
|
||||
(re.compile(r"contr[oô]le\s+de\s+compte\s+d'?utilisateur", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
(re.compile(r"\buser\s+account\s+control\b", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
(re.compile(r"voulez-vous\s+autoriser\s+cette\s+application", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
(re.compile(r"do\s+you\s+want\s+to\s+allow\s+this\s+app", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
|
||||
# CredUI / Sécurité Windows
|
||||
(re.compile(r"\bs[eé]curit[eé]\s+windows\b", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"\bwindows\s+security\b", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"entrer\s+les\s+informations\s+d'?identification", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"enter\s+(?:your\s+)?credentials?", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"connectez-vous\s+[aà]\s+votre\s+compte", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"\bsign\s+in\s+to\s+your\s+account\b", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
|
||||
# SmartScreen
|
||||
(re.compile(r"windows\s+a\s+prot[eé]g[eé]", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"windows\s+protected\s+your\s+pc", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"\bsmartscreen\b", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"\b[eé]diteur\s+inconnu\b", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"\bunknown\s+publisher\b", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
|
||||
# Windows Defender
|
||||
(re.compile(r"windows\s+defender", re.IGNORECASE),
|
||||
SystemDialogCategory.DEFENDER),
|
||||
(re.compile(r"menace\s+d[eé]tect[eé]e", re.IGNORECASE),
|
||||
SystemDialogCategory.DEFENDER),
|
||||
(re.compile(r"threat\s+detected", re.IGNORECASE),
|
||||
SystemDialogCategory.DEFENDER),
|
||||
|
||||
# Driver
|
||||
(re.compile(r"installer\s+ce\s+pilote", re.IGNORECASE),
|
||||
SystemDialogCategory.DRIVER),
|
||||
(re.compile(r"install\s+this\s+driver", re.IGNORECASE),
|
||||
SystemDialogCategory.DRIVER),
|
||||
(re.compile(r"signature\s+num[eé]rique\s+du\s+pilote", re.IGNORECASE),
|
||||
SystemDialogCategory.DRIVER),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fonctions de détection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _normalize_process(name: str) -> str:
|
||||
"""Normaliser un nom de processus pour comparaison."""
|
||||
if not name:
|
||||
return ""
|
||||
name = name.strip().lower()
|
||||
# Enlever le chemin éventuel
|
||||
if "\\" in name or "/" in name:
|
||||
name = name.replace("\\", "/").split("/")[-1]
|
||||
# Assurer suffixe .exe pour matcher le dictionnaire
|
||||
if not name.endswith(".exe") and name:
|
||||
# Les process_name peuvent venir sans .exe (psutil) — on ajoute
|
||||
# pour avoir une clé uniforme
|
||||
name_with_exe = name + ".exe"
|
||||
if name_with_exe in _PROCESS_NAMES_SYSTEM:
|
||||
return name_with_exe
|
||||
return name
|
||||
|
||||
|
||||
def _check_class_name(class_name: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Vérifier si un ClassName UIA matche un dialogue système.
|
||||
|
||||
Returns:
|
||||
(category, matched_class, reason) si match, None sinon.
|
||||
"""
|
||||
if not class_name:
|
||||
return None
|
||||
|
||||
# Match exact
|
||||
if class_name in _CLASS_NAMES_SYSTEM:
|
||||
cat = _CLASS_NAMES_SYSTEM[class_name]
|
||||
return (cat, class_name, f"ClassName UIA '{class_name}' = dialogue système {cat}")
|
||||
|
||||
# Match insensible à la casse + normalisation espaces
|
||||
cn_norm = class_name.strip()
|
||||
for known, cat in _CLASS_NAMES_SYSTEM.items():
|
||||
if cn_norm.lower() == known.lower():
|
||||
return (cat, class_name, f"ClassName UIA ~= '{known}' ({cat})")
|
||||
|
||||
# Détection souple UAC (il existe quelques variantes de la classe secure)
|
||||
if "secure uap" in class_name.lower() or "uap dummy" in class_name.lower():
|
||||
return (SystemDialogCategory.UAC, class_name,
|
||||
f"ClassName '{class_name}' contient 'Secure UAP' → UAC")
|
||||
|
||||
# Credential XAML Host
|
||||
if "credential" in class_name.lower() and "xaml" in class_name.lower():
|
||||
return (SystemDialogCategory.CREDUI, class_name,
|
||||
f"ClassName '{class_name}' contient Credential+Xaml → CredUI")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_process_name(process_name: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Vérifier si un nom de processus est un dialogue système.
|
||||
|
||||
Returns:
|
||||
(category, matched_process, reason) si match, None sinon.
|
||||
"""
|
||||
if not process_name:
|
||||
return None
|
||||
|
||||
norm = _normalize_process(process_name)
|
||||
if norm in _PROCESS_NAMES_SYSTEM:
|
||||
cat = _PROCESS_NAMES_SYSTEM[norm]
|
||||
return (cat, process_name, f"Processus '{norm}' = {cat}")
|
||||
return None
|
||||
|
||||
|
||||
def _check_title(title: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Vérifier si un titre de fenêtre matche un dialogue système.
|
||||
|
||||
Returns:
|
||||
(category, matched_pattern, reason) si match, None sinon.
|
||||
"""
|
||||
if not title:
|
||||
return None
|
||||
|
||||
for pattern, cat in _TITLE_PATTERNS_SYSTEM:
|
||||
m = pattern.search(title)
|
||||
if m:
|
||||
return (cat, m.group(0),
|
||||
f"Titre '{title[:60]}' matche '{pattern.pattern}' → {cat}")
|
||||
return None
|
||||
|
||||
|
||||
def is_system_dialog(
|
||||
uia_snapshot: Optional[Dict[str, Any]] = None,
|
||||
window_info: Optional[Dict[str, Any]] = None,
|
||||
) -> SystemDialogDetection:
|
||||
"""Déterminer si la fenêtre active est un dialogue système critique.
|
||||
|
||||
La détection combine plusieurs signaux — **un seul suffit à bloquer**.
|
||||
On préfère un faux positif (pause inutile) à un faux négatif (clic UAC).
|
||||
|
||||
Args:
|
||||
uia_snapshot: Dict avec champs `class_name`, `process_name`,
|
||||
`parent_path`, `name`. Peut être None si UIA indisponible.
|
||||
window_info: Dict avec champs `title`, `app_name`. Peut être None.
|
||||
|
||||
Returns:
|
||||
SystemDialogDetection avec is_system_dialog=True si un dialogue
|
||||
système est détecté.
|
||||
|
||||
Exemples::
|
||||
|
||||
det = is_system_dialog(window_info={"title": "User Account Control"})
|
||||
assert det.is_system_dialog # UAC détecté
|
||||
|
||||
det = is_system_dialog(uia_snapshot={"class_name": "$$$Secure UAP Dummy Window Class$$$"})
|
||||
assert det.is_system_dialog # UAC via ClassName
|
||||
|
||||
det = is_system_dialog(window_info={"title": "OSIRIS - Patient Dupont"})
|
||||
assert not det.is_system_dialog # Application métier → OK
|
||||
"""
|
||||
# ── Signal 1 : ClassName UIA ──
|
||||
if uia_snapshot:
|
||||
cn = uia_snapshot.get("class_name", "") or ""
|
||||
r = _check_class_name(cn)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="class_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# Explorer aussi les parents (le champ cliqué peut être un bouton
|
||||
# interne dont la ClassName est "Button", mais le root de la fenêtre
|
||||
# est le Consent.exe).
|
||||
for parent in uia_snapshot.get("parent_path", []) or []:
|
||||
p_cn = parent.get("class_name", "") or ""
|
||||
r = _check_class_name(p_cn)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="parent_class_name",
|
||||
matched_value=matched,
|
||||
reason=f"Parent : {reason}",
|
||||
)
|
||||
|
||||
# ── Signal 2 : Process name ──
|
||||
if uia_snapshot:
|
||||
pn = uia_snapshot.get("process_name", "") or ""
|
||||
r = _check_process_name(pn)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="process_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if window_info:
|
||||
app = window_info.get("app_name", "") or ""
|
||||
r = _check_process_name(app)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="app_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# ── Signal 3 : Titre de fenêtre ──
|
||||
if window_info:
|
||||
title = window_info.get("title", "") or ""
|
||||
r = _check_title(title)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="window_title",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if uia_snapshot:
|
||||
# Certains dialogues système remontent leur titre dans uia.name
|
||||
uia_name = uia_snapshot.get("name", "") or ""
|
||||
r = _check_title(uia_name)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="uia_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return SystemDialogDetection(is_system_dialog=False)
|
||||
|
||||
|
||||
def detect_current_system_dialog() -> SystemDialogDetection:
|
||||
"""Analyser l'écran actuel et détecter un dialogue système.
|
||||
|
||||
Helper autonome qui interroge à la fois `get_active_window_info()` et
|
||||
le helper UIA (si dispo) pour obtenir la détection la plus fiable.
|
||||
|
||||
Returns:
|
||||
SystemDialogDetection. Si un signal matche, is_system_dialog=True.
|
||||
Si rien n'est disponible (Linux, UIA absent), is_system_dialog=False
|
||||
mais le caller peut encore fallback sur une analyse par titre.
|
||||
"""
|
||||
window_info: Optional[Dict[str, Any]] = None
|
||||
uia_snapshot: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Fenêtre active (cross-platform)
|
||||
try:
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
window_info = get_active_window_info()
|
||||
except Exception as e: # pragma: no cover — best-effort
|
||||
logger.debug(f"[SYS-DIALOG] window_info indisponible : {e}")
|
||||
|
||||
# UIA local (Windows uniquement, via lea_uia.exe)
|
||||
try:
|
||||
from .uia_helper import get_shared_helper
|
||||
helper = get_shared_helper()
|
||||
if helper.available:
|
||||
# On capture l'élément focalisé (root = fenêtre active)
|
||||
element = helper.capture_focused(max_depth=2)
|
||||
if element is not None:
|
||||
uia_snapshot = element.to_dict()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug(f"[SYS-DIALOG] UIA indisponible : {e}")
|
||||
|
||||
detection = is_system_dialog(
|
||||
uia_snapshot=uia_snapshot, window_info=window_info,
|
||||
)
|
||||
|
||||
if detection.is_system_dialog:
|
||||
logger.warning(
|
||||
f"[SYS-DIALOG] BLOCAGE — dialogue système détecté "
|
||||
f"[{detection.category}] via {detection.matched_signal}='{detection.matched_value}' "
|
||||
f"— {detection.reason}"
|
||||
)
|
||||
return detection
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SystemDialogCategory",
|
||||
"SystemDialogDetection",
|
||||
"is_system_dialog",
|
||||
"detect_current_system_dialog",
|
||||
]
|
||||
Reference in New Issue
Block a user