DEV Community

Cover image for El escaneo estuvo verde por meses, luego marcó un CVE que debió haber atrapado desde el día uno

El escaneo estuvo verde por meses, luego marcó un CVE que debió haber atrapado desde el día uno

El stack: GitHub Actions, pip-audit (Python), pnpm audit (Node), y
Trivy (contenedor/OS). El remate llega a la mitad: un docker build
--cache-from
que calladito derrotó el parchado de OS que el escaneo
suponía que estaba pasando.

TL;DR

Hay dos tipos de CVE y necesitan dos escáneres distintos:

Capa Qué cubre Herramienta Cuándo Compuerta
Dependencias de la app tu requirements.txt / package.json pip-audit, pnpm audit cada despliegue falla en deps de runtime HIGH/CRITICAL
Imagen base / OS libssh2, openssl, glibc … en la imagen Trivy cron semanal falla en CRITICAL fixed

Y una trampa que hace mentir a la segunda capa: docker build --cache-from
:latest
reutiliza la capa cacheada del apt-get upgrade, así que los
parches de seguridad del OS nunca se publican de verdad aunque el
escaneo siga pasando, hasta que Debian publica un arreglo para algo que ya
estaba en tu imagen vieja, y el escaneo se voltea a rojo sobre un CVE que
"deberías" haber tenido parchado.

Dos tipos de CVE

Una vulnerabilidad en tu app vive en uno de dos lugares, y se descubren, se
arreglan, y se escanean de manera completamente distinta:

  1. Tus dependencias. cryptography, pyjwt, axios, form-data. Las
    fijas en requirements.txt / pnpm-lock.yaml. Un arreglo significa que
    subes una versión. Se escanean consultando bases de datos de avisos
    (PyPI Advisory DB, GitHub Advisory DB) contra tu lockfile.

  2. La imagen base. python:3.12-slim trae todo un userland de Debian:
    libssh2, openssl, libgnutls, glibc. Nunca nombraste estos;
    vinieron con el FROM. Un arreglo significa que Debian publica un
    .deb parchado y recompilas para que apt-get upgrade lo jale. Se
    escanean leyendo las versiones de los paquetes instalados de la imagen
    construida.

Un escáner de dependencias (pip-audit) es ciego al segundo; un escáner de
contenedor (Trivy) es la herramienta equivocada para el primero (ve los
paquetes del OS, no tu intención en requirements.txt). Necesitas los dos,
cableados en puntos distintos de la tubería.

Capa 1: dependencias de la aplicación, en cada despliegue

Los CVE de deps de app son baratos de escanear y baratos de arreglar (subes
un pin), así que gatean cada despliegue, no un cron semanal.

Python, en el workflow de despliegue del backend:

- name: pip-audit (dependency CVEs)
  run: |
    cd backend
    uv pip install --system pip-audit
    # Falla el build ante cualquier vulnerabilidad HIGH/CRITICAL en deps de runtime.
    # Las deps solo-de-dev se auditan pero no bloquean.
    pip-audit -r requirements.txt --strict --ignore-vuln PYSEC-2025-183
    pip-audit -r requirements-dev.txt || true
Enter fullscreen mode Exit fullscreen mode

Node, en el workflow de despliegue del frontend:

- name: Audit runtime dependencies (pnpm audit)
  run: |
    cd frontend
    # Falla ante CVEs HIGH/CRITICAL en deps de runtime; las deps de dev se reportan
    # pero no bloquean (muchos falsos positivos del tooling de build).
    pnpm audit --prod --audit-level high
Enter fullscreen mode Exit fullscreen mode

Tres decisiones deliberadas, todas se ganan su lugar:

  1. Las deps de runtime bloquean; las de dev no. pip-audit -r
    requirements-dev.txt || true
    y pnpm audit --prod dicen lo mismo: un
    CVE en pytest o vite no le va a llegar a los usuarios. Gatear sobre
    avisos de herramientas de build convierte la compuerta en ruido que
    aprendes a ignorar, y una compuerta ignorada no es compuerta. Reporta
    los hallazgos de deps de dev, no bloquees por ellos.

  2. --strict + un ignore explícito y justificado. pip-audit
    --strict
    falla ante cualquier hallazgo, y luego un solo
    --ignore-vuln PYSEC-2025-183 recorta un aviso disputado, con la
    razón escrita junto a él en el workflow (un aviso de longitud de llave
    HS256 de PyJWT cuyo propio texto pone la responsabilidad en la app, que
    satisfacemos forzando una longitud de SECRET_KEY ≥ 32 en prod). La
    regla: nunca un ignore genérico; cada supresión nombra el CVE y el
    control compensatorio. Un || true pelón sobre la auditoría de runtime
    habría escondido cada CVE futuro.

  3. El arreglo es una subida de una línea. Cuando esta compuerta se
    dispara, el remedio es aburrido y ese es el punto:

   # python-multipart 0.0.27 -> 0.0.31  (cierra el aviso de ReDoS)
   # form-data >=4.0.6                   (GHSA-hmw2-7cc7-3qxx)
Enter fullscreen mode Exit fullscreen mode

Capa 2: la imagen base, en un cron semanal

Los CVE de paquetes del OS no los puedes arreglar editando un archivo:
esperas a Debian y recompilas. Así que escanearlos en cada despliegue es
trabajo desperdiciado; un cron semanal es la cadencia correcta. Trivy
escanea la imagen que ya está empujada al registro:

on:
  schedule:
    - cron: "0 3 * * 0"   # domingos 03:00 UTC
  workflow_dispatch:        # re-correr a mano después de un arreglo

jobs:
  trivy:
    strategy:
      fail-fast: false
      matrix:
        image: [myapp-backend, myapp-intelligence]
    steps:
      - name: Scan ${{ matrix.image }}:latest
        uses: aquasecurity/trivy-action@master
        env:
          # Las imágenes son arm64 (Graviton Fargate). Sin esta pista, el escaneo
          # remoto de Trivy resolvía linux/amd64 y daba error "no child with
          # platform linux/amd64 in index", escaneando nada en silencio.
          TRIVY_PLATFORM: linux/arm64
        with:
          image-ref: ${{ steps.ecr-login.outputs.registry }}/${{ matrix.image }}:latest
          severity: CRITICAL
          exit-code: "1"
          ignore-unfixed: true
Enter fullscreen mode Exit fullscreen mode

Los dos ajustes que hacen de esto una señal y no ruido:

  • ignore-unfixed: true. Si Debian no ha publicado un parche, no lo puedes arreglar recompilando; bloquear ante hallazgos no-parcheables convierte la compuerta en un semáforo en rojo permanente que rodeas. Trivy igual marca cualquier cosa etiquetada como fixed; esos son los que un recompilado puede resolver, así que esos son los que vale la pena tronar el build por ellos.
  • fail-fast: false en la matrix. Un CRITICAL en la imagen del backend no debería abortar el escaneo de inteligencia. Quieres la foto completa cada domingo, no la primera falla.

Un casi-accidente sutil vive en ese comentario: TRIVY_PLATFORM. Un
registro multi-arch más un escáner que cae por default a amd64 significó
que Trivy resolvía una arquitectura que no estaba en el index y escaneaba
nada, pasando no porque la imagen estuviera limpia sino porque nunca
miró. Una palomita verde de un escaneo que examinó cero paquetes es el tipo
de verde más peligroso.

La trampa: un escaneo semanal que calladito dejó de parchar

Por meses el escaneo semanal de Trivy estuvo verde. Luego un domingo:

myapp-intelligence:latest (debian 13.5)
Total: 1 (CRITICAL: 1)

┌──────────────┬────────────────┬──────────┬────────┬───────────────────┬──────────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Installed Version │  Fixed Version   │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┼──────────────────┤
│ libssh2-1t64 │ CVE-2026-55200 │ CRITICAL │ fixed  │ 1.11.1-1          │ 1.11.1-1+deb13u1 │
└──────────────┴────────────────┴──────────┴────────┴───────────────────┴──────────────────┘
Error: Process completed with exit code 1.
Enter fullscreen mode Exit fullscreen mode

Status: fixed. Debian publicó 1.11.1-1+deb13u1; la imagen todavía
corría 1.11.1-1. La pregunta obvia: el Dockerfile corre apt-get upgrade
en cada build, ¿cómo es que la imagen no está parchada?

El Dockerfile, con un comentario que resultó ser una promesa que no podía
cumplir:

# apt-get upgrade jala los últimos parches de seguridad ... el escaneo semanal de Trivy
# nos saca de los CVE CRITICAL en cuanto Debian los publica.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get upgrade -y \
    && apt-get install -y --no-install-recommends curl build-essential libpq-dev
Enter fullscreen mode Exit fullscreen mode

El comando de build:

docker build --cache-from $REGISTRY/$IMAGE:latest --cache-to type=inline ...
Enter fullscreen mode Exit fullscreen mode

Ahí está. --cache-from :latest deja que BuildKit reutilice una capa
construida antes cuando la instrucción que la produjo está byte-por-byte
sin cambios. La línea RUN apt-get update && apt-get upgrade nunca cambia,
así que BuildKit reutiliza la capa cacheada de la última imagen y el
upgrade nunca corre de verdad. apt-get upgrade queda congelado en
cualesquiera versiones de paquetes que existían el día que esa capa se
construyó por primera vez.

El comentario decía "parchamos en cada recompilado". El caché decía
"parchamos una vez, luego nunca más". El escaneo no lo atrapó por meses por
una razón silenciosa: la imagen estaba al día el día que la capa se
construyó, y se quedó exactamente tan al día como ese día para siempre.

Solo se volteó a rojo cuando Debian arregló algo que ya estaba instalado:
libssh2. El hueco había estado ahí todo el tiempo; el CVE nada más se
metió en él.

Esto fue peor para una imagen que para la otra. El servicio se despliega
solo cuando su propio código fuente cambia, lo cual es raro, así que su
capa de apt era la más vieja y fue la primera en ser atrapada. La imagen
que se despliega seguido tenía el bug idéntico, enmascarado solo porque
algo más en su Dockerfile seguía cambiando e incidentalmente reventaba la
capa lo bastante seguido.

El arreglo: hacer que el caché expire a propósito

Quieres que la capa de apt cachee dentro de una ventana y recompile a
través
de ella. Un build arg que cambia con un calendario hace
exactamente eso:

# APT_REFRESH revienta el caché de esta capa. Sin él, --cache-from reutiliza la
# capa del apt-get upgrade cuando el texto del Dockerfile no cambia, así que el
# upgrade calladito nunca vuelve a correr y nos perdemos los parches de Debian.
ARG APT_REFRESH=unset
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    echo "apt-refresh=${APT_REFRESH}" \
    && apt-get update && apt-get upgrade -y \
    && apt-get install -y --no-install-recommends curl build-essential libpq-dev
Enter fullscreen mode Exit fullscreen mode
# CI pasa el año-semana ISO, así que la capa recompila a lo mucho una vez por semana,
# alineada con el escaneo semanal de Trivy, mientras los despliegues de la misma semana igual la cachean.
docker build \
  --cache-from $REGISTRY/$IMAGE:latest --cache-to type=inline \
  --build-arg APT_REFRESH=$(date -u +%G-W%V) \
  ...
Enter fullscreen mode Exit fullscreen mode

date -u +%G-W%V es 2026-W27: constante por una semana, distinto la
siguiente. Dentro de una semana, los despliegues pegan al caché y se quedan
rápidos. Una vez por semana, el valor cambia, la capa (y todo lo de después
de ella) recompila, y apt-get upgrade jala lo que sea que Debian haya
publicado. La cadencia coincide con el escaneo: para cuando Trivy mira el
domingo, el recompilado de esa semana ya jaló los parches. El default
APT_REFRESH=unset mantiene el docker compose build local cacheando
normal; solo CI pasa el valor rotatorio.

Fíjate en lo que esto no es: no es --no-cache (eso tira el caché de
wheels de uv y el caché de descarga de apt también, haciendo lento cada
build). Los mounts de --mount=type=cache persisten los .debs descargados
y los wheels a través de los builds; solo el caché de la capa se revienta.
El recompilado vuelve a correr apt-get upgrade pero vuelve a descargar
casi nada.

Lo que NO ayudó

  • Confiar en el comentario en lugar del caché. "Corremos apt-get upgrade" era cierto de la instrucción y falso del build. El Dockerfile se leía como parchado; la imagen no lo estaba.
  • Un escaneo que pasa como prueba de parchado. Verde significaba "ningún CRITICAL fixed hoy", no "estás jalando parches". Esos dos divergen en silencio hasta que aterriza un arreglo para algo que ya tienes.
  • --no-cache como el arreglo. Funciona pero tira los cachés de descarga también; reventar la capa semanalmente con los mount caches persistentes consigue el parche sin la lentitud.
  • Escanear en la arquitectura equivocada. Una imagen multi-arch + un escáner que cae por default a amd64 = un escaneo de nada que reporta éxito.

Qué sí ayudaría a futuro (en orden de palanca)

  1. Fija la imagen base por digest y súbela deliberadamente. FROM python:3.12-slim@sha256:… hace "¿en qué OS estamos?" explícito y revisable, en lugar de lo que sea que :slim resolvió ese día. Compensación: un PR periódico (o un bot) para avanzar el digest.
  2. Automatiza las subidas de dependencias. Un bot que abre los PRs estilo python-multipart 0.0.27 -> 0.0.31 convierte los hallazgos de la Capa 1 en revisar-y-mergear en lugar de persecuciones manuales. Compensación: volumen de PRs que triar.
  3. Corre Trivy en modo filesystem en CI también. Escanear los lockfiles en cada PR atrapa una dep vulnerable antes de que siquiera se construya, complementando el escaneo de registro de lo que ya se publicó.
  4. Emite el resultado del escaneo como métrica. "Semanas desde el último CRITICAL" en un dashboard hace de la postura de seguridad una tendencia, no un correo de domingo.

Lecciones

  1. Un escaneo que pasa prueba lo que chequeó, no lo que supusiste. Verde de Trivy significaba ningún CRITICAL fixed ese día, no que los parches estuvieran fluyendo. Verifica el mecanismo, no solo el resultado.
  2. Hay dos superficies de CVE; necesitas un escáner para cada una. Las deps de app (pip-audit / pnpm audit) y los paquetes del OS (Trivy) son bases de datos distintas, arreglos distintos, cadencias distintas. Una sola herramienta no cubre el terreno de la otra.
  3. Los cachés de build pueden congelar tu postura de seguridad en silencio. Cualquier cosa que jale "latest" dentro de una capa cacheada (apt-get upgrade, curl | sh, un install sin fijar) corre una vez y luego nunca más bajo --cache-from. Haz que esas capas expiren a propósito.
  4. Gatea sobre lo accionable; reporta el resto. Bloquea ante los CRITICAL fixed y las deps de runtime HIGH/CRITICAL; no bloquees ante los CVE de OS sin arreglar ni ante avisos de deps de dev. Una compuerta que se dispara ante lo no-accionable termina deshabilitada.
  5. Cada supresión nombra un CVE y una razón. --ignore-vuln PYSEC-2025-183 con el control compensatorio escrito junto a él es una decisión; || true sobre toda la auditoría es una venda en los ojos.
  6. Un escaneo en la arquitectura equivocada es peor que ningún escaneo: reporta una seguridad que nunca midió. Fija la plataforma.

Top comments (0)