Merge branch 'feature-frontend-build-bundle-supply-chain-contract' into develop
This commit is contained in:
@@ -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.
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string[]>} */
|
||||
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`,
|
||||
);
|
||||
@@ -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<string[]>} */
|
||||
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");
|
||||
Reference in New Issue
Block a user