El día que pnpm audit tumbó todos los deploys (y qué correr en su lugar)
Un deploy de frontend falló. No había cambiado código en el pipeline, no se había subido ninguna dependencia, el lockfile era byte por byte idéntico al del deploy que había pasado en verde 23 horas antes. El falló: ERR_PNPM_AUDIT_BAD_RESPONSE - The audit endpoint responded with 410:
{"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}
npm había retirado el endpoint al que pnpm audit hace POST. Mi compuerta de seguridad ahora estaba fallando por infraestructura, no por vulnerabilidades, y como era una compuerta dura, bloqueaba todos los deploys de frontend que iban detrás, incluyendo un arreglo de bug sin relación que estaba esperando para salir.
TL;DR pnpm audit --prod --audit-level high → osv-scanner + filtro
pnpm audit |
osv-scanner + filtro |
|
|---|---|---|
| Cómo obtiene los datos | POST al endpoint de audit de npm | escanea el lockfile contra la base de datos de OSV |
| Falla cuando retiran el endpoint | sí, bloquea todos los deploys | no (no hay endpoint) |
| Solo prod | integrado (--prod) |
reconstruido desde pnpm ls --prod |
| Umbral de severidad | integrado (--audit-level) |
reconstruido del CVSS en el reporte |
| Depende de | que npm mantenga viva una API legada | un binario de escáner versionado + una base de datos pública |
Dos ideas:
- Una compuerta que falla por infraestructura es peor que no tener compuerta.
- No terceerices un chequeo de seguridad a un endpoint de un proveedor. Escanea un artefacto que tú controlas (el lockfile) contra una base de datos. Los endpoints se retiran; tu lockfile no.
Qué era pnpm audit en realidad
Parece un escaneo local. No lo es. pnpm audit serializa tu árbol de dependencias, le hace POST al endpoint de audit del registry, y renderiza los advisories que regresen. El chequeo es una llamada de red a una API específica de npm:
POST https://registry.npmjs.org/-/npm/v1/security/audits/quick
npm deprecó ese endpoint (y su hermano /audits) en favor de una API más nueva de bulk-advisory, y con el tiempo puso los dos en HTTP 410 Gone. Cualquier herramienta que siga llamando al endpoint viejo, incluyendo el pnpm audit de un montón de imágenes de CI fijadas, ahora falla sin condición. No es "no se encontraron vulnerabilidades", no es "se encontraron algunas": es un error duro, en cada corrida.
La compuerta había estado en verde por meses. Nada de mi lado cambió. Un tercero retiró una API y mi pipeline de deploy se puso en rojo.
Los tres arreglos equivocados
Ponle || true. La forma más rápida de desbloquear. También la peor: convertiste una compuerta de seguridad en un comentario. No da ninguna protección y miente en verde para siempre. Si la compuerta no vale la pena arreglarla, bórrala, no dejes una decorativa en la que la gente confía.
Sube la versión de la herramienta. Sospecha razonable: quizá un pnpm más nuevo usa el endpoint nuevo. Lo revisé: tanto la versión fijada como la última le hacen POST al /audits/quick retirado. El endpoint ya no existe para nadie; subir de versión no lo conjura de vuelta. (Puede variar conforme pnpm migre, pero "ojalá la herramienta se haya movido a la API nueva" no es un plan.)
Agrega una lista de ignorados. Cámbiate a un escáner, luego suprime cada hallazgo que salte. Esto invierte la compuerta: ahora bloquea por default y tú justificas las excepciones con la mano, así que cada advisory nuevo, incluyendo el ruido de dev que el viejo flag --prod excluía gratis, se vuelve un triaje manual. La lista de negados crece, y el día que caiga uno real queda sepultado en el mismo movimiento con el que descartas el ruido.
El arreglo correcto mantiene la compuerta con sentido: la misma pregunta, otra fuente de datos.
Qué debería afirmar la compuerta en realidad
Antes de reemplazarla, escribe qué significaban los flags viejos, porque un escáner no los va a reproducir a menos que lo obligues:
pnpm audit --prod --audit-level high
▲▲ │ │
│ └ piso de severidad: solo HIGH/CRITICAL.
│ MEDIUM/LOW se reportan, nunca bloquean.
└ alcance: solo dependencias de RUNTIME.
Las de dev/build son ruidosas (bundlers, frameworks de pruebas, sus árboles transitivos) y no llegan a los usuarios.
Ese segundo flag es el que la gente olvida, y es de carga estructural. La mayor parte del ruido de CVEs en un proyecto de frontend vive en el árbol de dev. Un reemplazo que escanee el lockfile entero va a "encontrar más vulnerabilidades" y se va a sentir más minucioso, cuando en realidad nada más bloquea deploys por tooling de build que nunca llega a un usuario. Preservar --prod no es un lujo: es la diferencia entre una compuerta que la gente respeta y una que rodean.
El reemplazo: escanea el lockfile, no un endpoint
osv-scanner lee pnpm-lock.yaml directo y checa cada paquete contra la base de datos de OSV. Ningún POST a npm, nada que retirar:
# 02-osv-scanner-step/audit.sh (forma)
osv-scanner scan source --lockfile=pnpm-lock.yaml --format=json > osv.json
Pero osv-scanner es tosco a propósito: reporta todos los advisories en toda severidad a lo largo del lockfile entero, y sale con código distinto de cero si encuentra cualquier cosa. Apúntalo a un proyecto real y va a fallar por una dependencia transitiva de solo-dev de baja severidad desde el día uno. Tal cual sale de la caja, es la trampa de "escanear el lockfile entero" de la sección anterior.
Así que no usamos su código de salida. Tomamos su JSON y le volvemos a imponer las dos reglas de la compuerta vieja nosotros mismos.
Preservando --prod y --audit-level high
Dos insumos, un script chico:
osv-scanner scan source --lockfile=pnpm-lock.yaml --format=json > osv.json || true
pnpm ls --prod --depth Infinity --json > prod.json
node audit-prod-gate.mjs # sale con 1 solo ante un HIGH/CRITICAL de runtime
El || true en el escaneo: el "distinto de cero ante cualquier advisory" de osv-scanner mataría el paso antes de que corra mi filtro. Quiero sus hallazgos, no su veredicto.
pnpm ls --prod da el árbol de dependencias de runtime, el alcance de --prod, reconstruido. Lo que no esté ahí es de solo dev/build y no puede bloquear.
La compuerta en sí (archivo completo en la carpeta de código):
// 03-preserve-semantics/audit-prod-gate.mjs (núcleo)
const HIGH_CVSS = 7.0; // HIGH empieza en 7.0, CRITICAL en 9.0: los dos bloquean.
const prodNames = new Set();
(function walk(deps) {
// aplana pnpm ls --prod
for (const [name, info] of Object.entries(deps ?? {})) {
prodNames.add(name);
walk(info.dependencies);
}
})(prodTree.dependencies);
const blocking = [];
for (const pkg of osv.results.flatMap((r) => r.packages)) {
if (!prodNames.has(pkg.package.name)) continue; // solo-dev → salta
for (const g of pkg.groups ?? []) {
// uno por advisory
if (Number.parseFloat(g.max_severity) >= HIGH_CVSS) {
// CVSS de OSV
blocking.push(`${pkg.package.name} @ ${pkg.package.version} ${g.ids}`);
}
}
}
process.exit(blocking.length ? 1 : 0);
groups[].max_severity es el puntaje CVSS que osv-scanner ya calculó: no rederivo la severidad, solo le pongo el umbral. La pertenencia a prod por nombre es conservadora a propósito: si un paquete aparece en cualquier parte del árbol de runtime, trato sus advisories como enviables. Mejor bloquear uno real que dejarlo pasar por un tecnicismo.
Verificar que hace lo correcto
El valor de una compuerta de seguridad está por completo en su comportamiento en los bordes, así que prueba ambas direcciones contra el lockfile real. La mía encontró 8 advisories:
Total 2 packages affected by 8 known vulnerabilities (0 Critical, 3 High, 3 Medium, 2 Low)
undici 7.26.0 3× HIGH (CVSS 7.4-7.5) ← solo-dev
protobufjs 7.6.2 1× MEDIUM (CVSS 5.3) ← runtime
De manera ingenua, "3 vulnerabilidades HIGH" suena a que la compuerta debería gritar. No debería, y aquí está la prueba de que los dos filtros funcionan:
# 04-verify/split.sh
$ pnpm why undici --prod
(empty) # undici NO está en el árbol de runtime → excluido
$ pnpm why protobufjs --prod
protobufjs 7.6.2 └─┬ amazon-chime-sdk-js # runtime, pero MEDIUM < HIGH → debajo de la compuerta
Así que la compuerta pasa, correctamente. Los tres advisories HIGH están en tooling de build que nunca se envía; el único advisory de runtime está debajo del piso de severidad. Para probar que no solo está por encima, baja el umbral a 5.0 en local: entonces marca el protobufjs de runtime y falla. La compuerta discrimina; no está atorada en abierto.
Esta es la prueba que importa. No "¿corre el escáner?" sino "¿bloquea lo que debe y deja pasar lo que debe?", sobre tu árbol real.
Trampas
- Descarta el código de salida del escáner, quédate con sus hallazgos.
osv-scannersale distinto de cero ante cualquier advisory. Si no le pones|| true(o configuras lo suyo), falla el paso antes de que corra tu filtro de prod/severidad, y vuelves a bloquear por ruido del árbol de dev. - Fija la versión del escáner.
releases/latestpuede cambiar la forma de la salida o el comportamiento de falla por default entre corridas, justo la inestabilidad de la que estás tratando de escapar. Fijav2.4.0(o la que sea), sube de versión a propósito. - Haz la descarga consciente de la arquitectura. Los runners ARM autoalojados y el
ubuntu-latestx86 necesitan binarios distintos.case "$(uname -m)" in aarch64|arm64) …le gana a un_amd64fijo a mano. - Decide qué significa un CVSS ausente. Algunos advisories no traen puntaje CVSS.
parseFloat("")esNaN, así que no cruza el umbral y no bloquea. Es una decisión deliberada (empatar con el viejo comportamiento etiquetado por severidad), pero tómala a propósito, y loguea el conteo. - Que
pnpm auditparezca local engaña a todos. Es una llamada de red. Trata cualquier "audit" que le llame a un registry como una dependencia de disponibilidad de tu pipeline, no como un chequeo local.
Lo que salté a propósito
- Auditar dependencias de dev como compuerta bloqueante. No se envían. Las reporto (
osv-scannerimprime las 8) pero solo bloquea un HIGH/CRITICAL de runtime, igual que el viejo--prod. - Subir versiones automático. Renovate/Dependabot es otro ciclo aparte. El trabajo de esta compuerta es detener un deploy malo, no abrir PRs.
La forma de todo esto
ANTES:
árbol de dependencias ─POST─► endpoint de npm audit ──► veredicto ──► compuerta
│
(retirado: 410) ──► compuerta en rojo para siempre
DESPUÉS:
pnpm-lock.yaml ──► osv-scanner ──► hallazgos JSON ─┐
pnpm ls --prod ──► conjunto de paquetes de runtime ┴─► filtro (prod ∧ CVSS ≥ 7) ──► compuerta
Un principio, dos mitades. Una compuerta de seguridad debe fallar por vulnerabilidades, no por el clima, así que escanea un artefacto que tú controlas contra una base de datos, no el endpoint de un proveedor que te puede dar 410 por debajo. Y cuando cambies de herramienta, migra la semántica, no solo el comando: los flags viejos codificaban decisiones reales (solo runtime, solo HIGH/CRITICAL), y un reemplazo que los suelta en silencio no es más minucioso, es una compuerta distinta y más ruidosa que la gente va a aprender a ignorar.
Comments
No comments yet. Start the discussion.