fix: fail closed on release input discovery
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user