// scripts/release-lib.test.ts // PB-26-211: release tool hardening — security tests for the argv-based runner. import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildCommand, safeEnv, cwsUploadEnv, amoSignEnv, resolveBin, preflight, packageChromeZip, collectDirectory, uploadChrome, signFirefox, CWS_SECRET_NAMES, AMO_SECRET_NAMES, type CommandSpec, type Runner, } from "./release-lib"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES]; const HELD_ENV: Record = {}; function setEnv(name: string, val: string) { HELD_ENV[name] = process.env[name]; process.env[name] = val; } function restoreEnv() { for (const [name, val] of Object.entries(HELD_ENV)) { if (val === undefined) delete process.env[name]; else process.env[name] = val; } for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key]; } afterEach(restoreEnv); // ─── 1. No shell, no npx ─────────────────────────────────────────────────── describe("PB-26-211: argv-based runner — no shell, no npx", () => { it("buildCommand returns a CommandSpec with array args, never a string", () => { const spec = buildCommand("/bin/echo", ["hello", "world"]); expect(Array.isArray(spec.args)).toBe(true); expect(spec.args).toEqual(["hello", "world"]); expect(typeof spec.executable).toBe("string"); }); it("no release/CI script source contains execSync or npx", () => { const scripts = [ "scripts/release.ts", "scripts/release-firefox.ts", "scripts/release-lib.ts", "scripts/ci.ts", ]; for (const p of scripts) { const src = readFileSync(join(ROOT, p), "utf8"); expect(src, `${p} must not use execSync`).not.toContain("execSync"); expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/); } }); it("no release script builds commands via string concatenation (.join)", () => { const scripts = [ "scripts/release.ts", "scripts/release-firefox.ts", "scripts/release-lib.ts", ]; for (const p of scripts) { const src = readFileSync(join(ROOT, p), "utf8"); expect(src, `${p} must not join command strings`).not.toContain("].join("); } }); it("fake runner captures argv — no captured spec uses a shell or npx", () => { const captured: CommandSpec[] = []; const fake: Runner = (exec, args, opts) => { captured.push(buildCommand(exec, args, opts)); }; for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value"); packageChromeZip("9.9.9"); uploadChrome("9.9.9", fake); signFirefox("/tmp/fake-source.zip", fake); // packageChromeZip no longer shells out at all (PB-26-611), so only the // upload and the AMO sign remain. expect(captured.length).toBeGreaterThanOrEqual(2); for (const spec of captured) { expect(spec.executable).not.toBe("npx"); expect(spec.args).not.toContain("npx"); expect(Array.isArray(spec.args)).toBe(true); } }); }); // ─── 2. Child environment isolation ──────────────────────────────────────── describe("PB-26-211: CI/build child environments contain no store credentials", () => { it("safeEnv strips all CWS and AMO secret names", () => { for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value"); const env = safeEnv(); for (const name of ALL_SECRET_NAMES) { expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name); } }); it("safeEnv preserves non-secret environment variables", () => { setEnv("PATH", "/usr/bin:/bin"); setEnv("HOME", "/tmp/test-home"); const env = safeEnv(); expect(env.PATH).toBe("/usr/bin:/bin"); expect(env.HOME).toBe("/tmp/test-home"); }); it("safeEnv strips build-behavior overrides from release children", () => { for (const [name, value] of Object.entries({ BUILD_TARGET: "firefox", PEACH_E2E_PASSKEYS: "1", PEACH_DEBUG: "1", DEBUG: "vite:*", NODE_ENV: "development", MODE: "development", VITE_MODE: "development", CI: "true", })) setEnv(name, value); const env = safeEnv(); for (const name of [ "BUILD_TARGET", "PEACH_E2E_PASSKEYS", "PEACH_DEBUG", "DEBUG", "NODE_ENV", "MODE", "VITE_MODE", "CI", ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name); }); it("safeEnv strips the release browser gate from release children", () => { setEnv("PEACH_RELEASE_BROWSER_GATE", "0"); setEnv("BUILD_TARGET", "firefox"); const env = safeEnv(); expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE"); expect(env).not.toHaveProperty("BUILD_TARGET"); }); }); // ─── 3. Credential disjointness ──────────────────────────────────────────── describe("PB-26-211: Chrome and AMO credentials are disjoint", () => { it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => { for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name); const env = cwsUploadEnv(); // CWS child env names are present expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID"); expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET"); expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN"); expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID"); // AMO credentials are absent expect(env).not.toHaveProperty("AMO_JWT_ISSUER"); expect(env).not.toHaveProperty("AMO_JWT_SECRET"); expect(env).not.toHaveProperty("WEB_EXT_API_KEY"); expect(env).not.toHaveProperty("WEB_EXT_API_SECRET"); }); it("amoSignEnv contains AMO credentials but no CWS credentials", () => { for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name); const env = amoSignEnv(); // AMO child env names are present expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER"); expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET"); // CWS credentials are absent expect(env).not.toHaveProperty("CWS_CLIENT_ID"); expect(env).not.toHaveProperty("CWS_CLIENT_SECRET"); expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN"); expect(env).not.toHaveProperty("CWS_EXTENSION_ID"); expect(env).not.toHaveProperty("PUBLISHER_ID"); expect(env).not.toHaveProperty("CLIENT_ID"); expect(env).not.toHaveProperty("CLIENT_SECRET"); expect(env).not.toHaveProperty("REFRESH_TOKEN"); }); }); // ─── 4. Shell metacharacter path safety ──────────────────────────────────── describe("PB-26-211: paths with shell metacharacters remain one argv element", () => { it("buildCommand treats a metacharacter path as a single arg", () => { const evilPath = "/tmp/$(whoami); echo pwned"; const spec = buildCommand("/bin/echo", [evilPath]); expect(spec.args).toHaveLength(1); expect(spec.args[0]).toBe(evilPath); }); it("spawnSync with argv arrays does not execute shell injections", () => { const { spawnSync } = require("node:child_process"); // If a shell were used, $(whoami) would be replaced with the username. const evilPath = "/tmp/$(whoami)"; const result = spawnSync("/bin/echo", [evilPath], { stdout: "pipe", stderr: "pipe", shell: false, }); const out = result.stdout?.toString().trim(); expect(out).toBe(evilPath); // If command substitution ran, the output would contain a username, not $(). expect(out).toContain("$(whoami)"); }); }); // ─── 5. Missing binary failure ───────────────────────────────────────────── describe("PB-26-211: missing local binary fails before any network access", () => { it("resolveBin throws when the tool is not in node_modules/.bin", () => { expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow( /not found/, ); }); it("resolveBin throws before returning a path, preventing network calls", () => { let threw = false; try { resolveBin("another-missing-tool-67890"); } catch { threw = true; } expect(threw).toBe(true); }); }); // ─── 6. Lockfile / version preflight ─────────────────────────────────────── describe("PB-26-211: preflight rejects non-exact release-tool versions", () => { it("preflight passes when release-critical deps are exact", () => { // After pinning, all release-critical deps have exact versions. // This will fail RED until versions are pinned. expect(() => preflight()).not.toThrow(); }); it("package.json has exact versions for release-critical dev deps", () => { const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); const devDeps = pkg.devDependencies ?? {}; const critical = [ "chrome-webstore-upload-cli", "web-ext", "typescript", "@playwright/test", ]; for (const dep of critical) { const v = devDeps[dep]; expect(v, `${dep} must be in devDependencies`).toBeDefined(); expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/); } }); }); // ─── 7. PB-26-611 reproducible Chrome archive ────────────────────────────── describe.skipIf(!existsSync(join(ROOT, "dist-chrome", "manifest.json")))( "PB-26-611: Chrome extension archive is reproducible", () => { afterEach(() => { const zipPath = join(ROOT, "artifacts", "peach-chrome-9.9.9.zip"); if (existsSync(zipPath)) rmSync(zipPath); }); it("two archives built from the same dist are byte-identical", () => { const first = readFileSync(packageChromeZip("9.9.9")); const second = readFileSync(packageChromeZip("9.9.9")); expect(first.length).toBeGreaterThan(0); expect(createHash("sha256").update(first).digest("hex")).toBe( createHash("sha256").update(second).digest("hex"), ); }); it("preserves paths relative to the distribution root", () => { const fixture = join(tmpdir(), `peach-release-paths-${process.pid}`); rmSync(fixture, { recursive: true, force: true }); mkdirSync(join(fixture, "assets"), { recursive: true }); mkdirSync(join(fixture, "icons"), { recursive: true }); writeFileSync(join(fixture, "manifest.json"), "{}"); writeFileSync(join(fixture, "assets", "entry.js"), "export {};"); writeFileSync(join(fixture, "icons", "16.png"), "icon"); try { expect(collectDirectory(fixture).map((entry) => entry.path)).toEqual([ "assets/entry.js", "icons/16.png", "manifest.json", ]); } finally { rmSync(fixture, { recursive: true, force: true }); } }); it("embeds no source maps, .DS_Store or __MACOSX entries", () => { const zip = packageChromeZip("9.9.9"); const bytes = readFileSync(zip); const text = bytes.toString("latin1"); expect(text).not.toContain(".map"); expect(text).not.toContain(".DS_Store"); expect(text).not.toContain("__MACOSX"); }); it("release-lib no longer shells out to the system zip binary", () => { const source = readFileSync(join(ROOT, "scripts", "release-lib.ts"), "utf8"); expect(source).not.toContain('"zip"'); expect(source).toContain("createDeterministicZip"); }); }, );