feat(saas): enforce signed release evidence and verified deployments

This commit is contained in:
2026-09-11 12:11:39 +08:00
parent b99588a4e4
commit b0235088ad
9 changed files with 552 additions and 118 deletions

270
release-artifact.js Normal file
View File

@@ -0,0 +1,270 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.releaseCanonicalJson = releaseCanonicalJson;
exports.releaseJsonChecksum = releaseJsonChecksum;
exports.assertReleaseArtifactPath = assertReleaseArtifactPath;
exports.compiledArtifactDigestFromEntries = compiledArtifactDigestFromEntries;
exports.calculateReleaseCompiledDigest = calculateReleaseCompiledDigest;
exports.captureReleaseDependencies = captureReleaseDependencies;
exports.createReleaseArtifactBundle = createReleaseArtifactBundle;
exports.inspectReleaseArtifactBundle = inspectReleaseArtifactBundle;
exports.verifyLocalReleaseArtifact = verifyLocalReleaseArtifact;
exports.signReleaseEvidence = signReleaseEvidence;
exports.verifyReleaseEvidence = verifyReleaseEvidence;
exports.validateReleaseDependencyManifest = validateReleaseDependencyManifest;
exports.captureReleaseSourceInputs = captureReleaseSourceInputs;
exports.materializeCommittedReleaseSource = materializeCommittedReleaseSource;
const fs_1 = require("fs");
const path_1 = require("path");
const child_process_1 = require("child_process");
const crypto_1 = require("crypto");
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
const MAX_FILES = 10000;
const MAX_DEPENDENCIES = 150000;
const SHA = /^[a-f0-9]{64}$/;
const SIGNATURE_DOMAINS = {
build: 'COOL_SAAS_RELEASE_BUILD_V2\0',
task: 'COOL_SAAS_RELEASE_TASK_V2\0',
run: 'COOL_SAAS_RELEASE_RUN_V2\0',
claim: 'COOL_SAAS_RELEASE_CLAIM_V2\0',
};
function ensure(value, message) {
if (!value)
throw new Error(`Release artifact refused: ${message}`);
}
function releaseCanonicalJson(value) {
if (value === null || typeof value === 'string' || typeof value === 'boolean')
return JSON.stringify(value);
if (typeof value === 'number') {
ensure(Number.isFinite(value) && !Object.is(value, -0), 'invalid JSON number');
return JSON.stringify(value);
}
if (Array.isArray(value))
return `[${value.map(releaseCanonicalJson).join(',')}]`;
ensure(value && typeof value === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(value)), 'invalid JSON value');
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${releaseCanonicalJson(value[key])}`).join(',')}}`;
}
function releaseJsonChecksum(value) {
return (0, crypto_1.createHash)('sha256').update(releaseCanonicalJson(value), 'utf8').digest('hex');
}
function assertReleaseArtifactPath(name) {
ensure(typeof name === 'string' && name.length > 0 && name.length <= 1024 && !/[\\:\x00-\x1f]/.test(name), 'invalid relative artifact path');
ensure(!name.startsWith('/') && name.split('/').every(part => part && part !== '.' && part !== '..' && !/[. ]$/.test(part) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)), 'unsafe relative artifact path');
return name;
}
function inside(root, target) {
const name = (0, path_1.relative)(root, target);
return name === '' || (!(0, path_1.isAbsolute)(name) && name !== '..' && !name.startsWith(`..${path_1.sep}`));
}
function regularDirectoryFiles(root) {
var _a;
ensure(((_a = (0, fs_1.lstatSync)(root, { throwIfNoEntry: false })) === null || _a === void 0 ? void 0 : _a.isDirectory()) && !(0, fs_1.lstatSync)(root).isSymbolicLink(), 'artifact directory is missing or linked');
const files = [];
const visit = (directory, depth = 0) => {
ensure(depth <= 64, 'artifact directory nesting is too deep');
for (const name of (0, fs_1.readdirSync)(directory).sort()) {
const absolute = (0, path_1.resolve)(directory, name);
const stat = (0, fs_1.lstatSync)(absolute);
ensure(!stat.isSymbolicLink(), 'artifact must not contain symbolic links');
if (stat.isDirectory())
visit(absolute, depth + 1);
else {
ensure(stat.isFile(), 'artifact contains a non-file entry');
files.push({ path: assertReleaseArtifactPath((0, path_1.relative)(root, absolute).replace(/\\/g, '/')), absolute });
ensure(files.length <= MAX_FILES, 'too many artifact files');
}
}
};
visit(root);
ensure(files.length > 0, 'compiled artifact is empty');
return files.sort((left, right) => left.path.localeCompare(right.path));
}
function compiledArtifactDigestFromEntries(entries) {
ensure(entries.length > 0, 'compiled artifact is empty');
const hash = (0, crypto_1.createHash)('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8');
const seen = new Set();
for (const { path, bytes } of [...entries].sort((left, right) => left.path.localeCompare(right.path))) {
assertReleaseArtifactPath(path);
ensure(!seen.has(path.toLowerCase()), 'duplicate or case-colliding compiled path');
seen.add(path.toLowerCase());
hash.update(`${Buffer.byteLength(path, 'utf8')}:`, 'utf8').update(path, 'utf8').update(`:${bytes.length}:`, 'utf8').update(bytes);
}
return `sha256:${hash.digest('hex')}`;
}
function calculateReleaseCompiledDigest(artifactDir) {
return compiledArtifactDigestFromEntries(regularDirectoryFiles((0, path_1.resolve)(artifactDir)).map(file => ({ path: file.path, bytes: (0, fs_1.readFileSync)(file.absolute) })));
}
/** Captures the installed dependency bytes, including pnpm's physical store.
* Links must resolve inside node_modules. Absolute OS link text is normalized
* to its target relative to that root so copying the same tree is portable. */
function captureReleaseDependencies(projectDir) {
const root = (0, path_1.resolve)(projectDir, 'node_modules');
const rootStat = (0, fs_1.lstatSync)(root, { throwIfNoEntry: false });
ensure((rootStat === null || rootStat === void 0 ? void 0 : rootStat.isDirectory()) && !rootStat.isSymbolicLink(), 'node_modules must be an independent local directory');
const actualRoot = (0, fs_1.realpathSync)(root);
const entries = [];
const visit = (directory, depth = 0) => {
ensure(depth <= 64, 'dependency nesting is too deep');
for (const name of (0, fs_1.readdirSync)(directory).sort()) {
const absolute = (0, path_1.resolve)(directory, name);
const entryPath = assertReleaseArtifactPath((0, path_1.relative)(root, absolute).replace(/\\/g, '/'));
const stat = (0, fs_1.lstatSync)(absolute);
if (stat.isSymbolicLink()) {
const target = (0, fs_1.realpathSync)(absolute);
ensure(inside(actualRoot, target) && target !== actualRoot, 'dependency link escapes node_modules');
entries.push({ path: entryPath, type: 'link', target: assertReleaseArtifactPath((0, path_1.relative)(actualRoot, target).replace(/\\/g, '/')) });
}
else if (stat.isDirectory())
visit(absolute, depth + 1);
else {
ensure(stat.isFile() && stat.size <= 256 * 1024 * 1024, 'invalid or oversized dependency file');
const bytes = (0, fs_1.readFileSync)(absolute);
entries.push({ path: entryPath, type: 'file', size: bytes.length, sha256: (0, crypto_1.createHash)('sha256').update(bytes).digest('hex') });
}
ensure(entries.length <= MAX_DEPENDENCIES, 'too many dependency entries');
}
};
visit(root);
ensure(entries.length > 0, 'dependencies are empty');
return { formatVersion: 1, entries: entries.sort((a, b) => a.path.localeCompare(b.path)) };
}
function createReleaseArtifactBundle(projectDir) {
const root = (0, path_1.resolve)(projectDir);
const files = [];
const required = ['.cool-saas-candidate.json', '.cool-saas-service.json', 'bootstrap.js', 'release-environment.js', 'start-stable.js', 'start-frozen.js', 'release-artifact.js', 'package.json', 'scripts/published-packages.json'];
const locks = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'].filter(name => (0, fs_1.lstatSync)((0, path_1.resolve)(root, name), { throwIfNoEntry: false }));
ensure(locks.length === 1, 'exactly one dependency lock is required');
for (const name of [...required, ...locks]) {
const absolute = (0, path_1.resolve)(root, name);
const stat = (0, fs_1.lstatSync)(absolute, { throwIfNoEntry: false });
ensure((stat === null || stat === void 0 ? void 0 : stat.isFile()) && !stat.isSymbolicLink(), `required deployment file is missing or linked: ${name}`);
ensure(inside((0, fs_1.realpathSync)(root), (0, fs_1.realpathSync)(absolute)), 'deployment file escapes project');
files.push({ path: name, content: (0, fs_1.readFileSync)(absolute).toString('base64') });
}
for (const file of regularDirectoryFiles((0, path_1.resolve)(root, 'dist')))
files.push({ path: `dist/${file.path}`, content: (0, fs_1.readFileSync)(file.absolute).toString('base64') });
const dependencies = captureReleaseDependencies(root);
files.push({ path: 'release-dependencies.json', content: Buffer.from(releaseCanonicalJson(dependencies), 'utf8').toString('base64') });
const bundle = { formatVersion: 1, files: files.sort((a, b) => a.path.localeCompare(b.path)) };
inspectReleaseArtifactBundle(bundle);
return bundle;
}
function inspectReleaseArtifactBundle(bundle) {
ensure(bundle && Object.keys(bundle).sort().join(',') === 'files,formatVersion' && bundle.formatVersion === 1 && Array.isArray(bundle.files), 'invalid bundle shape');
ensure(bundle.files.length > 0 && bundle.files.length <= MAX_FILES, 'invalid bundle file count');
ensure(Buffer.byteLength(releaseCanonicalJson(bundle), 'utf8') <= MAX_BUNDLE_BYTES, 'artifact bundle exceeds size limit');
const files = new Map();
const names = new Set();
for (const file of bundle.files) {
ensure(file && Object.keys(file).sort().join(',') === 'content,path', 'invalid artifact file shape');
assertReleaseArtifactPath(file.path);
ensure(typeof file.content === 'string', 'invalid artifact content');
const bytes = Buffer.from(file.content, 'base64');
ensure(bytes.toString('base64') === file.content, 'non-canonical base64 artifact content');
ensure(!names.has(file.path.toLowerCase()), 'duplicate or case-colliding artifact path');
names.add(file.path.toLowerCase());
files.set(file.path, bytes);
}
const compiled = [...files].filter(([name]) => name.startsWith('dist/')).map(([name, bytes]) => ({ path: name.slice(5), bytes }));
return { files, artifactDigest: compiledArtifactDigestFromEntries(compiled), bundleDigest: `sha256:${releaseJsonChecksum(bundle)}` };
}
function verifyLocalReleaseArtifact(projectDir, bundle) {
const inspected = inspectReleaseArtifactBundle(bundle);
const dependencyBytes = inspected.files.get('release-dependencies.json');
ensure(dependencyBytes, 'installed dependency evidence is missing');
const expected = JSON.parse(dependencyBytes.toString('utf8'));
const actual = captureReleaseDependencies(projectDir);
ensure(releaseJsonChecksum(expected) === releaseJsonChecksum(actual), 'installed dependency bytes or link targets differ from the signed build');
for (const [name, bytes] of inspected.files) {
if (name === 'release-dependencies.json')
continue;
const absolute = (0, path_1.resolve)(projectDir, name);
const stat = (0, fs_1.lstatSync)(absolute, { throwIfNoEntry: false });
ensure((stat === null || stat === void 0 ? void 0 : stat.isFile()) && !stat.isSymbolicLink() && inside((0, fs_1.realpathSync)(projectDir), (0, fs_1.realpathSync)(absolute)), `deployment file is missing or linked: ${name}`);
ensure((0, fs_1.readFileSync)(absolute).equals(bytes), `deployment file differs from signed build: ${name}`);
}
ensure(calculateReleaseCompiledDigest((0, path_1.resolve)(projectDir, 'dist')) === inspected.artifactDigest, 'compiled file set differs from signed build');
return inspected;
}
function signReleaseEvidence(kind, keyId, claims, privateKeyPem) {
ensure(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(keyId), 'invalid signing key id');
const key = (0, crypto_1.createPrivateKey)(privateKeyPem);
ensure(key.asymmetricKeyType === 'ed25519', 'release evidence requires Ed25519');
const unsigned = { algorithm: 'Ed25519', keyId, claims };
const bytes = Buffer.from(SIGNATURE_DOMAINS[kind] + releaseCanonicalJson(unsigned), 'utf8');
return { ...unsigned, signature: (0, crypto_1.sign)(null, bytes, key).toString('base64url') };
}
function verifyReleaseEvidence(kind, envelope, trustedKeys) {
ensure(envelope && Object.keys(envelope).sort().join(',') === 'algorithm,claims,keyId,signature' && envelope.algorithm === 'Ed25519', 'invalid signed evidence envelope');
ensure(Object.prototype.hasOwnProperty.call(trustedKeys, envelope.keyId), 'untrusted evidence signer');
const signature = Buffer.from(envelope.signature || '', 'base64url');
ensure(signature.length === 64 && signature.toString('base64url') === envelope.signature, 'invalid evidence signature encoding');
const key = (0, crypto_1.createPublicKey)(trustedKeys[envelope.keyId]);
ensure(key.asymmetricKeyType === 'ed25519', 'invalid trusted evidence key');
const unsigned = { algorithm: envelope.algorithm, keyId: envelope.keyId, claims: envelope.claims };
ensure((0, crypto_1.verify)(null, Buffer.from(SIGNATURE_DOMAINS[kind] + releaseCanonicalJson(unsigned), 'utf8'), key, signature), 'release evidence signature does not match');
return envelope.claims;
}
function validateReleaseDependencyManifest(value) {
ensure(value && value.formatVersion === 1 && Array.isArray(value.entries) && value.entries.length > 0 && value.entries.length <= MAX_DEPENDENCIES, 'invalid dependency manifest');
const paths = new Set();
for (const entry of value.entries) {
assertReleaseArtifactPath(entry.path);
ensure(!paths.has(entry.path.toLowerCase()), 'duplicate dependency path');
paths.add(entry.path.toLowerCase());
if (entry.type === 'file')
ensure(Number.isSafeInteger(entry.size) && entry.size >= 0 && SHA.test(entry.sha256 || ''), 'invalid dependency file proof');
else {
ensure(entry.type === 'link', 'invalid dependency entry type');
assertReleaseArtifactPath(entry.target);
}
}
}
/** Captures every authored input, including unanticipated build-script inputs.
* Only conventional generated output roots are excluded. */
function captureReleaseSourceInputs(projectDir) {
const root = (0, fs_1.realpathSync)(projectDir);
const files = [];
const walk = (directory) => {
for (const entry of (0, fs_1.readdirSync)(directory, { withFileTypes: true })) {
const absolute = (0, path_1.resolve)(directory, entry.name);
const name = (0, path_1.relative)(root, absolute).replace(/\\/g, '/');
if (/^(?:dist|node_modules|logs?|coverage|\.cache|\.git)(?:\/|$)/.test(name) || /^[^/]+\.tsbuildinfo$/.test(name))
continue;
assertReleaseArtifactPath(name);
const stat = (0, fs_1.lstatSync)(absolute);
ensure(!stat.isSymbolicLink(), `source input is linked: ${name}`);
if (stat.isDirectory())
walk(absolute);
else {
ensure(stat.isFile(), `source input is not a regular file: ${name}`);
files.push({ path: name, sha256: (0, crypto_1.createHash)('sha256').update((0, fs_1.readFileSync)(absolute)).digest('hex') });
}
}
};
walk(root);
return files.sort((a, b) => a.path.localeCompare(b.path));
}
/** Reads exact Git blobs instead of a mutable checkout while a build runs. */
function materializeCommittedReleaseSource(repository, commit, destination) {
ensure(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit), 'invalid source commit');
ensure(!(0, fs_1.lstatSync)(destination, { throwIfNoEntry: false }), 'source snapshot destination already exists');
const entries = (0, child_process_1.execFileSync)('git', ['ls-tree', '-r', '-z', commit], { cwd: repository, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }).toString('utf8').split('\0').filter(Boolean);
ensure(entries.length > 0 && entries.length <= 30000, 'invalid source snapshot size');
(0, fs_1.mkdirSync)(destination, { recursive: true });
for (const entry of entries) {
const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t([\s\S]+)$/.exec(entry);
ensure(match, 'source snapshot contains a link or submodule');
const name = assertReleaseArtifactPath(match[3]);
// Credentials are never build inputs, even if an old repository tracked one.
ensure(!/(^|\/)(?:\.env(?:\..*)?|.*\.pem|.*\.private\.(?:json|txt))$/i.test(name), 'private configuration must not be tracked in a release');
if (/(^|\/)\.npmrc$/i.test(name))
continue;
ensure(!name.startsWith('node_modules/') && !name.startsWith('dist/'), 'compiled dependencies/artifacts must not be tracked as source');
const target = (0, path_1.resolve)(destination, name);
ensure(inside((0, path_1.resolve)(destination), target), 'source snapshot path escaped destination');
const bytes = (0, child_process_1.execFileSync)('git', ['cat-file', 'blob', match[2]], { cwd: repository, windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
(0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
(0, fs_1.writeFileSync)(target, bytes, { flag: 'wx', mode: match[1] === '100755' ? 0o755 : 0o644 });
}
}