fix: fail closed on release input discovery

This commit is contained in:
DongHyeonka
2026-08-02 05:11:30 +09:00
parent 381d5549e2
commit d6c98489ee
9 changed files with 975 additions and 69 deletions
+18 -38
View File
@@ -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) => ({
+177
View File
@@ -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;
}
+317
View File
@@ -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
View File
@@ -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)) {
+2
View File
@@ -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}`);