release-lib.test.ts

9.4 KB · raw

1// scripts/release-lib.test.ts2// PB-26-211: release tool hardening — security tests for the argv-based runner.3import { readFileSync } from "node:fs";4import { fileURLToPath } from "node:url";5import { dirname, join, resolve } from "node:path";6import { afterEach, describe, expect, it } from "vitest";78import {9  buildCommand,10  safeEnv,11  cwsUploadEnv,12  amoSignEnv,13  resolveBin,14  preflight,15  packageChromeZip,16  uploadChrome,17  signFirefox,18  CWS_SECRET_NAMES,19  AMO_SECRET_NAMES,20  type CommandSpec,21  type Runner,22} from "./release-lib";2324const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");25const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2627const HELD_ENV: Record<string, string | undefined> = {};2829function setEnv(name: string, val: string) {30  HELD_ENV[name] = process.env[name];31  process.env[name] = val;32}3334function restoreEnv() {35  for (const [name, val] of Object.entries(HELD_ENV)) {36    if (val === undefined) delete process.env[name];37    else process.env[name] = val;38  }39  for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];40}4142afterEach(restoreEnv);4344// ─── 1. No shell, no npx ───────────────────────────────────────────────────4546describe("PB-26-211: argv-based runner — no shell, no npx", () => {47  it("buildCommand returns a CommandSpec with array args, never a string", () => {48    const spec = buildCommand("/bin/echo", ["hello", "world"]);49    expect(Array.isArray(spec.args)).toBe(true);50    expect(spec.args).toEqual(["hello", "world"]);51    expect(typeof spec.executable).toBe("string");52  });5354  it("no release/CI script source contains execSync or npx", () => {55    const scripts = [56      "scripts/release.ts",57      "scripts/release-firefox.ts",58      "scripts/release-lib.ts",59      "scripts/ci.ts",60    ];61    for (const p of scripts) {62      const src = readFileSync(join(ROOT, p), "utf8");63      expect(src, `${p} must not use execSync`).not.toContain("execSync");64      expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);65    }66  });6768  it("no release script builds commands via string concatenation (.join)", () => {69    const scripts = [70      "scripts/release.ts",71      "scripts/release-firefox.ts",72      "scripts/release-lib.ts",73    ];74    for (const p of scripts) {75      const src = readFileSync(join(ROOT, p), "utf8");76      expect(src, `${p} must not join command strings`).not.toContain("].join(");77    }78  });7980  it("fake runner captures argv — no captured spec uses a shell or npx", () => {81    const captured: CommandSpec[] = [];82    const fake: Runner = (exec, args, opts) => {83      captured.push(buildCommand(exec, args, opts));84    };8586    for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");8788    packageChromeZip("9.9.9", fake);89    uploadChrome("9.9.9", fake);90    signFirefox("/tmp/fake-source.zip", fake);9192    expect(captured.length).toBeGreaterThanOrEqual(3);93    for (const spec of captured) {94      expect(spec.executable).not.toBe("npx");95      expect(spec.args).not.toContain("npx");96      expect(Array.isArray(spec.args)).toBe(true);97    }98  });99});100101// ─── 2. Child environment isolation ────────────────────────────────────────102103describe("PB-26-211: CI/build child environments contain no store credentials", () => {104  it("safeEnv strips all CWS and AMO secret names", () => {105    for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");106107    const env = safeEnv();108    for (const name of ALL_SECRET_NAMES) {109      expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);110    }111  });112113  it("safeEnv preserves non-secret environment variables", () => {114    setEnv("PATH", "/usr/bin:/bin");115    setEnv("HOME", "/tmp/test-home");116117    const env = safeEnv();118    expect(env.PATH).toBe("/usr/bin:/bin");119    expect(env.HOME).toBe("/tmp/test-home");120  });121122  it("safeEnv strips build-behavior overrides from release children", () => {123    for (const [name, value] of Object.entries({124      BUILD_TARGET: "firefox",125      PEACH_E2E_PASSKEYS: "1",126      PEACH_DEBUG: "1",127      DEBUG: "vite:*",128      NODE_ENV: "development",129      MODE: "development",130      VITE_MODE: "development",131      CI: "true",132    })) setEnv(name, value);133134    const env = safeEnv();135    for (const name of [136      "BUILD_TARGET",137      "PEACH_E2E_PASSKEYS",138      "PEACH_DEBUG",139      "DEBUG",140      "NODE_ENV",141      "MODE",142      "VITE_MODE",143      "CI",144    ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);145  });146147  it("safeEnv strips the release browser gate from release children", () => {148    setEnv("PEACH_RELEASE_BROWSER_GATE", "0");149    setEnv("BUILD_TARGET", "firefox");150151    const env = safeEnv();152    expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE");153    expect(env).not.toHaveProperty("BUILD_TARGET");154  });155});156157// ─── 3. Credential disjointness ────────────────────────────────────────────158159describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {160  it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {161    for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);162163    const env = cwsUploadEnv();164165    // CWS child env names are present166    expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");167    expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");168    expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");169    expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");170171    // AMO credentials are absent172    expect(env).not.toHaveProperty("AMO_JWT_ISSUER");173    expect(env).not.toHaveProperty("AMO_JWT_SECRET");174    expect(env).not.toHaveProperty("WEB_EXT_API_KEY");175    expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");176  });177178  it("amoSignEnv contains AMO credentials but no CWS credentials", () => {179    for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);180181    const env = amoSignEnv();182183    // AMO child env names are present184    expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");185    expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");186187    // CWS credentials are absent188    expect(env).not.toHaveProperty("CWS_CLIENT_ID");189    expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");190    expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");191    expect(env).not.toHaveProperty("CWS_EXTENSION_ID");192    expect(env).not.toHaveProperty("PUBLISHER_ID");193    expect(env).not.toHaveProperty("CLIENT_ID");194    expect(env).not.toHaveProperty("CLIENT_SECRET");195    expect(env).not.toHaveProperty("REFRESH_TOKEN");196  });197});198199// ─── 4. Shell metacharacter path safety ────────────────────────────────────200201describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {202  it("buildCommand treats a metacharacter path as a single arg", () => {203    const evilPath = "/tmp/$(whoami); echo pwned";204    const spec = buildCommand("/bin/echo", [evilPath]);205    expect(spec.args).toHaveLength(1);206    expect(spec.args[0]).toBe(evilPath);207  });208209  it("spawnSync with argv arrays does not execute shell injections", () => {210    const { spawnSync } = require("node:child_process");211    // If a shell were used, $(whoami) would be replaced with the username.212    const evilPath = "/tmp/$(whoami)";213    const result = spawnSync("/bin/echo", [evilPath], {214      stdout: "pipe",215      stderr: "pipe",216      shell: false,217    });218    const out = result.stdout?.toString().trim();219    expect(out).toBe(evilPath);220    // If command substitution ran, the output would contain a username, not $().221    expect(out).toContain("$(whoami)");222  });223});224225// ─── 5. Missing binary failure ─────────────────────────────────────────────226227describe("PB-26-211: missing local binary fails before any network access", () => {228  it("resolveBin throws when the tool is not in node_modules/.bin", () => {229    expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(230      /not found/,231    );232  });233234  it("resolveBin throws before returning a path, preventing network calls", () => {235    let threw = false;236    try {237      resolveBin("another-missing-tool-67890");238    } catch {239      threw = true;240    }241    expect(threw).toBe(true);242  });243});244245// ─── 6. Lockfile / version preflight ───────────────────────────────────────246247describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {248  it("preflight passes when release-critical deps are exact", () => {249    // After pinning, all release-critical deps have exact versions.250    // This will fail RED until versions are pinned.251    expect(() => preflight()).not.toThrow();252  });253254  it("package.json has exact versions for release-critical dev deps", () => {255    const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));256    const devDeps = pkg.devDependencies ?? {};257    const critical = [258      "chrome-webstore-upload-cli",259      "web-ext",260      "typescript",261      "@playwright/test",262    ];263    for (const dep of critical) {264      const v = devDeps[dep];265      expect(v, `${dep} must be in devDependencies`).toBeDefined();266      expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);267    }268  });269});