CreaRack-SL

Auditoría Suprema 2 · Cola monitoring A: sondas, validación de targets y evasión SSRF

Cuándo

04-09-2026 · commit 98ddfd3a (PR #496, v1.104.0). Tercer dominio de la cola MEDIA/BAJA de la Auditoría Suprema 2 (task #286) — primer PR de tres para monitoring (bloque A: sondas y targets; quedan B —CNS e insights— y C —ITSM y notificaciones—). Implementación en Sonnet con las decisiones fijadas de antemano; revisión de Fable 5.1 con dos correcciones: SNMP v1 no existe ni en el SaaS ni en el Agente y no debía aceptarse al validar, y un presupuesto de sonda agotado antes del primer intento debía dar un error de presupuesto explícito, no el “no validated IPs” que sugiere otra causa.

Síntomas visibles

Doce hallazgos MEDIA en el mismo commit:

  1. PUT /targets/{id} devolvía 500 genérico (no 400) al desmarcar las 4 sondas de un target — el código no estaba declarado en el response de ninja.
  2. El ajuste “Smoothing” de Chart Defaults se guardaba pero no tenía efecto: _get_smoothing_level leía una clave de Valkey que nadie escribe desde la migración a ui_prefs.
  3. hours sin acotar producía 500 (nan/inf) en /vm/ping y /vm/stats, y un valor como 1e9 llegaba tal cual a VictoriaMetrics.
  4. Las claves de OID (snmp_extras_*, snmp_fast_*, VendorProfile.monitoring_oids) no se validaban contra el patrón del ingest — una clave con mayúsculas o : producía una métrica que no nacía nunca, y el alta de perfil de fabricante no tenía ninguna validación.
  5. El ping y el chequeo HTTP manuales pisaban la semántica de last_status: un equipo con ICMP bloqueado pero sonda TCP viva se marcaba down, y un 500 de la web marcaba el equipo entero como caído.
  6. El techo de 30s del SNMP síncrono era inerte en el camino asyncio.run (el que usan las vistas síncronas bajo daphne) — solo aplicaba en la rama async.
  7. El timeout_ms configurado en el target nunca llegaba al SNMP: from_config leía una clave de config que nadie escribe, y en la práctica siempre se usaban 5s fijos.
  8. Observatory y Wireless compartían la misma clave de sweep en deep_discover_job_key; lanzar un barrido mientras corría el otro devolvía already_running con el total ajeno.
  9. El comando de servidor ping_targets sondaba IPs privadas (terreno del Agente, no del servidor) y persistía resultados Blocked destination del guard como si fuesen una medida real.
  10. La sonda HTTP no tenía presupuesto agregado: un dominio con 8 registros A caídos podía consumir ~80s de un hilo síncrono, y try_each_ip reintentaba ante CUALQUIER excepción, no solo fallos de conexión.
  11. El config del target (http_method, http_headers, http_expected_codes, snmp_port, snmp_interface, snmp_version, timeout_ms) no validaba sus claves conocidas — valores inválidos salían a la red sin comprobar.
  12. Batería de evasión SSRF incompleta en net_guard: 198.18.0.0/15 (benchmarking), 224.0.0.0/4 (multicast) y localhost. (forma FQDN con punto final) se colaban por no estar en las listas de rangos/hosts bloqueados.

Causa raíz

No hay un patrón único — es la cola de una auditoría grande, agrupable en:

  • Validación ausente en el borde de la API: el config del target, las claves de OID y hours viajaban del front a la red/BD sin ninguna comprobación de forma o rango.
  • Config que no llega al servicio que lo necesita: timeout_ms y el ajuste de smoothing existían en el modelo/ui_prefs pero el código de lectura seguía apuntando a una fuente vieja (clave hardcodeada o clave de Valkey obsoleta).
  • Estados distintos colapsados en uno solo: “no se puede sondear con este método” (ICMP bloqueado, presupuesto agotado) y “el equipo está caído” se trataban como el mismo hecho, generando falsos positivos de caída.
  • Guard SSRF con enumeración incompleta y comparación por forma, no por destino: mismo patrón de fondo que la Tanda 9 (IPv6 mapeada) — un rango no listado o una forma alternativa del mismo hostname (localhost. con punto final) se cuela hasta que se enumera explícitamente.
  • Retry sin límites: sin cota de tiempo ni de tipo de excepción, cualquier fallo — incluido uno de programación — se consideraba “prueba la siguiente IP”.

Fix aplicado

Commit 98ddfd3a (PR #496):

  • monitoring/api/targets.py: PUT declara el 400 en response con el motivo del rechazo.
  • metrics_reader_base.py: _get_smoothing_level lee get_ui_pref(org, "chart_defaults"); _clamp_hours se aplica en _get_cache_key, _hours_to_promql_duration y _calculate_step.
  • monitoring/api/schemas.py + network/api/common.py: las claves de OID (alta manual y VendorProfile.monitoring_oids) pasan por DYNAMIC_METRIC_RE; nuevo validador de config del target (método HTTP en {GET, HEAD}, cabeceras ≤20 sin Host/Authorization/Cookie/Content-Length/Transfer-Encoding, expected_codes no vacío en 100..599, snmp_port/snmp_interface, snmp_version en {v2c, v3}, timeout_ms), aplicado en POST/PUT/bulk/PATCH.
  • monitoring/api/operations.py: ping usa resolve_reachability; el chequeo HTTP deja de tocar last_status del host.
  • monitoring/api/common.py: run_async envuelve la corrutina en asyncio.wait_for también en el camino asyncio.run.
  • snmp_service.py + monitoring/api/snmp.py: from_config recibe timeout_ms=target.timeout_ms, acotado 100..120000 ms.
  • deep_discover_service.py + 4 llamadores + terminal/api/sentinel_ingest.py: deep_discover_job_key(org_id, page) compone la clave en un único sitio; Wireless usa clave propia.
  • monitoring/management/commands/ping_targets.py: salta targets de IP privada y no persiste resultados Blocked destination del guard.
  • http_service.py + net_guard.py: presupuesto compartido PROBE_BUDGET_SECONDS=12.0 y MAX_PROBE_IPS=3; try_each_ip/try_each_ip_async ganan un deadline y solo reintentan ante OSError/ssl.SSLError/httpx.ConnectError/httpx.ConnectTimeout (antes: cualquier Exception); si el presupuesto se agota sin ningún intento, _raise_after_loop lanza TimeoutError explícito en vez del RuntimeError genérico de “no validated IPs”.
  • net_guard.py: BLOCKED_IP_NETWORKS gana 198.18.0.0/15 y 224.0.0.0/4; nuevo helper _strip_trailing_dot normaliza hostnames tipo localhost. antes de comparar contra BLOCKED_HOSTNAMES.
  • 66 tests nuevos en tests/api/test_audit_monitoring_a.py. Medido en PROD antes de mergear: 0 datos guardados (240 targets, 79 perfiles de fabricante con 263 OIDs) chocan con las validaciones nuevas.

Lecciones

  • Un guard de red que enumera rangos/hosts bloqueados necesita revisión periódica: cada forma alternativa de representar el mismo destino (un rango no listado, un hostname con punto final) es una vía de evasión hasta que se añade explícitamente — no es una lista que se “cierra” una vez.
  • Un retry entre IPs solo debe disparar ante fallos de CONEXIÓN; tratar cualquier excepción como reintentable oculta errores de programación y deja que una sonda fallida consuma tiempo sin límite.
  • Un campo de configuración que vive en el modelo pero cuyo lector sigue apuntando a otra fuente (clave hardcodeada, caché vieja) es indistinguible de no tener el campo — hay que trazar el dato hasta la llamada de red o lectura real.
  • “No se puede sondear con este método” y “el equipo está caído” son estados distintos; colapsarlos genera alertas falsas y erosiona la confianza en el dashboard.

Preventivos futuros

  • Quedan 20 hallazgos de monitoring en la cola: bloque B (CNS e insights, 11) y bloque C (ITSM y notificaciones, 15, incluidos los del ingest del deep-discovery y de Wireless/UPS) — próximos PRs de esta misma serie.
  • Ayuda de usuario sin cambios: crearack--monitoring--ping-http y --dashboards ya describían el comportamiento correcto; el código se pone a la altura de la ayuda.

Véase también

  • [[incident—20260901—auditoria-suprema-2-tanda9-ssrf-ipv6-xss-stencils]]
  • [[incident—20260831—auditoria-suprema-2-tanda1-monitoring-racks]]
  • [[entity—monitoring—service—net-guard]]
  • [[entity—monitoring—service—http-service]]
  • [[entity—monitoring—service—tcp-service]]
  • [[entity—network—service—device-config]]
  • [[concept—seguridad—ssrf-mitigacion]]