Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .audit-allowlist.json
Original file line number Diff line number Diff line change
@@ -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." }
]
}
12 changes: 8 additions & 4 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/release-bundles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<plat>-<arch>.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-<plat>-<arch>.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.

Expand Down
Binary file added launcher/extract.mjs
Binary file not shown.
239 changes: 239 additions & 0 deletions launcher/extract.test.ts
Original file line number Diff line number Diff line change
@@ -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<EntryOpts & { data?: Buffer }>): 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<EntryOpts & { data?: Buffer }>): 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<EntryOpts & { data?: Buffer }>): string {
const p = forDest + ".tar.gz";
writeFileSync(p, makeTarGz(entries));
return p;
}
});
Loading
Loading