release-lib.test.ts

11.8 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, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";5import { tmpdir } from "node:os";6import { fileURLToPath } from "node:url";7import { dirname, join, resolve } from "node:path";8import { afterEach, describe, expect, it } from "vitest";910import {11  buildCommand,12  safeEnv,13  cwsUploadEnv,14  amoSignEnv,15  resolveBin,16  preflight,17  packageChromeZip,18  collectDirectory,19  uploadChrome,20  signFirefox,21  CWS_SECRET_NAMES,22  AMO_SECRET_NAMES,23  type CommandSpec,24  type Runner,25} from "./release-lib";2627const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");28const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2930const HELD_ENV: Record<string, string | undefined> = {};3132function setEnv(name: string, val: string) {33  HELD_ENV[name] = process.env[name];34  process.env[name] = val;35}3637function restoreEnv() {38  for (const [name, val] of Object.entries(HELD_ENV)) {39    if (val === undefined) delete process.env[name];40    else process.env[name] = val;41  }42  for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];43}4445afterEach(restoreEnv);4647// ─── 1. No shell, no npx ───────────────────────────────────────────────────4849describe("PB-26-211: argv-based runner — no shell, no npx", () => {50  it("buildCommand returns a CommandSpec with array args, never a string", () => {51    const spec = buildCommand("/bin/echo", ["hello", "world"]);52    expect(Array.isArray(spec.args)).toBe(true);53    expect(spec.args).toEqual(["hello", "world"]);54    expect(typeof spec.executable).toBe("string");55  });5657  it("no release/CI script source contains execSync or npx", () => {58    const scripts = [59      "scripts/release.ts",60      "scripts/release-firefox.ts",61      "scripts/release-lib.ts",62      "scripts/ci.ts",63    ];64    for (const p of scripts) {65      const src = readFileSync(join(ROOT, p), "utf8");66      expect(src, `${p} must not use execSync`).not.toContain("execSync");67      expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);68    }69  });7071  it("no release script builds commands via string concatenation (.join)", () => {72    const scripts = [73      "scripts/release.ts",74      "scripts/release-firefox.ts",75      "scripts/release-lib.ts",76    ];77    for (const p of scripts) {78      const src = readFileSync(join(ROOT, p), "utf8");79      expect(src, `${p} must not join command strings`).not.toContain("].join(");80    }81  });8283  it("fake runner captures argv — no captured spec uses a shell or npx", () => {84    const captured: CommandSpec[] = [];85    const fake: Runner = (exec, args, opts) => {86      captured.push(buildCommand(exec, args, opts));87    };8889    for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");9091    packageChromeZip("9.9.9");92    uploadChrome("9.9.9", fake);93    signFirefox("/tmp/fake-source.zip", fake);9495    // packageChromeZip no longer shells out at all (PB-26-611), so only the96    // upload and the AMO sign remain.97    expect(captured.length).toBeGreaterThanOrEqual(2);98    for (const spec of captured) {99      expect(spec.executable).not.toBe("npx");100      expect(spec.args).not.toContain("npx");101      expect(Array.isArray(spec.args)).toBe(true);102    }103  });104});105106// ─── 2. Child environment isolation ────────────────────────────────────────107108describe("PB-26-211: CI/build child environments contain no store credentials", () => {109  it("safeEnv strips all CWS and AMO secret names", () => {110    for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");111112    const env = safeEnv();113    for (const name of ALL_SECRET_NAMES) {114      expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);115    }116  });117118  it("safeEnv preserves non-secret environment variables", () => {119    setEnv("PATH", "/usr/bin:/bin");120    setEnv("HOME", "/tmp/test-home");121122    const env = safeEnv();123    expect(env.PATH).toBe("/usr/bin:/bin");124    expect(env.HOME).toBe("/tmp/test-home");125  });126127  it("safeEnv strips build-behavior overrides from release children", () => {128    for (const [name, value] of Object.entries({129      BUILD_TARGET: "firefox",130      PEACH_E2E_PASSKEYS: "1",131      PEACH_DEBUG: "1",132      DEBUG: "vite:*",133      NODE_ENV: "development",134      MODE: "development",135      VITE_MODE: "development",136      CI: "true",137    })) setEnv(name, value);138139    const env = safeEnv();140    for (const name of [141      "BUILD_TARGET",142      "PEACH_E2E_PASSKEYS",143      "PEACH_DEBUG",144      "DEBUG",145      "NODE_ENV",146      "MODE",147      "VITE_MODE",148      "CI",149    ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);150  });151152  it("safeEnv strips the release browser gate from release children", () => {153    setEnv("PEACH_RELEASE_BROWSER_GATE", "0");154    setEnv("BUILD_TARGET", "firefox");155156    const env = safeEnv();157    expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE");158    expect(env).not.toHaveProperty("BUILD_TARGET");159  });160});161162// ─── 3. Credential disjointness ────────────────────────────────────────────163164describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {165  it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {166    for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);167168    const env = cwsUploadEnv();169170    // CWS child env names are present171    expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");172    expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");173    expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");174    expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");175176    // AMO credentials are absent177    expect(env).not.toHaveProperty("AMO_JWT_ISSUER");178    expect(env).not.toHaveProperty("AMO_JWT_SECRET");179    expect(env).not.toHaveProperty("WEB_EXT_API_KEY");180    expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");181  });182183  it("amoSignEnv contains AMO credentials but no CWS credentials", () => {184    for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);185186    const env = amoSignEnv();187188    // AMO child env names are present189    expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");190    expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");191192    // CWS credentials are absent193    expect(env).not.toHaveProperty("CWS_CLIENT_ID");194    expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");195    expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");196    expect(env).not.toHaveProperty("CWS_EXTENSION_ID");197    expect(env).not.toHaveProperty("PUBLISHER_ID");198    expect(env).not.toHaveProperty("CLIENT_ID");199    expect(env).not.toHaveProperty("CLIENT_SECRET");200    expect(env).not.toHaveProperty("REFRESH_TOKEN");201  });202});203204// ─── 4. Shell metacharacter path safety ────────────────────────────────────205206describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {207  it("buildCommand treats a metacharacter path as a single arg", () => {208    const evilPath = "/tmp/$(whoami); echo pwned";209    const spec = buildCommand("/bin/echo", [evilPath]);210    expect(spec.args).toHaveLength(1);211    expect(spec.args[0]).toBe(evilPath);212  });213214  it("spawnSync with argv arrays does not execute shell injections", () => {215    const { spawnSync } = require("node:child_process");216    // If a shell were used, $(whoami) would be replaced with the username.217    const evilPath = "/tmp/$(whoami)";218    const result = spawnSync("/bin/echo", [evilPath], {219      stdout: "pipe",220      stderr: "pipe",221      shell: false,222    });223    const out = result.stdout?.toString().trim();224    expect(out).toBe(evilPath);225    // If command substitution ran, the output would contain a username, not $().226    expect(out).toContain("$(whoami)");227  });228});229230// ─── 5. Missing binary failure ─────────────────────────────────────────────231232describe("PB-26-211: missing local binary fails before any network access", () => {233  it("resolveBin throws when the tool is not in node_modules/.bin", () => {234    expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(235      /not found/,236    );237  });238239  it("resolveBin throws before returning a path, preventing network calls", () => {240    let threw = false;241    try {242      resolveBin("another-missing-tool-67890");243    } catch {244      threw = true;245    }246    expect(threw).toBe(true);247  });248});249250// ─── 6. Lockfile / version preflight ───────────────────────────────────────251252describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {253  it("preflight passes when release-critical deps are exact", () => {254    // After pinning, all release-critical deps have exact versions.255    // This will fail RED until versions are pinned.256    expect(() => preflight()).not.toThrow();257  });258259  it("package.json has exact versions for release-critical dev deps", () => {260    const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));261    const devDeps = pkg.devDependencies ?? {};262    const critical = [263      "chrome-webstore-upload-cli",264      "web-ext",265      "typescript",266      "@playwright/test",267    ];268    for (const dep of critical) {269      const v = devDeps[dep];270      expect(v, `${dep} must be in devDependencies`).toBeDefined();271      expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);272    }273  });274});275276// ─── 7. PB-26-611 reproducible Chrome archive ──────────────────────────────277278describe.skipIf(!existsSync(join(ROOT, "dist-chrome", "manifest.json")))(279  "PB-26-611: Chrome extension archive is reproducible",280  () => {281    afterEach(() => {282      const zipPath = join(ROOT, "artifacts", "peach-chrome-9.9.9.zip");283      if (existsSync(zipPath)) rmSync(zipPath);284    });285286    it("two archives built from the same dist are byte-identical", () => {287      const first = readFileSync(packageChromeZip("9.9.9"));288      const second = readFileSync(packageChromeZip("9.9.9"));289290      expect(first.length).toBeGreaterThan(0);291      expect(createHash("sha256").update(first).digest("hex")).toBe(292        createHash("sha256").update(second).digest("hex"),293      );294    });295296    it("preserves paths relative to the distribution root", () => {297      const fixture = join(tmpdir(), `peach-release-paths-${process.pid}`);298      rmSync(fixture, { recursive: true, force: true });299      mkdirSync(join(fixture, "assets"), { recursive: true });300      mkdirSync(join(fixture, "icons"), { recursive: true });301      writeFileSync(join(fixture, "manifest.json"), "{}");302      writeFileSync(join(fixture, "assets", "entry.js"), "export {};");303      writeFileSync(join(fixture, "icons", "16.png"), "icon");304305      try {306        expect(collectDirectory(fixture).map((entry) => entry.path)).toEqual([307          "assets/entry.js",308          "icons/16.png",309          "manifest.json",310        ]);311      } finally {312        rmSync(fixture, { recursive: true, force: true });313      }314    });315316    it("embeds no source maps, .DS_Store or __MACOSX entries", () => {317      const zip = packageChromeZip("9.9.9");318      const bytes = readFileSync(zip);319      const text = bytes.toString("latin1");320321      expect(text).not.toContain(".map");322      expect(text).not.toContain(".DS_Store");323      expect(text).not.toContain("__MACOSX");324    });325326    it("release-lib no longer shells out to the system zip binary", () => {327      const source = readFileSync(join(ROOT, "scripts", "release-lib.ts"), "utf8");328      expect(source).not.toContain('"zip"');329      expect(source).toContain("createDeterministicZip");330    });331  },332);