feat(security): API streaming fail-closed + /image privé + target_memory prefix fix
P0-B — /api/v1/traces/stream/image retiré de _PUBLIC_PATHS : - Bearer token obligatoire pour upload d'image - Évite uploads anonymes de contenu arbitraire P0-C — Fail-closed si RPA_API_TOKEN absent : - sys.exit(1) au démarrage avec message fatal - Mode dev : RPA_AUTH_DISABLED=true pour désactiver explicitement - Log INFO des 8 premiers chars du token (diagnostic) Fix target_memory prefix empilé : - Strip "memory_" répétés avant stockage dans replay_memory.py - Évite "memory_memory_memory_template_matching" en base live_session_manager : améliorations mineures de la gestion sessions. 10 tests auth API stream. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,13 @@ from .execution_plan_runner import (
|
||||
inject_plan_into_queue,
|
||||
)
|
||||
|
||||
# Pipeline d'anonymisation PII (OCR + NER côté serveur).
|
||||
# Import paresseux : on ne charge pas docTR tant qu'aucune image n'est reçue.
|
||||
try:
|
||||
from core.anonymisation import blur_pii_on_image as _blur_pii_on_image
|
||||
except ImportError:
|
||||
_blur_pii_on_image = None
|
||||
|
||||
# Instance globale du vérificateur de replay (comparaison screenshots avant/après)
|
||||
_replay_verifier = ReplayVerifier()
|
||||
_replay_learner = ReplayLearner()
|
||||
@@ -82,25 +89,77 @@ logger = logging.getLogger("api_stream")
|
||||
# =========================================================================
|
||||
# Authentification par token Bearer (sécurité HIGH)
|
||||
# =========================================================================
|
||||
# Le token est lu depuis l'environnement ou généré au démarrage.
|
||||
# Le token est lu depuis l'environnement obligatoirement.
|
||||
# Tous les endpoints requièrent le header Authorization: Bearer <token>,
|
||||
# sauf /health, /docs et /openapi.json (publics).
|
||||
API_TOKEN = os.environ.get("RPA_API_TOKEN", secrets.token_hex(32))
|
||||
#
|
||||
# Fail-closed P0-C :
|
||||
# - En production (défaut), RPA_API_TOKEN DOIT être défini.
|
||||
# - Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true
|
||||
# Dans ce mode, aucun token n'est requis et l'API log un WARNING au boot.
|
||||
# - Sans token ET sans RPA_AUTH_DISABLED=true → arrêt immédiat du process
|
||||
# (sys.exit 1) avec message fatal clair. On NE génère PLUS de token
|
||||
# aléatoire en silence : cela cassait tous les agents clients sans bruit.
|
||||
_AUTH_DISABLED = os.environ.get("RPA_AUTH_DISABLED", "").lower() in (
|
||||
"1", "true", "yes",
|
||||
)
|
||||
_API_TOKEN_ENV = os.environ.get("RPA_API_TOKEN", "").strip()
|
||||
|
||||
if _AUTH_DISABLED:
|
||||
# Mode dev explicite : on tolère l'absence de token mais on log très fort.
|
||||
logger.warning(
|
||||
"[SÉCURITÉ] RPA_AUTH_DISABLED=true — authentification Bearer DÉSACTIVÉE. "
|
||||
"NE JAMAIS utiliser cette configuration en production. Tous les "
|
||||
"endpoints sont accessibles sans token."
|
||||
)
|
||||
API_TOKEN = _API_TOKEN_ENV or secrets.token_hex(32)
|
||||
elif not _API_TOKEN_ENV:
|
||||
# Fail-closed : pas de génération silencieuse. On arrête le serveur.
|
||||
_FATAL_MSG = (
|
||||
"[SÉCURITÉ] FATAL — RPA_API_TOKEN est absent ou vide. "
|
||||
"Refus de démarrer le serveur de streaming : générer un token "
|
||||
"aléatoire interne casserait tous les agents clients qui utilisent "
|
||||
"le token persistant (.env.local). "
|
||||
"Pour fixer : définir RPA_API_TOKEN=<32 hex chars> dans l'environnement. "
|
||||
"Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true."
|
||||
)
|
||||
logger.critical(_FATAL_MSG)
|
||||
print(_FATAL_MSG, flush=True)
|
||||
# Utiliser sys.exit pour un arrêt propre (raise RuntimeError est accroché
|
||||
# par uvicorn sur Python 3.11, sys.exit remonte BaseException).
|
||||
import sys as _sys
|
||||
_sys.exit(1)
|
||||
else:
|
||||
API_TOKEN = _API_TOKEN_ENV
|
||||
# Log non-sensible : 8 premiers caractères seulement pour aider au diagnostic.
|
||||
logger.info(
|
||||
f"[SÉCURITÉ] Token API chargé (8 premiers caractères : "
|
||||
f"{API_TOKEN[:8]}…) — auth Bearer obligatoire"
|
||||
)
|
||||
|
||||
# Endpoints publics (pas besoin de token)
|
||||
# En production, /docs et /redoc sont désactivés (voir ci-dessous)
|
||||
# Paths publics : pas de token requis
|
||||
# /replay/next est public car l'agent Rust legacy n'envoie pas de token
|
||||
# et c'est un endpoint read-only (polling, pas d'écriture)
|
||||
#
|
||||
# Fix P0-B : /api/v1/traces/stream/image RETIRÉ de la liste publique.
|
||||
# L'upload d'image écrit sur disque + déclenche du travail VLM : exiger
|
||||
# un token Bearer. Tous les agents V1 déployés envoient déjà le token
|
||||
# (cf. agent_v0/agent_v1/network/streamer.py:_auth_headers).
|
||||
_PUBLIC_PATHS = {
|
||||
"/health", "/docs", "/openapi.json", "/redoc",
|
||||
"/api/v1/traces/stream/replay/next",
|
||||
"/api/v1/traces/stream/image",
|
||||
}
|
||||
|
||||
|
||||
async def _verify_token(request: Request):
|
||||
"""Middleware de vérification du token API Bearer."""
|
||||
"""Middleware de vérification du token API Bearer.
|
||||
|
||||
Bypass si RPA_AUTH_DISABLED=true (mode dev local uniquement).
|
||||
"""
|
||||
if _AUTH_DISABLED:
|
||||
return
|
||||
if request.url.path in _PUBLIC_PATHS:
|
||||
return
|
||||
auth = request.headers.get("Authorization", "")
|
||||
@@ -490,6 +549,12 @@ class ReplayResultReport(BaseModel):
|
||||
target_spec: Optional[Dict[str, Any]] = None # Spec complete de la cible
|
||||
# Correction humaine (mode apprentissage supervisé)
|
||||
correction: Optional[Dict[str, Any]] = None # {x_pct, y_pct, uia_snapshot, crop_b64}
|
||||
# Sécurité : signalement d'un dialogue système critique détecté
|
||||
# (UAC, CredUI, SmartScreen...). Quand ce champ est présent, l'agent
|
||||
# refuse toute interaction et le serveur bascule en paused_need_help.
|
||||
# Cf. agent_v1/core/system_dialog_guard.py
|
||||
system_dialog: Optional[Dict[str, Any]] = None # {category, matched_signal, matched_value, reason, context}
|
||||
needs_human: Optional[bool] = None
|
||||
|
||||
|
||||
class ErrorCallbackConfig(BaseModel):
|
||||
@@ -837,6 +902,40 @@ _som_enrichment_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="som_enrich",
|
||||
)
|
||||
|
||||
# ThreadPool dédié à l'anonymisation PII (OCR + NER).
|
||||
# Activable via RPA_PII_BLUR_SERVER (default : true). 1 worker suffit, le
|
||||
# pipeline est rapide (<2 s par screenshot) et le blur peut prendre du retard
|
||||
# sur la capture sans bloquer ni le replay ni le grounding (ils utilisent le
|
||||
# fichier _full.png brut).
|
||||
_PII_BLUR_ENABLED = os.environ.get("RPA_PII_BLUR_SERVER", "true").lower() in ("true", "1", "yes")
|
||||
_pii_blur_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="pii_blur",
|
||||
)
|
||||
|
||||
|
||||
def _produce_blurred_version(raw_path: str, shot_id: str) -> None:
|
||||
"""Exécute (en thread) le pipeline de blur PII sur un screenshot brut.
|
||||
|
||||
Écrit `<stem>_blurred.png` à côté du fichier brut pour l'affichage
|
||||
dashboard/cleaner. Le fichier brut `<stem>.png` reste intact pour le
|
||||
grounding, le replay et l'entraînement.
|
||||
"""
|
||||
if _blur_pii_on_image is None:
|
||||
return
|
||||
try:
|
||||
raw = Path(raw_path)
|
||||
out = raw.with_name(f"{raw.stem}_blurred{raw.suffix or '.png'}")
|
||||
# Évite de retraiter si déjà floutée (robustesse aux doubles réceptions)
|
||||
if out.exists() and out.stat().st_mtime >= raw.stat().st_mtime:
|
||||
return
|
||||
result = _blur_pii_on_image(raw, out)
|
||||
logger.debug(
|
||||
"pii_blur : %s → %d PII (%.0fms, ner=%s)",
|
||||
shot_id, result.count, result.elapsed_ms, result.ner_engine,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("pii_blur : échec sur %s (%s)", shot_id, e)
|
||||
|
||||
# Clics en attente d'enrichissement (le screenshot n'est pas encore arrivé)
|
||||
# Clé : (session_id, screenshot_id) → dict avec les infos nécessaires
|
||||
_pending_click_enrichments: Dict[tuple, Dict[str, Any]] = {}
|
||||
@@ -1163,6 +1262,20 @@ async def stream_image(
|
||||
|
||||
file_path_str = str(file_path)
|
||||
|
||||
# Anonymisation PII côté serveur (OCR + NER + blur ciblé).
|
||||
# On ne floute QUE les screenshots affichés dans le dashboard / cleaner :
|
||||
# shot_XXXX_full (screenshots d'action) et heartbeats (vue live).
|
||||
# Les crops, focus, window sont utilisés pour le grounding/template — pas
|
||||
# d'affichage humain direct donc pas besoin de version floutée.
|
||||
# Le fichier brut (shot_XXXX_full.png) reste intact pour le replay,
|
||||
# le grounding VLM et l'entraînement. La version floutée est écrite en
|
||||
# parallèle sous shot_XXXX_full_blurred.png.
|
||||
if _PII_BLUR_ENABLED and _blur_pii_on_image is not None and (
|
||||
("_full" in shot_id and shot_id.startswith("shot_"))
|
||||
or shot_id.startswith("heartbeat_")
|
||||
):
|
||||
_pii_blur_executor.submit(_produce_blurred_version, file_path_str, shot_id)
|
||||
|
||||
# Crops : traitement léger (pas d'analyse ScreenAnalyzer)
|
||||
if "_crop" in shot_id:
|
||||
result = worker.process_crop_direct(session_id, shot_id, file_path_str)
|
||||
@@ -3212,6 +3325,92 @@ async def report_action_result(report: ReplayResultReport):
|
||||
replay_state["completed_actions"] += 1
|
||||
replay_state["current_action_index"] += 1
|
||||
|
||||
elif not report.success and (report.system_dialog or (report.error or "").startswith("system_dialog:")):
|
||||
# ── SÉCURITÉ : dialogue système Windows détecté (UAC / CredUI / SmartScreen) ──
|
||||
# L'agent REFUSE de cliquer automatiquement sur ces dialogues.
|
||||
# On bascule immédiatement en paused_need_help — l'humain doit
|
||||
# valider manuellement (saisir mdp, autoriser l'élévation…).
|
||||
# Cf. agent_v1/core/system_dialog_guard.py
|
||||
_sys_info = report.system_dialog or {}
|
||||
_sys_category = (
|
||||
_sys_info.get("category")
|
||||
or (report.error or "system_dialog:unknown").split(":", 1)[-1]
|
||||
)
|
||||
_sys_reason = _sys_info.get("reason", "")
|
||||
_tspec_sys = (original_action or {}).get("target_spec") or report.target_spec or {}
|
||||
|
||||
# Message utilisateur adapté à la catégorie
|
||||
_cat_messages = {
|
||||
"uac_consent": (
|
||||
"Une demande d'élévation de privilèges (UAC) est apparue. "
|
||||
"Je ne clique jamais automatiquement dessus — merci de valider "
|
||||
"ou refuser toi-même, puis relance-moi."
|
||||
),
|
||||
"windows_credential_prompt": (
|
||||
"Windows me demande un mot de passe / identifiants. "
|
||||
"Merci de remplir toi-même, puis relance-moi."
|
||||
),
|
||||
"smartscreen": (
|
||||
"SmartScreen a bloqué l'application. "
|
||||
"Merci de vérifier et débloquer manuellement si légitime."
|
||||
),
|
||||
"windows_defender": (
|
||||
"Windows Defender signale une alerte. "
|
||||
"Merci de vérifier manuellement."
|
||||
),
|
||||
"driver_install": (
|
||||
"Une installation de pilote est demandée. "
|
||||
"Merci de valider manuellement."
|
||||
),
|
||||
}
|
||||
_pause_msg_sys = _cat_messages.get(
|
||||
_sys_category,
|
||||
"Un dialogue système Windows est apparu. "
|
||||
"Je ne clique pas automatiquement dessus — merci de gérer manuellement."
|
||||
)
|
||||
|
||||
replay_state["status"] = "paused_need_help"
|
||||
replay_state["failed_action"] = {
|
||||
"action_id": action_id,
|
||||
"type": (original_action or {}).get("type", "unknown"),
|
||||
"target_description": f"Dialogue système : {_sys_category}",
|
||||
"screenshot_b64": screenshot_after or report.screenshot,
|
||||
"target_spec": _tspec_sys,
|
||||
"reason": "system_dialog",
|
||||
"system_dialog": _sys_info,
|
||||
"error_detail": _sys_reason or (report.error or ""),
|
||||
}
|
||||
replay_state["pause_message"] = _pause_msg_sys
|
||||
error_entry = {
|
||||
"action_id": action_id,
|
||||
"error": f"system_dialog:{_sys_category}",
|
||||
"retry_count": retry_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
replay_state["error_log"].append(error_entry)
|
||||
logger.critical(
|
||||
f"[SECURITE] Replay PAUSE supervisee (dialogue systeme) : "
|
||||
f"{action_id} — categorie={_sys_category} — "
|
||||
f"signal={_sys_info.get('matched_signal', '?')}='{_sys_info.get('matched_value', '?')}' "
|
||||
f"— reason={_sys_reason}"
|
||||
)
|
||||
try:
|
||||
log_replay_failure(
|
||||
replay_id=replay_state["replay_id"],
|
||||
action_id=action_id,
|
||||
target_spec=_tspec_sys,
|
||||
screenshot_b64=screenshot_after or report.screenshot,
|
||||
error=f"system_dialog:{_sys_category}",
|
||||
extra={
|
||||
"system_dialog": _sys_info,
|
||||
"category": _sys_category,
|
||||
"matched_signal": _sys_info.get("matched_signal", ""),
|
||||
"matched_value": _sys_info.get("matched_value", ""),
|
||||
},
|
||||
)
|
||||
except Exception as _log_exc:
|
||||
logger.debug("log_replay_failure skip (system_dialog): %s", _log_exc)
|
||||
|
||||
elif not report.success and agent_warning == "wrong_window":
|
||||
# L'agent a détecté en pré-vérification que la fenêtre active
|
||||
# n'est pas celle attendue. Même philosophie que no_screen_change :
|
||||
|
||||
Reference in New Issue
Block a user