fix: fail closed on release input discovery
This commit is contained in:
@@ -9,13 +9,34 @@
|
||||
"public",
|
||||
"schemas",
|
||||
".storybook",
|
||||
".gitea/workflows/quality-gates.yml",
|
||||
".dependency-cruiser.json",
|
||||
".nvmrc",
|
||||
".npmrc",
|
||||
"eslint.config.ts",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"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",
|
||||
"vite.config.ts",
|
||||
"vite.service-worker.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts"
|
||||
"playwright.config.ts",
|
||||
"playwright.capabilities.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts"
|
||||
],
|
||||
"generatedRoots": ["dist", "artifacts/release"],
|
||||
"optionalRoots": ["artifacts/release"],
|
||||
"excludedPaths": [
|
||||
"tests/fixtures/security/secret-detection/forbidden"
|
||||
],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { gzipSync } from "node:zlib";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
@@ -33,6 +32,10 @@ import {
|
||||
type DependencyInventoryDiff,
|
||||
} from "./lib/supply-chain.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import {
|
||||
buildRepositoryFileInventory,
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./lib/repository-file-inventory.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
@@ -59,21 +62,6 @@ async function jsonDocument(file: string): Promise<Document> {
|
||||
return documentValue(parsed, file);
|
||||
}
|
||||
|
||||
async function filesWithin(directory: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(target) : [target];
|
||||
}),
|
||||
);
|
||||
return nested.flat().sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(file: string): Promise<string> {
|
||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||
}
|
||||
@@ -162,7 +150,19 @@ export async function buildDependencyInventory() {
|
||||
}
|
||||
|
||||
const packageJson = await jsonDocument("package.json");
|
||||
const outputFiles = await filesWithin("dist");
|
||||
const secretScanPolicy = documentValue(
|
||||
JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")),
|
||||
"secret scan policy",
|
||||
);
|
||||
const inventoryPolicy = parseRepositoryFileInventoryPolicy(secretScanPolicy);
|
||||
const repositoryInventory = await buildRepositoryFileInventory({
|
||||
trackedRoots: inventoryPolicy.trackedRoots,
|
||||
generatedRoots: inventoryPolicy.generatedRoots,
|
||||
optionalRoots: inventoryPolicy.optionalRoots,
|
||||
});
|
||||
const outputFiles = repositoryInventory.generatedFiles.filter(
|
||||
(file) => file === "dist" || file.startsWith("dist/"),
|
||||
);
|
||||
if (outputFiles.length === 0) {
|
||||
throw new Error("dist is missing; run the production build first");
|
||||
}
|
||||
@@ -278,27 +278,7 @@ const vulnerabilityReport = {
|
||||
blocking: vulnerabilityResult.blocking,
|
||||
};
|
||||
|
||||
const sourceFiles = (
|
||||
await Promise.all(
|
||||
[
|
||||
"src",
|
||||
"scripts",
|
||||
"config",
|
||||
"public",
|
||||
"schemas",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"vite.config.ts",
|
||||
].map(async (target) => {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
return metadata.isDirectory() ? filesWithin(target) : [target];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const sourceFiles = [...repositoryInventory.trackedFiles];
|
||||
const sourceSetSha256 = await digestFileSet(sourceFiles);
|
||||
|
||||
const components = inventory.dependencies.map((dependency) => ({
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { BuildManifestArtifact } from "../../src/contracts/release-artifacts.ts";
|
||||
import { moduleInventoryArtifactSchema } from "../contracts/release-artifacts.ts";
|
||||
|
||||
type VerifyBuildManifestOutputsDependencies = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
readBytes?: (target: string) => Promise<Buffer>;
|
||||
realpathPath?: (target: string) => Promise<string>;
|
||||
assertRegularFile?: (target: string) => Promise<void>;
|
||||
assertDirectory?: (target: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
function isSafeRelativePath(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
!path.posix.isAbsolute(value) &&
|
||||
!path.win32.isAbsolute(value) &&
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0") &&
|
||||
path.posix.normalize(value) === value &&
|
||||
value !== ".." &&
|
||||
!value.startsWith("../")
|
||||
);
|
||||
}
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function belongsToApprovedRoot(value: string, approvedRoot: string): boolean {
|
||||
return value.startsWith(`${approvedRoot}/`);
|
||||
}
|
||||
|
||||
async function defaultAssertRegularFile(target: string): Promise<void> {
|
||||
const metadata = await lstat(target);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new TypeError("not a regular file");
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultAssertDirectory(target: string): Promise<void> {
|
||||
const metadata = await lstat(target);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
throw new TypeError("not a directory");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyBuildManifestOutputs(
|
||||
manifest: BuildManifestArtifact,
|
||||
dependencies: VerifyBuildManifestOutputsDependencies = {},
|
||||
): Promise<string[]> {
|
||||
const repositoryRoot = path.resolve(dependencies.repositoryRoot ?? process.cwd());
|
||||
const readBytes = dependencies.readBytes ?? readFile;
|
||||
const realpathPath = dependencies.realpathPath ?? realpath;
|
||||
const assertRegularFile = dependencies.assertRegularFile ?? defaultAssertRegularFile;
|
||||
const assertDirectory = dependencies.assertDirectory ?? defaultAssertDirectory;
|
||||
const mismatches: string[] = [];
|
||||
const resolvedRoot = await realpathPath(repositoryRoot);
|
||||
const approvedRoots = new Map<string, Promise<string | null>>();
|
||||
|
||||
function resolveApprovedRoot(relativeRoot: string): Promise<string | null> {
|
||||
const existing = approvedRoots.get(relativeRoot);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
const absoluteRoot = path.resolve(repositoryRoot, relativeRoot);
|
||||
try {
|
||||
await assertDirectory(absoluteRoot);
|
||||
const resolvedApprovedRoot = await realpathPath(absoluteRoot);
|
||||
return isWithinRoot(resolvedRoot, resolvedApprovedRoot)
|
||||
? resolvedApprovedRoot
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
approvedRoots.set(relativeRoot, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function confinedPath(
|
||||
label: string,
|
||||
relativePath: string,
|
||||
kind: "file" | "directory",
|
||||
approvedRoot?: string,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
!isSafeRelativePath(relativePath) ||
|
||||
(approvedRoot !== undefined &&
|
||||
!belongsToApprovedRoot(relativePath, approvedRoot))
|
||||
) {
|
||||
mismatches.push(`buildManifest:${label}:path`);
|
||||
return null;
|
||||
}
|
||||
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
||||
if (!isWithinRoot(repositoryRoot, absolutePath)) {
|
||||
mismatches.push(`buildManifest:${label}:path`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (kind === "file") await assertRegularFile(absolutePath);
|
||||
else await assertDirectory(absolutePath);
|
||||
const resolvedPath = await realpathPath(absolutePath);
|
||||
const resolvedApprovedRoot = approvedRoot
|
||||
? await resolveApprovedRoot(approvedRoot)
|
||||
: resolvedRoot;
|
||||
if (
|
||||
resolvedApprovedRoot === null ||
|
||||
!isWithinRoot(resolvedRoot, resolvedPath) ||
|
||||
!isWithinRoot(resolvedApprovedRoot, resolvedPath)
|
||||
) {
|
||||
mismatches.push(`buildManifest:${label}:path`);
|
||||
return null;
|
||||
}
|
||||
return absolutePath;
|
||||
} catch {
|
||||
mismatches.push(`buildManifest:${label}:missing`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.outputs.directory !== "dist") {
|
||||
mismatches.push("buildManifest:directory:path");
|
||||
} else {
|
||||
await confinedPath("directory", manifest.outputs.directory, "directory");
|
||||
}
|
||||
await confinedPath(
|
||||
"viteManifest",
|
||||
manifest.outputs.viteManifest,
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
await confinedPath(
|
||||
"runtimeConfigSchema",
|
||||
manifest.outputs.runtimeConfigSchema,
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
for (const [chunkId, chunkPath] of Object.entries(manifest.outputs.routeChunks)) {
|
||||
if (!isSafeRelativePath(chunkPath)) {
|
||||
mismatches.push(`buildManifest:routeChunk:${chunkId}:path`);
|
||||
continue;
|
||||
}
|
||||
await confinedPath(
|
||||
`routeChunk:${chunkId}`,
|
||||
path.posix.join(manifest.outputs.directory, chunkPath),
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
}
|
||||
const moduleInventoryPath = await confinedPath(
|
||||
"moduleInventory",
|
||||
manifest.outputs.moduleInventory,
|
||||
"file",
|
||||
"artifacts/quality",
|
||||
);
|
||||
if (moduleInventoryPath) {
|
||||
try {
|
||||
const bytes = await readBytes(moduleInventoryPath);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
if (digest !== manifest.moduleInventoryHash) {
|
||||
mismatches.push("buildManifest:moduleInventoryHash");
|
||||
}
|
||||
try {
|
||||
moduleInventoryArtifactSchema.parse(JSON.parse(bytes.toString("utf8")));
|
||||
} catch {
|
||||
mismatches.push("buildManifest:moduleInventory:invalid");
|
||||
}
|
||||
} catch {
|
||||
mismatches.push("buildManifest:moduleInventory:missing");
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type GitFileListResult = Readonly<{
|
||||
error?: Error;
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: Buffer;
|
||||
stderr: Buffer;
|
||||
}>;
|
||||
|
||||
export type RepositoryFileInventory = Readonly<{
|
||||
trackedFiles: readonly string[];
|
||||
generatedFiles: readonly string[];
|
||||
files: readonly string[];
|
||||
}>;
|
||||
|
||||
export type RepositoryFileInventoryPolicy = Readonly<{
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots: readonly string[];
|
||||
optionalRoots: readonly string[];
|
||||
}>;
|
||||
|
||||
type InventoryOptions = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots?: readonly string[];
|
||||
optionalRoots?: readonly string[];
|
||||
runGit?: (repositoryRoot: string) => GitFileListResult;
|
||||
lstatPath?: (target: string) => Promise<Stats>;
|
||||
realpathPath?: (target: string) => Promise<string>;
|
||||
assertReadable?: (target: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requiredStringArray(
|
||||
value: unknown,
|
||||
label: string,
|
||||
options: Readonly<{ allowEmpty: boolean }>,
|
||||
): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
(!options.allowEmpty && value.length === 0) ||
|
||||
value.some((entry) => typeof entry !== "string" || entry.length === 0)
|
||||
) {
|
||||
throw new TypeError(`${label} must be an array of non-empty strings`);
|
||||
}
|
||||
if (new Set(value).size !== value.length) {
|
||||
throw new TypeError(`${label} must not contain duplicate roots`);
|
||||
}
|
||||
return Object.freeze([...value] as string[]);
|
||||
}
|
||||
|
||||
export function parseRepositoryFileInventoryPolicy(
|
||||
value: unknown,
|
||||
): RepositoryFileInventoryPolicy {
|
||||
if (!isRecord(value)) {
|
||||
throw new TypeError("repository inventory policy must be an object");
|
||||
}
|
||||
return Object.freeze({
|
||||
trackedRoots: requiredStringArray(value.trackedRoots, "trackedRoots", {
|
||||
allowEmpty: false,
|
||||
}),
|
||||
generatedRoots: requiredStringArray(
|
||||
value.generatedRoots,
|
||||
"generatedRoots",
|
||||
{ allowEmpty: true },
|
||||
),
|
||||
optionalRoots:
|
||||
value.optionalRoots === undefined
|
||||
? Object.freeze([])
|
||||
: requiredStringArray(value.optionalRoots, "optionalRoots", {
|
||||
allowEmpty: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function defaultGitFileList(repositoryRoot: string): GitFileListResult {
|
||||
const result = spawnSync("git", ["ls-files", "-z"], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: "buffer",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return {
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? Buffer.alloc(0),
|
||||
stderr: result.stderr ?? Buffer.alloc(0),
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultAssertReadable(target: string): Promise<void> {
|
||||
const handle = await open(target, "r");
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
function normalizeRepositoryPath(value: string, label: string): string {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
path.posix.isAbsolute(value) ||
|
||||
path.win32.isAbsolute(value) ||
|
||||
value.includes("\\") ||
|
||||
value.includes("\0")
|
||||
) {
|
||||
throw new TypeError(`${label} must be a repository-relative POSIX path`);
|
||||
}
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized !== value.replace(/\/$/u, "")
|
||||
) {
|
||||
throw new TypeError(`${label} must be a repository-relative POSIX path`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function belongsToRoots(file: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => file === root || file.startsWith(`${root}/`));
|
||||
}
|
||||
|
||||
function parseGitFileList(result: GitFileListResult): string[] {
|
||||
if (
|
||||
result.error ||
|
||||
result.status !== 0 ||
|
||||
result.signal !== null ||
|
||||
result.stderr.byteLength > 0
|
||||
) {
|
||||
const detail = result.error?.message ?? result.stderr.toString("utf8").trim();
|
||||
throw new Error(
|
||||
`git ls-files failed${result.signal ? ` (${result.signal})` : ""}${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (result.stdout.byteLength === 0) return [];
|
||||
if (result.stdout.at(-1) !== 0) {
|
||||
throw new TypeError("git ls-files returned output without a terminal NUL");
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = utf8Decoder.decode(result.stdout);
|
||||
} catch (error) {
|
||||
throw new TypeError("git ls-files returned malformed UTF-8", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const rows = decoded.slice(0, -1).split("\0");
|
||||
if (rows.some((row) => row.length === 0)) {
|
||||
throw new TypeError("git ls-files returned an empty NUL-delimited path");
|
||||
}
|
||||
const normalized = rows.map((row) =>
|
||||
normalizeRepositoryPath(row, "git ls-files path"),
|
||||
);
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("git ls-files returned a duplicate path");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === code
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildRepositoryFileInventory(
|
||||
options: InventoryOptions,
|
||||
): Promise<RepositoryFileInventory> {
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const lstatPath = options.lstatPath ?? lstat;
|
||||
const realpathPath = options.realpathPath ?? realpath;
|
||||
const assertReadable = options.assertReadable ?? defaultAssertReadable;
|
||||
const trackedRoots = options.trackedRoots.map((root) =>
|
||||
normalizeRepositoryPath(root, "tracked root"),
|
||||
);
|
||||
const generatedRoots = (options.generatedRoots ?? []).map((root) =>
|
||||
normalizeRepositoryPath(root, "generated root"),
|
||||
);
|
||||
const optionalRoots = new Set(
|
||||
(options.optionalRoots ?? []).map((root) =>
|
||||
normalizeRepositoryPath(root, "optional root"),
|
||||
),
|
||||
);
|
||||
for (const root of optionalRoots) {
|
||||
if (!generatedRoots.includes(root)) {
|
||||
throw new TypeError(`optional root is not generated: ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedRepositoryRoot = await realpathPath(repositoryRoot);
|
||||
|
||||
async function validateRegularFile(relativePath: string): Promise<void> {
|
||||
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
||||
if (!isWithinRoot(repositoryRoot, absolutePath)) {
|
||||
throw new TypeError(`repository inventory path escapes root: ${relativePath}`);
|
||||
}
|
||||
const metadata = await lstatPath(absolutePath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`repository inventory path is not a regular file: ${relativePath}`);
|
||||
}
|
||||
const resolvedPath = await realpathPath(absolutePath);
|
||||
if (!isWithinRoot(resolvedRepositoryRoot, resolvedPath)) {
|
||||
throw new TypeError(`repository inventory symlink escapes root: ${relativePath}`);
|
||||
}
|
||||
try {
|
||||
await assertReadable(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`repository inventory file is unreadable: ${relativePath}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRoot(
|
||||
relativeRoot: string,
|
||||
optional: boolean,
|
||||
): Promise<Stats | null> {
|
||||
const absoluteRoot = path.resolve(repositoryRoot, relativeRoot);
|
||||
try {
|
||||
const metadata = await lstatPath(absoluteRoot);
|
||||
const resolvedRoot = await realpathPath(absoluteRoot);
|
||||
if (!isWithinRoot(resolvedRepositoryRoot, resolvedRoot)) {
|
||||
throw new TypeError(`repository root escapes repository: ${relativeRoot}`);
|
||||
}
|
||||
if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) {
|
||||
throw new TypeError(`repository root is not a regular file or directory: ${relativeRoot}`);
|
||||
}
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
if (optional && hasErrorCode(error, "ENOENT")) return null;
|
||||
throw new Error(`required repository root is unavailable: ${relativeRoot}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of trackedRoots) {
|
||||
await validateRoot(root, false);
|
||||
}
|
||||
|
||||
const trackedFiles = parseGitFileList(
|
||||
(options.runGit ?? defaultGitFileList)(repositoryRoot),
|
||||
)
|
||||
.filter((file) => belongsToRoots(file, trackedRoots))
|
||||
.sort();
|
||||
for (const root of trackedRoots) {
|
||||
if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) {
|
||||
throw new Error(`required tracked file inventory is empty: ${root}`);
|
||||
}
|
||||
}
|
||||
for (const file of trackedFiles) {
|
||||
await validateRegularFile(file);
|
||||
}
|
||||
|
||||
const generatedFiles: string[] = [];
|
||||
async function collectGenerated(relativeTarget: string): Promise<void> {
|
||||
const absoluteTarget = path.resolve(repositoryRoot, relativeTarget);
|
||||
const metadata = await lstatPath(absoluteTarget);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`generated inventory path is a symlink: ${relativeTarget}`);
|
||||
}
|
||||
if (metadata.isFile()) {
|
||||
await validateRegularFile(relativeTarget);
|
||||
generatedFiles.push(relativeTarget);
|
||||
return;
|
||||
}
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new TypeError(`generated inventory path is not regular: ${relativeTarget}`);
|
||||
}
|
||||
const entries = await readdir(absoluteTarget, { withFileTypes: true });
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
await collectGenerated(
|
||||
normalizeRepositoryPath(
|
||||
`${relativeTarget}/${entry.name}`,
|
||||
"generated inventory path",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of generatedRoots) {
|
||||
const metadata = await validateRoot(root, optionalRoots.has(root));
|
||||
if (metadata) await collectGenerated(root);
|
||||
}
|
||||
|
||||
if (new Set(generatedFiles).size !== generatedFiles.length) {
|
||||
throw new TypeError("generated inventory contains duplicate paths");
|
||||
}
|
||||
const uniqueTracked = [...trackedFiles].sort();
|
||||
const uniqueGenerated = [...generatedFiles].sort();
|
||||
return Object.freeze({
|
||||
trackedFiles: Object.freeze(uniqueTracked),
|
||||
generatedFiles: Object.freeze(uniqueGenerated),
|
||||
files: Object.freeze([...new Set([...uniqueTracked, ...uniqueGenerated])].sort()),
|
||||
});
|
||||
}
|
||||
+18
-30
@@ -1,7 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
buildRepositoryFileInventory,
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./lib/repository-file-inventory.ts";
|
||||
|
||||
type SecretFinding = Readonly<{
|
||||
ruleId: string;
|
||||
file: string;
|
||||
@@ -19,6 +24,7 @@ type SecretPolicy = Readonly<{
|
||||
excludedPaths: readonly string[];
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots: readonly string[];
|
||||
optionalRoots: readonly string[];
|
||||
allowlist: readonly AllowlistEntry[];
|
||||
}>;
|
||||
|
||||
@@ -41,6 +47,7 @@ function strings(value: unknown): string[] {
|
||||
|
||||
function parsePolicy(value: unknown): SecretPolicy {
|
||||
const document = isRecord(value) ? value : {};
|
||||
const inventoryPolicy = parseRepositoryFileInventoryPolicy(value);
|
||||
const allowlist = Array.isArray(document.allowlist)
|
||||
? document.allowlist.map((rawEntry) => {
|
||||
const entry = isRecord(rawEntry) ? rawEntry : {};
|
||||
@@ -56,8 +63,9 @@ function parsePolicy(value: unknown): SecretPolicy {
|
||||
: [];
|
||||
return Object.freeze({
|
||||
excludedPaths: Object.freeze(strings(document.excludedPaths)),
|
||||
trackedRoots: Object.freeze(strings(document.trackedRoots)),
|
||||
generatedRoots: Object.freeze(strings(document.generatedRoots)),
|
||||
trackedRoots: inventoryPolicy.trackedRoots,
|
||||
generatedRoots: inventoryPolicy.generatedRoots,
|
||||
optionalRoots: inventoryPolicy.optionalRoots,
|
||||
allowlist: Object.freeze(allowlist),
|
||||
});
|
||||
}
|
||||
@@ -88,23 +96,6 @@ const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
async function filesWithin(target: string): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
if (metadata.isFile()) return [target];
|
||||
const entries = await readdir(target, { withFileTypes: true });
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const child = path.join(target, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(child) : [child];
|
||||
}),
|
||||
);
|
||||
return nested.flat();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const excluded = new Set(
|
||||
policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
|
||||
);
|
||||
@@ -124,10 +115,12 @@ for (const entry of allowlist) {
|
||||
}
|
||||
}
|
||||
|
||||
const roots = [...policy.trackedRoots, ...policy.generatedRoots];
|
||||
const scanFiles = (
|
||||
await Promise.all(roots.map((root) => filesWithin(root)))
|
||||
).flat();
|
||||
const inventory = await buildRepositoryFileInventory({
|
||||
trackedRoots: policy.trackedRoots,
|
||||
generatedRoots: policy.generatedRoots,
|
||||
optionalRoots: policy.optionalRoots,
|
||||
});
|
||||
const scanFiles = inventory.files;
|
||||
for (const scanFile of [...new Set(scanFiles)].sort()) {
|
||||
const normalized = scanFile.replaceAll("\\", "/");
|
||||
if (
|
||||
@@ -138,12 +131,7 @@ for (const scanFile of [...new Set(scanFiles)].sort()) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(scanFile, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = await readFile(scanFile, "utf8");
|
||||
for (const pattern of patterns) {
|
||||
pattern.expression.lastIndex = 0;
|
||||
for (const match of content.matchAll(pattern.expression)) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
import { verifyBuildManifestOutputs } from "./lib/build-manifest-outputs.ts";
|
||||
import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import { releaseVerificationArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
@@ -109,6 +110,7 @@ const actualAssetManifestHash = createHash("sha256")
|
||||
.digest("hex");
|
||||
|
||||
const artifactMismatches: string[] = [...artifactComparison.mismatches];
|
||||
artifactMismatches.push(...(await verifyBuildManifestOutputs(buildManifest)));
|
||||
for (const [token, value] of Object.entries(projectReleaseTokens(release))) {
|
||||
if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) {
|
||||
artifactMismatches.push(`releaseToken:${token}`);
|
||||
|
||||
@@ -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