diff --git a/scripts/release-plan-producer.mts b/scripts/release-plan-producer.mts index 8b4125fdca72..5264e0b7bd31 100644 --- a/scripts/release-plan-producer.mts +++ b/scripts/release-plan-producer.mts @@ -1,7 +1,16 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, +} from "node:fs"; import { builtinModules, createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; @@ -81,7 +90,10 @@ const TOOLING_LOCKFILE_PATH = "pnpm-lock.yaml"; const YAML_PACKAGE_VERSION = "2.9.0"; const YAML_PACKAGE_INTEGRITY = "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="; -const YAML_PACKAGE_JSON_SHA256 = "20b8b197cbd10dad245d45e463dfe58e4c8c25a47e24bc4256ad9ab58bf35683"; +const YAML_PACKAGE_TREE_SHA256 = "610ccacfe592d226ac1eb04842d1f591c5381f2a68b9f785643101d10db52c27"; +const YAML_PACKAGE_MAX_FILES = 512; +const YAML_PACKAGE_MAX_ENTRIES = 1024; +const YAML_PACKAGE_MAX_BYTES = 4 * 1024 * 1024; const BUILTIN_IMPORTS = new Set([ ...builtinModules, ...builtinModules.map((specifier) => `node:${specifier}`), @@ -313,6 +325,87 @@ function verifyYamlLockfile(lockfileText: string) { } } +function assertYamlPackagePath(path: string) { + if ( + !path || + !/^[\x20-\x7e]+$/u.test(path) || + path.includes("\\") || + posix.isAbsolute(path) || + path.split("/").some((component) => component === "." || component === "..") + ) { + throw new Error(`installed yaml package contains an unsafe path: ${JSON.stringify(path)}`); + } +} + +function verifyInstalledYamlPackageTree(packageRoot: string) { + const rootStat = lstatSync(packageRoot); + if (!rootStat.isDirectory()) { + throw new Error("installed yaml package root must be a directory"); + } + + const records: string[] = []; + let entryCount = 0; + let fileCount = 0; + let totalBytes = 0; + const walk = (directory: string, relativeDirectory = "") => { + for (const name of readdirSync(directory).toSorted(compareAscii)) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name; + assertYamlPackagePath(relativePath); + entryCount += 1; + if (entryCount > YAML_PACKAGE_MAX_ENTRIES) { + throw new Error( + `installed yaml package exceeds ${YAML_PACKAGE_MAX_ENTRIES} filesystem entries`, + ); + } + + const absolutePath = join(directory, name); + const stat = lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + throw new Error(`installed yaml package must not contain symbolic links: ${relativePath}`); + } + if (stat.isDirectory()) { + records.push(JSON.stringify(["directory", relativePath])); + walk(absolutePath, relativePath); + continue; + } + if (!stat.isFile()) { + throw new Error(`installed yaml package must contain only directories and files`); + } + if (stat.nlink !== 1) { + throw new Error(`installed yaml package files must have one link: ${relativePath}`); + } + + fileCount += 1; + if (fileCount > YAML_PACKAGE_MAX_FILES) { + throw new Error(`installed yaml package exceeds ${YAML_PACKAGE_MAX_FILES} files`); + } + totalBytes += stat.size; + if (totalBytes > YAML_PACKAGE_MAX_BYTES) { + throw new Error(`installed yaml package exceeds ${YAML_PACKAGE_MAX_BYTES} bytes`); + } + const bytes = readFileSync(absolutePath); + if (bytes.byteLength !== stat.size) { + throw new Error(`installed yaml package file changed while being read: ${relativePath}`); + } + records.push( + JSON.stringify([ + "file", + relativePath, + bytes.byteLength, + createHash("sha256").update(bytes).digest("hex"), + ]), + ); + } + }; + walk(packageRoot); + + const manifest = `${records.toSorted(compareAscii).join("\n")}\n`; + const digest = createHash("sha256").update(manifest, "ascii").digest("hex"); + if (digest !== YAML_PACKAGE_TREE_SHA256) { + throw new Error(`installed yaml package tree must match yaml@${YAML_PACKAGE_VERSION}`); + } +} + function loadVerifiedYamlParser(repoRoot: string, toolingSha: string): ParseYaml { const packageJsonBytes = readVerifiedToolingRootBytes( repoRoot, @@ -335,7 +428,7 @@ function loadVerifiedYamlParser(repoRoot: string, toolingSha: string): ParseYaml const toolingRequire = createRequire(resolve(EXECUTION_ROOT, TOOLING_PACKAGE_JSON_PATH)); const packageJsonPath = realpathSync(toolingRequire.resolve("yaml/package.json")); - const packageRoot = dirname(packageJsonPath); + const packageRoot = realpathSync(dirname(packageJsonPath)); const modulePath = realpathSync(toolingRequire.resolve("yaml")); const moduleRelativePath = relative(packageRoot, modulePath); if ( @@ -345,13 +438,17 @@ function loadVerifiedYamlParser(repoRoot: string, toolingSha: string): ParseYaml ) { throw new Error("resolved yaml module must be owned by its installed package"); } - const installedPackageJsonBytes = readFileSync(packageJsonPath); - const installedPackageJsonSha = createHash("sha256") - .update(installedPackageJsonBytes) - .digest("hex"); - if (installedPackageJsonSha !== YAML_PACKAGE_JSON_SHA256) { - throw new Error(`installed yaml package.json must match yaml@${YAML_PACKAGE_VERSION}`); + const packageJsonRelativePath = relative(packageRoot, packageJsonPath); + if ( + packageJsonRelativePath === ".." || + packageJsonRelativePath.startsWith(`..${sep}`) || + isAbsolute(packageJsonRelativePath) + ) { + throw new Error("resolved yaml package.json must be owned by its installed package"); } + verifyInstalledYamlPackageTree(packageRoot); + + const installedPackageJsonBytes = readFileSync(packageJsonPath); const installedPackageJson = JSON.parse(installedPackageJsonBytes.toString("utf8")) as { name?: unknown; version?: unknown; diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts index bbfbfdb7edb8..6d9160379d00 100644 --- a/test/scripts/release-plan-producer.test.ts +++ b/test/scripts/release-plan-producer.test.ts @@ -1,5 +1,15 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -266,6 +276,50 @@ function trustedToolingGh(toolingFullRef: string, toolingSha: string) { }; } +function runYamlPackageSubprocess( + mutate?: (params: { packageRoot: string; sentinelPath: string }) => void, +) { + const fixture = createFixtureRepo(); + const packageRoot = join(fixture.root, "node_modules/yaml"); + cpSync(realpathSync(resolve("node_modules/yaml")), packageRoot, { recursive: true }); + const sentinelPath = join(fixture.root, "yaml-executed"); + mutate?.({ packageRoot, sentinelPath }); + writeFixture( + fixture.root, + "yaml-package-harness.mts", + ` +import { produceReleasePlan } from "./scripts/release-plan-producer.mts"; + +const toolingFullRef = ${JSON.stringify(fixture.toolingFullRef)}; +const toolingSha = ${JSON.stringify(fixture.toolingSha)}; +produceReleasePlan({ + repoRoot: ${JSON.stringify(fixture.root)}, + intent: "publish", + candidateSha: ${JSON.stringify(fixture.candidateSha)}, + candidateRef: ${JSON.stringify(fixture.candidateRef)}, + toolingSha, + toolingFullRef, + runGh: () => JSON.stringify({ + ref: toolingFullRef, + object: { type: "commit", sha: toolingSha }, + }), +}); +`, + ); + const tsxImport = pathToFileURL(createRequire(import.meta.url).resolve("tsx")).href; + return { + result: spawnSync( + process.execPath, + ["--import", tsxImport, join(fixture.root, "yaml-package-harness.mts")], + { + cwd: fixture.root, + encoding: "utf8", + }, + ), + sentinelPath, + }; +} + describe("release plan producer", () => { it("derives purpose, profile, tag, and soak from the canonical version parser", () => { expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({ @@ -603,7 +657,66 @@ produceReleasePlan({ ); expect(result.status).toBe(1); - expect(result.stderr).toContain("installed yaml package.json must match yaml@2.9.0"); + expect(result.stderr).toContain("installed yaml package tree must match yaml@2.9.0"); + }); + + it("accepts the complete installed yaml package tree", () => { + const { result } = runYamlPackageSubprocess(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + }); + + it("rejects a changed yaml entry before executing it", () => { + const { result, sentinelPath } = runYamlPackageSubprocess(({ packageRoot, sentinelPath }) => { + const entryPath = join(packageRoot, "dist/index.js"); + writeFileSync( + entryPath, + `require("node:fs").writeFileSync(${JSON.stringify(sentinelPath)}, "executed");\n${readFileSync(entryPath, "utf8")}`, + ); + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("installed yaml package tree must match yaml@2.9.0"); + expect(existsSync(sentinelPath)).toBe(false); + }); + + it("rejects a changed yaml transitive module before execution", () => { + const { result, sentinelPath } = runYamlPackageSubprocess(({ packageRoot, sentinelPath }) => { + const transitivePath = join(packageRoot, "dist/public-api.js"); + writeFileSync( + transitivePath, + `require("node:fs").writeFileSync(${JSON.stringify(sentinelPath)}, "executed");\n${readFileSync(transitivePath, "utf8")}`, + ); + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("installed yaml package tree must match yaml@2.9.0"); + expect(existsSync(sentinelPath)).toBe(false); + }); + + it("rejects internal yaml package symlinks", () => { + const { result } = runYamlPackageSubprocess(({ packageRoot }) => { + const transitivePath = join(packageRoot, "dist/public-api.js"); + unlinkSync(transitivePath); + symlinkSync("index.js", transitivePath); + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("installed yaml package must not contain symbolic links"); + }); + + it("rejects extra yaml package files", () => { + const { result } = runYamlPackageSubprocess(({ packageRoot }) => { + writeFileSync(join(packageRoot, "unexpected.js"), "module.exports = {};\n"); + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("installed yaml package tree must match yaml@2.9.0"); + }); + + it("rejects missing yaml transitive files through tree attestation", () => { + const { result } = runYamlPackageSubprocess(({ packageRoot }) => { + rmSync(join(packageRoot, "dist/public-api.js")); + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("installed yaml package tree must match yaml@2.9.0"); + expect(result.stderr).not.toContain("MODULE_NOT_FOUND"); }); it("rejects malformed publishable plugins while producing the plan", () => {