TLP:AMBER · Limitado a la organización y sus clientes. 2026-09-25

AppSec

La métrica que mentía: catorce días de exposición en verde

Un CVE de 9.8 estuvo catorce días sin parchear en un servicio expuesto y el cuadro de mando marcó verde los catorce. No fallaron ni el escáner ni el equipo: fallaba la definición de la métrica, y eso se arregla con una línea de SQL y una puerta en el despliegue.

Expediente № 011
Publicado
Clasificación TLP:AMBER
Severidad Crítica
Lectura 9 min
Perfil Ambos

TL;DR

Medíamos el tiempo de remediación desde que se asigna el ticket, no desde que el CVE existe. Con esa definición, un equipo ocupado produce un cuadro de mando impecable mientras el servicio sigue expuesto. La corrección son dos cosas concretas: cambiar el origen del reloj en la consulta, y mover el escaneo de informar a bloquear con una política que caduca sola.

La primera pregunta del comité fue «¿por qué no estaba parcheado?». Es la pregunta equivocada y durante años la respondí de frente, que es peor. El parche existía desde el primer día. El escáner lo reportó a las cuatro horas. El ticket se creó, se asignó y se midió contra su acuerdo de nivel de servicio.

Nada de eso detiene un despliegue, y nada de eso apareció en rojo.

Los hechos#

  1. Publicación del CVE

    Ejecución remota sin autenticación en una librería de serialización, CVSS 9.8. El proveedor publica la versión corregida el mismo día.
  2. El escáner lo detecta

    Cuatro horas. El análisis de composición de software identifica el paquete vulnerable en la imagen del servicio y abre el hallazgo.
  3. Ticket asignado

    Aquí arrancaba nuestro reloj. El equipo estaba a tres días de cerrar un sprint con fecha comprometida, así que el ticket entra al siguiente.
  4. Se incumple el acuerdo interno

    Siete días para severidad crítica. El recordatorio automático va al mismo equipo que ya lo sabía, sin cambiar la prioridad de nada.
  5. Escalamiento por casualidad

    Aparece en una revisión de métricas. Parche aplicado en cuatro horas. Catorce días desde que la señal existía, y ninguna alerta en todo ese tiempo.

No hubo explotación, y eso lo sabemos por los registros de acceso del balanceador, no por suerte declarada. Cuando preguntamos al proveedor si había campañas activas, la respuesta fue que el impacto observado era limitado y contenido . Dos semanas después aparecieron las primeras redes de bots explotándolo a escala.

Dónde estaba el fallo#

Revisamos el proceso paso a paso buscando el punto de ruptura y no lo encontramos, porque no existía. Cada componente hizo lo suyo. El fallo estaba en la definición de lo que medíamos.

Las dos consultas#

Nuestro cuadro de mando calculaba así el tiempo de remediación:

metricas/mttr_v1.sql sql
-- Lo que medíamos. Parece razonable y es inútil.
SELECT
  severidad,
  percentile_cont(0.5) WITHIN GROUP (
    ORDER BY EXTRACT(EPOCH FROM (h.resuelto_en - t.asignado_en)) / 86400
  ) AS mediana_dias
FROM hallazgos h
JOIN tickets t ON t.hallazgo_id = h.id
WHERE h.resuelto_en IS NOT NULL
  AND h.detectado_en >= now() - INTERVAL '90 days'
GROUP BY severidad;

El reloj arranca en t.asignado_en. Todo lo que ocurre antes de que alguien acepte el trabajo es invisible, y todo lo que un equipo tarda en aceptar el trabajo mejora el número.

Lo que debíamos medir:

metricas/exposicion.sql sql
-- Tiempo de EXPOSICIÓN: desde que el arreglo está disponible públicamente
-- hasta que deja de estar desplegado lo vulnerable.
SELECT
  h.severidad,
  h.expuesto_a_internet,
  percentile_cont(0.5) WITHIN GROUP (
    ORDER BY EXTRACT(EPOCH FROM (
      COALESCE(h.remediado_en, now()) - c.parche_publicado_en
    )) / 3600
  ) AS mediana_horas,
  count(*) FILTER (
    WHERE COALESCE(h.remediado_en, now()) - c.parche_publicado_en
          > INTERVAL '24 hours'
    AND h.severidad = 'critica'
    AND h.expuesto_a_internet
  ) AS incumplimientos
FROM hallazgos h
JOIN cves c ON c.id = h.cve_id
WHERE h.detectado_en >= now() - INTERVAL '90 days'
GROUP BY h.severidad, h.expuesto_a_internet;

Tres diferencias que importan:

c.parche_publicado_en
El reloj arranca cuando el arreglo existe en el mundo, no cuando nosotros nos enteramos. Así el retraso del escáner también cuenta, que es justo lo que queremos vigilar.
COALESCE(h.remediado_en, now())
Los hallazgos abiertos entran en la mediana con su antigüedad actual. La versión anterior filtraba por resuelto_en IS NOT NULL, así que lo que llevaba cien días abierto no contaba: el número mejoraba cuanto peor iba.
h.expuesto_a_internet
Separar por exposición, porque catorce días en un servicio interno y catorce días en uno público no son el mismo hecho.

Error común

El filtro resuelto_en IS NOT NULL es el error más común que veo en métricas de vulnerabilidades, y el más difícil de detectar mirando el gráfico: solo mide lo que ya se arregló. Un programa que no arregla nada tiene un tiempo de remediación excelente.

Con la consulta nueva, aplicada al mismo periodo, el incidente aparecía en rojo el día tres.

La puerta#

Medir mejor solo cambia cuándo te enteras. Lo que faltaba era que un hallazgo crítico detuviera algo por sí solo, sin que una persona tuviera que asumir el costo político de detenerlo.

Antes

  1. confirmar
  2. construir escanear abre hallazgo, no bloquea
  3. desplegar

Después

  1. confirmar
  2. construir
  3. evaluar política deniega, asigna dueño, plazo 24 h
  4. desplegar
El escaneo ya existía. El cambio fue moverlo de informar a decidir, y situar la decisión en un punto donde no hace falta que nadie sea valiente.

La política se evalúa en el despliegue, escrita como código y revisada como código:

politicas/despliegue.rego rego
package despliegue

import rego.v1

# Deniega el despliegue si la imagen trae un hallazgo crítico con parche
# disponible y el servicio queda expuesto a internet.
deny contains msg if {
	some h in input.hallazgos
	h.severidad == "critica"
	h.parche_disponible
	input.servicio.expuesto_a_internet
	not excepcion_vigente(h.cve)
	msg := sprintf(
		"%s sin parchear en servicio expuesto (parche desde %s)",
		[h.cve, h.parche_publicado_en],
	)
}

# Una excepción solo vale si está aprobada Y no ha caducado.
excepcion_vigente(cve) if {
	some e in data.excepciones
	e.cve == cve
	e.servicio == input.servicio.nombre
	e.aprobada_por != ""
	time.parse_rfc3339_ns(e.caduca_en) > time.now_ns()
}

Y la llamada desde el flujo de despliegue:

.github/workflows/desplegar.yml yaml
- name: Evaluar política de despliegue
  run: |
    syft "$IMAGEN" -o cyclonedx-json > sbom.json
    grype sbom:sbom.json -o json > hallazgos.json
    jq -n --slurpfile h hallazgos.json --arg svc "$SERVICIO" \
       '{hallazgos: $h[0].matches, servicio: {nombre: $svc, expuesto_a_internet: true}}' \
       > entrada.json
    # --fail-defined devuelve código distinto de cero si `deny` tiene contenido.
    # Sin esta bandera, opa imprime la denegación y el despliegue continúa.
    opa eval --fail-defined -d politicas/ -d excepciones/ \
       -i entrada.json 'data.despliegue.deny'    

En la práctica

La línea que más discusión generó fue caduca_en. Sin caducidad obligatoria las excepciones se acumulan y al cabo de un año la puerta no bloquea nada. Con ella, excepcion_vigente devuelve falso sola y el despliegue vuelve a fallar sin que nadie tenga que acordarse.

El detalle de implementación: la caducidad se compara en la evaluación, no en un proceso nocturno que limpia la tabla. Un proceso nocturno se cae y nadie se entera.

Qué pasa cuando la puerta se cae#

Esta es la parte que se salta todo el mundo y la que de verdad hay que decidir.

Si el servicio de políticas no responde, opa eval falla y con --fail-defined el despliegue se detiene. Eso es fallo cerrado: seguro y capaz de parar la entrega de toda la organización porque un contenedor se reinició.

La alternativa, capturar el error y continuar, es fallo abierto: no rompe nada y convierte la puerta en decoración. La primera caída silenciosa la deja inerte para siempre.

Fallo cerradoFallo abierto
Puerta caídaNadie despliegaTodos despliegan sin control
Quién se enteraTodo el mundo, de inmediatoNadie
Riesgo que introduceDisponibilidad de la entregaVuelve al estado anterior, sin avisar
Quién debe decidirloEl negocioNadie lo decide, se hereda

Elegimos fallo cerrado con una vía de escape explícita: una etiqueta en el despliegue que salta la puerta, firmada y registrada, que dispara una alerta al canal del equipo de seguridad. Usarla es trivial y visible, que es exactamente la combinación que queremos.

Para el directorio

Traducción para el comité: elegir fallo cerrado significa aceptar que un fallo de la herramienta de seguridad puede parar los despliegues. Es una decisión de apetito de riesgo, no técnica, y si no la toman ustedes la toma por omisión quien escriba el try/except.

La matriz no ayudó#

Llevamos el caso al comité con la matriz de riesgo en la primera lámina.

Donde situamos el incidente. La casilla es correcta y aun así la conversación no avanzó.
InsignificanteMenorModeradoMayor
Casi seguroMediaAltaCríticaCrítica
ProbableBajaMediaAlta, posición de este casoCrítica
PosibleBajaMediaMediaAlta
ImprobableBajaBajaMediaAlta

La casilla estaba bien calculada. El problema es que una matriz colapsa una distribución en una celda: el mismo «probable por moderado» describe un riesgo de catorce días de exposición y uno de catorce horas. Toda la información que permitía decidir, el tiempo, quedaba fuera del instrumento.

La segunda vez llevé la curva de exposición de los últimos noventa días y tres opciones con su costo. Salió aprobado en once minutos.

Una matriz describe el problema. Un comité necesita elegir. Si la lámina no termina en opciones con su costo, la reunión termina en conversación.
Nota del CISO · Comité de riesgos

Resultados, y cómo se midieron#

A los seis meses, con la consulta nueva sobre la misma tabla de hallazgos:

IndicadorAntesDespués
Mediana de exposición, crítica y expuesto9,1 días19 horas
Percentil 90 de exposición31 días3,2 días
Hallazgos críticos abiertos más de 24 h140
Despliegues denegados por la puerta07
Excepciones concedidas03, todas caducadas en plazo
Escalamientos que llegaron al CISO1, por casualidad0

El percentil 90 es el número que miro, no la mediana.1 Una mediana buena con una cola de treinta y un días significa que lo raro es lo que te va a doler.

Riesgo

Ninguno de estos números vale si la tabla hallazgos solo contiene lo que el escáner ve. Un servicio que no está en el inventario tiene exposición cero por construcción. Antes de presumir de métrica, compare el inventario del escáner con el de la nube.

Qué haría distinto#

  1. Revisar la consulta antes que el proceso. Pasé semanas mirando el flujo de trabajo y el fallo estaba en un JOIN y un filtro. Una métrica mal definida no se nota nunca, porque su trabajo es decirte que todo va bien.
  2. Escribir la puerta antes de ampliar la detección. Teníamos un escáner excelente alimentando un proceso que no podía actuar. Comprar más detección habría sido comprar más señales que nadie podía atender.
  3. Decidir el modo de fallo por escrito y con firma. Si no se decide, alguien lo decide igual dentro de un try/except, y ese alguien no tiene el mandato.
  4. Poner caducidad a la excepción en el momento de concederla, nunca después.

Lección aprendida

Cuando un postmortem no encuentra a nadie que se equivocara, el fallo suele estar en el diseño del sistema y no en su ejecución. Y cuando además el cuadro de mando estaba en verde, el fallo está en la definición de la métrica, que es el sitio donde nadie mira porque parece matemática y es política.

  1. La mediana esconde exactamente los casos que causan incidentes. Si solo puede publicar un número en el cuadro de mando, publique el percentil 90 y el conteo de hallazgos abiertos por encima del umbral. Son dos cifras y ninguna se puede mejorar sin arreglar algo. ↩︎