release-lib.test.ts

11.0 KB · raw

1// scripts/release-lib.test.ts2// PB-26-211: release tool hardening — security tests for the argv-based runner.3import { createHash } from "node:crypto";4import { existsSync, readFileSync, rmSync } from "node:fs";5import { fileURLToPath } from "node:url";6import { dirname, join, resolve } from "node:path";7import { afterEach, describe, expect, it } from "vitest";89import {10  buildCommand,11  safeEnv,12  cwsUploadEnv,13  amoSignEnv,14  resolveBin,15  preflight,16  packageChromeZip,17  uploadChrome,18  signFirefox,19  CWS_SECRET_NAMES,20  AMO_SECRET_NAMES,21  type CommandSpec,22  type Runner,23} from "./release-lib";2425const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");26const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2728const HELD_ENV: Record<string, string | undefined> = {};2930function setEnv(name: string, val: string) {31  HELD_ENV[name] = process.env[name];32  process.env[name] = val;33}3435function restoreEnv() {36  for (const [name, val] of Object.entries(HELD_ENV)) {37    if (val === undefined) delete process.env[name];38    else process.env[name] = val;39  }40  for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];41}4243afterEach(restoreEnv);4445// ─── 1. No shell, no npx ───────────────────────────────────────────────────4647describe("PB-26-211: argv-based runner — no shell, no npx", () => {48  it("buildCommand returns a CommandSpec with array args, never a string", () => {49    const spec = buildCommand("/bin/echo", ["hello", "world"]);50    expect(Array.isArray(spec.args)).toBe(true);51    expect(spec.args).toEqual(["hello", "world"]);52    expect(typeof spec.executable).toBe("string");53  });5455  it("no release/CI script source contains execSync or npx", () => {56    const scripts = [57      "scripts/release.ts",58      "scripts/release-firefox.ts",59      "scripts/release-lib.ts",60      "scripts/ci.ts",61    ];62    for (const p of scripts) {63      const src = readFileSync(join(ROOT, p), "utf8");64      expect(src, `${p} must not use execSync`).not.toContain("execSync");65      expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);66    }67  });6869  it("no release script builds commands via string concatenation (.join)", () => {70    const scripts = [71      "scripts/release.ts",72      "scripts/release-firefox.ts",73      "scripts/release-lib.ts",74    ];75    for (const p of scripts) {76      const src = readFileSync(join(ROOT, p), "utf8");77      expect(src, `${p} must not join command strings`).not.toContain("].join(");78    }79  });8081  it("fake runner captures argv — no captured spec uses a shell or npx", () => {82    const captured: CommandSpec[] = [];83    const fake: Runner = (exec, args, opts) => {84      captured.push(buildCommand(exec, args, opts));85    };8687    for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");8889    packageChromeZip("9.9.9");90    uploadChrome("9.9.9", fake);91    signFirefox("/tmp/fake-source.zip", fake);9293    // packageChromeZip no longer shells out at all (PB-26-611), so only the94    // upload and the AMO sign remain.95    expect(captured.length).toBeGreaterThanOrEqual(2);96    for (const spec of captured) {97      expect(spec.executable).not.toBe("npx");98      expect(spec.args).not.toContain("npx");99      expect(Array.isArray(spec.args)).toBe(true);100    }101  });102});103104// ─── 2. Child environment isolation ────────────────────────────────────────105106describe("PB-26-211: CI/build child environments contain no store credentials", () => {107  it("safeEnv strips all CWS and AMO secret names", () => {108    for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");109110    const env = safeEnv();111    for (const name of ALL_SECRET_NAMES) {112      expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);113    }114  });115116  it("safeEnv preserves non-secret environment variables", () => {117    setEnv("PATH", "/usr/bin:/bin");118    setEnv("HOME", "/tmp/test-home");119120    const env = safeEnv();121    expect(env.PATH).toBe("/usr/bin:/bin");122    expect(env.HOME).toBe("/tmp/test-home");123  });124125  it("safeEnv strips build-behavior overrides from release children", () => {126    for (const [name, value] of Object.entries({127      BUILD_TARGET: "firefox",128      PEACH_E2E_PASSKEYS: "1",129      PEACH_DEBUG: "1",130      DEBUG: "vite:*",131      NODE_ENV: "development",132      MODE: "development",133      VITE_MODE: "development",134      CI: "true",135    })) setEnv(name, value);136137    const env = safeEnv();138    for (const name of [139      "BUILD_TARGET",140      "PEACH_E2E_PASSKEYS",141      "PEACH_DEBUG",142      "DEBUG",143      "NODE_ENV",144      "MODE",145      "VITE_MODE",146      "CI",147    ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);148  });149150  it("safeEnv strips the release browser gate from release children", () => {151    setEnv("PEACH_RELEASE_BROWSER_GATE", "0");152    setEnv("BUILD_TARGET", "firefox");153154    const env = safeEnv();155    expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE");156    expect(env).not.toHaveProperty("BUILD_TARGET");157  });158});159160// ─── 3. Credential disjointness ────────────────────────────────────────────161162describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {163  it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {164    for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);165166    const env = cwsUploadEnv();167168    // CWS child env names are present169    expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");170    expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");171    expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");172    expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");173174    // AMO credentials are absent175    expect(env).not.toHaveProperty("AMO_JWT_ISSUER");176    expect(env).not.toHaveProperty("AMO_JWT_SECRET");177    expect(env).not.toHaveProperty("WEB_EXT_API_KEY");178    expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");179  });180181  it("amoSignEnv contains AMO credentials but no CWS credentials", () => {182    for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);183184    const env = amoSignEnv();185186    // AMO child env names are present187    expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");188    expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");189190    // CWS credentials are absent191    expect(env).not.toHaveProperty("CWS_CLIENT_ID");192    expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");193    expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");194    expect(env).not.toHaveProperty("CWS_EXTENSION_ID");195    expect(env).not.toHaveProperty("PUBLISHER_ID");196    expect(env).not.toHaveProperty("CLIENT_ID");197    expect(env).not.toHaveProperty("CLIENT_SECRET");198    expect(env).not.toHaveProperty("REFRESH_TOKEN");199  });200});201202// ─── 4. Shell metacharacter path safety ────────────────────────────────────203204describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {205  it("buildCommand treats a metacharacter path as a single arg", () => {206    const evilPath = "/tmp/$(whoami); echo pwned";207    const spec = buildCommand("/bin/echo", [evilPath]);208    expect(spec.args).toHaveLength(1);209    expect(spec.args[0]).toBe(evilPath);210  });211212  it("spawnSync with argv arrays does not execute shell injections", () => {213    const { spawnSync } = require("node:child_process");214    // If a shell were used, $(whoami) would be replaced with the username.215    const evilPath = "/tmp/$(whoami)";216    const result = spawnSync("/bin/echo", [evilPath], {217      stdout: "pipe",218      stderr: "pipe",219      shell: false,220    });221    const out = result.stdout?.toString().trim();222    expect(out).toBe(evilPath);223    // If command substitution ran, the output would contain a username, not $().224    expect(out).toContain("$(whoami)");225  });226});227228// ─── 5. Missing binary failure ─────────────────────────────────────────────229230describe("PB-26-211: missing local binary fails before any network access", () => {231  it("resolveBin throws when the tool is not in node_modules/.bin", () => {232    expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(233      /not found/,234    );235  });236237  it("resolveBin throws before returning a path, preventing network calls", () => {238    let threw = false;239    try {240      resolveBin("another-missing-tool-67890");241    } catch {242      threw = true;243    }244    expect(threw).toBe(true);245  });246});247248// ─── 6. Lockfile / version preflight ───────────────────────────────────────249250describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {251  it("preflight passes when release-critical deps are exact", () => {252    // After pinning, all release-critical deps have exact versions.253    // This will fail RED until versions are pinned.254    expect(() => preflight()).not.toThrow();255  });256257  it("package.json has exact versions for release-critical dev deps", () => {258    const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));259    const devDeps = pkg.devDependencies ?? {};260    const critical = [261      "chrome-webstore-upload-cli",262      "web-ext",263      "typescript",264      "@playwright/test",265    ];266    for (const dep of critical) {267      const v = devDeps[dep];268      expect(v, `${dep} must be in devDependencies`).toBeDefined();269      expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);270    }271  });272});273274// ─── 7. PB-26-611 reproducible Chrome archive ──────────────────────────────275276describe.skipIf(!existsSync(join(ROOT, "dist-chrome", "manifest.json")))(277  "PB-26-611: Chrome extension archive is reproducible",278  () => {279    afterEach(() => {280      const zipPath = join(ROOT, "artifacts", "peach-chrome-9.9.9.zip");281      if (existsSync(zipPath)) rmSync(zipPath);282    });283284    it("two archives built from the same dist are byte-identical", () => {285      const first = readFileSync(packageChromeZip("9.9.9"));286      const second = readFileSync(packageChromeZip("9.9.9"));287288      expect(first.length).toBeGreaterThan(0);289      expect(createHash("sha256").update(first).digest("hex")).toBe(290        createHash("sha256").update(second).digest("hex"),291      );292    });293294    it("embeds no source maps, .DS_Store or __MACOSX entries", () => {295      const zip = packageChromeZip("9.9.9");296      const bytes = readFileSync(zip);297      const text = bytes.toString("latin1");298299      expect(text).not.toContain(".map");300      expect(text).not.toContain(".DS_Store");301      expect(text).not.toContain("__MACOSX");302    });303304    it("release-lib no longer shells out to the system zip binary", () => {305      const source = readFileSync(join(ROOT, "scripts", "release-lib.ts"), "utf8");306      expect(source).not.toContain('"zip"');307      expect(source).toContain("createDeterministicZip");308    });309  },310);