diff --git a/docs/security/supply-chain.md b/docs/security/supply-chain.md new file mode 100644 index 0000000..f8eba98 --- /dev/null +++ b/docs/security/supply-chain.md @@ -0,0 +1,18 @@ +# Build and supply-chain gate + +Merge and release controls: + +- frozen `pnpm-lock.yaml` installation; drift is blocking +- clean production build with hashed assets and build manifest +- machine-readable bundle sizes and checksums +- source plus built-asset credential-pattern scan +- direct dependency inventory and lockfile digest +- base/head dependency diff review record + +Organization-specific vulnerability severity, denied-license list, SBOM format, +and scanner selection remain policy inputs. An approved suppression must record +reason, owner, expiry, affected package, and compensating control. Expired +suppressions are blocking. + +`artifacts/security/dependency-diff.json` is a local baseline. CI replaces it +with the actual base/head direct and transitive lockfile diff before release. diff --git a/package.json b/package.json index cb373b9..2f56afb 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite", "build": "vite build && node scripts/generate-build-manifest.mjs", + "build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security", "preview": "vite preview", "lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0", "check:architecture": "node scripts/check-architecture.mjs", @@ -24,7 +25,10 @@ "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs", "review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs", - "test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" + "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration", + "verify:lockfile": "corepack pnpm install --frozen-lockfile", + "generate:supply-chain": "node scripts/generate-supply-chain.mjs", + "scan:security": "node scripts/security-scan.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/schemas/artifacts/build-manifest.schema.json b/schemas/artifacts/build-manifest.schema.json new file mode 100644 index 0000000..7bb681e --- /dev/null +++ b/schemas/artifacts/build-manifest.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "build-manifest.schema.json", + "type": "object", + "required": [ + "schemaVersion", + "buildId", + "commitSha", + "generatedAt", + "buildContext", + "outputs" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "buildId": { "type": "string", "minLength": 1 }, + "commitSha": { "type": "string", "minLength": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "buildContext": { + "type": "object", + "required": ["nodeVersion", "packageManagerVersion", "runnerImage"], + "properties": { + "nodeVersion": { "type": "string" }, + "packageManagerVersion": { "type": "string" }, + "runnerImage": { "type": "string" } + }, + "additionalProperties": false + }, + "outputs": { + "type": "object", + "required": ["directory", "viteManifest"], + "properties": { + "directory": { "type": "string" }, + "viteManifest": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/scripts/generate-supply-chain.mjs b/scripts/generate-supply-chain.mjs new file mode 100644 index 0000000..d5f978e --- /dev/null +++ b/scripts/generate-supply-chain.mjs @@ -0,0 +1,102 @@ +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { + mkdir, + readFile, + readdir, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +/** @param {string} directory @returns {Promise} */ +async function filesWithin(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = /** @type {string[][]} */ (await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesWithin(target) : [target]; + }), + )); + return nested.flat().sort(); +} + +const packageJson = JSON.parse(await readFile("package.json", "utf8")); +const lockfile = await readFile("pnpm-lock.yaml"); +const outputFiles = await filesWithin("dist"); + +const outputs = await Promise.all( + outputFiles.map(async (outputFile) => { + const content = await readFile(outputFile); + const metadata = await stat(outputFile); + return { + path: outputFile, + bytes: metadata.size, + gzipBytes: gzipSync(content).byteLength, + sha256: createHash("sha256").update(content).digest("hex"), + }; + }), +); + +const dependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, +}; +const inventory = Object.entries(dependencies) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, version]) => ({ name, version, direct: true })); + +await mkdir("artifacts/performance", { recursive: true }); +await mkdir("artifacts/release", { recursive: true }); +await mkdir("artifacts/security", { recursive: true }); + +await writeFile( + "artifacts/performance/bundle.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + context: { + nodeVersion: process.version, + packageManager: packageJson.packageManager, + runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, + }, + outputs, + }, + null, + 2, + )}\n`, +); + +await writeFile( + "artifacts/release/dependency-inventory.json", + `${JSON.stringify( + { + schemaVersion: 1, + lockfileSha256: createHash("sha256").update(lockfile).digest("hex"), + dependencies: inventory, + }, + null, + 2, + )}\n`, +); + +await writeFile( + "artifacts/release/checksums.txt", + `${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`, +); + +await writeFile( + "artifacts/security/dependency-diff.json", + `${JSON.stringify( + { + schemaVersion: 1, + reviewStatus: "local-baseline", + directDependencies: inventory.length, + highRiskUnreviewed: [], + lockfileSha256: createHash("sha256").update(lockfile).digest("hex"), + }, + null, + 2, + )}\n`, +); diff --git a/scripts/security-scan.mjs b/scripts/security-scan.mjs new file mode 100644 index 0000000..684ee77 --- /dev/null +++ b/scripts/security-scan.mjs @@ -0,0 +1,82 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const scanRoots = ["src", "dist"]; +const findings = /** @type {Array<{ruleId: string, file: string}>} */ ([]); +const patterns = [ + { 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, + }, +]; + +/** @param {string} directory @returns {Promise} */ +async function filesWithin(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = /** @type {string[][]} */ (await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesWithin(target) : [target]; + }), + )); + return nested.flat(); +} + +for (const root of scanRoots) { + for (const scanFile of await filesWithin(root)) { + if (/\.(png|jpg|jpeg|gif|woff2?|zip)$/i.test(scanFile)) continue; + const content = await readFile(scanFile, "utf8"); + for (const pattern of patterns) { + pattern.expression.lastIndex = 0; + if (pattern.expression.test(content)) { + findings.push({ ruleId: pattern.id, file: scanFile }); + } + } + } +} + +const sarif = { + version: "2.1.0", + $schema: + "https://json.schemastore.org/sarif-2.1.0.json", + runs: [ + { + tool: { + driver: { + name: "ca-frontend-secret-scan", + rules: patterns.map((pattern) => ({ + id: pattern.id, + shortDescription: { text: "Potential credential material" }, + })), + }, + }, + results: findings.map((finding) => ({ + ruleId: finding.ruleId, + message: { text: "Potential secret material must be removed." }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: finding.file }, + }, + }, + ], + })), + }, + ], +}; + +await mkdir("artifacts/security", { recursive: true }); +await writeFile( + "artifacts/security/scan.sarif", + `${JSON.stringify(sarif, null, 2)}\n`, +); + +if (findings.length > 0) { + process.stderr.write(`Security scan found ${findings.length} blocking result(s).\n`); + process.exit(1); +} +process.stdout.write("Source and built-asset secret scan: PASS\n");