fix: cover every tracked release input

This commit is contained in:
DongHyeonka
2026-08-02 05:26:36 +09:00
parent d6c98489ee
commit 76d0ab0f62
14 changed files with 357 additions and 73 deletions
+65
View File
@@ -0,0 +1,65 @@
import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
type Document = Record<string, unknown>;
function record(value: unknown, label: string): Document {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be an object`);
}
return value as Document;
}
const artifact = "artifacts/security/scan-fixture.sarif";
const scan = spawnSync(
"node",
[
"scripts/security-scan.ts",
"--policy",
"tests/fixtures/security/secret-detection/forbidden-policy.json",
"--artifact",
artifact,
],
{ encoding: "utf8" },
);
if (scan.error || scan.signal || scan.status !== 1) {
throw new Error(
`forbidden security fixture did not fail exactly: ${scan.error?.message ?? scan.stderr}`,
);
}
const sarif = record(
JSON.parse(await readFile(artifact, "utf8")),
"security fixture SARIF",
);
const runs = Array.isArray(sarif.runs) ? sarif.runs : [];
const run = record(runs[0], "security fixture SARIF run");
const results = Array.isArray(run.results) ? run.results : [];
const actual = results
.map((rawResult) => {
const result = record(rawResult, "security fixture result");
const locations = Array.isArray(result.locations) ? result.locations : [];
const location = record(locations[0], "security fixture location");
const physical = record(
location.physicalLocation,
"security fixture physical location",
);
const artifactLocation = record(
physical.artifactLocation,
"security fixture artifact location",
);
return `${String(artifactLocation.uri)}:${String(result.ruleId)}`;
})
.sort();
const root = "tests/fixtures/security/secret-detection/forbidden";
const expected = [
`${root}/config.json:assigned-secret`,
`${root}/dist.ts:assigned-secret`,
`${root}/source.ts:aws-access-key`,
].sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`security fixture findings mismatch: expected ${expected.join(", ")}; received ${actual.join(", ")}`,
);
}
process.stdout.write("Security fixtures: 3 forbidden files detected\n");
+3 -2
View File
@@ -20,6 +20,7 @@ import {
buildDate,
} from "./lib/build-environment.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts";
assertCiBuildEnvironment(process.env);
type ViteManifestEntry = Readonly<{
@@ -38,7 +39,7 @@ const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const buildTime = buildDate(process.env);
const builtAt = buildTime.toISOString();
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8");
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
const moduleInventory = await readFile(
"dist/.vite/module-inventory.json",
@@ -90,7 +91,7 @@ const manifest = buildManifestArtifactSchema.parse({
},
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
viteManifest: CANONICAL_VITE_MANIFEST_PATH,
moduleInventory: "artifacts/quality/vite-module-inventory.json",
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
+2 -11
View File
@@ -32,6 +32,7 @@ import {
type DependencyInventoryDiff,
} from "./lib/supply-chain.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
@@ -66,16 +67,6 @@ async function sha256File(file: string): Promise<string> {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
async function digestFileSet(files: string[]): Promise<string> {
const rows = await Promise.all(
files.sort().map(async (file) => ({
path: file.replaceAll("\\", "/"),
sha256: await sha256File(file),
})),
);
return supplyChainDigest(rows);
}
async function optionalJson(file: string): Promise<Document | null> {
try {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
@@ -279,7 +270,7 @@ const vulnerabilityReport = {
};
const sourceFiles = [...repositoryInventory.trackedFiles];
const sourceSetSha256 = await digestFileSet(sourceFiles);
const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles);
const components = inventory.dependencies.map((dependency) => ({
type: "library",
+18 -7
View File
@@ -13,6 +13,8 @@ type VerifyBuildManifestOutputsDependencies = Readonly<{
assertDirectory?: (target: string) => Promise<void>;
}>;
export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json";
function isSafeRelativePath(value: string): boolean {
return (
value.length > 0 &&
@@ -28,7 +30,12 @@ function isSafeRelativePath(value: string): boolean {
function isWithinRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function belongsToApprovedRoot(value: string, approvedRoot: string): boolean {
@@ -127,12 +134,16 @@ export async function verifyBuildManifestOutputs(
} else {
await confinedPath("directory", manifest.outputs.directory, "directory");
}
await confinedPath(
"viteManifest",
manifest.outputs.viteManifest,
"file",
"dist",
);
if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) {
mismatches.push("buildManifest:viteManifest:path");
} else {
await confinedPath(
"viteManifest",
manifest.outputs.viteManifest,
"file",
"dist",
);
}
await confinedPath(
"runtimeConfigSchema",
manifest.outputs.runtimeConfigSchema,
+19
View File
@@ -0,0 +1,19 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { supplyChainDigest } from "./supply-chain.ts";
export async function digestReleaseInputFiles(
files: readonly string[],
readBytes: (file: string) => Promise<Buffer> = readFile,
): Promise<string> {
const rows = await Promise.all(
[...files].sort().map(async (file) => ({
path: file,
sha256: createHash("sha256")
.update(await readBytes(file))
.digest("hex"),
})),
);
return supplyChainDigest(rows);
}
+15 -9
View File
@@ -131,11 +131,12 @@ function normalizeRepositoryPath(value: string, label: string): string {
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}/`));
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function parseGitFileList(result: GitFileListResult): string[] {
@@ -261,9 +262,7 @@ export async function buildRepositoryFileInventory(
const trackedFiles = parseGitFileList(
(options.runGit ?? defaultGitFileList)(repositoryRoot),
)
.filter((file) => belongsToRoots(file, trackedRoots))
.sort();
).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}`);
@@ -309,9 +308,16 @@ export async function buildRepositoryFileInventory(
}
const uniqueTracked = [...trackedFiles].sort();
const uniqueGenerated = [...generatedFiles].sort();
const trackedSet = new Set(uniqueTracked);
const collisions = uniqueGenerated.filter((file) => trackedSet.has(file));
if (collisions.length > 0) {
throw new TypeError(
`tracked and generated inventory paths collide: ${collisions.join(", ")}`,
);
}
return Object.freeze({
trackedFiles: Object.freeze(uniqueTracked),
generatedFiles: Object.freeze(uniqueGenerated),
files: Object.freeze([...new Set([...uniqueTracked, ...uniqueGenerated])].sort()),
files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()),
});
}
+73
View File
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
export type SecretFinding = Readonly<{
ruleId: string;
file: string;
line: number;
fingerprint: string;
}>;
export type SecretAllowlistEntry = Readonly<{
path: string;
ruleId: string;
expiresAt: string;
}>;
const secretPatterns: readonly Readonly<{
id: string;
expression: RegExp;
}>[] = [
{
id: "private-key",
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
},
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{
id: "assigned-secret",
expression:
/(?<![\w])(["']?)(?:client_secret|password|private_key)\1(?![\w])\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
},
];
export function secretScanRules(): readonly Readonly<{
id: string;
expression: RegExp;
}>[] {
return secretPatterns;
}
export function findSecretMatches(
file: string,
content: string,
options: Readonly<{
allowlist?: readonly SecretAllowlistEntry[];
now?: number;
}> = {},
): SecretFinding[] {
const allowlist = options.allowlist ?? [];
const now = options.now ?? Date.now();
const findings: SecretFinding[] = [];
for (const pattern of secretPatterns) {
pattern.expression.lastIndex = 0;
for (const match of content.matchAll(pattern.expression)) {
const isAllowed = allowlist.some(
(entry) =>
entry.path === file &&
entry.ruleId === pattern.id &&
Date.parse(entry.expiresAt) > now,
);
if (isAllowed) continue;
const matchIndex = match.index ?? 0;
findings.push({
ruleId: pattern.id,
file,
line: content.slice(0, matchIndex).split(/\r?\n/u).length,
fingerprint: createHash("sha256")
.update(`${pattern.id}:${file}:${String(matchIndex)}`)
.digest("hex"),
});
}
}
return findings;
}
+18 -42
View File
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -6,13 +5,12 @@ import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import {
findSecretMatches,
secretScanRules,
type SecretFinding,
} from "./lib/secret-scan.ts";
type SecretFinding = Readonly<{
ruleId: string;
file: string;
line: number;
fingerprint: string;
}>;
type AllowlistEntry = Readonly<{
path: string;
ruleId: string;
@@ -25,6 +23,7 @@ type SecretPolicy = Readonly<{
trackedRoots: readonly string[];
generatedRoots: readonly string[];
optionalRoots: readonly string[];
includedPaths: readonly string[];
allowlist: readonly AllowlistEntry[];
}>;
@@ -66,6 +65,7 @@ function parsePolicy(value: unknown): SecretPolicy {
trackedRoots: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
includedPaths: Object.freeze(strings(document.includedPaths)),
allowlist: Object.freeze(allowlist),
});
}
@@ -82,23 +82,14 @@ const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8"));
const policy = parsePolicy(rawPolicy);
const findings: SecretFinding[] = [];
const policyFailures: string[] = [];
const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [
{
id: "private-key",
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
},
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{
id: "assigned-secret",
expression:
/\b(?:client_secret|password|private_key)\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
},
];
const patterns = secretScanRules();
const excluded = new Set(
policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
);
const included = policy.includedPaths.map((entry) =>
entry.replaceAll("\\", "/"),
);
const allowlist = policy.allowlist;
for (const entry of allowlist) {
const expiry = Date.parse(entry.expiresAt);
@@ -124,6 +115,10 @@ const scanFiles = inventory.files;
for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/");
if (
(included.length > 0 &&
!included.some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
)) ||
[...excluded].some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
) ||
@@ -132,28 +127,9 @@ for (const scanFile of [...new Set(scanFiles)].sort()) {
continue;
}
const content = await readFile(scanFile, "utf8");
for (const pattern of patterns) {
pattern.expression.lastIndex = 0;
for (const match of content.matchAll(pattern.expression)) {
const isAllowed = allowlist.some(
(entry) =>
entry.path === normalized &&
entry.ruleId === pattern.id &&
Date.parse(entry.expiresAt) > Date.now(),
);
if (isAllowed) continue;
const matchIndex = match.index ?? 0;
const prefix = content.slice(0, matchIndex);
findings.push({
ruleId: pattern.id,
file: normalized,
line: prefix.split(/\r?\n/).length,
fingerprint: createHash("sha256")
.update(`${pattern.id}:${normalized}:${String(matchIndex)}`)
.digest("hex"),
});
}
}
findings.push(
...findSecretMatches(normalized, content, { allowlist }),
);
}
const sarif = {
+5 -2
View File
@@ -22,7 +22,10 @@ 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 {
CANONICAL_VITE_MANIFEST_PATH,
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";
@@ -103,7 +106,7 @@ const runtimeConfigJsonSchema = requireRecord(
JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")),
"runtime config JSON schema",
);
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8");
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
const actualAssetManifestHash = createHash("sha256")
.update(viteManifest)