diff --git a/config/security/secret-scan-policy.json b/config/security/secret-scan-policy.json index 1c39b85..adeeabe 100644 --- a/config/security/secret-scan-policy.json +++ b/config/security/secret-scan-policy.json @@ -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" ], diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index 6f04663..404abb4 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -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; @@ -59,21 +62,6 @@ async function jsonDocument(file: string): Promise { return documentValue(parsed, file); } -async function filesWithin(directory: string): Promise { - 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 { 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) => ({ diff --git a/scripts/lib/build-manifest-outputs.ts b/scripts/lib/build-manifest-outputs.ts new file mode 100644 index 0000000..d7a25f2 --- /dev/null +++ b/scripts/lib/build-manifest-outputs.ts @@ -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; + realpathPath?: (target: string) => Promise; + assertRegularFile?: (target: string) => Promise; + assertDirectory?: (target: string) => Promise; +}>; + +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 { + const metadata = await lstat(target); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new TypeError("not a regular file"); + } +} + +async function defaultAssertDirectory(target: string): Promise { + 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 { + 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>(); + + function resolveApprovedRoot(relativeRoot: string): Promise { + 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 { + 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; +} diff --git a/scripts/lib/repository-file-inventory.ts b/scripts/lib/repository-file-inventory.ts new file mode 100644 index 0000000..230ade4 --- /dev/null +++ b/scripts/lib/repository-file-inventory.ts @@ -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; + realpathPath?: (target: string) => Promise; + assertReadable?: (target: string) => Promise; +}>; + +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +function isRecord(value: unknown): value is Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()), + }); +} diff --git a/scripts/security-scan.ts b/scripts/security-scan.ts index c4d0ed8..01ce9e8 100644 --- a/scripts/security-scan.ts +++ b/scripts/security-scan.ts @@ -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 { - 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)) { diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index 311f8d3..94d7d05 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -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}`); diff --git a/tests/unit/release-artifacts.test.ts b/tests/unit/release-artifacts.test.ts index 7c01d88..61093b6 100644 --- a/tests/unit/release-artifacts.test.ts +++ b/tests/unit/release-artifacts.test.ts @@ -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([ + ["/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([ + ["/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); diff --git a/tests/unit/repository-file-inventory.test.ts b/tests/unit/repository-file-inventory.test.ts new file mode 100644 index 0000000..5cd472f --- /dev/null +++ b/tests/unit/repository-file-inventory.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index 08ea25f..6db56c6 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -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: