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:
- #19 — sin guard de destino:
/check,/network/ping-icmp,/network/banner,/signage/deployy/signage/configureaceptaban 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. - #13 — CORS congelado:
allowed_origins_forse 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. - #17 — DPAPI en fallo abierto: si el cifrado de Windows fallaba,
encrypt_secretregistraba el error y devolvía el secreto EN CLARO, que acababa persistido enmetrics.db(comunidades SNMP, claves SNMPv3 de la flota del cliente). - #26/#27 — ingest mudo: una métrica con timestamp futuro rescribía igualmente
last_status/last_latencydel target aunque fuese rechazada por la ventana de ingesta; y el camporejectedno traía ningún motivo (el lote se purgaba a las 48h sin pista de qué había fallado). - #35/#36 — respuestas que mentían:
receive_agent_metricsescribíapacket_loss=0inventado para tipos sin ese dato, y dos handlers devolvíanstr(e)crudo al cliente en vez de un mensaje genérico. - #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.
- Además:
ai_remediatecon blocklist evaluada antes de normalizar el comando (#18), conexionesaiohttpsin cerrar por conector (#15), la tarea deconnect()sin cancelar enforce_reset(#16), VACUUM de SQLite bloqueando el loop async (#14),/checkcon IP por defecto127.0.0.1si 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/*.localhosty el host del SaaS vinculado; compara también la forma IPv4 mapeada de una IPv6. Gemelo del ya existentemonitoring/services/net_guard.pydel 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: nuevaLiveOriginsCORSMiddleware, que re-ejecuta el__init__deCORSMiddlewarede 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_fordocumenta ahora explícitamente que, una vez vinculado, nunca vuelve a devolver["*"].terminal/agent/core/crypto.py:SecureStorageErrornueva;encrypt_secretla lanza si DPAPI falla, en vez de devolver el valor en claro. El llamador respondesuccess: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:BulkMetricsResponseganarejections: list[dict](tope 50,{index, target_id, reason}) sin tocarerrors(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_metricsya no inventapacket_loss=0; los dos handlers que devolvíanstr(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; unaaiohttp.ClientSessionpor conector cerrada enforce_reset/disconnect; VACUUM de SQLite movido aasyncio.to_thread. - 41 tests nuevos en
tests/agent/test_agent_cola_media_b2.py+ 11 entests/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 (
errorsvacío), cualquier otro campo nuevo tiene que respetar ESE contrato o se rompe en producción de forma sutil — de ahí separarrejectionsdeerrorsen 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
.exe2.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/setupcon 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]]