fix: cover every tracked release input
This commit is contained in:
@@ -81,6 +81,7 @@
|
|||||||
"verify:reproducible-build": "node scripts/verify-reproducible-build.ts",
|
"verify:reproducible-build": "node scripts/verify-reproducible-build.ts",
|
||||||
"scan:security": "node scripts/security-scan.ts",
|
"scan:security": "node scripts/security-scan.ts",
|
||||||
"scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
|
"scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
|
||||||
|
"check:security:fixtures": "node scripts/check-security-fixtures.ts",
|
||||||
"check:browser-security": "node scripts/check-browser-security.ts",
|
"check:browser-security": "node scripts/check-browser-security.ts",
|
||||||
"check:browser-file-storage-boundaries": "node scripts/check-browser-file-storage-boundaries.ts",
|
"check:browser-file-storage-boundaries": "node scripts/check-browser-file-storage-boundaries.ts",
|
||||||
"check:realtime-boundaries": "node scripts/check-realtime-boundaries.ts",
|
"check:realtime-boundaries": "node scripts/check-realtime-boundaries.ts",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
buildDate,
|
buildDate,
|
||||||
} from "./lib/build-environment.ts";
|
} from "./lib/build-environment.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
|
import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts";
|
||||||
|
|
||||||
assertCiBuildEnvironment(process.env);
|
assertCiBuildEnvironment(process.env);
|
||||||
type ViteManifestEntry = Readonly<{
|
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 runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
|
||||||
const buildTime = buildDate(process.env);
|
const buildTime = buildDate(process.env);
|
||||||
const builtAt = buildTime.toISOString();
|
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 viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
|
||||||
const moduleInventory = await readFile(
|
const moduleInventory = await readFile(
|
||||||
"dist/.vite/module-inventory.json",
|
"dist/.vite/module-inventory.json",
|
||||||
@@ -90,7 +91,7 @@ const manifest = buildManifestArtifactSchema.parse({
|
|||||||
},
|
},
|
||||||
outputs: {
|
outputs: {
|
||||||
directory: "dist",
|
directory: "dist",
|
||||||
viteManifest: "dist/.vite/manifest.json",
|
viteManifest: CANONICAL_VITE_MANIFEST_PATH,
|
||||||
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||||
routeChunks,
|
routeChunks,
|
||||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
type DependencyInventoryDiff,
|
type DependencyInventoryDiff,
|
||||||
} from "./lib/supply-chain.ts";
|
} from "./lib/supply-chain.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
|
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
|
||||||
import {
|
import {
|
||||||
buildRepositoryFileInventory,
|
buildRepositoryFileInventory,
|
||||||
parseRepositoryFileInventoryPolicy,
|
parseRepositoryFileInventoryPolicy,
|
||||||
@@ -66,16 +67,6 @@ async function sha256File(file: string): Promise<string> {
|
|||||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
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> {
|
async function optionalJson(file: string): Promise<Document | null> {
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||||
@@ -279,7 +270,7 @@ const vulnerabilityReport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sourceFiles = [...repositoryInventory.trackedFiles];
|
const sourceFiles = [...repositoryInventory.trackedFiles];
|
||||||
const sourceSetSha256 = await digestFileSet(sourceFiles);
|
const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles);
|
||||||
|
|
||||||
const components = inventory.dependencies.map((dependency) => ({
|
const components = inventory.dependencies.map((dependency) => ({
|
||||||
type: "library",
|
type: "library",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type VerifyBuildManifestOutputsDependencies = Readonly<{
|
|||||||
assertDirectory?: (target: string) => Promise<void>;
|
assertDirectory?: (target: string) => Promise<void>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json";
|
||||||
|
|
||||||
function isSafeRelativePath(value: string): boolean {
|
function isSafeRelativePath(value: string): boolean {
|
||||||
return (
|
return (
|
||||||
value.length > 0 &&
|
value.length > 0 &&
|
||||||
@@ -28,7 +30,12 @@ function isSafeRelativePath(value: string): boolean {
|
|||||||
|
|
||||||
function isWithinRoot(root: string, target: string): boolean {
|
function isWithinRoot(root: string, target: string): boolean {
|
||||||
const relative = path.relative(root, target);
|
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 {
|
function belongsToApprovedRoot(value: string, approvedRoot: string): boolean {
|
||||||
@@ -127,12 +134,16 @@ export async function verifyBuildManifestOutputs(
|
|||||||
} else {
|
} else {
|
||||||
await confinedPath("directory", manifest.outputs.directory, "directory");
|
await confinedPath("directory", manifest.outputs.directory, "directory");
|
||||||
}
|
}
|
||||||
await confinedPath(
|
if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) {
|
||||||
"viteManifest",
|
mismatches.push("buildManifest:viteManifest:path");
|
||||||
manifest.outputs.viteManifest,
|
} else {
|
||||||
"file",
|
await confinedPath(
|
||||||
"dist",
|
"viteManifest",
|
||||||
);
|
manifest.outputs.viteManifest,
|
||||||
|
"file",
|
||||||
|
"dist",
|
||||||
|
);
|
||||||
|
}
|
||||||
await confinedPath(
|
await confinedPath(
|
||||||
"runtimeConfigSchema",
|
"runtimeConfigSchema",
|
||||||
manifest.outputs.runtimeConfigSchema,
|
manifest.outputs.runtimeConfigSchema,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -131,11 +131,12 @@ function normalizeRepositoryPath(value: string, label: string): string {
|
|||||||
|
|
||||||
function isWithinRoot(root: string, target: string): boolean {
|
function isWithinRoot(root: string, target: string): boolean {
|
||||||
const relative = path.relative(root, target);
|
const relative = path.relative(root, target);
|
||||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
return (
|
||||||
}
|
relative === "" ||
|
||||||
|
(relative !== ".." &&
|
||||||
function belongsToRoots(file: string, roots: readonly string[]): boolean {
|
!relative.startsWith(`..${path.sep}`) &&
|
||||||
return roots.some((root) => file === root || file.startsWith(`${root}/`));
|
!path.isAbsolute(relative))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGitFileList(result: GitFileListResult): string[] {
|
function parseGitFileList(result: GitFileListResult): string[] {
|
||||||
@@ -261,9 +262,7 @@ export async function buildRepositoryFileInventory(
|
|||||||
|
|
||||||
const trackedFiles = parseGitFileList(
|
const trackedFiles = parseGitFileList(
|
||||||
(options.runGit ?? defaultGitFileList)(repositoryRoot),
|
(options.runGit ?? defaultGitFileList)(repositoryRoot),
|
||||||
)
|
).sort();
|
||||||
.filter((file) => belongsToRoots(file, trackedRoots))
|
|
||||||
.sort();
|
|
||||||
for (const root of trackedRoots) {
|
for (const root of trackedRoots) {
|
||||||
if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) {
|
if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) {
|
||||||
throw new Error(`required tracked file inventory is empty: ${root}`);
|
throw new Error(`required tracked file inventory is empty: ${root}`);
|
||||||
@@ -309,9 +308,16 @@ export async function buildRepositoryFileInventory(
|
|||||||
}
|
}
|
||||||
const uniqueTracked = [...trackedFiles].sort();
|
const uniqueTracked = [...trackedFiles].sort();
|
||||||
const uniqueGenerated = [...generatedFiles].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({
|
return Object.freeze({
|
||||||
trackedFiles: Object.freeze(uniqueTracked),
|
trackedFiles: Object.freeze(uniqueTracked),
|
||||||
generatedFiles: Object.freeze(uniqueGenerated),
|
generatedFiles: Object.freeze(uniqueGenerated),
|
||||||
files: Object.freeze([...new Set([...uniqueTracked, ...uniqueGenerated])].sort()),
|
files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,3 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -6,13 +5,12 @@ import {
|
|||||||
buildRepositoryFileInventory,
|
buildRepositoryFileInventory,
|
||||||
parseRepositoryFileInventoryPolicy,
|
parseRepositoryFileInventoryPolicy,
|
||||||
} from "./lib/repository-file-inventory.ts";
|
} 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<{
|
type AllowlistEntry = Readonly<{
|
||||||
path: string;
|
path: string;
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
@@ -25,6 +23,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[];
|
||||||
allowlist: readonly AllowlistEntry[];
|
allowlist: readonly AllowlistEntry[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -66,6 +65,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)),
|
||||||
allowlist: Object.freeze(allowlist),
|
allowlist: Object.freeze(allowlist),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -82,23 +82,14 @@ const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8"));
|
|||||||
const policy = parsePolicy(rawPolicy);
|
const policy = parsePolicy(rawPolicy);
|
||||||
const findings: SecretFinding[] = [];
|
const findings: SecretFinding[] = [];
|
||||||
const policyFailures: string[] = [];
|
const policyFailures: string[] = [];
|
||||||
const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [
|
const patterns = secretScanRules();
|
||||||
{
|
|
||||||
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 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);
|
||||||
@@ -124,6 +115,10 @@ const scanFiles = inventory.files;
|
|||||||
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}/`),
|
||||||
) ||
|
) ||
|
||||||
@@ -132,28 +127,9 @@ for (const scanFile of [...new Set(scanFiles)].sort()) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = await readFile(scanFile, "utf8");
|
const content = await readFile(scanFile, "utf8");
|
||||||
for (const pattern of patterns) {
|
findings.push(
|
||||||
pattern.expression.lastIndex = 0;
|
...findSecretMatches(normalized, content, { allowlist }),
|
||||||
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"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sarif = {
|
const sarif = {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import {
|
|||||||
ROUTE_RUNTIME_CONTRACT,
|
ROUTE_RUNTIME_CONTRACT,
|
||||||
} from "../src/features/installed-feature-contracts.ts";
|
} from "../src/features/installed-feature-contracts.ts";
|
||||||
import { assertMatchesJsonSchema } from "./lib/json-schema.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 { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
import { releaseVerificationArtifactSchema } from "./contracts/release-artifacts.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")),
|
JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")),
|
||||||
"runtime config JSON schema",
|
"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 viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
|
||||||
const actualAssetManifestHash = createHash("sha256")
|
const actualAssetManifestHash = createHash("sha256")
|
||||||
.update(viteManifest)
|
.update(viteManifest)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"tests/fixtures/security/secret-detection/forbidden"
|
"tests/fixtures/security/secret-detection/forbidden"
|
||||||
],
|
],
|
||||||
"generatedRoots": [],
|
"generatedRoots": [],
|
||||||
|
"includedPaths": [
|
||||||
|
"tests/fixtures/security/secret-detection/forbidden"
|
||||||
|
],
|
||||||
"excludedPaths": [],
|
"excludedPaths": [],
|
||||||
"allowlist": []
|
"allowlist": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,20 @@ describe("release artifact contracts", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("requires the canonical Vite manifest declared by the build writer", async () => {
|
||||||
|
const mismatches = await verifyBuildManifestOutputs(
|
||||||
|
{
|
||||||
|
...buildManifest,
|
||||||
|
outputs: {
|
||||||
|
...buildManifest.outputs,
|
||||||
|
viteManifest: "dist/.vite/tampered-manifest.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ repositoryRoot: process.cwd() },
|
||||||
|
);
|
||||||
|
expect(mismatches).toContain("buildManifest:viteManifest:path");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a realpath escape from a declared build output", async () => {
|
it("rejects a realpath escape from a declared build output", async () => {
|
||||||
const mismatches = await verifyBuildManifestOutputs(buildManifest, {
|
const mismatches = await verifyBuildManifestOutputs(buildManifest, {
|
||||||
repositoryRoot: "/repo",
|
repositoryRoot: "/repo",
|
||||||
@@ -167,6 +181,39 @@ describe("release artifact contracts", () => {
|
|||||||
expect(mismatches).toContain("buildManifest:routeChunk:route-home:path");
|
expect(mismatches).toContain("buildManifest:routeChunk:route-home:path");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows a legal dist child whose name begins with two dots", async () => {
|
||||||
|
const moduleInventoryBytes = Buffer.from(
|
||||||
|
'{"schemaVersion":1,"chunks":[]}\n',
|
||||||
|
);
|
||||||
|
const files = new Map<string, Buffer>([
|
||||||
|
["/repo/dist/.vite/manifest.json", Buffer.from("{}\n")],
|
||||||
|
["/repo/artifacts/quality/vite-module-inventory.json", moduleInventoryBytes],
|
||||||
|
["/repo/dist/runtime-config.schema.json", Buffer.from("{}\n")],
|
||||||
|
["/repo/dist/..assets/home.js", Buffer.from("chunk\n")],
|
||||||
|
]);
|
||||||
|
const mismatches = await verifyBuildManifestOutputs(
|
||||||
|
{
|
||||||
|
...buildManifest,
|
||||||
|
moduleInventoryHash: createHash("sha256")
|
||||||
|
.update(moduleInventoryBytes)
|
||||||
|
.digest("hex"),
|
||||||
|
outputs: {
|
||||||
|
...buildManifest.outputs,
|
||||||
|
routeChunks: { "route-home": "..assets/home.js" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repositoryRoot: "/repo",
|
||||||
|
readBytes: async (target) =>
|
||||||
|
files.get(target) ?? Promise.reject(new Error("missing")),
|
||||||
|
realpathPath: async (target) => target,
|
||||||
|
assertRegularFile: async () => undefined,
|
||||||
|
assertDirectory: async () => undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(mismatches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["viteManifest", "package.json"],
|
["viteManifest", "package.json"],
|
||||||
["runtimeConfigSchema", "schemas/artifacts/build-manifest.schema.json"],
|
["runtimeConfigSchema", "schemas/artifacts/build-manifest.schema.json"],
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ function gitResult(
|
|||||||
async function repositoryFixture() {
|
async function repositoryFixture() {
|
||||||
const root = await mkdtemp(path.join(tmpdir(), "repository-inventory-"));
|
const root = await mkdtemp(path.join(tmpdir(), "repository-inventory-"));
|
||||||
await mkdir(path.join(root, "src"));
|
await mkdir(path.join(root, "src"));
|
||||||
|
await mkdir(path.join(root, "docs"));
|
||||||
await writeFile(path.join(root, "src", "tracked.ts"), "tracked\n");
|
await writeFile(path.join(root, "src", "tracked.ts"), "tracked\n");
|
||||||
await writeFile(path.join(root, "src", "untracked.ts"), "untracked\n");
|
await writeFile(path.join(root, "src", "untracked.ts"), "untracked\n");
|
||||||
|
await writeFile(path.join(root, "README.md"), "readme\n");
|
||||||
|
await writeFile(path.join(root, "docs", "outside-policy.md"), "docs\n");
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +93,23 @@ describe("repository file inventory", () => {
|
|||||||
expect(inventory.files).not.toContain("src/untracked.ts");
|
expect(inventory.files).not.toContain("src/untracked.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes every Git-tracked file outside mandatory policy roots", async () => {
|
||||||
|
const repositoryRoot = await repositoryFixture();
|
||||||
|
const inventory = await buildRepositoryFileInventory({
|
||||||
|
repositoryRoot,
|
||||||
|
trackedRoots: ["src"],
|
||||||
|
runGit: () =>
|
||||||
|
gitResult("src/tracked.ts\0README.md\0docs/outside-policy.md\0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inventory.trackedFiles).toEqual([
|
||||||
|
"README.md",
|
||||||
|
"docs/outside-policy.md",
|
||||||
|
"src/tracked.ts",
|
||||||
|
]);
|
||||||
|
expect(inventory.files).toEqual(inventory.trackedFiles);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects duplicate tracked paths instead of silently deduplicating", async () => {
|
it("rejects duplicate tracked paths instead of silently deduplicating", async () => {
|
||||||
const repositoryRoot = await repositoryFixture();
|
const repositoryRoot = await repositoryFixture();
|
||||||
await expect(
|
await expect(
|
||||||
@@ -214,6 +234,34 @@ describe("repository file inventory", () => {
|
|||||||
expect(inventory.files).toEqual(["dist/asset.js", "src/tracked.ts"]);
|
expect(inventory.files).toEqual(["dist/asset.js", "src/tracked.ts"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an exact tracked and generated path collision", async () => {
|
||||||
|
const repositoryRoot = await repositoryFixture();
|
||||||
|
await writeFile(path.join(repositoryRoot, "package.json"), "{}\n");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildRepositoryFileInventory({
|
||||||
|
repositoryRoot,
|
||||||
|
trackedRoots: ["package.json"],
|
||||||
|
generatedRoots: ["package.json"],
|
||||||
|
runGit: () => gitResult("package.json\0"),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/tracked.*generated.*package\.json/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a legal child whose name begins with two dots", async () => {
|
||||||
|
const repositoryRoot = await repositoryFixture();
|
||||||
|
await mkdir(path.join(repositoryRoot, "..assets"));
|
||||||
|
await writeFile(path.join(repositoryRoot, "..assets", "legal.ts"), "legal\n");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildRepositoryFileInventory({
|
||||||
|
repositoryRoot,
|
||||||
|
trackedRoots: ["..assets"],
|
||||||
|
runGit: () => gitResult("..assets/legal.ts\0"),
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ trackedFiles: ["..assets/legal.ts"] });
|
||||||
|
});
|
||||||
|
|
||||||
it("fails closed when an inventoried file cannot be read", async () => {
|
it("fails closed when an inventoried file cannot be read", async () => {
|
||||||
const repositoryRoot = await repositoryFixture();
|
const repositoryRoot = await repositoryFixture();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
validateDependencyReview,
|
validateDependencyReview,
|
||||||
validateLicensePolicy,
|
validateLicensePolicy,
|
||||||
} from "../../scripts/lib/supply-chain.ts";
|
} from "../../scripts/lib/supply-chain.ts";
|
||||||
|
import { digestReleaseInputFiles } from "../../scripts/lib/release-input-evidence.ts";
|
||||||
|
import { findSecretMatches } from "../../scripts/lib/secret-scan.ts";
|
||||||
|
|
||||||
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
||||||
const dependency = {
|
const dependency = {
|
||||||
@@ -35,6 +37,42 @@ describe("supply-chain policy", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("binds provenance digest behavior to tracked files outside policy roots", async () => {
|
||||||
|
const contents = new Map([
|
||||||
|
["src/app.ts", Buffer.from("app\n")],
|
||||||
|
["README.md", Buffer.from("one\n")],
|
||||||
|
]);
|
||||||
|
const first = await digestReleaseInputFiles(
|
||||||
|
["README.md", "src/app.ts"],
|
||||||
|
async (file) => contents.get(file)!,
|
||||||
|
);
|
||||||
|
contents.set("README.md", Buffer.from("two\n"));
|
||||||
|
const second = await digestReleaseInputFiles(
|
||||||
|
["README.md", "src/app.ts"],
|
||||||
|
async (file) => contents.get(file)!,
|
||||||
|
);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects every forbidden secret fixture, including quoted JSON keys", async () => {
|
||||||
|
const fixtureRoot = "tests/fixtures/security/secret-detection/forbidden";
|
||||||
|
const findings = (
|
||||||
|
await Promise.all(
|
||||||
|
["source.ts", "dist.ts", "config.json"].map(async (file) =>
|
||||||
|
findSecretMatches(
|
||||||
|
`${fixtureRoot}/${file}`,
|
||||||
|
await readFile(`${fixtureRoot}/${file}`, "utf8"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
expect(findings.map((finding) => [finding.file, finding.ruleId])).toEqual([
|
||||||
|
[`${fixtureRoot}/source.ts`, "aws-access-key"],
|
||||||
|
[`${fixtureRoot}/dist.ts`, "assigned-secret"],
|
||||||
|
[`${fixtureRoot}/config.json`, "assigned-secret"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("covers every mandatory release input in the secret scan policy", async () => {
|
it("covers every mandatory release input in the secret scan policy", async () => {
|
||||||
const policy = JSON.parse(
|
const policy = JSON.parse(
|
||||||
await readFile("config/security/secret-scan-policy.json", "utf8"),
|
await readFile("config/security/secret-scan-policy.json", "utf8"),
|
||||||
@@ -55,6 +93,8 @@ describe("supply-chain policy", () => {
|
|||||||
".gitea/workflows/quality-gates.yml",
|
".gitea/workflows/quality-gates.yml",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"vite.service-worker.config.ts",
|
"vite.service-worker.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"playwright.config.ts",
|
||||||
"playwright.capabilities.config.ts",
|
"playwright.capabilities.config.ts",
|
||||||
"playwright.dev.config.ts",
|
"playwright.dev.config.ts",
|
||||||
"playwright.storybook.config.ts",
|
"playwright.storybook.config.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user