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.
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#
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.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.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.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.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:
-- 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:
-- 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 filtroresuelto_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
- confirmar
- construir escanear abre hallazgo, no bloquea
- desplegar
Después
- confirmar
- construir
- evaluar política deniega, asigna dueño, plazo 24 h
- desplegar
La política se evalúa en el despliegue, escrita como código y revisada como código:
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:
- 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 cerrado | Fallo abierto | |
|---|---|---|
| Puerta caída | Nadie despliega | Todos despliegan sin control |
| Quién se entera | Todo el mundo, de inmediato | Nadie |
| Riesgo que introduce | Disponibilidad de la entrega | Vuelve al estado anterior, sin avisar |
| Quién debe decidirlo | El negocio | Nadie 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 eltry/except.La matriz no ayudó#
Llevamos el caso al comité con la matriz de riesgo en la primera lámina.
| Insignificante | Menor | Moderado | Mayor | |
|---|---|---|---|---|
| Casi seguro | Media | Alta | Crítica | Crítica |
| Probable | Baja | Media | Alta, posición de este caso | Crítica |
| Posible | Baja | Media | Media | Alta |
| Improbable | Baja | Baja | Media | Alta |
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.
Resultados, y cómo se midieron#
A los seis meses, con la consulta nueva sobre la misma tabla de hallazgos:
| Indicador | Antes | Después |
|---|---|---|
| Mediana de exposición, crítica y expuesto | 9,1 días | 19 horas |
| Percentil 90 de exposición | 31 días | 3,2 días |
| Hallazgos críticos abiertos más de 24 h | 14 | 0 |
| Despliegues denegados por la puerta | 0 | 7 |
| Excepciones concedidas | 0 | 3, todas caducadas en plazo |
| Escalamientos que llegaron al CISO | 1, por casualidad | 0 |
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 tablahallazgos 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#
- Revisar la consulta antes que el proceso. Pasé semanas mirando el flujo
de trabajo y el fallo estaba en un
JOINy un filtro. Una métrica mal definida no se nota nunca, porque su trabajo es decirte que todo va bien. - 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.
- 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. - 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.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. ↩︎