fix: make security fixtures fail closed

This commit is contained in:
DongHyeonka
2026-08-02 05:40:58 +09:00
parent 76d0ab0f62
commit 100a3bb6ba
8 changed files with 374 additions and 83 deletions
+1 -2
View File
@@ -275,13 +275,12 @@
"script": "check:supply-chain:provider-fixtures", "script": "check:supply-chain:provider-fixtures",
"expect": "pass" "expect": "pass"
}, },
{ "script": "scan:security:fixture", "expect": "fail", "expectedExitCode": 1, "expectedDiagnosticId": "Security scan found" }, { "script": "check:security:fixtures", "expect": "pass" },
{ "script": "check:browser-security", "expect": "pass" } { "script": "check:browser-security", "expect": "pass" }
], ],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt", "logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [ "evidence": [
"artifacts/security/scan.sarif", "artifacts/security/scan.sarif",
"artifacts/security/scan-fixture.sarif",
"artifacts/release/dependency-inventory.json", "artifacts/release/dependency-inventory.json",
"artifacts/release/sbom.cdx.json", "artifacts/release/sbom.cdx.json",
"artifacts/release/provenance.json", "artifacts/release/provenance.json",
+8 -63
View File
@@ -1,65 +1,10 @@
import { spawnSync } from "node:child_process"; import {
import { readFile } from "node:fs/promises"; checkNonmatchingSecurityIncludeFixture,
checkSecurityFixtures,
} from "./lib/security-fixture-check.ts";
type Document = Record<string, unknown>; await checkNonmatchingSecurityIncludeFixture();
await checkSecurityFixtures();
function record(value: unknown, label: string): Document { process.stdout.write(
if (!value || typeof value !== "object" || Array.isArray(value)) { "Security fixtures: unmatched include rejected; 3 forbidden files detected\n",
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");
+12 -8
View File
@@ -107,13 +107,17 @@ async function defaultAssertReadable(target: string): Promise<void> {
await handle.close(); await handle.close();
} }
function normalizeRepositoryPath(value: string, label: string): string { export function normalizeRepositoryRelativePath(
value: string,
label = "repository path",
): string {
if ( if (
value.length === 0 || value.length === 0 ||
path.posix.isAbsolute(value) || path.posix.isAbsolute(value) ||
path.win32.isAbsolute(value) || path.win32.isAbsolute(value) ||
value.includes("\\") || value.includes("\\") ||
value.includes("\0") value.includes("\0") ||
value.endsWith("/")
) { ) {
throw new TypeError(`${label} must be a repository-relative POSIX path`); throw new TypeError(`${label} must be a repository-relative POSIX path`);
} }
@@ -122,7 +126,7 @@ function normalizeRepositoryPath(value: string, label: string): string {
normalized === "." || normalized === "." ||
normalized === ".." || normalized === ".." ||
normalized.startsWith("../") || normalized.startsWith("../") ||
normalized !== value.replace(/\/$/u, "") normalized !== value
) { ) {
throw new TypeError(`${label} must be a repository-relative POSIX path`); throw new TypeError(`${label} must be a repository-relative POSIX path`);
} }
@@ -168,7 +172,7 @@ function parseGitFileList(result: GitFileListResult): string[] {
throw new TypeError("git ls-files returned an empty NUL-delimited path"); throw new TypeError("git ls-files returned an empty NUL-delimited path");
} }
const normalized = rows.map((row) => const normalized = rows.map((row) =>
normalizeRepositoryPath(row, "git ls-files path"), normalizeRepositoryRelativePath(row, "git ls-files path"),
); );
if (new Set(normalized).size !== normalized.length) { if (new Set(normalized).size !== normalized.length) {
throw new TypeError("git ls-files returned a duplicate path"); throw new TypeError("git ls-files returned a duplicate path");
@@ -193,14 +197,14 @@ export async function buildRepositoryFileInventory(
const realpathPath = options.realpathPath ?? realpath; const realpathPath = options.realpathPath ?? realpath;
const assertReadable = options.assertReadable ?? defaultAssertReadable; const assertReadable = options.assertReadable ?? defaultAssertReadable;
const trackedRoots = options.trackedRoots.map((root) => const trackedRoots = options.trackedRoots.map((root) =>
normalizeRepositoryPath(root, "tracked root"), normalizeRepositoryRelativePath(root, "tracked root"),
); );
const generatedRoots = (options.generatedRoots ?? []).map((root) => const generatedRoots = (options.generatedRoots ?? []).map((root) =>
normalizeRepositoryPath(root, "generated root"), normalizeRepositoryRelativePath(root, "generated root"),
); );
const optionalRoots = new Set( const optionalRoots = new Set(
(options.optionalRoots ?? []).map((root) => (options.optionalRoots ?? []).map((root) =>
normalizeRepositoryPath(root, "optional root"), normalizeRepositoryRelativePath(root, "optional root"),
), ),
); );
for (const root of optionalRoots) { for (const root of optionalRoots) {
@@ -290,7 +294,7 @@ export async function buildRepositoryFileInventory(
const entries = await readdir(absoluteTarget, { withFileTypes: true }); const entries = await readdir(absoluteTarget, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
await collectGenerated( await collectGenerated(
normalizeRepositoryPath( normalizeRepositoryRelativePath(
`${relativeTarget}/${entry.name}`, `${relativeTarget}/${entry.name}`,
"generated inventory path", "generated inventory path",
), ),
+59
View File
@@ -0,0 +1,59 @@
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
export function parseSecretScanIncludedPaths(
value: unknown,
): readonly string[] | null {
if (value === undefined) return null;
if (
!Array.isArray(value) ||
value.length === 0 ||
value.some(
(entry) =>
typeof entry !== "string" ||
entry.length === 0 ||
entry.trim() !== entry,
)
) {
throw new TypeError(
"includedPaths must be a non-empty array of repository-relative POSIX paths",
);
}
const normalized = value.map((entry) =>
normalizeRepositoryRelativePath(entry as string, "included path"),
);
if (new Set(normalized).size !== normalized.length) {
throw new TypeError("includedPaths must not contain duplicate paths");
}
return Object.freeze(normalized);
}
export function selectIncludedInventoryFiles(
inventoryFiles: readonly string[],
includedPaths: readonly string[] | null,
): readonly string[] {
if (includedPaths === null) return Object.freeze([...inventoryFiles]);
const validatedIncludedPaths = parseSecretScanIncludedPaths(includedPaths);
if (validatedIncludedPaths === null) {
throw new TypeError("includedPaths unexpectedly omitted");
}
for (const includedPath of validatedIncludedPaths) {
if (
!inventoryFiles.some(
(file) =>
file === includedPath || file.startsWith(`${includedPath}/`),
)
) {
throw new Error(
`secret scan included path matches no inventory file: ${includedPath}`,
);
}
}
return Object.freeze(
inventoryFiles.filter((file) =>
validatedIncludedPaths.some(
(includedPath) =>
file === includedPath || file.startsWith(`${includedPath}/`),
),
),
);
}
+170
View File
@@ -0,0 +1,170 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type ScanResult = Readonly<{
error?: Error;
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}>;
type SecurityFixtureCheckDependencies = Readonly<{
createTempDirectory?: () => Promise<string>;
runScan?: (artifactPath: string, policyPath: string) => ScanResult;
readArtifact?: (artifactPath: string) => Promise<string>;
cleanup?: (directory: string) => Promise<void>;
}>;
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;
}
function defaultScan(artifactPath: string, policyPath: string): ScanResult {
const scan = spawnSync(
"node",
[
"scripts/security-scan.ts",
"--policy",
policyPath,
"--artifact",
artifactPath,
],
{ encoding: "utf8" },
);
return {
...(scan.error ? { error: scan.error } : {}),
status: scan.status,
signal: scan.signal,
stdout: scan.stdout ?? "",
stderr: scan.stderr ?? "",
};
}
function assertExactFindings(rawArtifact: string): void {
const sarif = record(JSON.parse(rawArtifact), "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(", ")}`,
);
}
}
export async function checkSecurityFixtures(
dependencies: SecurityFixtureCheckDependencies = {},
): Promise<void> {
const createTempDirectory =
dependencies.createTempDirectory ??
(() => mkdtemp(path.join(tmpdir(), "ca-security-fixture-")));
const runScan = dependencies.runScan ?? defaultScan;
const readArtifact = dependencies.readArtifact ?? ((target) => readFile(target, "utf8"));
const cleanup =
dependencies.cleanup ??
((directory) => rm(directory, { recursive: true, force: true }));
const directory = await createTempDirectory();
const artifactPath = path.join(directory, "scan-fixture.sarif");
try {
const scan = runScan(
artifactPath,
"tests/fixtures/security/secret-detection/forbidden-policy.json",
);
const expectedDiagnostic = "Security scan found 3 blocking result(s).";
if (
scan.error ||
scan.status !== 1 ||
scan.signal !== null ||
scan.stderr !== `${expectedDiagnostic}\n`
) {
throw new Error(
`forbidden security fixture did not fail exactly: ${scan.error?.message ?? scan.stderr}`,
);
}
assertExactFindings(await readArtifact(artifactPath));
} finally {
await cleanup(directory);
}
}
export async function checkNonmatchingSecurityIncludeFixture(
dependencies: SecurityFixtureCheckDependencies = {},
): Promise<void> {
const createTempDirectory =
dependencies.createTempDirectory ??
(() => mkdtemp(path.join(tmpdir(), "ca-security-include-fixture-")));
const runScan = dependencies.runScan ?? defaultScan;
const readArtifact =
dependencies.readArtifact ?? ((target) => readFile(target, "utf8"));
const cleanup =
dependencies.cleanup ??
((directory) => rm(directory, { recursive: true, force: true }));
const directory = await createTempDirectory();
const artifactPath = path.join(directory, "scan-fixture.sarif");
try {
const includedPath =
"tests/fixtures/security/secret-detection/misspelled";
const scan = runScan(
artifactPath,
"tests/fixtures/security/secret-detection/nonmatching-policy.json",
);
if (
scan.error ||
scan.status !== 1 ||
scan.signal !== null ||
!scan.stderr.includes(
`secret scan included path matches no inventory file: ${includedPath}`,
)
) {
throw new Error(
`nonmatching security include fixture did not fail closed: ${scan.error?.message ?? scan.stderr}`,
);
}
try {
await readArtifact(artifactPath);
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT"
) {
return;
}
throw error;
}
throw new Error("nonmatching security include fixture wrote an artifact");
} finally {
await cleanup(directory);
}
}
+10 -10
View File
@@ -10,6 +10,10 @@ import {
secretScanRules, secretScanRules,
type SecretFinding, type SecretFinding,
} from "./lib/secret-scan.ts"; } from "./lib/secret-scan.ts";
import {
parseSecretScanIncludedPaths,
selectIncludedInventoryFiles,
} from "./lib/secret-scan-policy.ts";
type AllowlistEntry = Readonly<{ type AllowlistEntry = Readonly<{
path: string; path: string;
@@ -23,7 +27,7 @@ type SecretPolicy = Readonly<{
trackedRoots: readonly string[]; trackedRoots: readonly string[];
generatedRoots: readonly string[]; generatedRoots: readonly string[];
optionalRoots: readonly string[]; optionalRoots: readonly string[];
includedPaths: readonly string[]; includedPaths: readonly string[] | null;
allowlist: readonly AllowlistEntry[]; allowlist: readonly AllowlistEntry[];
}>; }>;
@@ -65,7 +69,7 @@ function parsePolicy(value: unknown): SecretPolicy {
trackedRoots: inventoryPolicy.trackedRoots, trackedRoots: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots, generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots, optionalRoots: inventoryPolicy.optionalRoots,
includedPaths: Object.freeze(strings(document.includedPaths)), includedPaths: parseSecretScanIncludedPaths(document.includedPaths),
allowlist: Object.freeze(allowlist), allowlist: Object.freeze(allowlist),
}); });
} }
@@ -87,9 +91,6 @@ const patterns = secretScanRules();
const excluded = new Set( const excluded = new Set(
policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")), policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
); );
const included = policy.includedPaths.map((entry) =>
entry.replaceAll("\\", "/"),
);
const allowlist = policy.allowlist; const allowlist = policy.allowlist;
for (const entry of allowlist) { for (const entry of allowlist) {
const expiry = Date.parse(entry.expiresAt); const expiry = Date.parse(entry.expiresAt);
@@ -111,14 +112,13 @@ const inventory = await buildRepositoryFileInventory({
generatedRoots: policy.generatedRoots, generatedRoots: policy.generatedRoots,
optionalRoots: policy.optionalRoots, optionalRoots: policy.optionalRoots,
}); });
const scanFiles = inventory.files; const scanFiles = selectIncludedInventoryFiles(
inventory.files,
policy.includedPaths,
);
for (const scanFile of [...new Set(scanFiles)].sort()) { for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/"); const normalized = scanFile.replaceAll("\\", "/");
if ( if (
(included.length > 0 &&
!included.some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
)) ||
[...excluded].some( [...excluded].some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`), (entry) => normalized === entry || normalized.startsWith(`${entry}/`),
) || ) ||
@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"trackedRoots": [
"tests/fixtures/security/secret-detection/forbidden"
],
"generatedRoots": [],
"includedPaths": [
"tests/fixtures/security/secret-detection/misspelled"
],
"excludedPaths": [],
"allowlist": []
}
+102
View File
@@ -12,6 +12,11 @@ import {
} from "../../scripts/lib/supply-chain.ts"; } from "../../scripts/lib/supply-chain.ts";
import { digestReleaseInputFiles } from "../../scripts/lib/release-input-evidence.ts"; import { digestReleaseInputFiles } from "../../scripts/lib/release-input-evidence.ts";
import { findSecretMatches } from "../../scripts/lib/secret-scan.ts"; import { findSecretMatches } from "../../scripts/lib/secret-scan.ts";
import {
parseSecretScanIncludedPaths,
selectIncludedInventoryFiles,
} from "../../scripts/lib/secret-scan-policy.ts";
import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.ts";
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
const dependency = { const dependency = {
@@ -26,6 +31,103 @@ const dependency = {
}; };
describe("supply-chain policy", () => { describe("supply-chain policy", () => {
it.each([
["empty", []],
["empty entry", [""]],
["blank entry", [" "]],
["absolute", ["/src"]],
["backslash", ["src\\file.ts"]],
["dot", ["."]],
["dotdot", [".."]],
["traversal", ["src/../docs"]],
["trailing slash", ["src/"]],
["mixed", ["src", 42]],
["duplicate", ["src", "src"]],
])("rejects %s secret-scan include paths", (_name, includedPaths) => {
expect(() => parseSecretScanIncludedPaths(includedPaths)).toThrow();
});
it("requires every configured include path to match the inventory", () => {
expect(
selectIncludedInventoryFiles(
["README.md", "src/app.ts"],
["src"],
),
).toEqual(["src/app.ts"]);
expect(() =>
selectIncludedInventoryFiles(
["README.md", "src/app.ts"],
["misspelled"],
),
).toThrow(/misspelled/u);
expect(
selectIncludedInventoryFiles(
["README.md", "src/app.ts"],
null,
),
).toEqual(["README.md", "src/app.ts"]);
});
it("rejects a crashed fixture scan and cannot reuse a stale repository artifact", async () => {
const cleaned: string[] = [];
await expect(
checkSecurityFixtures({
createTempDirectory: async () => "/tmp/fresh-security-fixture",
runScan: () => ({
status: 1,
signal: null,
stdout: "",
stderr: "Security scan found 3 blocking result(s).\n",
}),
readArtifact: async (artifactPath) => {
expect(artifactPath).toBe(
"/tmp/fresh-security-fixture/scan-fixture.sarif",
);
throw Object.assign(new Error("fresh artifact missing"), {
code: "ENOENT",
});
},
cleanup: async (directory) => {
cleaned.push(directory);
},
}),
).rejects.toThrow(/fresh artifact missing/u);
expect(cleaned).toEqual(["/tmp/fresh-security-fixture"]);
await expect(
checkSecurityFixtures({
createTempDirectory: async () => "/tmp/fresh-security-fixture",
runScan: () => ({
status: null,
signal: "SIGTERM",
stdout: "",
stderr: "Security scan found 3 blocking result(s).\n",
}),
readArtifact: async () => "{}",
cleanup: async () => undefined,
}),
).rejects.toThrow(/did not fail exactly/u);
});
it("wires the exact security fixture checker as a passing CI gate", async () => {
const gates = JSON.parse(await readFile("config/ci/gates.json", "utf8")) as {
gates: Record<string, { steps: unknown[]; evidence: string[] }>;
};
const securityGate = gates.gates["FE-GATE-013"]!;
expect(securityGate.steps).toContainEqual({
script: "check:security:fixtures",
expect: "pass",
});
expect(securityGate.steps).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ script: "scan:security:fixture" }),
]),
);
expect(securityGate.evidence).not.toContain(
"artifacts/security/scan-fixture.sarif",
);
});
it("uses one fail-closed repository inventory for provenance and secret scanning", async () => { it("uses one fail-closed repository inventory for provenance and secret scanning", async () => {
const [provenanceSource, securitySource] = await Promise.all([ const [provenanceSource, securitySource] = await Promise.all([
readFile("scripts/generate-supply-chain.ts", "utf8"), readFile("scripts/generate-supply-chain.ts", "utf8"),