diff --git a/package.json b/package.json index ee58db4..22ae7e5 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "verify:reproducible-build": "node scripts/verify-reproducible-build.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", + "check:security:fixtures": "node scripts/check-security-fixtures.ts", "check:browser-security": "node scripts/check-browser-security.ts", "check:browser-file-storage-boundaries": "node scripts/check-browser-file-storage-boundaries.ts", "check:realtime-boundaries": "node scripts/check-realtime-boundaries.ts", diff --git a/scripts/check-security-fixtures.ts b/scripts/check-security-fixtures.ts new file mode 100644 index 0000000..4bd9ddf --- /dev/null +++ b/scripts/check-security-fixtures.ts @@ -0,0 +1,65 @@ +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; + +type Document = Record; + +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"); diff --git a/scripts/generate-build-manifest.ts b/scripts/generate-build-manifest.ts index 7e65c92..af7a7b9 100644 --- a/scripts/generate-build-manifest.ts +++ b/scripts/generate-build-manifest.ts @@ -20,6 +20,7 @@ import { buildDate, } from "./lib/build-environment.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts"; assertCiBuildEnvironment(process.env); type ViteManifestEntry = Readonly<{ @@ -38,7 +39,7 @@ const releaseId = process.env.RELEASE_ID ?? "local-release"; const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; const buildTime = buildDate(process.env); const builtAt = buildTime.toISOString(); -const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); +const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8"); const viteManifestObject = parseViteManifest(JSON.parse(viteManifest)); const moduleInventory = await readFile( "dist/.vite/module-inventory.json", @@ -90,7 +91,7 @@ const manifest = buildManifestArtifactSchema.parse({ }, outputs: { directory: "dist", - viteManifest: "dist/.vite/manifest.json", + viteManifest: CANONICAL_VITE_MANIFEST_PATH, moduleInventory: "artifacts/quality/vite-module-inventory.json", routeChunks, runtimeConfigSchema: "dist/runtime-config.schema.json", diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index 404abb4..bb16593 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -32,6 +32,7 @@ import { type DependencyInventoryDiff, } from "./lib/supply-chain.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts"; import { buildRepositoryFileInventory, parseRepositoryFileInventoryPolicy, @@ -66,16 +67,6 @@ async function sha256File(file: string): Promise { return createHash("sha256").update(await readFile(file)).digest("hex"); } -async function digestFileSet(files: string[]): Promise { - 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 { try { const parsed: unknown = JSON.parse(await readFile(file, "utf8")); @@ -279,7 +270,7 @@ const vulnerabilityReport = { }; const sourceFiles = [...repositoryInventory.trackedFiles]; -const sourceSetSha256 = await digestFileSet(sourceFiles); +const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles); const components = inventory.dependencies.map((dependency) => ({ type: "library", diff --git a/scripts/lib/build-manifest-outputs.ts b/scripts/lib/build-manifest-outputs.ts index d7a25f2..0f7dea4 100644 --- a/scripts/lib/build-manifest-outputs.ts +++ b/scripts/lib/build-manifest-outputs.ts @@ -13,6 +13,8 @@ type VerifyBuildManifestOutputsDependencies = Readonly<{ assertDirectory?: (target: string) => Promise; }>; +export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json"; + function isSafeRelativePath(value: string): boolean { return ( value.length > 0 && @@ -28,7 +30,12 @@ function isSafeRelativePath(value: string): boolean { function isWithinRoot(root: string, target: string): boolean { const relative = path.relative(root, target); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); } function belongsToApprovedRoot(value: string, approvedRoot: string): boolean { @@ -127,12 +134,16 @@ export async function verifyBuildManifestOutputs( } else { await confinedPath("directory", manifest.outputs.directory, "directory"); } - await confinedPath( - "viteManifest", - manifest.outputs.viteManifest, - "file", - "dist", - ); + if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) { + mismatches.push("buildManifest:viteManifest:path"); + } else { + await confinedPath( + "viteManifest", + manifest.outputs.viteManifest, + "file", + "dist", + ); + } await confinedPath( "runtimeConfigSchema", manifest.outputs.runtimeConfigSchema, diff --git a/scripts/lib/release-input-evidence.ts b/scripts/lib/release-input-evidence.ts new file mode 100644 index 0000000..3c280e7 --- /dev/null +++ b/scripts/lib/release-input-evidence.ts @@ -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 = readFile, +): Promise { + const rows = await Promise.all( + [...files].sort().map(async (file) => ({ + path: file, + sha256: createHash("sha256") + .update(await readBytes(file)) + .digest("hex"), + })), + ); + return supplyChainDigest(rows); +} diff --git a/scripts/lib/repository-file-inventory.ts b/scripts/lib/repository-file-inventory.ts index 230ade4..aa99dfd 100644 --- a/scripts/lib/repository-file-inventory.ts +++ b/scripts/lib/repository-file-inventory.ts @@ -131,11 +131,12 @@ function normalizeRepositoryPath(value: string, label: string): string { function isWithinRoot(root: string, target: string): boolean { const relative = path.relative(root, target); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function belongsToRoots(file: string, roots: readonly string[]): boolean { - return roots.some((root) => file === root || file.startsWith(`${root}/`)); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); } function parseGitFileList(result: GitFileListResult): string[] { @@ -261,9 +262,7 @@ export async function buildRepositoryFileInventory( const trackedFiles = parseGitFileList( (options.runGit ?? defaultGitFileList)(repositoryRoot), - ) - .filter((file) => belongsToRoots(file, trackedRoots)) - .sort(); + ).sort(); for (const root of trackedRoots) { if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) { throw new Error(`required tracked file inventory is empty: ${root}`); @@ -309,9 +308,16 @@ export async function buildRepositoryFileInventory( } const uniqueTracked = [...trackedFiles].sort(); const uniqueGenerated = [...generatedFiles].sort(); + const trackedSet = new Set(uniqueTracked); + const collisions = uniqueGenerated.filter((file) => trackedSet.has(file)); + if (collisions.length > 0) { + throw new TypeError( + `tracked and generated inventory paths collide: ${collisions.join(", ")}`, + ); + } return Object.freeze({ trackedFiles: Object.freeze(uniqueTracked), generatedFiles: Object.freeze(uniqueGenerated), - files: Object.freeze([...new Set([...uniqueTracked, ...uniqueGenerated])].sort()), + files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()), }); } diff --git a/scripts/lib/secret-scan.ts b/scripts/lib/secret-scan.ts new file mode 100644 index 0000000..c9ab8d9 --- /dev/null +++ b/scripts/lib/secret-scan.ts @@ -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: + /(?[] { + 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; +} diff --git a/scripts/security-scan.ts b/scripts/security-scan.ts index 01ce9e8..7208c59 100644 --- a/scripts/security-scan.ts +++ b/scripts/security-scan.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -6,13 +5,12 @@ import { buildRepositoryFileInventory, parseRepositoryFileInventoryPolicy, } from "./lib/repository-file-inventory.ts"; +import { + findSecretMatches, + secretScanRules, + type SecretFinding, +} from "./lib/secret-scan.ts"; -type SecretFinding = Readonly<{ - ruleId: string; - file: string; - line: number; - fingerprint: string; -}>; type AllowlistEntry = Readonly<{ path: string; ruleId: string; @@ -25,6 +23,7 @@ type SecretPolicy = Readonly<{ trackedRoots: readonly string[]; generatedRoots: readonly string[]; optionalRoots: readonly string[]; + includedPaths: readonly string[]; allowlist: readonly AllowlistEntry[]; }>; @@ -66,6 +65,7 @@ function parsePolicy(value: unknown): SecretPolicy { trackedRoots: inventoryPolicy.trackedRoots, generatedRoots: inventoryPolicy.generatedRoots, optionalRoots: inventoryPolicy.optionalRoots, + includedPaths: Object.freeze(strings(document.includedPaths)), allowlist: Object.freeze(allowlist), }); } @@ -82,23 +82,14 @@ const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8")); const policy = parsePolicy(rawPolicy); const findings: SecretFinding[] = []; const policyFailures: string[] = []; -const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [ - { - id: "private-key", - expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, - }, - { id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g }, - { id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g }, - { - id: "assigned-secret", - expression: - /\b(?:client_secret|password|private_key)\s*[:=]\s*["'][^"'${}]{12,}["']/gi, - }, -]; +const patterns = secretScanRules(); const excluded = new Set( policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")), ); +const included = policy.includedPaths.map((entry) => + entry.replaceAll("\\", "/"), +); const allowlist = policy.allowlist; for (const entry of allowlist) { const expiry = Date.parse(entry.expiresAt); @@ -124,6 +115,10 @@ const scanFiles = inventory.files; for (const scanFile of [...new Set(scanFiles)].sort()) { const normalized = scanFile.replaceAll("\\", "/"); if ( + (included.length > 0 && + !included.some( + (entry) => normalized === entry || normalized.startsWith(`${entry}/`), + )) || [...excluded].some( (entry) => normalized === entry || normalized.startsWith(`${entry}/`), ) || @@ -132,28 +127,9 @@ for (const scanFile of [...new Set(scanFiles)].sort()) { continue; } const content = await readFile(scanFile, "utf8"); - for (const pattern of patterns) { - pattern.expression.lastIndex = 0; - for (const match of content.matchAll(pattern.expression)) { - const isAllowed = allowlist.some( - (entry) => - entry.path === normalized && - entry.ruleId === pattern.id && - Date.parse(entry.expiresAt) > Date.now(), - ); - if (isAllowed) continue; - const matchIndex = match.index ?? 0; - const prefix = content.slice(0, matchIndex); - findings.push({ - ruleId: pattern.id, - file: normalized, - line: prefix.split(/\r?\n/).length, - fingerprint: createHash("sha256") - .update(`${pattern.id}:${normalized}:${String(matchIndex)}`) - .digest("hex"), - }); - } - } + findings.push( + ...findSecretMatches(normalized, content, { allowlist }), + ); } const sarif = { diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index 94d7d05..06e14ec 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -22,7 +22,10 @@ import { ROUTE_RUNTIME_CONTRACT, } from "../src/features/installed-feature-contracts.ts"; import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; -import { verifyBuildManifestOutputs } from "./lib/build-manifest-outputs.ts"; +import { + CANONICAL_VITE_MANIFEST_PATH, + verifyBuildManifestOutputs, +} from "./lib/build-manifest-outputs.ts"; import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; import { releaseVerificationArtifactSchema } from "./contracts/release-artifacts.ts"; @@ -103,7 +106,7 @@ const runtimeConfigJsonSchema = requireRecord( JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")), "runtime config JSON schema", ); -const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); +const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8"); const viteManifestObject = parseViteManifest(JSON.parse(viteManifest)); const actualAssetManifestHash = createHash("sha256") .update(viteManifest) diff --git a/tests/fixtures/security/secret-detection/forbidden-policy.json b/tests/fixtures/security/secret-detection/forbidden-policy.json index 434e141..40a2fd6 100644 --- a/tests/fixtures/security/secret-detection/forbidden-policy.json +++ b/tests/fixtures/security/secret-detection/forbidden-policy.json @@ -4,6 +4,9 @@ "tests/fixtures/security/secret-detection/forbidden" ], "generatedRoots": [], + "includedPaths": [ + "tests/fixtures/security/secret-detection/forbidden" + ], "excludedPaths": [], "allowlist": [] } diff --git a/tests/unit/release-artifacts.test.ts b/tests/unit/release-artifacts.test.ts index 61093b6..73eeb3f 100644 --- a/tests/unit/release-artifacts.test.ts +++ b/tests/unit/release-artifacts.test.ts @@ -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 () => { const mismatches = await verifyBuildManifestOutputs(buildManifest, { repositoryRoot: "/repo", @@ -167,6 +181,39 @@ describe("release artifact contracts", () => { 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([ + ["/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([ ["viteManifest", "package.json"], ["runtimeConfigSchema", "schemas/artifacts/build-manifest.schema.json"], diff --git a/tests/unit/repository-file-inventory.test.ts b/tests/unit/repository-file-inventory.test.ts index 5cd472f..8293e62 100644 --- a/tests/unit/repository-file-inventory.test.ts +++ b/tests/unit/repository-file-inventory.test.ts @@ -26,8 +26,11 @@ function gitResult( async function repositoryFixture() { const root = await mkdtemp(path.join(tmpdir(), "repository-inventory-")); 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", "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; } @@ -90,6 +93,23 @@ describe("repository file inventory", () => { 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 () => { const repositoryRoot = await repositoryFixture(); await expect( @@ -214,6 +234,34 @@ describe("repository file inventory", () => { 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 () => { const repositoryRoot = await repositoryFixture(); await expect( diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index 6db56c6..26c2b27 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -10,6 +10,8 @@ import { validateDependencyReview, validateLicensePolicy, } 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 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 () => { const policy = JSON.parse( await readFile("config/security/secret-scan-policy.json", "utf8"), @@ -55,6 +93,8 @@ describe("supply-chain policy", () => { ".gitea/workflows/quality-gates.yml", "vite.config.ts", "vite.service-worker.config.ts", + "vitest.config.ts", + "playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts", "playwright.storybook.config.ts",