CreaRack-SL

Auditoría Suprema 2 · Cola terminal: Agente 2.27.0 (guard de destino, CORS vivo, DPAPI fail-closed) e ingest honesto

Cuándo

05-09-2026 · commit e8d2c088 (PR #509, v1.116.0, Agente local → 2.27.0). Cierra el dominio terminal de la cola MEDIA de la Auditoría Suprema 2 (task #286): de 38 hallazgos totales de este dominio, los 12 ALTA y varios MEDIA ya se habían cerrado en tandas anteriores (5 y 6, v1.98.0); este PR revisa uno a uno los 24 restantes — 15 arreglados, 2 ya resueltos de antes (#28 lock del deep-discovery, #32 caché del liveness), 1 con premisa falsa (#25), 5 diferidos con motivo documentado y 1 sin examinar (#33). Implementación con un agente Opus en worktree (sin shell — aislamiento perdido) y verificación de la sesión principal: 246 tests de tests/agent + terminal + 89 vecinos del ingest, mypy limpio.

Síntomas visibles

El Agente que corre dentro de la red del cliente actuaba como un proxy sin ninguna barrera: podía apuntar sus propios comandos de red contra sí mismo, contra metadata de nube o contra el servidor de CreaRack; un fallo del almacén seguro de Windows dejaba contraseñas en claro en disco; el servidor rechazaba métricas del Agente sin decir por qué (y una con la hora mal puesta podía pintar un equipo como sano sin dato real); y la subida de capturas de pantalla de cartelería no tenía tope de tamaño ni comprobaba que fuera una imagen de verdad.

Hallazgos concretos cerrados en este PR:

  1. #19 — sin guard de destino: /check, /network/ping-icmp, /network/banner, /signage/deploy y /signage/configure aceptaban loopback, 0.0.0.0, direcciones link-local/metadata (169.254.0.0/16, fe80::/10, ::1) y el propio host del SaaS como destino.
  2. #13 — CORS congelado: allowed_origins_for se evaluaba una sola vez al arrancar; un Agente recién instalado (sin vincular) arrancaba con ["*"] y seguía así después de emparejarse por /saas/setup, justo cuando ya había credenciales que proteger.
  3. #17 — DPAPI en fallo abierto: si el cifrado de Windows fallaba, encrypt_secret registraba el error y devolvía el secreto EN CLARO, que acababa persistido en metrics.db (comunidades SNMP, claves SNMPv3 de la flota del cliente).
  4. #26/#27 — ingest mudo: una métrica con timestamp futuro rescribía igualmente last_status/last_latency del target aunque fuese rechazada por la ventana de ingesta; y el campo rejected no traía ningún motivo (el lote se purgaba a las 48h sin pista de qué había fallado).
  5. #35/#36 — respuestas que mentían: receive_agent_metrics escribía packet_loss=0 inventado para tipos sin ese dato, y dos handlers devolvían str(e) crudo al cliente en vez de un mensaje genérico.
  6. #34 — subida de capturas sin tope: el endpoint de screenshots de signage aceptaba cualquier tamaño y cualquier contenido, sobre el mismo volumen que sirve el instalador y los paquetes firmados del Agente.
  7. Además: ai_remediate con blocklist evaluada antes de normalizar el comando (#18), conexiones aiohttp sin cerrar por conector (#15), la tarea de connect() sin cancelar en force_reset (#16), VACUUM de SQLite bloqueando el loop async (#14), /check con IP por defecto 127.0.0.1 si el host venía vacío (#20), y sin topes en la ejecución de comandos en lote (#24: ahora 50 equipos, 20 órdenes, 1 MB de salida por equipo, semáforo 5).

Causa raíz

Un solo patrón de fondo, repetido en capas distintas del Agente: el borde de confianza está mal trazado. El Agente asume que quien le habla (el SaaS que lo vincula, el navegador que abre su consola local, el propio sistema operativo al cifrar) es fiable y no revalida en cada punto de contacto:

  • El guard de red no existía — cualquier “host” en el payload de un comando se usaba tal cual, sin comprobar que apuntara a un equipo de la red del cliente y no al propio Agente o a la nube.
  • El middleware CORS calculaba su política de orígenes UNA vez, en el arranque — una decisión de seguridad tomada antes de que existiera la información (el emparejamiento) que la hace correcta.
  • El cifrado trataba un fallo de la capa de sistema operativo como “seguir adelante en claro” en vez de “parar y avisar” — fail-open donde tocaba fail-closed.
  • El ingest bulk confundía “no pude guardar esta métrica” con silencio total, y confundía además “rechazada por ventana de tiempo” con “no toca el estado vivo” — dos invariantes que nadie había separado explícitamente.

Fix aplicado

Commit e8d2c088 (PR #509):

  • terminal/agent/core/net_guard.py (nuevo): is_allowed_target(host, saas_url) resuelve el nombre a IP(s) y exige que TODAS sean válidas — rechaza loopback, 0.0.0.0, link-local/metadata IPv4 e IPv6, localhost/*.localhost y el host del SaaS vinculado; compara también la forma IPv4 mapeada de una IPv6. Gemelo del ya existente monitoring/services/net_guard.py del lado SaaS. Aplicado en /check, /network/ping-icmp, /network/banner, /signage/deploy, /signage/configure; los /network/* de lote/subred quedan pendientes.
  • terminal/agent/core/local_token.py: nueva LiveOriginsCORSMiddleware, que re-ejecuta el __init__ de CORSMiddleware de Starlette cada vez que los orígenes derivados cambian de verdad (tras un emparejamiento), en vez de fijarlos una sola vez al arrancar. allowed_origins_for documenta ahora explícitamente que, una vez vinculado, nunca vuelve a devolver ["*"].
  • terminal/agent/core/crypto.py: SecureStorageError nueva; encrypt_secret la lanza si DPAPI falla, en vez de devolver el valor en claro. El llamador responde success:false, error:"secure storage unavailable" — el Agente se queda sin esos targets en esa máquina en vez de guardarlos legibles.
  • terminal/api/sentinel_ingest.py: BulkMetricsResponse gana rejections: list[dict] (tope 50, {index, target_id, reason}) sin tocar errors (ese campo sigue reservado a fallos de servidor porque el Agente instalado solo da el lote por procesado si viene vacío). El guard de frescura de estado vivo ahora también descarta timestamps futuros, no solo pasados.
  • terminal/api/sentinel.py: receive_agent_metrics ya no inventa packet_loss=0; los dos handlers que devolvían str(e) responden mensaje genérico + logger.exception.
  • terminal/api/agent_credentials.py: tope de 5 MB y validación Pillow (JPEG/PNG/WebP íntegros) en la subida de capturas de signage.
  • Topes del clúster (cluster_engine.py): 50 equipos, 20 órdenes, 1 MB de salida por equipo, semáforo 5; una aiohttp.ClientSession por conector cerrada en force_reset/disconnect; VACUUM de SQLite movido a asyncio.to_thread.
  • 41 tests nuevos en tests/agent/test_agent_cola_media_b2.py + 11 en tests/api/test_terminal_cola_media_b2.py.

Diferidos con motivo: #21 (.verified en carpeta escribible, cruzado con Authenticode), #22 (exención de /traps/recent, decisión de Edu pendiente), #29 (promote sin ack, producto), #31 (E/S del ingest en el request, ADR aparte), #19-lote/subred (arriba). Sin examinar: #33 (prefiltro del re-enlace).

Lecciones

  • Un Agente que vive dentro de la red del cliente es, de facto, un proxy: cualquier endpoint que acepte un “host” en el payload necesita el mismo guard de destino que un SSRF clásico, aunque el Agente no sea un servidor web público.
  • Una política de seguridad derivada de config (orígenes CORS, credenciales vinculadas) que se calcula una sola vez en el arranque queda desactualizada en el momento exacto en que empieza a importar — hay que re-derivarla en cada petición o invalidarla explícitamente al cambiar la config.
  • Fail-open en una capa de cifrado del sistema operativo es indistinguible de no cifrar nunca, salvo que alguien lea el log en el momento exacto del fallo. El fail-closed traslada la decisión al llamador, que sí puede reaccionar (avisar al servidor, saltar el target).
  • Cuando un cliente automatizado (aquí: el propio Agente instalado) decide “reintentar” mirando un solo campo (errors vacío), cualquier otro campo nuevo tiene que respetar ESE contrato o se rompe en producción de forma sutil — de ahí separar rejections de errors en vez de fusionarlos.

Preventivos futuros

  • Queda 1 hallazgo sin examinar (#33, prefiltro del re-enlace) y 5 diferidos con dueño pendiente de asignar salvo #29 (producto, agendado 1.99.0).
  • Requiere compilar y publicar el .exe 2.27.0; la flota se actualiza sola tras la publicación — pendiente de verificar la primera actualización real en un equipo de la flota.
  • Click-test del emparejamiento en caliente de CORS (#13) pendiente: forzar un /saas/setup con el Agente ya arrancado y confirmar que dejan de servirse peticiones desde el origen * viejo.

Véase también

  • [[incident—20260904—auditoria-suprema-2-cola-monitoring-a-sondas-y-targets]]
  • [[incident—20260905—auditoria-suprema-2-cola-frontend-csp-csrf-escapes]]
  • [[entity—terminal—service—agente-auth-local]]
  • [[entity—monitoring—service—net-guard]]
  • [[concept—seguridad—ssrf-mitigacion]]
  • [[decision—20260901—tanda-5-auth-agente-2a-pasada]]