DEV Community

Cover image for El día que `pnpm audit` tumbó todos los deploys (y qué correr en su lugar)

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, nose 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 (at
https://registry.npmjs.org/-/npm/v1/security/audits/quick) responded with
410: {"error":"This endpoint is being retired. Use the bulk advisory
endpoint instead."}
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo elchesco / blog-pnpm-audit-osv-scanner-code

Code companion — replacing a dead `pnpm audit` gate with osv-scanner

Code companion — replacing a dead pnpm audit gate with osv-scanner

Reading order

  1. 00-dead-gate/ — what broke. Note it's a network call, not a local scan; when npm retired the endpoint it failed on infrastructure and blocked every deploy.
  2. 02-osv-scanner-step/ — scan pnpm-lock.yaml against the OSV DB. No endpoint to retire. But osv-scanner is blunt: it fails on any advisory across the whole lockfile, so we discard its exit code (|| true) and filter ourselves.
  3. 03-preserve-semantics/audit-prod-gate.mjs — re-impose the old flags --prod (runtime tree from pnpm ls --prod) and --audit-level high (CVSS ≥ 7 from the OSV report). Dev/build deps report but never block.
  4. 04-verify/split.sh — the test that matters: does it block the thing it should and pass the thing it should, on your real tree?

The two principles

  1. A gate that fails on infrastructure is worse than no gate. It blocks good work while…

TL;DR

pnpm audit --prod --audit-level high 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:

  1. Una compuerta que falla por infraestructura es peor que no tener compuerta..
  2. 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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode

groups[].max_severity es el puntaje CVSS que osv-scanner ya calculó: no re derivo 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-scanner sale 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/latest puede 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. Fija v2.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-latest x86 necesitan binarios distintos. case "$(uname -m)" in aarch64|arm64) … le gana a un _amd64 fijo a mano.
  • Decide qué significa un CVSS ausente. Algunos advisories no traen puntaje CVSS. parseFloat("") es NaN, 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 audit parezca 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-scanner imprime 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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)