fix: make security fixtures fail closed
This commit is contained in:
@@ -107,13 +107,17 @@ async function defaultAssertReadable(target: string): Promise<void> {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
function normalizeRepositoryPath(value: string, label: string): string {
|
||||
export function normalizeRepositoryRelativePath(
|
||||
value: string,
|
||||
label = "repository path",
|
||||
): string {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
path.posix.isAbsolute(value) ||
|
||||
path.win32.isAbsolute(value) ||
|
||||
value.includes("\\") ||
|
||||
value.includes("\0")
|
||||
value.includes("\0") ||
|
||||
value.endsWith("/")
|
||||
) {
|
||||
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.startsWith("../") ||
|
||||
normalized !== value.replace(/\/$/u, "")
|
||||
normalized !== value
|
||||
) {
|
||||
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");
|
||||
}
|
||||
const normalized = rows.map((row) =>
|
||||
normalizeRepositoryPath(row, "git ls-files path"),
|
||||
normalizeRepositoryRelativePath(row, "git ls-files path"),
|
||||
);
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("git ls-files returned a duplicate path");
|
||||
@@ -193,14 +197,14 @@ export async function buildRepositoryFileInventory(
|
||||
const realpathPath = options.realpathPath ?? realpath;
|
||||
const assertReadable = options.assertReadable ?? defaultAssertReadable;
|
||||
const trackedRoots = options.trackedRoots.map((root) =>
|
||||
normalizeRepositoryPath(root, "tracked root"),
|
||||
normalizeRepositoryRelativePath(root, "tracked root"),
|
||||
);
|
||||
const generatedRoots = (options.generatedRoots ?? []).map((root) =>
|
||||
normalizeRepositoryPath(root, "generated root"),
|
||||
normalizeRepositoryRelativePath(root, "generated root"),
|
||||
);
|
||||
const optionalRoots = new Set(
|
||||
(options.optionalRoots ?? []).map((root) =>
|
||||
normalizeRepositoryPath(root, "optional root"),
|
||||
normalizeRepositoryRelativePath(root, "optional root"),
|
||||
),
|
||||
);
|
||||
for (const root of optionalRoots) {
|
||||
@@ -290,7 +294,7 @@ export async function buildRepositoryFileInventory(
|
||||
const entries = await readdir(absoluteTarget, { withFileTypes: true });
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
await collectGenerated(
|
||||
normalizeRepositoryPath(
|
||||
normalizeRepositoryRelativePath(
|
||||
`${relativeTarget}/${entry.name}`,
|
||||
"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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user