fix: fail closed on release input discovery

This commit is contained in:
DongHyeonka
2026-08-02 05:11:30 +09:00
parent 381d5549e2
commit d6c98489ee
9 changed files with 975 additions and 69 deletions
+142
View File
@@ -1,7 +1,10 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { verifyBuildManifestOutputs } from "../../scripts/lib/build-manifest-outputs.ts";
import {
dependencyInventoryArtifactSchema,
fieldWebVitalsArtifactSchema,
@@ -58,6 +61,145 @@ const buildManifest = {
} as const;
describe("release artifact contracts", () => {
it("verifies confined build outputs and the raw module inventory bytes", async () => {
const moduleInventoryBytes = Buffer.from(
'{"schemaVersion":1,"chunks":[]}\n',
);
const files = new Map<string, Buffer>([
["/repo/dist/.vite/manifest.json", Buffer.from("{}\n")],
["/repo/artifacts/quality/vite-module-inventory.json", moduleInventoryBytes],
["/repo/dist/runtime-config.schema.json", Buffer.from("{}\n")],
["/repo/dist/assets/home.js", Buffer.from("chunk\n")],
]);
const manifest = {
...buildManifest,
moduleInventoryHash: createHash("sha256")
.update(moduleInventoryBytes)
.digest("hex"),
};
await expect(
verifyBuildManifestOutputs(manifest, {
repositoryRoot: "/repo",
readBytes: async (target) => files.get(target) ?? Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
realpathPath: async (target) => target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
}),
).resolves.toEqual([]);
});
it.each([
["missing", undefined, /moduleInventory:missing/u],
[
"tampered",
Buffer.from('{"schemaVersion":1,"chunks":[{"fileName":"other.js","modules":[]}]}\n'),
/moduleInventoryHash/u,
],
] as const)("rejects a %s module inventory", async (_name, bytes, expected) => {
const files = new Map<string, Buffer>([
["/repo/dist/.vite/manifest.json", Buffer.from("{}\n")],
["/repo/dist/runtime-config.schema.json", Buffer.from("{}\n")],
["/repo/dist/assets/home.js", Buffer.from("chunk\n")],
...(bytes ? [["/repo/artifacts/quality/vite-module-inventory.json", bytes] as const] : []),
]);
const mismatches = await verifyBuildManifestOutputs(buildManifest, {
repositoryRoot: "/repo",
readBytes: async (target) => files.get(target) ?? Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
realpathPath: async (target) => target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
});
expect(mismatches.join("\n")).toMatch(expected);
});
it.each(["../outside", "/absolute", "dist\\escape"])(
"rejects unsafe build-manifest output path %s",
async (unsafePath) => {
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
outputs: { ...buildManifest.outputs, moduleInventory: unsafePath },
},
{
repositoryRoot: "/repo",
realpathPath: async (target) => target,
},
);
expect(mismatches).toContain("buildManifest:moduleInventory:path");
},
);
it("rejects a realpath escape from a declared build output", async () => {
const mismatches = await verifyBuildManifestOutputs(buildManifest, {
repositoryRoot: "/repo",
realpathPath: async (target) =>
target.endsWith("vite-module-inventory.json") ? "/outside/inventory.json" : target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
readBytes: async () => Buffer.from("inventory\n"),
});
expect(mismatches).toContain("buildManifest:moduleInventory:path");
});
it("rejects a nested symlink that resolves inside the repository but outside dist", async () => {
const moduleInventoryBytes = Buffer.from(
'{"schemaVersion":1,"chunks":[]}\n',
);
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
moduleInventoryHash: createHash("sha256")
.update(moduleInventoryBytes)
.digest("hex"),
},
{
repositoryRoot: "/repo",
realpathPath: async (target) =>
target === "/repo/dist/assets/home.js"
? "/repo/src/home.js"
: target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
readBytes: async () => moduleInventoryBytes,
},
);
expect(mismatches).toContain("buildManifest:routeChunk:route-home:path");
});
it.each([
["viteManifest", "package.json"],
["runtimeConfigSchema", "schemas/artifacts/build-manifest.schema.json"],
["moduleInventory", "package.json"],
] as const)("rejects %s outside its approved output root", async (field, value) => {
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
outputs: { ...buildManifest.outputs, [field]: value },
},
{ repositoryRoot: process.cwd() },
);
expect(mismatches).toContain(`buildManifest:${field}:path`);
});
it("rejects a parse-invalid module inventory even when its raw hash matches", async () => {
const bytes = Buffer.from("{}\n");
const mismatches = await verifyBuildManifestOutputs(
{
...buildManifest,
moduleInventoryHash: createHash("sha256").update(bytes).digest("hex"),
},
{
repositoryRoot: "/repo",
readBytes: async () => bytes,
realpathPath: async (target) => target,
assertRegularFile: async () => undefined,
assertDirectory: async () => undefined,
},
);
expect(mismatches).toContain("buildManifest:moduleInventory:invalid");
});
it("projects the nested V2 contract-set digest without a legacy scalar", () => {
const release = parseReleaseArtifact(releaseV2);
@@ -0,0 +1,230 @@
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
type GitFileListResult,
} from "../../scripts/lib/repository-file-inventory.ts";
function gitResult(
stdout: Buffer | string,
overrides: Partial<GitFileListResult> = {},
): GitFileListResult {
return {
status: 0,
signal: null,
stdout: typeof stdout === "string" ? Buffer.from(stdout) : stdout,
stderr: Buffer.alloc(0),
...overrides,
};
}
async function repositoryFixture() {
const root = await mkdtemp(path.join(tmpdir(), "repository-inventory-"));
await mkdir(path.join(root, "src"));
await writeFile(path.join(root, "src", "tracked.ts"), "tracked\n");
await writeFile(path.join(root, "src", "untracked.ts"), "untracked\n");
return root;
}
describe("repository file inventory", () => {
it("rejects malformed root policies instead of filtering invalid entries", () => {
expect(() =>
parseRepositoryFileInventoryPolicy({
trackedRoots: ["src", 42],
generatedRoots: [],
}),
).toThrow(/trackedRoots/u);
expect(() =>
parseRepositoryFileInventoryPolicy({
trackedRoots: [],
generatedRoots: [],
}),
).toThrow(/trackedRoots/u);
});
it.each([
["spawn failure", { error: new Error("spawn ENOENT") }],
["non-zero exit", { status: 2, stderr: Buffer.from("fatal") }],
["signal", { status: null, signal: "SIGTERM" }],
["stderr output", { stderr: Buffer.from("warning") }],
] as const)("fails closed on git %s", async (_name, failure) => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0", failure),
}),
).rejects.toThrow(/git ls-files/u);
});
it.each([
["malformed UTF-8", Buffer.from([0xc3, 0x28, 0])],
["embedded empty NUL row", Buffer.from("src/tracked.ts\0\0")],
["missing terminal NUL", Buffer.from("src/tracked.ts")],
])("rejects %s output", async (_name, stdout) => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult(stdout),
}),
).rejects.toThrow(/git ls-files/u);
});
it("uses only tracked files and sorts the normalized inventory", async () => {
const repositoryRoot = await repositoryFixture();
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0"),
});
expect(inventory.trackedFiles).toEqual(["src/tracked.ts"]);
expect(inventory.files).not.toContain("src/untracked.ts");
});
it("rejects duplicate tracked paths instead of silently deduplicating", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0src/tracked.ts\0"),
}),
).rejects.toThrow(/duplicate/u);
});
it("fails when a required root is missing", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["missing"],
runGit: () => gitResult(""),
}),
).rejects.toThrow(/required repository root.*missing/u);
});
it("fails when an existing required root has no tracked match", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult(""),
}),
).rejects.toThrow(/tracked file.*src/u);
});
it("ignores only exact ENOENT for configured optional generated roots", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
generatedRoots: ["optional-output"],
optionalRoots: ["optional-output"],
runGit: () => gitResult("src/tracked.ts\0"),
}),
).resolves.toMatchObject({ generatedFiles: [] });
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
generatedRoots: ["optional-output"],
optionalRoots: ["optional-output"],
runGit: () => gitResult("src/tracked.ts\0"),
lstatPath: async (target) => {
if (target.endsWith("optional-output")) {
throw Object.assign(new Error("denied"), { code: "EACCES" });
}
const { lstat } = await import("node:fs/promises");
return lstat(target);
},
}),
).rejects.toThrow(/optional-output/u);
});
it.each(["/absolute", "../escape", "src\\windows.ts"])(
"rejects unsafe configured path %s",
async (unsafePath) => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: [unsafePath],
runGit: () => gitResult(""),
}),
).rejects.toThrow(/repository-relative POSIX path/u);
},
);
it("rejects tracked traversal and non-regular files", async () => {
const repositoryRoot = await repositoryFixture();
for (const trackedPath of ["../escape", "src"]) {
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult(`${trackedPath}\0`),
}),
).rejects.toThrow(/git ls-files|regular file/u);
}
});
it("rejects a tracked symlink whose real path escapes the repository", async () => {
const repositoryRoot = await repositoryFixture();
const outside = await mkdtemp(path.join(tmpdir(), "inventory-outside-"));
await writeFile(path.join(outside, "secret.ts"), "secret\n");
await symlink(
path.join(outside, "secret.ts"),
path.join(repositoryRoot, "src", "link.ts"),
);
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/link.ts\0"),
}),
).rejects.toThrow(/symlink|regular file/u);
});
it("adds only explicitly configured generated regular files", async () => {
const repositoryRoot = await repositoryFixture();
await mkdir(path.join(repositoryRoot, "dist"));
await writeFile(path.join(repositoryRoot, "dist", "asset.js"), "asset\n");
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
generatedRoots: ["dist"],
runGit: () => gitResult("src/tracked.ts\0"),
});
expect(inventory.generatedFiles).toEqual(["dist/asset.js"]);
expect(inventory.files).toEqual(["dist/asset.js", "src/tracked.ts"]);
});
it("fails closed when an inventoried file cannot be read", async () => {
const repositoryRoot = await repositoryFixture();
await expect(
buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: ["src"],
runGit: () => gitResult("src/tracked.ts\0"),
assertReadable: async () => {
throw Object.assign(new Error("denied"), { code: "EACCES" });
},
}),
).rejects.toThrow(/src\/tracked\.ts/u);
});
});
+49
View File
@@ -1,3 +1,5 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
@@ -22,6 +24,53 @@ const dependency = {
};
describe("supply-chain policy", () => {
it("uses one fail-closed repository inventory for provenance and secret scanning", async () => {
const [provenanceSource, securitySource] = await Promise.all([
readFile("scripts/generate-supply-chain.ts", "utf8"),
readFile("scripts/security-scan.ts", "utf8"),
]);
for (const source of [provenanceSource, securitySource]) {
expect(source).toContain("buildRepositoryFileInventory");
expect(source).not.toContain("async function filesWithin");
}
});
it("covers every mandatory release input in the secret scan policy", async () => {
const policy = JSON.parse(
await readFile("config/security/secret-scan-policy.json", "utf8"),
) as { trackedRoots: string[] };
expect(policy.trackedRoots).toEqual(
expect.arrayContaining([
"index.html",
".dependency-cruiser.json",
".nvmrc",
".npmrc",
"eslint.config.ts",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts",
"schemas",
"config",
".gitea/workflows/quality-gates.yml",
"vite.config.ts",
"vite.service-worker.config.ts",
"playwright.capabilities.config.ts",
"playwright.dev.config.ts",
"playwright.storybook.config.ts",
"playwright.visual.config.ts",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.base.json",
"tsconfig.node.json",
"tsconfig.recipes.json",
"tsconfig.service-worker.json",
"tsconfig.test.json",
"tsconfig.web-worker.json",
]),
);
});
it("parses every top-level lockfile package and validates SRI", () => {
const parsed = parsePnpmLockfilePackages(`
packages: