diff --git a/.audit-allowlist.json b/.audit-allowlist.json new file mode 100644 index 0000000..3a60fa2 --- /dev/null +++ b/.audit-allowlist.json @@ -0,0 +1,35 @@ +{ + "$comment": [ + "Baseline del gate de auditoría M1 (enforced vía scripts/audit-gate.mjs, alias `npm run audit:gate`).", + "El gate corre `npm audit --omit=dev --audit-level=high` y BLOQUEA cualquier CVE high+ NUEVO en deps de", + "PRODUCCIÓN (lo que viaja en el bundle standalone). Las advisories de acá son el backlog CONOCIDO al", + "2026-06-22: aceptadas temporalmente para no frenar releases mientras se saldan. Cada upgrade que cierre", + "una debe ELIMINAR su entrada (el gate avisa cuáles quedaron obsoletas). Burn-down pendiente: subir Next a", + "una versión sin estos CVEs (upgrade mayor, breaking), reemplazar/quitar xlsx (sin fix), y `npm audit fix`", + "de las no-breaking (form-data, lodash, ws, flatted, minimatch, picomatch)." + ], + "created": "2026-06-22", + "allow": [ + { "id": "GHSA-h25m-26qc-wcjf", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "DoS deserialización RSC. Studio corre en localhost single-user: superficie de ataque remoto baja." }, + { "id": "GHSA-q4gf-8mx6-v5v3", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "DoS Server Components." }, + { "id": "GHSA-8h8q-6873-q5fj", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "DoS Server Components." }, + { "id": "GHSA-26hh-7cqf-hhc6", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "Middleware/proxy bypass (segment-prefetch)." }, + { "id": "GHSA-mg66-mrh9-m8jx", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "DoS por agotamiento de conexiones (Cache Components)." }, + { "id": "GHSA-c4j6-fc7j-m34r", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "SSRF vía WebSocket upgrades." }, + { "id": "GHSA-492v-c6pp-mqqv", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "Middleware/proxy bypass (route params)." }, + { "id": "GHSA-267c-6grr-h53f", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "Middleware/proxy bypass (segment-prefetch)." }, + { "id": "GHSA-36qx-fr4f-26g5", "pkg": "next", "severity": "high", "fix": "upgrade-mayor (breaking)", "note": "Middleware/proxy bypass (Pages Router i18n)." }, + { "id": "GHSA-fjxv-7rqg-78g4", "pkg": "form-data", "severity": "critical", "fix": "npm audit fix (no-breaking)", "note": "Random inseguro para boundary. Transitiva. Saldar en el burn-down." }, + { "id": "GHSA-hmw2-7cc7-3qxx", "pkg": "form-data", "severity": "high", "fix": "npm audit fix (no-breaking)", "note": "CRLF injection. Transitiva. Saldar en el burn-down." }, + { "id": "GHSA-r5fr-rjxr-66jc", "pkg": "lodash / lodash-es", "severity": "high", "fix": "npm audit fix (no-breaking)", "note": "Code injection vía _.template. Saldar en el burn-down." }, + { "id": "GHSA-3ppc-4f35-3m26", "pkg": "minimatch", "severity": "high", "fix": "npm audit fix", "note": "ReDoS. Transitiva. Saldar en el burn-down." }, + { "id": "GHSA-7r86-cg39-jmmj", "pkg": "minimatch", "severity": "high", "fix": "npm audit fix", "note": "ReDoS matchOne(). Transitiva." }, + { "id": "GHSA-23c5-xmqv-rm74", "pkg": "minimatch", "severity": "high", "fix": "npm audit fix", "note": "ReDoS extglobs anidados. Transitiva." }, + { "id": "GHSA-c2c7-rcm5-vvqj", "pkg": "picomatch", "severity": "high", "fix": "npm audit fix", "note": "ReDoS extglob quantifiers. Transitiva." }, + { "id": "GHSA-96hv-2xvq-fx4p", "pkg": "ws", "severity": "high", "fix": "npm audit fix (no-breaking)", "note": "Memory exhaustion DoS. Transitiva (miniflare/dev)." }, + { "id": "GHSA-25h7-pfq9-p65f", "pkg": "flatted", "severity": "high", "fix": "npm audit fix (no-breaking)", "note": "DoS recursión en parse(). Transitiva." }, + { "id": "GHSA-rf6f-7fwh-wjgh", "pkg": "flatted", "severity": "high", "fix": "npm audit fix (no-breaking)", "note": "Prototype pollution vía parse(). Transitiva." }, + { "id": "GHSA-4r6h-8v6p-xvw6", "pkg": "xlsx", "severity": "high", "fix": "SIN FIX", "note": "Prototype pollution SheetJS. No hay fix en npm: evaluar reemplazo o dejar de exponer import/export xlsx." }, + { "id": "GHSA-5pgg-2g8v-p4x9", "pkg": "xlsx", "severity": "high", "fix": "SIN FIX", "note": "ReDoS SheetJS. No hay fix en npm: evaluar reemplazo." } + ] +} diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ee3c62f..0b1c249 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -18,12 +18,16 @@ jobs: with: node-version: 20 + # npm ci (no `npm install`): instala el árbol EXACTO del lockfile, así el + # gate de auditoría ve el mismo árbol que el release (determinismo M1). - name: Install dependencies - run: npm install + run: npm ci - # Auditoría de deps (M1). No bloquea el check: deja el registro visible en el log. - - name: Audit dependencies (high+) - run: npm audit --audit-level=high || true + # Auditoría de deps (M1). Gate DURO enforced: bloquea CVE high+ NUEVOS en deps de + # producción. El backlog conocido vive en .audit-allowlist.json con motivo + fecha; + # el gate falla ante cualquier high+ fuera de esa lista. Ver scripts/audit-gate.mjs. + - name: Audit dependencies (gate high+, prod) + run: npm run audit:gate - name: Run Check run: npm run staged diff --git a/.github/workflows/release-bundles.yml b/.github/workflows/release-bundles.yml index 551385b..31a53f2 100644 --- a/.github/workflows/release-bundles.yml +++ b/.github/workflows/release-bundles.yml @@ -46,9 +46,12 @@ jobs: - name: Install deps (sin prepare) run: npm ci --ignore-scripts --no-audit --no-fund - # Auditoría de deps (M1). No bloquea el build: deja el registro visible en el log. - - name: Audit dependencies (high+) - run: npm audit --audit-level=high || true + # Auditoría de deps (M1). Gate DURO enforced: bloquea CVE high+ NUEVOS en deps de + # producción (lo que viaja en el bundle). El backlog conocido vive en + # .audit-allowlist.json con motivo + fecha; el gate falla ante cualquier high+ + # fuera de esa lista. Ver scripts/audit-gate.mjs. + - name: Audit dependencies (gate high+, prod) + run: npm run audit:gate # Sin FORK_LOCAL ⇒ next.config usa output:'standalone'. - name: Build standalone diff --git a/README.md b/README.md index 72f5e59..fd77a2b 100644 --- a/README.md +++ b/README.md @@ -192,17 +192,28 @@ La vía rápida tiene dos piezas: > necesita las `devDependencies` y eso rompería un install desde el registry). El launcher la > reemplaza como paquete npm. El flujo `npx github:` sigue andando para correr desde el código. -Para cortar una versión `X.Y.Z` (deben coincidir el tag, `launcher/package.json` y la app): +El publish **lo hace el CI, no a mano**: el workflow [`release-bundles.yml`](./.github/workflows/release-bundles.yml) +buildea los bundles por plataforma, los sube como assets del Release **con provenance** (npm +provenance + GitHub artifact attestations) y publica el launcher a npm con `npm publish --provenance` +vía OIDC (`id-token: write`, sin tokens de larga vida en tu máquina). Por eso cortar una versión es +solo un push de tag: ```bash -# 1) bump de versión en ambos package.json a X.Y.Z (app raíz + launcher/) -# 2) commit + tag + push del tag → dispara el CI que buildea los bundles por plataforma +# 1) bump de versión en ambos package.json a X.Y.Z (app raíz + launcher/). +# El CI verifica que launcher/package.json coincida con el tag y aborta el publish si no. +# 2) commit + push del bump +git commit -am "release vX.Y.Z" && git push +# 3) tag + push del tag → dispara el CI, que: +# - buildea quick-outerbase--.tar.gz y crea el Release vX.Y.Z (con attestations) +# - publica el launcher a npm con provenance git tag vX.Y.Z && git push origin vX.Y.Z -# (el workflow crea el Release vX.Y.Z y sube quick-outerbase--.tar.gz) -# 3) publicar el launcher a npm (necesita cuenta en npmjs.com + npm login) -cd launcher && npm publish --access public ``` +> **No publiques el launcher a mano** (`npm publish` desde `launcher/`): salteás la provenance y las +> attestations, que son justo la garantía de que el paquete en npm salió de este repo vía CI. El +> único camino soportado es el push del tag. El publish necesita el secret `NPM_TOKEN` configurado +> en el repo (lo usa el CI, no vos). + Después, `npx quick-outerbase@X.Y.Z --url "..."` baja el bundle del Release vX.Y.Z. El launcher no incluye ningún `.env`, credencial ni base; el `DATABASE_URL` siempre lo provee el usuario. diff --git a/launcher/extract.mjs b/launcher/extract.mjs new file mode 100644 index 0000000..bf30056 Binary files /dev/null and b/launcher/extract.mjs differ diff --git a/launcher/extract.test.ts b/launcher/extract.test.ts new file mode 100644 index 0000000..01b4c34 --- /dev/null +++ b/launcher/extract.test.ts @@ -0,0 +1,239 @@ +/** + * Test de B1: extracción in-process del bundle (gunzip + parser ustar propio), + * el reemplazo del viejo `tar -xzf` que dependía del binario del PATH. + * + * Arma tarballs en memoria (sin depender del `tar` del sistema) y comprueba que: + * - parsea y extrae archivos regulares y directorios (round-trip de contenido) + * - resuelve nombres largos vía GNU longname ('L') y vía prefix ustar + * - RECHAZA path traversal (../) — defensa contra tarballs maliciosos + * - RECHAZA symlinks que escapan del directorio destino + * - RECHAZA un header corrupto / no-tar + * - RECHAZA un .gz inválido + */ +import { gzipSync } from "node:zlib"; +import { + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, + lstatSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { parseTar, extractTarGz, extractEntries } from "./extract.mjs"; + +const BLOCK = 512; +const isWin = process.platform === "win32"; + +// --- Mini-builder de tar ustar en memoria (para fixtures sin `tar` del sistema) --- +function octalField(value: number, len: number): string { + return value.toString(8).padStart(len - 1, "0") + "\0"; +} + +interface EntryOpts { + name: string; + size?: number; + type?: string; + mode?: number; + linkname?: string; + prefix?: string; +} + +function makeHeader(opts: EntryOpts): Buffer { + const { name, size = 0, type = "0", mode = 0o644, linkname = "", prefix = "" } = opts; + const h = Buffer.alloc(BLOCK); + h.write(name, 0, 100, "utf8"); + h.write(octalField(mode, 8), 100, "ascii"); + h.write(octalField(0, 8), 108, "ascii"); // uid + h.write(octalField(0, 8), 116, "ascii"); // gid + h.write(octalField(size, 12), 124, "ascii"); + h.write(octalField(0, 12), 136, "ascii"); // mtime + h.write(type, 156, "ascii"); + if (linkname) h.write(linkname, 157, 100, "utf8"); + h.write("ustar\0", 257, "ascii"); + h.write("00", 263, "ascii"); + if (prefix) h.write(prefix, 345, 155, "utf8"); + // checksum: campo (148-155) como espacios, sumar, y escribir "NNNNNN\0 " + for (let i = 148; i < 156; i++) h[i] = 0x20; + let sum = 0; + for (let i = 0; i < BLOCK; i++) sum += h[i]; + h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii"); + return h; +} + +function makeData(data: Buffer): Buffer { + const padded = Math.ceil(data.length / BLOCK) * BLOCK; + const b = Buffer.alloc(padded); + data.copy(b); + return b; +} + +function makeTar(entries: Array): Buffer { + const blocks: Buffer[] = []; + for (const e of entries) { + const size = e.data ? e.data.length : e.size ?? 0; + blocks.push(makeHeader({ ...e, size })); + if (e.data) blocks.push(makeData(e.data)); + } + blocks.push(Buffer.alloc(BLOCK * 2)); // dos bloques cero = fin + return Buffer.concat(blocks); +} + +function makeTarGz(entries: Array): Buffer { + return gzipSync(makeTar(entries)); +} + +describe("B1 — extracción in-process del bundle", () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), "qob-extract-")); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("parsea archivos regulares y directorios", () => { + const entries = parseTar( + makeTar([ + { name: "app/", type: "5" }, + { name: "app/server.js", data: Buffer.from("console.log(1)") }, + ]) + ); + expect(entries).toHaveLength(2); + expect(entries[0].type).toBe("5"); + expect(entries[1].name).toBe("app/server.js"); + expect(entries[1].data?.toString()).toBe("console.log(1)"); + }); + + it("extrae a disco con el contenido intacto (round-trip)", () => { + const dest = path.join(dir, "rt"); + const serverBytes = Buffer.from("// server\nmodule.exports = 42;\n"); + const written = extractTarGz( + makeTarGzPath(dest, [ + { name: "./", type: "5" }, + { name: "./server.js", data: serverBytes }, + { name: "./.next/", type: "5" }, + { name: "./.next/chunk.js", data: Buffer.from("var x = 1;") }, + ]), + dest + ); + expect(written).toBe(2); // 2 archivos (los dirs no cuentan) + expect(readFileSync(path.join(dest, "server.js"))).toEqual(serverBytes); + expect(readFileSync(path.join(dest, ".next/chunk.js")).toString()).toBe("var x = 1;"); + }); + + it("crea directorios padre faltantes aunque no haya entry de dir", () => { + const dest = path.join(dir, "nodir"); + extractTarGz( + makeTarGzPath(dest, [ + { name: "deep/nested/path/file.txt", data: Buffer.from("hola") }, + ]), + dest + ); + expect(readFileSync(path.join(dest, "deep/nested/path/file.txt")).toString()).toBe("hola"); + }); + + it("resuelve nombres largos vía GNU longname ('L')", () => { + const longPath = "node_modules/" + "a".repeat(120) + "/index.js"; + const entries = parseTar( + makeTar([ + { name: "././@LongLink", type: "L", data: Buffer.from(longPath + "\0") }, + { name: "node_modules/aaaa-truncado/index.js", data: Buffer.from("X") }, + ]) + ); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe(longPath); + }); + + it("resuelve nombres largos vía prefix ustar", () => { + const entries = parseTar( + makeTar([{ name: "index.js", prefix: "a/very/long/prefix/dir", data: Buffer.from("Y") }]) + ); + expect(entries[0].name).toBe("a/very/long/prefix/dir/index.js"); + }); + + it("RECHAZA path traversal con '..' (no escribe fuera del destino)", () => { + const dest = path.join(dir, "trav"); + expect(() => + extractEntries( + [{ name: "../evil.txt", type: "0", mode: 0o644, size: 4, linkname: "", data: Buffer.from("evil") }], + dest + ) + ).toThrow(/traversal/i); + expect(existsSync(path.join(dir, "evil.txt"))).toBe(false); + }); + + it("RECHAZA un nombre con path absoluto que escapa del destino", () => { + const dest = path.join(dir, "abs"); + const outside = isWin ? "C:/Windows/evil.txt" : "/tmp/qob-evil-absolute.txt"; + expect(() => + extractEntries( + [{ name: outside, type: "0", mode: 0o644, size: 1, linkname: "", data: Buffer.from("x") }], + dest + ) + ).toThrow(/traversal/i); + }); + + it("RECHAZA un symlink que apunta fuera del destino", () => { + const dest = path.join(dir, "sym"); + expect(() => + extractEntries( + [{ name: "link", type: "2", mode: 0o777, size: 0, linkname: "../../etc/passwd", data: null }], + dest + ) + ).toThrow(/escapa/i); + }); + + it("acepta un symlink relativo que queda dentro del destino", () => { + const dest = path.join(dir, "sym-ok"); + // link en sub/ que apunta a ../target.txt → dest/target.txt (dentro). No tira. + expect(() => + extractEntries( + [ + { name: "target.txt", type: "0", mode: 0o644, size: 2, linkname: "", data: Buffer.from("ok") }, + { name: "sub/link", type: "2", mode: 0o777, size: 0, linkname: "../target.txt", data: null }, + ], + dest + ) + ).not.toThrow(); + // En Windows symlinkSync puede no tener permisos: ahí solo exigimos que no tire. + if (!isWin) { + expect(lstatSync(path.join(dest, "sub/link")).isSymbolicLink()).toBe(true); + } + }); + + it("RECHAZA un PAX header con size negativo (bundle malicioso)", () => { + // Record PAX "11 size=-4\n" (11 chars exactos) → size negativo inyectado. + expect(() => + parseTar( + makeTar([ + { name: "PaxHeader", type: "x", data: Buffer.from("11 size=-4\n") }, + { name: "file.txt", data: Buffer.from("data") }, + ]) + ) + ).toThrow(/PAX inválido|negativo/i); + }); + + it("RECHAZA un header corrupto / que no es un tar", () => { + const garbage = Buffer.alloc(BLOCK, 0xff); // no es bloque cero ni header válido + expect(() => parseTar(garbage)).toThrow(/checksum|corrupto|tar/i); + }); + + it("RECHAZA un .gz inválido", () => { + const dest = path.join(dir, "badgz"); + const p = path.join(dir, "bad.tar.gz"); + writeFileSync(p, Buffer.from("esto no es gzip")); + expect(() => extractTarGz(p, dest)).toThrow(/gzip/i); + }); + + // Helper: arma un .tar.gz en disco y devuelve su path. + function makeTarGzPath(forDest: string, entries: Array): string { + const p = forDest + ".tar.gz"; + writeFileSync(p, makeTarGz(entries)); + return p; + } +}); diff --git a/launcher/launcher.mjs b/launcher/launcher.mjs index 0fba52e..6344156 100644 --- a/launcher/launcher.mjs +++ b/launcher/launcher.mjs @@ -20,6 +20,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { loadExpected, verifyBundleChecksum } from "./checksum.mjs"; +import { extractTarGz } from "./extract.mjs"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -144,8 +145,6 @@ const serverJs = path.join(bundleDir, "server.js"); async function ensureBundle() { if (existsSync(serverJs)) return; // ya cacheado mkdirSync(bundleDir, { recursive: true }); - // El .tgz va ADENTRO del dir destino y extraemos con cwd + basename: así - // tar nunca recibe una ruta con ':' (GNU tar la tomaría como host remoto). const innerTgz = path.join(bundleDir, "_bundle.tar.gz"); const localOverride = process.env.QUICK_OUTERBASE_BUNDLE; @@ -175,7 +174,18 @@ async function ensureBundle() { fail(e.message); } } - extractInDir(bundleDir, "_bundle.tar.gz"); + // Extracción in-process (gunzip + parser ustar propio): sin depender del + // binario `tar` del PATH. Rechaza path traversal y links que escapen del dir. + try { + extractTarGz(innerTgz, bundleDir); + } catch (e) { + // Borramos TODO el bundleDir, no solo el tgz: una extracción a medias deja + // server.js escrito (va al principio del tar) y el próximo run cortocircuitaría + // en existsSync(serverJs) sobre un árbol incompleto → server roto que no se + // autocura. rmSync recursivo también se lleva el innerTgz. + rmSync(bundleDir, { recursive: true, force: true }); + fail("Falló la extracción del bundle: " + (e?.message || e)); + } try { rmSync(innerTgz, { force: true }); } catch { @@ -199,18 +209,6 @@ async function download(fromUrl, toFile) { await pipeline(Readable.fromWeb(res.body), createWriteStream(toFile)); } -function extractInDir(dir, fname) { - // tar disponible en Windows 10+ (tar.exe/bsdtar), macOS y Linux. Corremos con - // cwd=dir y solo el basename → sin rutas con ':' que rompan GNU tar. - const r = spawnSync("tar", ["-xzf", fname], { cwd: dir, stdio: "inherit" }); - if (r.error || r.status !== 0) { - fail( - "Falló la extracción con `tar`. Asegurate de tener `tar` en el PATH " + - "(Windows 10+ lo trae como tar.exe)." - ); - } -} - // --- Subset whitelisteado de env para el server (A2: defensa en profundidad) --- // El runtime corre en otro proceso; no tiene por qué heredar TODO process.env (que // puede traer tokens de CI, claves de otros servicios, etc.). Pasamos solo lo que el diff --git a/launcher/package.json b/launcher/package.json index 7777f5d..5c52a97 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -13,6 +13,7 @@ "files": [ "launcher.mjs", "checksum.mjs", + "extract.mjs", "checksums.json", "AVISO_LICENCIA.md", "LICENSE" @@ -27,6 +28,7 @@ "database-gui", "database-gui-cli", "database-client", + "database-client-terminal", "database-manager", "database-management", "sql-client", diff --git a/package.json b/package.json index e0a2f7a..049c3b6 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "prepare": "node bin/prepare-build.mjs", "tsc": "tsc --noEmit --skipLibCheck", "test": "jest", + "audit:gate": "node scripts/audit-gate.mjs", "staged": "npm run typecheck && npm run lint && jest", "typecheck": "tsc --noEmit --skipLibCheck", "format": "prettier --check .", diff --git a/scripts/audit-gate.mjs b/scripts/audit-gate.mjs new file mode 100644 index 0000000..0d04a48 --- /dev/null +++ b/scripts/audit-gate.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// audit-gate.mjs — gate de seguridad M1 (ENFORCED). +// +// Corre `npm audit --omit=dev --json` (solo deps de PRODUCCIÓN: es lo que viaja +// en el bundle standalone y lo que realmente expone al usuario; el tooling de +// dev — eslint/jest/etc — no se publica). Ignora las advisories listadas en +// `.audit-allowlist.json` (backlog conocido, con issue de burn-down) y FALLA si +// aparece CUALQUIER CVE high+ NUEVO. +// +// Filosofía: el gate bloquea REGRESIONES, no el backlog histórico. Cada entrada +// del allowlist lleva motivo + fecha; al saldar el issue de burn-down hay que +// sacarlas (el gate avisa cuáles ya no aplican). +import { execFileSync } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const allowPath = path.join(__dirname, "..", ".audit-allowlist.json"); +const SEVERITIES = new Set(["high", "critical"]); + +function loadAllow() { + if (!existsSync(allowPath)) return new Set(); + try { + const j = JSON.parse(readFileSync(allowPath, "utf8")); + const ids = (j.allow || []).map((e) => (typeof e === "string" ? e : e && e.id)); + return new Set(ids.filter(Boolean)); + } catch (e) { + console.error("No pude leer .audit-allowlist.json:", e.message); + process.exit(2); + } +} + +function runAudit() { + // npm audit sale con código != 0 cuando encuentra vulns: capturamos el stdout + // igual (trae el JSON), no lo tratamos como error del comando. + const isWin = process.platform === "win32"; + try { + // En Windows `npm` es un .cmd → requiere shell. Los args son flags fijos + // (sin input externo), así que el shell no agrega superficie de inyección. + return execFileSync(isWin ? "npm.cmd" : "npm", ["audit", "--omit=dev", "--json"], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + shell: isWin, + }); + } catch (e) { + // npm sale != 0 cuando HAY vulns y trae el JSON en stdout: eso es válido. + // Pero stdout vacío/whitespace = fallo real del comando → fail-closed. + if (e.stdout && e.stdout.trim()) return e.stdout; + console.error("npm audit no devolvió salida utilizable:", e.message); + process.exit(2); + } +} + +function ghsaFromUrl(url) { + const m = (url || "").match(/GHSA-[0-9a-z-]+/i); + return m ? m[0] : null; +} + +const allow = loadAllow(); + +// Parseo fail-closed: cualquier salida que NO sea un reporte de auditoría real +// (error de registry/red, offline, JSON inválido, sin metadata) → exit 2. Un +// hiccup del registry NO puede desactivar el gate en silencio. +let report; +try { + report = JSON.parse(runAudit()); +} catch (e) { + console.error("npm audit devolvió JSON inválido (fail-closed):", e.message); + process.exit(2); +} +if (!report || typeof report !== "object" || report.error || report.message) { + console.error( + "npm audit reportó un error en vez de un reporte (¿offline/registry caído?). Fail-closed." + ); + if (report && (report.message || report.error)) { + console.error(" npm:", report.message || JSON.stringify(report.error)); + } + process.exit(2); +} +if ( + typeof report.vulnerabilities !== "object" || + report.vulnerabilities === null || + !report.metadata || + typeof report.metadata.dependencies === "undefined" +) { + // `vulnerabilities: {}` (objeto vacío) = escaneó y no encontró nada → OK. + // Ausencia del campo o de metadata = NO se auditó nada → fail-closed. + console.error("npm audit no produjo un reporte completo (sin vulnerabilities/metadata). Fail-closed."); + process.exit(2); +} +const vulns = report.vulnerabilities; + +// Junta las advisories high+ reales. Identidad: GHSA si lo hay (para que el +// allowlist por GHSA siga funcionando); si no, fallback al `source` numérico +// (siempre presente en los via-objects de npm audit) o name|title. NUNCA se +// descarta una advisory por no tener GHSA: sin GHSA no se puede allowlistear, +// así que cae en "sin permitir" y bloquea (fail-closed). +const found = new Map(); // key -> { severity, title, pkg, ghsa|null } +for (const [pkg, v] of Object.entries(vulns)) { + for (const via of v.via || []) { + if (typeof via !== "object") continue; // string = arista transitiva; la hoja trae el advisory + if (!SEVERITIES.has(via.severity)) continue; + const ghsa = ghsaFromUrl(via.url); + const key = + ghsa || + (via.source != null ? `source:${via.source}` : `name:${via.name || pkg}|${via.title || ""}`); + if (!found.has(key)) { + found.set(key, { severity: via.severity, title: via.title, pkg: via.name || pkg, ghsa }); + } + } +} + +const allowedHit = [...found.entries()].filter(([, info]) => info.ghsa && allow.has(info.ghsa)); +const unallowed = [...found.entries()].filter(([, info]) => !(info.ghsa && allow.has(info.ghsa))); + +console.log( + `Audit gate (prod, high+): ${found.size} advisories detectadas — ` + + `${allowedHit.length} en allowlist, ${unallowed.length} sin permitir.` +); + +if (allowedHit.length) { + console.log("\nEn allowlist (backlog conocido, ver issue de burn-down):"); + for (const [, info] of allowedHit) { + console.log(` · ${info.ghsa} [${info.severity}] ${info.pkg}: ${info.title}`); + } +} + +const foundGhsas = new Set([...found.values()].map((i) => i.ghsa).filter(Boolean)); +const stale = [...allow].filter((id) => !foundGhsas.has(id)); +if (stale.length) { + console.log(`\n⚠ Allowlist con entradas ya resueltas (sacalas de .audit-allowlist.json): ${stale.join(", ")}`); +} + +if (unallowed.length) { + console.error("\n✖ CVE high+ NUEVO en deps de producción (no está en el allowlist):"); + for (const [key, info] of unallowed) { + console.error(` · ${info.ghsa || key} [${info.severity}] ${info.pkg}: ${info.title}`); + if (info.ghsa) console.error(` https://github.com/advisories/${info.ghsa}`); + } + console.error( + "\nArreglá la dep (`npm audit fix`), o si es aceptado/sin fix, sumalo a " + + ".audit-allowlist.json con motivo + fecha." + ); + process.exit(1); +} + +console.log("\n✔ Sin CVE high+ nuevos en producción. Gate OK."); +process.exit(0);