
El 19 de marzo de 2026, alguien comprometió las releases de Trivy en GitHub. Las versiones v0.69.4, v0.69.5 y v0.69.6 fueron reemplazadas por binarios que contenían un credential stealer. Durante cuatro días, cualquier pipeline de CI/CD o Docker build que descargara Trivy desde la página oficial de releases ejecutó código malicioso con permisos suficientes para leer todo el sistema de archivos del contenedor. El incidente se registró como CVE-2026-33634, con una puntuación CVSS de 9.4.
Nuestro Dockerfile descargaba Trivy latest. Sin verificación. Cada build obtenía el binario más reciente de GitHub Releases y lo ejecutaba directamente. Si hubiéramos reconstruido la imagen durante esa ventana de cuatro días, habríamos instalado el binario comprometido sin saberlo.
Este artículo explica las tres medidas que aplicamos en nuestro proyecto para cerrar ese vector y otros similares en el pipeline de npm. Son cambios simples, que se implementan en minutos, pero que marcan la diferencia entre un build vulnerable y uno que se defiende solo.
Cuando construyes una imagen Docker, no escribes todo el código que acaba dentro. Descargas imágenes base, instalas binarios de terceros, ejecutas npm install que trae cientos de dependencias transitivas. Cada uno de esos pasos confía en una fuente externa.
Un ataque de supply chain explota esa confianza. En lugar de atacar tu código directamente, el atacante compromete algo que tu código consume: un binario que descargas, un paquete npm que instalas, una herramienta que dejas en la imagen final. Tú no cambias nada en tu repositorio, pero tu siguiente build produce una imagen con código malicioso.
En el contexto de Docker y Node.js, hay tres superficies de ataque principales.
Binarios descargados con curl. Si descargas un ejecutable de internet sin verificar su integridad, cualquier compromiso del servidor de origen (o un ataque man-in-the-middle) te entrega un binario diferente al que esperabas.
Scripts de npm. Cualquier paquete puede definir scripts postinstall o preinstall que se ejecutan automáticamente durante npm install. Si una dependencia transitiva se compromete, su script se ejecuta con los permisos de tu build.
Herramientas innecesarias en producción. Dejar npm, yarn o corepack en la imagen final añade dependencias transitivas que nunca usas pero que acumulan vulnerabilidades y amplían la superficie de ataque.
Cada una de estas superficies se puede cerrar con una medida específica. Veamos las tres.
El CVE-2026-33634 no fue un ataque sofisticado. Alguien obtuvo acceso a las credenciales de un mantenedor y usó ese acceso para reemplazar los binarios en tres releases consecutivas de Trivy en GitHub. El payload era un credential stealer que exfiltraba variables de entorno y ficheros de configuración del contenedor donde se ejecutaba. En el mismo contexto, en la gestión de secretos con Infisical cubrimos el otro lado del problema.
El ataque estuvo activo del 19 al 23 de marzo de 2026. Cualquier pipeline que descargara Trivy durante esa ventana (sin fijar versión) instaló el binario comprometido. Esto incluye miles de builds de CI/CD que usan Trivy como escáner de vulnerabilidades, irónicamente la herramienta de seguridad se convirtió en el vector de ataque. Como complemento, en un pipeline de seguridad completo sin presupuesto enterprise cubrimos la otra cara de la moneda.
Nuestro Dockerfile descargaba Trivy usando un patrón muy común: detectar la última versión disponible y descargarla directamente.
# ❌ Patrón vulnerable: descarga la última versión sin verificar
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/binEste patrón tiene dos problemas. Primero, no fija la versión: cada build puede obtener un binario diferente, lo que rompe la reproducibilidad. Segundo, no verifica la integridad: si el binario descargado ha sido manipulado, el build lo acepta sin rechistar.
La solución tiene dos partes: fijar la versión exacta del binario y verificar su checksum antes de usarlo.
# --- Trivy downloader (pinned version + checksum verification) ---
# CVE-2026-33634: Trivy supply chain attack compromised v0.69.4-6.
# ALWAYS pin to a known-good version and verify the SHA256 checksum.
# To update: change TRIVY_VERSION and TRIVY_SHA256 below, then rebuild.
FROM base AS trivy-downloader
ARG TRIVY_VERSION=0.69.3
ARG TRIVY_SHA256=1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && \
echo "Downloading Trivy v${TRIVY_VERSION}..." && \
curl -fL --retry 3 --retry-delay 5 -o /tmp/trivy.tar.gz \
https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz && \
echo "${TRIVY_SHA256} /tmp/trivy.tar.gz" | sha256sum -c - && \
tar xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy && \
rm -f /tmp/trivy.tar.gz && \
rm -rf /var/lib/apt/lists/*Vamos línea por línea.
ARG TRIVY_VERSION=0.69.3 fija la versión a la última release conocida antes del compromiso. No hay ambigüedad: cada build descarga exactamente esta versión.
ARG TRIVY_SHA256=... almacena el hash SHA256 del archivo comprimido de esa versión. Este hash se obtiene de una fuente verificada antes de que ocurra el compromiso y se compromete directamente en el Dockerfile.
echo "${TRIVY_SHA256} /tmp/trivy.tar.gz" | sha256sum -c - es la línea clave. Compara el hash del archivo descargado contra el hash esperado. Si no coinciden, el comando falla con código de salida distinto de cero y el build se detiene inmediatamente. No hay forma de que un binario manipulado pase esta verificación sin que alguien modifique también el hash en el Dockerfile, lo que requiere acceso a tu repositorio.
Usamos un multi-stage build con una etapa dedicada (trivy-downloader) para descargar y verificar el binario. El runner final solo copia el ejecutable verificado con COPY --from=trivy-downloader. Esto mantiene las herramientas de descarga (curl, ca-certificates) fuera de la imagen de producción.
Cuando necesites actualizar Trivy, solo tienes que cambiar los dos ARG y reconstruir. El comentario en el propio Dockerfile documenta exactamente qué hacer.
npm permite que cualquier paquete defina scripts que se ejecutan automáticamente durante la instalación. Los más comunes son preinstall (antes de instalar) y postinstall (después). Un uso legítimo es compilar módulos nativos: better-sqlite3, por ejemplo, necesita ejecutar node-gyp para compilar sus bindings de C++ durante la instalación.
Pero este mismo mecanismo es un vector de ataque conocido. Si un atacante compromete un paquete npm (o una de sus dependencias transitivas) y añade un postinstall malicioso, ese script se ejecuta automáticamente en cada npm install. No necesitas importar el paquete en tu código, no necesitas llamar ninguna función. Solo con que esté en tu package-lock.json, su script se ejecuta con los permisos de tu build.
Esto no es teórico. Incidentes como event-stream (2018), ua-parser-js (2021) y coa (2021) usaron exactamente este mecanismo para distribuir malware a través del registro de npm.
La solución es desactivar todos los scripts de instalación por defecto y habilitar explícitamente solo los que necesitas.
Primero, creamos un .npmrc en la raíz del proyecto.
ignore-scripts=trueEsto desactiva todos los preinstall, postinstall y prepare de todas las dependencias, tanto en local como en CI. Ningún paquete puede ejecutar código arbitrario durante la instalación.
En el Dockerfile, la instalación de dependencias queda así.
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts && \
npm rebuild better-sqlite3npm ci --ignore-scripts instala todas las dependencias de forma limpia pero no ejecuta ningún script. Las dependencias se copian a node_modules pero los módulos nativos quedan sin compilar.
npm rebuild better-sqlite3 compila explícitamente solo el módulo nativo que sabemos que lo necesita. Este es el paso clave: en lugar de permitir que cientos de paquetes ejecuten scripts arbitrarios, reconstruimos únicamente lo que hemos auditado y sabemos que es legítimo.
El principio es simple: deniega por defecto, permite solo lo que has verificado.
Con ignore-scripts=true en el .npmrc, el npm install local tampoco ejecuta scripts. Esto significa que better-sqlite3 no se compila automáticamente y la aplicación no arranca hasta que lo compiles manualmente.
Para no complicar el flujo de desarrollo, añadimos un script postsetup en el package.json.
{
"scripts": {
"postsetup": "npm rebuild better-sqlite3 drizzle-kit"
}
}Después de clonar el proyecto y ejecutar npm install, solo necesitas ejecutar npm run postsetup para compilar los módulos nativos. Es un paso explícito y consciente, no algo que ocurre en silencio.
Una imagen de producción ejecuta tu aplicación. No instala paquetes, no ejecuta npm install, no necesita un gestor de dependencias. Sin embargo, la imagen base de Node.js incluye npm, yarn y corepack por defecto, y con ellos vienen docenas de dependencias transitivas.
Esas dependencias tienen sus propias vulnerabilidades. Si npm incluye una versión vulnerable de cross-spawn, glob, minimatch o tar, tu imagen hereda esas vulnerabilidades aunque nunca las uses. Los escáneres de seguridad las detectan y generan alertas que se mezclan con vulnerabilidades reales de tu aplicación, creando ruido que dificulta priorizar lo que importa. Desde la perspectiva de seguridad, en Hadolint y Dockle para análisis estático de Dockerfiles profundizamos en este aspecto.
En la etapa runner del Dockerfile, después de copiar la aplicación construida, eliminamos npm, yarn y corepack.
# Strip npm, yarn, corepack from runner — not needed, and their transitive
# deps (cross-spawn, glob, minimatch, tar) carry HIGH CVEs
RUN rm -rf /usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/opt/yarn* \
/usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkgEste paso va al final, después de que todo el npm ci y el npm run build se hayan completado en etapas anteriores del multi-stage build. La etapa runner solo necesita node para ejecutar server.js.
El resultado es una imagen más limpia, con menos vulnerabilidades reportadas por el escáner y sin herramientas que un atacante podría aprovechar si consigue acceso al contenedor.
Estas tres medidas no son alternativas entre sí. Son capas complementarias que protegen momentos diferentes del ciclo de vida del contenedor.
La verificación de checksums protege lo que descargas. Garantiza que el binario que obtienes de internet es exactamente el que esperas, sin modificaciones.
ignore-scripts protege lo que se ejecuta durante la instalación. Impide que código no auditado se ejecute como parte del build.
Eliminar el package manager protege lo que queda en producción. Reduce la superficie de ataque de la imagen final y elimina dependencias que nadie usa.
Cada capa es independiente. Si un atacante consigue sortear la verificación de checksums (porque comprometió la fuente antes de que fijaras el hash), los scripts siguen desactivados. Si un paquete comprometido consigue pasar sin necesitar scripts, el package manager no estará disponible en producción para descargar payloads adicionales.
Esto es defensa en profundidad. No basta con una sola medida, porque cada una tiene sus propios puntos ciegos. La combinación de las tres hace que un ataque exitoso necesite comprometer múltiples eslabones simultáneamente.
Nunca descargues un binario sin verificar su checksum. Da igual que venga de GitHub, de la página oficial del proyecto o de un mirror. Si no verificas la integridad, confías ciegamente en que nadie ha manipulado la fuente. Un sha256sum -c tarda milisegundos y detiene el build si algo no cuadra.
Si un paquete necesita postinstall, haz rebuild solo de ese paquete. La mayoría de dependencias no necesitan ejecutar scripts durante la instalación. Desactiva los scripts por defecto con ignore-scripts=true y compila explícitamente solo lo que has auditado.
Tu imagen de producción no necesita npm. Si tu CMD es node server.js, elimina npm, yarn y corepack del runner. Son herramientas de desarrollo, no de producción.
Los escáneres de vulnerabilidades son más útiles cuando no tienen ruido. Eliminar dependencias que no usas reduce las alertas irrelevantes y te permite centrarte en las vulnerabilidades reales de tu aplicación.
Cada capa de seguridad que añades hace que el siguiente ataque necesite comprometer un eslabón más. Ninguna medida individual es infalible. La seguridad se construye acumulando capas que se complementan entre sí.
La seguridad de tu contenedor no depende solo del código que escribes, sino del código que decides no ejecutar. Verificar lo que descargas, silenciar lo que no has auditado y eliminar lo que no necesitas son tres decisiones que cuestan minutos y cierran vectores de ataque que cuestan meses de respuesta a incidentes.
Los scripts E2E necesitan datos sensibles —tokens de API, credenciales, URLs privadas— sin que aparezcan en el código. En JMO Labs hemos añadido variables de script con modo privado: se inyectan automáticamente, se enmascaran en los logs y se acceden con una sintaxis limpia.

Las variables de entorno en texto plano son cómodas hasta que dejan de ser seguras. Explicamos cómo desplegamos Infisical como gestor de secretos self-hosted dentro de Dokploy y cómo conectamos nuestras aplicaciones para que lean las credenciales de forma cifrada y auditable.

Cada decisión técnica tiene un porqué. Elegimos Next.js sobre Astro, SQLite sobre PostgreSQL y Docker con Dokploy sobre Vercel. Aquí explicamos las razones y lo que aprendimos por el camino.