fix: make security fixtures fail closed
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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}/`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user