mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
1099 lines
31 KiB
JavaScript
1099 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const { spawnSync } = require('node:child_process');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const semver = require('semver');
|
|
|
|
const {
|
|
ARTIFACT_TYPE,
|
|
createCatalogPlan,
|
|
createCatalogReceipt,
|
|
} = require('./ql3-release-catalog-contract.cjs');
|
|
const {
|
|
RELEASE_SET_SCHEMA,
|
|
inspectReleaseSet,
|
|
} = require('./ql3-release-set-contract.cjs');
|
|
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
|
|
|
|
const SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
|
|
const WORKFLOW = '.github/workflows/ql3-image-release.yml';
|
|
const RELEASE_SCOPES = Object.freeze(['local', 'cluster', 'all']);
|
|
const TOOL_NAMES = Object.freeze(['regctl', 'cosign', 'gh']);
|
|
const MAX_TOOL_BYTES = 256 * 1024 * 1024;
|
|
const MAX_ARTIFACT_BYTES = 1024 * 1024;
|
|
const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
|
|
const MAX_REPORT_BYTES = 1024 * 1024;
|
|
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
|
const REPOSITORY_PATTERN =
|
|
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
|
|
|
class QingLong3ReleaseCatalogConsumptionError extends Error {
|
|
constructor(message) {
|
|
super(`QingLong 3 release catalog consumption failed: ${message}`);
|
|
this.name = 'QingLong3ReleaseCatalogConsumptionError';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new QingLong3ReleaseCatalogConsumptionError(message);
|
|
}
|
|
|
|
function sha256(value) {
|
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
|
|
function canonicalJson(value) {
|
|
return `${JSON.stringify(value)}\n`;
|
|
}
|
|
|
|
function exactKeys(value, expected) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
|
);
|
|
}
|
|
|
|
function resolveCanonicalAbsolute(value, label) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!path.isAbsolute(value) ||
|
|
value.length > 4096 ||
|
|
CONTROL.test(value) ||
|
|
path.resolve(value) !== value
|
|
) {
|
|
fail(`${label} must be one canonical absolute path`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function currentUid() {
|
|
if (typeof process.getuid !== 'function') {
|
|
fail('workstation owner identity is unavailable');
|
|
}
|
|
return process.getuid();
|
|
}
|
|
|
|
function canonicalPrivateDirectory(directoryPath, label) {
|
|
const resolved = resolveCanonicalAbsolute(directoryPath, label);
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(resolved);
|
|
} catch {
|
|
fail(`${label} must exist`);
|
|
}
|
|
if (
|
|
!stat.isDirectory() ||
|
|
stat.isSymbolicLink() ||
|
|
fs.realpathSync(resolved) !== resolved ||
|
|
stat.uid !== currentUid() ||
|
|
(stat.mode & 0o077) !== 0
|
|
) {
|
|
fail(`${label} must be one owner-private canonical directory`);
|
|
}
|
|
return Object.freeze({
|
|
path: resolved,
|
|
dev: stat.dev,
|
|
ino: stat.ino,
|
|
});
|
|
}
|
|
|
|
function preflightOutputDirectory(directoryPath) {
|
|
const resolved = resolveCanonicalAbsolute(directoryPath, 'output directory');
|
|
if (fs.existsSync(resolved)) {
|
|
fail('output directory must not already exist');
|
|
}
|
|
const parent = canonicalPrivateDirectory(
|
|
path.dirname(resolved),
|
|
'output parent',
|
|
);
|
|
return Object.freeze({ path: resolved, parent });
|
|
}
|
|
|
|
function executable(filePath, expectedName) {
|
|
const resolved = resolveCanonicalAbsolute(filePath, expectedName);
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(resolved);
|
|
} catch {
|
|
fail(`${expectedName} executable is unavailable`);
|
|
}
|
|
if (
|
|
path.basename(resolved) !== expectedName ||
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 2 ||
|
|
stat.size > MAX_TOOL_BYTES ||
|
|
fs.realpathSync(resolved) !== resolved ||
|
|
(stat.mode & 0o111) === 0 ||
|
|
(stat.mode & 0o022) !== 0
|
|
) {
|
|
fail(`${expectedName} must be one immutable canonical executable`);
|
|
}
|
|
return Object.freeze({
|
|
name: expectedName,
|
|
path: resolved,
|
|
dev: stat.dev,
|
|
ino: stat.ino,
|
|
sizeBytes: stat.size,
|
|
sha256: sha256(fs.readFileSync(resolved)),
|
|
});
|
|
}
|
|
|
|
function verifyExecutable(tool) {
|
|
const current = executable(tool.path, tool.name);
|
|
if (
|
|
current.dev !== tool.dev ||
|
|
current.ino !== tool.ino ||
|
|
current.sizeBytes !== tool.sizeBytes ||
|
|
current.sha256 !== tool.sha256
|
|
) {
|
|
fail(`${tool.name} executable changed during the ceremony`);
|
|
}
|
|
}
|
|
|
|
function readPrivateToken(filePath) {
|
|
const resolved = resolveCanonicalAbsolute(filePath, 'GitHub token file');
|
|
let before;
|
|
try {
|
|
before = fs.lstatSync(resolved);
|
|
} catch {
|
|
fail('GitHub token file is unavailable');
|
|
}
|
|
if (
|
|
!before.isFile() ||
|
|
before.isSymbolicLink() ||
|
|
before.size < 8 ||
|
|
before.size > 4096 ||
|
|
before.uid !== currentUid() ||
|
|
(before.mode & 0o077) !== 0 ||
|
|
fs.realpathSync(resolved) !== resolved
|
|
) {
|
|
fail('GitHub token file must be one owner-private canonical file');
|
|
}
|
|
let descriptor = -1;
|
|
let bytes;
|
|
try {
|
|
descriptor = fs.openSync(
|
|
resolved,
|
|
fs.constants.O_RDONLY |
|
|
(fs.constants.O_CLOEXEC ?? 0) |
|
|
(fs.constants.O_NOFOLLOW ?? 0),
|
|
);
|
|
const opened = fs.fstatSync(descriptor);
|
|
if (
|
|
opened.dev !== before.dev ||
|
|
opened.ino !== before.ino ||
|
|
opened.mode !== before.mode ||
|
|
opened.uid !== before.uid ||
|
|
opened.size !== before.size
|
|
) {
|
|
fail('GitHub token file changed while opening');
|
|
}
|
|
bytes = Buffer.alloc(opened.size);
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
const count = fs.readSync(
|
|
descriptor,
|
|
bytes,
|
|
offset,
|
|
bytes.length - offset,
|
|
offset,
|
|
);
|
|
if (count < 1) fail('GitHub token file read was incomplete');
|
|
offset += count;
|
|
}
|
|
const token = bytes.toString('utf8');
|
|
if (
|
|
token.trim() !== token ||
|
|
token.length < 8 ||
|
|
CONTROL.test(token) ||
|
|
/\s/u.test(token)
|
|
) {
|
|
fail('GitHub token file is malformed');
|
|
}
|
|
return Object.freeze({
|
|
bytes,
|
|
token,
|
|
dev: opened.dev,
|
|
ino: opened.ino,
|
|
});
|
|
} catch (error) {
|
|
if (bytes) bytes.fill(0);
|
|
if (error instanceof QingLong3ReleaseCatalogConsumptionError) throw error;
|
|
fail('GitHub token file could not be read safely');
|
|
} finally {
|
|
if (descriptor >= 0) fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function validateIdentity(options) {
|
|
if (
|
|
typeof options.version !== 'string' ||
|
|
!VERSION_PATTERN.test(options.version) ||
|
|
semver.valid(options.version) !== options.version ||
|
|
!/^[a-f0-9]{40}$/u.test(options.sourceRevision || '') ||
|
|
options.sourceRef !== `refs/tags/v${options.version}` ||
|
|
!RELEASE_SCOPES.includes(options.releaseScope) ||
|
|
!OWNER_PATTERN.test(options.repositoryOwner || '') ||
|
|
!REPOSITORY_PATTERN.test(options.sourceRepository || '') ||
|
|
!options.sourceRepository.startsWith(`${options.repositoryOwner}/`)
|
|
) {
|
|
fail('release identity is invalid');
|
|
}
|
|
return Object.freeze({
|
|
version: options.version,
|
|
sourceRevision: options.sourceRevision,
|
|
sourceRef: options.sourceRef,
|
|
releaseScope: options.releaseScope,
|
|
repositoryOwner: options.repositoryOwner,
|
|
sourceRepository: options.sourceRepository,
|
|
});
|
|
}
|
|
|
|
function releaseSetFileName(identity) {
|
|
return `qinglong3-release-set-${identity.version}-${identity.releaseScope}.json`;
|
|
}
|
|
|
|
function manifestFileName(identity) {
|
|
return `qinglong3-release-catalog-manifest-${identity.version}-${identity.releaseScope}.json`;
|
|
}
|
|
|
|
function reportFileName(identity) {
|
|
return `qinglong3-release-catalog-consumption-${identity.version}-${identity.releaseScope}.json`;
|
|
}
|
|
|
|
function catalogIdentity(identity) {
|
|
const repository = `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog`;
|
|
return Object.freeze({
|
|
repository,
|
|
discovery: `${repository}:v${identity.version}-${identity.releaseScope}`,
|
|
workflow: `${identity.sourceRepository}/${WORKFLOW}`,
|
|
workflowIdentity: `https://github.com/${identity.sourceRepository}/${WORKFLOW}@${identity.sourceRef}`,
|
|
});
|
|
}
|
|
|
|
function argvDigest(args) {
|
|
return sha256(Buffer.from(JSON.stringify(args), 'utf8'));
|
|
}
|
|
|
|
function runStep(tool, name, args, env, timeoutMs) {
|
|
verifyExecutable(tool);
|
|
const result = spawnSync(tool.path, args, {
|
|
encoding: 'buffer',
|
|
env,
|
|
timeout: timeoutMs,
|
|
maxBuffer: MAX_TRANSCRIPT_BYTES,
|
|
windowsHide: true,
|
|
});
|
|
const stdout = Buffer.isBuffer(result.stdout)
|
|
? result.stdout
|
|
: Buffer.alloc(0);
|
|
const stderr = Buffer.isBuffer(result.stderr)
|
|
? result.stderr
|
|
: Buffer.alloc(0);
|
|
if (
|
|
result.error ||
|
|
result.signal ||
|
|
result.status !== 0 ||
|
|
stdout.length > MAX_TRANSCRIPT_BYTES ||
|
|
stderr.length > MAX_TRANSCRIPT_BYTES
|
|
) {
|
|
stdout.fill(0);
|
|
stderr.fill(0);
|
|
fail(`${name} did not complete successfully`);
|
|
}
|
|
return Object.freeze({
|
|
record: Object.freeze({
|
|
sequence: 0,
|
|
name,
|
|
tool: tool.name,
|
|
executableSha256: tool.sha256,
|
|
argvSha256: argvDigest(args),
|
|
stdoutBytes: stdout.length,
|
|
stdoutSha256: sha256(stdout),
|
|
stderrBytes: stderr.length,
|
|
stderrSha256: sha256(stderr),
|
|
exitCode: 0,
|
|
}),
|
|
stdout,
|
|
stderr,
|
|
});
|
|
}
|
|
|
|
function parseDigestOutput(bytes) {
|
|
const text = bytes.toString('utf8');
|
|
const digest = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
if (
|
|
!DIGEST_PATTERN.test(digest) ||
|
|
(text !== digest && text !== `${digest}\n`)
|
|
) {
|
|
fail('catalog discovery did not resolve to one exact digest');
|
|
}
|
|
return digest;
|
|
}
|
|
|
|
function parseCanonicalReleaseSet(bytes, identity) {
|
|
if (bytes.length < 2 || bytes.length > MAX_ARTIFACT_BYTES) {
|
|
fail('release set exceeds the bounded artifact size');
|
|
}
|
|
const text = bytes.toString('utf8');
|
|
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
fail('release set must be valid UTF-8');
|
|
}
|
|
let value;
|
|
try {
|
|
value = JSON.parse(text);
|
|
} catch {
|
|
fail('release set must contain valid JSON');
|
|
}
|
|
if (canonicalJson(value) !== text) {
|
|
fail('release set must use exact canonical JSON encoding');
|
|
}
|
|
const inspection = inspectReleaseSet(value, identity);
|
|
return Object.freeze({ value, inspection });
|
|
}
|
|
|
|
function parseManifest(bytes, manifestDigest, plan) {
|
|
if (bytes.length < 2 || bytes.length > MAX_ARTIFACT_BYTES) {
|
|
fail('catalog manifest exceeds the bounded artifact size');
|
|
}
|
|
const text = bytes.toString('utf8');
|
|
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
fail('catalog manifest must be valid UTF-8');
|
|
}
|
|
const receipt = createCatalogReceipt(plan, text, manifestDigest);
|
|
return Object.freeze({ text, receipt });
|
|
}
|
|
|
|
function withSequence(records) {
|
|
return records.map((record, index) =>
|
|
Object.freeze({ ...record, sequence: index + 1 }),
|
|
);
|
|
}
|
|
|
|
function expectedArguments(identity, catalog, immutable, fileName) {
|
|
return Object.freeze([
|
|
Object.freeze(['image', 'digest', catalog.discovery]),
|
|
Object.freeze([
|
|
'verify',
|
|
'--certificate-identity',
|
|
catalog.workflowIdentity,
|
|
'--certificate-oidc-issuer',
|
|
'https://token.actions.githubusercontent.com',
|
|
immutable,
|
|
]),
|
|
Object.freeze([
|
|
'attestation',
|
|
'verify',
|
|
`oci://${immutable}`,
|
|
'--repo',
|
|
identity.sourceRepository,
|
|
'--signer-workflow',
|
|
catalog.workflow,
|
|
'--source-digest',
|
|
identity.sourceRevision,
|
|
'--source-ref',
|
|
identity.sourceRef,
|
|
'--deny-self-hosted-runners',
|
|
'--bundle-from-oci',
|
|
]),
|
|
Object.freeze(['artifact', 'get', '--file', fileName, immutable]),
|
|
Object.freeze(['manifest', 'get', immutable, '--format', 'raw-body']),
|
|
Object.freeze(['image', 'digest', catalog.discovery]),
|
|
]);
|
|
}
|
|
|
|
function createReport({
|
|
identity,
|
|
catalog,
|
|
manifestDigest,
|
|
plan,
|
|
receipt,
|
|
releaseSet,
|
|
releaseSetBytes,
|
|
manifestBytes,
|
|
tools,
|
|
steps,
|
|
observedAt,
|
|
}) {
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: SCHEMA,
|
|
observedAt,
|
|
release: {
|
|
version: identity.version,
|
|
sourceRevision: identity.sourceRevision,
|
|
sourceRef: identity.sourceRef,
|
|
scope: identity.releaseScope,
|
|
},
|
|
sourceRepository: identity.sourceRepository,
|
|
workflowIdentity: catalog.workflowIdentity,
|
|
discovery: {
|
|
reference: catalog.discovery,
|
|
authority: 'none',
|
|
initialDigest: manifestDigest,
|
|
finalDigest: manifestDigest,
|
|
stableDuringCeremony: true,
|
|
},
|
|
catalog: {
|
|
repository: 'qinglong3-release-catalog',
|
|
immutableReference: `${catalog.repository}@${manifestDigest}`,
|
|
manifestDigest,
|
|
artifactType: ARTIFACT_TYPE,
|
|
planDigest: plan.planDigest,
|
|
reconstructedReceiptDigest: receipt.receiptDigest,
|
|
},
|
|
releaseSet: {
|
|
schema: RELEASE_SET_SCHEMA,
|
|
releaseSetDigest: releaseSet.value.releaseSetDigest,
|
|
fileName: releaseSetFileName(identity),
|
|
contentDigest: sha256(releaseSetBytes),
|
|
bytes: releaseSetBytes.length,
|
|
imageCount: releaseSet.inspection.imageCount,
|
|
images: [...releaseSet.inspection.images],
|
|
references: [...releaseSet.inspection.references],
|
|
},
|
|
files: {
|
|
releaseSet: {
|
|
name: releaseSetFileName(identity),
|
|
bytes: releaseSetBytes.length,
|
|
sha256: sha256(releaseSetBytes),
|
|
},
|
|
catalogManifest: {
|
|
name: manifestFileName(identity),
|
|
bytes: manifestBytes.length,
|
|
sha256: sha256(manifestBytes),
|
|
},
|
|
},
|
|
tools: TOOL_NAMES.map((name) => ({
|
|
name,
|
|
sizeBytes: tools[name].sizeBytes,
|
|
sha256: tools[name].sha256,
|
|
})),
|
|
verification: {
|
|
discoveryResolvedTwice: true,
|
|
discoveryStable: true,
|
|
keylessSignature: 'exact_workflow_identity',
|
|
githubProvenance: 'source_tag_and_revision_bound',
|
|
releaseSetInspection: 'standalone_structure_identity_and_self_digest',
|
|
remoteManifestStructure: 'exact',
|
|
catalogReceiptReconstructed: true,
|
|
outputBytes: 'exact_downloaded_bytes',
|
|
},
|
|
steps,
|
|
claims: {
|
|
externalToolResults: 'exit_zero_with_digest_only_transcript',
|
|
offlineAudit: 'structure_identity_manifest_and_self_digest',
|
|
networkAccess: 'registry_and_github_verification_only',
|
|
workstationFileWrites: 'private_temporary_only_plus_final_bundle',
|
|
registryMutation: false,
|
|
githubMutation: false,
|
|
deploymentMutation: false,
|
|
credentialsIncluded: false,
|
|
discoveryTagAuthority: 'none',
|
|
actionAuthority: 'none',
|
|
},
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
contentDigest: sha256(JSON.stringify(unsigned)),
|
|
});
|
|
}
|
|
|
|
function writeNoReplace(filePath, bytes) {
|
|
let descriptor = -1;
|
|
try {
|
|
descriptor = fs.openSync(
|
|
filePath,
|
|
fs.constants.O_WRONLY |
|
|
fs.constants.O_CREAT |
|
|
fs.constants.O_EXCL |
|
|
(fs.constants.O_CLOEXEC ?? 0) |
|
|
(fs.constants.O_NOFOLLOW ?? 0),
|
|
0o600,
|
|
);
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
const count = fs.writeSync(
|
|
descriptor,
|
|
bytes,
|
|
offset,
|
|
bytes.length - offset,
|
|
offset,
|
|
);
|
|
if (count < 1) fail('evidence file write was incomplete');
|
|
offset += count;
|
|
}
|
|
fs.fsyncSync(descriptor);
|
|
} catch (error) {
|
|
if (error instanceof QingLong3ReleaseCatalogConsumptionError) throw error;
|
|
fail('evidence file could not be created without replacement');
|
|
} finally {
|
|
if (descriptor >= 0) fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function publishBundle(
|
|
output,
|
|
identity,
|
|
releaseSetBytes,
|
|
manifestBytes,
|
|
report,
|
|
) {
|
|
const parent = canonicalPrivateDirectory(output.parent.path, 'output parent');
|
|
if (
|
|
parent.dev !== output.parent.dev ||
|
|
parent.ino !== output.parent.ino ||
|
|
fs.existsSync(output.path)
|
|
) {
|
|
fail('output directory changed before publication');
|
|
}
|
|
try {
|
|
fs.mkdirSync(output.path, { mode: 0o700 });
|
|
} catch {
|
|
fail('output directory could not be created without replacement');
|
|
}
|
|
writeNoReplace(
|
|
path.join(output.path, releaseSetFileName(identity)),
|
|
releaseSetBytes,
|
|
);
|
|
writeNoReplace(
|
|
path.join(output.path, manifestFileName(identity)),
|
|
manifestBytes,
|
|
);
|
|
writeNoReplace(
|
|
path.join(output.path, reportFileName(identity)),
|
|
Buffer.from(canonicalJson(report), 'utf8'),
|
|
);
|
|
const descriptor = fs.openSync(output.path, fs.constants.O_RDONLY);
|
|
try {
|
|
fs.fsyncSync(descriptor);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function readBundleFile(directory, name, maximumBytes, requireCanonical) {
|
|
const filePath = path.join(directory, name);
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(filePath);
|
|
} catch {
|
|
fail(`bundle file is missing: ${name}`);
|
|
}
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 2 ||
|
|
stat.size > maximumBytes ||
|
|
stat.uid !== currentUid() ||
|
|
(stat.mode & 0o077) !== 0 ||
|
|
fs.realpathSync(filePath) !== filePath
|
|
) {
|
|
fail(`bundle file is not one owner-private canonical file: ${name}`);
|
|
}
|
|
const bytes = fs.readFileSync(filePath);
|
|
if (requireCanonical) {
|
|
const text = bytes.toString('utf8');
|
|
let value;
|
|
try {
|
|
value = JSON.parse(text);
|
|
} catch {
|
|
fail(`bundle file must contain valid JSON: ${name}`);
|
|
}
|
|
if (canonicalJson(value) !== text) {
|
|
fail(`bundle file must use canonical JSON: ${name}`);
|
|
}
|
|
return Object.freeze({ bytes, value });
|
|
}
|
|
return Object.freeze({ bytes });
|
|
}
|
|
|
|
function validateDynamicReport(report, identity, catalog, manifestDigest) {
|
|
if (
|
|
!exactKeys(report, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'observedAt',
|
|
'release',
|
|
'sourceRepository',
|
|
'workflowIdentity',
|
|
'discovery',
|
|
'catalog',
|
|
'releaseSet',
|
|
'files',
|
|
'tools',
|
|
'verification',
|
|
'steps',
|
|
'claims',
|
|
'contentDigest',
|
|
]) ||
|
|
report.schemaVersion !== 1 ||
|
|
report.schema !== SCHEMA ||
|
|
typeof report.observedAt !== 'string' ||
|
|
Number.isNaN(Date.parse(report.observedAt)) ||
|
|
new Date(report.observedAt).toISOString() !== report.observedAt ||
|
|
!Array.isArray(report.tools) ||
|
|
report.tools.length !== TOOL_NAMES.length ||
|
|
!Array.isArray(report.steps) ||
|
|
report.steps.length !== 6 ||
|
|
!DIGEST_PATTERN.test(report.contentDigest || '')
|
|
) {
|
|
fail('consumption report shape is invalid');
|
|
}
|
|
const tools = {};
|
|
for (let index = 0; index < TOOL_NAMES.length; index += 1) {
|
|
const tool = report.tools[index];
|
|
const name = TOOL_NAMES[index];
|
|
if (
|
|
!exactKeys(tool, ['name', 'sizeBytes', 'sha256']) ||
|
|
tool.name !== name ||
|
|
!Number.isSafeInteger(tool.sizeBytes) ||
|
|
tool.sizeBytes < 2 ||
|
|
tool.sizeBytes > MAX_TOOL_BYTES ||
|
|
!DIGEST_PATTERN.test(tool.sha256 || '')
|
|
) {
|
|
fail('consumption report tool evidence is invalid');
|
|
}
|
|
tools[name] = tool;
|
|
}
|
|
const immutable = `${catalog.repository}@${manifestDigest}`;
|
|
const arguments_ = expectedArguments(
|
|
identity,
|
|
catalog,
|
|
immutable,
|
|
releaseSetFileName(identity),
|
|
);
|
|
const names = [
|
|
'discovery_digest_before',
|
|
'keyless_signature',
|
|
'catalog_provenance',
|
|
'release_set_download',
|
|
'catalog_manifest_download',
|
|
'discovery_digest_after',
|
|
];
|
|
const toolNames = ['regctl', 'cosign', 'gh', 'regctl', 'regctl', 'regctl'];
|
|
for (let index = 0; index < report.steps.length; index += 1) {
|
|
const step = report.steps[index];
|
|
const tool = tools[toolNames[index]];
|
|
if (
|
|
!exactKeys(step, [
|
|
'sequence',
|
|
'name',
|
|
'tool',
|
|
'executableSha256',
|
|
'argvSha256',
|
|
'stdoutBytes',
|
|
'stdoutSha256',
|
|
'stderrBytes',
|
|
'stderrSha256',
|
|
'exitCode',
|
|
]) ||
|
|
step.sequence !== index + 1 ||
|
|
step.name !== names[index] ||
|
|
step.tool !== toolNames[index] ||
|
|
step.executableSha256 !== tool.sha256 ||
|
|
step.argvSha256 !== argvDigest(arguments_[index]) ||
|
|
!Number.isSafeInteger(step.stdoutBytes) ||
|
|
step.stdoutBytes < 0 ||
|
|
step.stdoutBytes > MAX_TRANSCRIPT_BYTES ||
|
|
!DIGEST_PATTERN.test(step.stdoutSha256 || '') ||
|
|
!Number.isSafeInteger(step.stderrBytes) ||
|
|
step.stderrBytes < 0 ||
|
|
step.stderrBytes > MAX_TRANSCRIPT_BYTES ||
|
|
!DIGEST_PATTERN.test(step.stderrSha256 || '') ||
|
|
(step.stdoutBytes === 0 &&
|
|
step.stdoutSha256 !== sha256(Buffer.alloc(0))) ||
|
|
(step.stderrBytes === 0 &&
|
|
step.stderrSha256 !== sha256(Buffer.alloc(0))) ||
|
|
step.exitCode !== 0
|
|
) {
|
|
fail('consumption report step evidence is invalid');
|
|
}
|
|
}
|
|
const digestOutputs = [
|
|
Buffer.from(manifestDigest, 'utf8'),
|
|
Buffer.from(`${manifestDigest}\n`, 'utf8'),
|
|
];
|
|
for (const index of [0, 5]) {
|
|
if (
|
|
!digestOutputs.some(
|
|
(bytes) =>
|
|
report.steps[index].stdoutBytes === bytes.length &&
|
|
report.steps[index].stdoutSha256 === sha256(bytes),
|
|
)
|
|
) {
|
|
fail('discovery digest transcript is invalid');
|
|
}
|
|
}
|
|
return Object.freeze({ tools, steps: report.steps });
|
|
}
|
|
|
|
function auditCeremonyBundle(options) {
|
|
const identity = validateIdentity(options);
|
|
const catalog = catalogIdentity(identity);
|
|
const directory = canonicalPrivateDirectory(
|
|
resolveCanonicalAbsolute(options.outputDirectory, 'output directory'),
|
|
'output directory',
|
|
);
|
|
const expectedNames = [
|
|
manifestFileName(identity),
|
|
reportFileName(identity),
|
|
releaseSetFileName(identity),
|
|
].sort();
|
|
if (
|
|
JSON.stringify(fs.readdirSync(directory.path).sort()) !==
|
|
JSON.stringify(expectedNames)
|
|
) {
|
|
fail('consumption bundle must contain the exact three evidence files');
|
|
}
|
|
const releaseFile = readBundleFile(
|
|
directory.path,
|
|
releaseSetFileName(identity),
|
|
MAX_ARTIFACT_BYTES,
|
|
true,
|
|
);
|
|
const manifestFile = readBundleFile(
|
|
directory.path,
|
|
manifestFileName(identity),
|
|
MAX_ARTIFACT_BYTES,
|
|
false,
|
|
);
|
|
const reportFile = readBundleFile(
|
|
directory.path,
|
|
reportFileName(identity),
|
|
MAX_REPORT_BYTES,
|
|
true,
|
|
);
|
|
const releaseSet = parseCanonicalReleaseSet(releaseFile.bytes, identity);
|
|
const manifestDigest = sha256(manifestFile.bytes);
|
|
const plan = createCatalogPlan(releaseSet.value, identity);
|
|
const manifest = parseManifest(manifestFile.bytes, manifestDigest, plan);
|
|
const dynamic = validateDynamicReport(
|
|
reportFile.value,
|
|
identity,
|
|
catalog,
|
|
manifestDigest,
|
|
);
|
|
const downloadStep = reportFile.value.steps[3];
|
|
const manifestStep = reportFile.value.steps[4];
|
|
if (
|
|
downloadStep.stdoutBytes !== releaseFile.bytes.length ||
|
|
downloadStep.stdoutSha256 !== sha256(releaseFile.bytes) ||
|
|
manifestStep.stdoutBytes !== manifestFile.bytes.length ||
|
|
manifestStep.stdoutSha256 !== manifestDigest
|
|
) {
|
|
fail('download transcripts differ from the durable bundle bytes');
|
|
}
|
|
const expected = createReport({
|
|
identity,
|
|
catalog,
|
|
manifestDigest,
|
|
plan,
|
|
receipt: manifest.receipt,
|
|
releaseSet,
|
|
releaseSetBytes: releaseFile.bytes,
|
|
manifestBytes: manifestFile.bytes,
|
|
tools: dynamic.tools,
|
|
steps: dynamic.steps,
|
|
observedAt: reportFile.value.observedAt,
|
|
});
|
|
if (JSON.stringify(reportFile.value) !== JSON.stringify(expected)) {
|
|
fail('consumption report differs from the verified bundle');
|
|
}
|
|
return Object.freeze({
|
|
compatible: true,
|
|
releaseScope: identity.releaseScope,
|
|
sourceRepository: identity.sourceRepository,
|
|
workflowIdentity: catalog.workflowIdentity,
|
|
releaseSetDigest: releaseSet.value.releaseSetDigest,
|
|
catalogManifestDigest: manifestDigest,
|
|
immutableReference: `${catalog.repository}@${manifestDigest}`,
|
|
imageCount: releaseSet.inspection.imageCount,
|
|
discoveryTagAuthority: 'none',
|
|
externalToolResultsReplayed: false,
|
|
deploymentMutation: false,
|
|
contentDigest: expected.contentDigest,
|
|
releaseSet: releaseSet.value,
|
|
});
|
|
}
|
|
|
|
function runCeremony(options) {
|
|
const identity = validateIdentity(options);
|
|
const output = preflightOutputDirectory(options.outputDirectory);
|
|
const tools = Object.freeze(
|
|
Object.fromEntries(
|
|
TOOL_NAMES.map((name) => [name, executable(options[name], name)]),
|
|
),
|
|
);
|
|
const identities = TOOL_NAMES.map(
|
|
(name) => `${tools[name].dev}:${tools[name].ino}`,
|
|
);
|
|
if (new Set(identities).size !== identities.length) {
|
|
fail('workstation tools must be three distinct executables');
|
|
}
|
|
const catalog = catalogIdentity(identity);
|
|
const temporary = fs.mkdtempSync(
|
|
path.join(output.parent.path, '.ql3-release-catalog-consumption-'),
|
|
);
|
|
const records = [];
|
|
const transcripts = [];
|
|
let token;
|
|
const keep = (result) => {
|
|
records.push(result.record);
|
|
transcripts.push(result.stdout, result.stderr);
|
|
return result;
|
|
};
|
|
try {
|
|
fs.chmodSync(temporary, 0o700);
|
|
const cacheDirectory = path.join(temporary, 'cache');
|
|
const configDirectory = path.join(temporary, 'config');
|
|
const temporaryDirectory = path.join(temporary, 'tmp');
|
|
for (const directory of [
|
|
cacheDirectory,
|
|
configDirectory,
|
|
temporaryDirectory,
|
|
]) {
|
|
fs.mkdirSync(directory, { mode: 0o700 });
|
|
}
|
|
token = readPrivateToken(options.githubTokenFile);
|
|
if (
|
|
TOOL_NAMES.some(
|
|
(name) =>
|
|
tools[name].dev === token.dev && tools[name].ino === token.ino,
|
|
)
|
|
) {
|
|
fail('GitHub token file cannot alias a workstation executable');
|
|
}
|
|
const publicEnv = Object.freeze({
|
|
LANG: 'C',
|
|
LC_ALL: 'C',
|
|
NO_COLOR: '1',
|
|
GH_PROMPT_DISABLED: '1',
|
|
XDG_CACHE_HOME: cacheDirectory,
|
|
XDG_CONFIG_HOME: configDirectory,
|
|
TMPDIR: temporaryDirectory,
|
|
});
|
|
const githubEnv = Object.freeze({ ...publicEnv, GH_TOKEN: token.token });
|
|
const before = keep(
|
|
runStep(
|
|
tools.regctl,
|
|
'discovery_digest_before',
|
|
['image', 'digest', catalog.discovery],
|
|
publicEnv,
|
|
300_000,
|
|
),
|
|
);
|
|
const manifestDigest = parseDigestOutput(before.stdout);
|
|
const immutable = `${catalog.repository}@${manifestDigest}`;
|
|
keep(
|
|
runStep(
|
|
tools.cosign,
|
|
'keyless_signature',
|
|
[
|
|
'verify',
|
|
'--certificate-identity',
|
|
catalog.workflowIdentity,
|
|
'--certificate-oidc-issuer',
|
|
'https://token.actions.githubusercontent.com',
|
|
immutable,
|
|
],
|
|
publicEnv,
|
|
300_000,
|
|
),
|
|
);
|
|
keep(
|
|
runStep(
|
|
tools.gh,
|
|
'catalog_provenance',
|
|
[
|
|
'attestation',
|
|
'verify',
|
|
`oci://${immutable}`,
|
|
'--repo',
|
|
identity.sourceRepository,
|
|
'--signer-workflow',
|
|
catalog.workflow,
|
|
'--source-digest',
|
|
identity.sourceRevision,
|
|
'--source-ref',
|
|
identity.sourceRef,
|
|
'--deny-self-hosted-runners',
|
|
'--bundle-from-oci',
|
|
],
|
|
githubEnv,
|
|
300_000,
|
|
),
|
|
);
|
|
const downloaded = keep(
|
|
runStep(
|
|
tools.regctl,
|
|
'release_set_download',
|
|
['artifact', 'get', '--file', releaseSetFileName(identity), immutable],
|
|
publicEnv,
|
|
300_000,
|
|
),
|
|
);
|
|
const releaseSet = parseCanonicalReleaseSet(downloaded.stdout, identity);
|
|
const plan = createCatalogPlan(releaseSet.value, identity);
|
|
const rawManifest = keep(
|
|
runStep(
|
|
tools.regctl,
|
|
'catalog_manifest_download',
|
|
['manifest', 'get', immutable, '--format', 'raw-body'],
|
|
publicEnv,
|
|
300_000,
|
|
),
|
|
);
|
|
const manifest = parseManifest(rawManifest.stdout, manifestDigest, plan);
|
|
const after = keep(
|
|
runStep(
|
|
tools.regctl,
|
|
'discovery_digest_after',
|
|
['image', 'digest', catalog.discovery],
|
|
publicEnv,
|
|
300_000,
|
|
),
|
|
);
|
|
if (parseDigestOutput(after.stdout) !== manifestDigest) {
|
|
fail('catalog discovery changed during the ceremony');
|
|
}
|
|
for (const tool of Object.values(tools)) verifyExecutable(tool);
|
|
const report = createReport({
|
|
identity,
|
|
catalog,
|
|
manifestDigest,
|
|
plan,
|
|
receipt: manifest.receipt,
|
|
releaseSet,
|
|
releaseSetBytes: downloaded.stdout,
|
|
manifestBytes: rawManifest.stdout,
|
|
tools,
|
|
steps: withSequence(records),
|
|
observedAt: new Date().toISOString(),
|
|
});
|
|
publishBundle(
|
|
output,
|
|
identity,
|
|
downloaded.stdout,
|
|
rawManifest.stdout,
|
|
report,
|
|
);
|
|
return report;
|
|
} finally {
|
|
if (token) token.bytes.fill(0);
|
|
for (const transcript of transcripts) transcript.fill(0);
|
|
try {
|
|
fs.rmSync(temporary, { recursive: true, force: true });
|
|
} catch {
|
|
// Only a private ceremony scratch directory is eligible for cleanup.
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const values = {};
|
|
for (const argument of argv) {
|
|
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
|
|
if (!match || Object.hasOwn(values, match[1])) {
|
|
fail('arguments are invalid');
|
|
}
|
|
values[match[1]] = match[2];
|
|
}
|
|
const identity = [
|
|
'mode',
|
|
'output-directory',
|
|
'release-scope',
|
|
'repository-owner',
|
|
'source-ref',
|
|
'source-repository',
|
|
'source-revision',
|
|
'version',
|
|
];
|
|
const expected =
|
|
values.mode === 'create'
|
|
? [...identity, 'cosign', 'gh', 'github-token-file', 'regctl']
|
|
: values.mode === 'audit'
|
|
? identity
|
|
: [];
|
|
if (
|
|
expected.length === 0 ||
|
|
JSON.stringify(Object.keys(values).sort()) !==
|
|
JSON.stringify(expected.sort())
|
|
) {
|
|
fail('arguments are invalid');
|
|
}
|
|
return Object.freeze({
|
|
mode: values.mode,
|
|
version: values.version,
|
|
sourceRevision: values['source-revision'],
|
|
sourceRef: values['source-ref'],
|
|
releaseScope: values['release-scope'],
|
|
repositoryOwner: values['repository-owner'],
|
|
sourceRepository: values['source-repository'],
|
|
outputDirectory: values['output-directory'],
|
|
...(values.regctl ? { regctl: values.regctl } : {}),
|
|
...(values.cosign ? { cosign: values.cosign } : {}),
|
|
...(values.gh ? { gh: values.gh } : {}),
|
|
...(values['github-token-file']
|
|
? { githubTokenFile: values['github-token-file'] }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
function runCli(argv, output = process.stdout) {
|
|
const options = parseArguments(argv);
|
|
const result =
|
|
options.mode === 'create'
|
|
? runCeremony(options)
|
|
: auditCeremonyBundle(options);
|
|
output.write(
|
|
canonicalJson({
|
|
schemaVersion: 1,
|
|
component: 'qinglong3-release-catalog-consumption-ceremony',
|
|
mode: options.mode,
|
|
compatible: true,
|
|
releaseScope: options.releaseScope,
|
|
immutableReference:
|
|
options.mode === 'create'
|
|
? result.catalog.immutableReference
|
|
: result.immutableReference,
|
|
contentDigest: result.contentDigest,
|
|
}),
|
|
);
|
|
return result;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
runCli(process.argv.slice(2));
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${
|
|
error instanceof Error
|
|
? error.message
|
|
: 'release catalog consumption failed'
|
|
}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = Object.freeze({
|
|
SCHEMA,
|
|
QingLong3ReleaseCatalogConsumptionError,
|
|
auditCeremonyBundle,
|
|
catalogIdentity,
|
|
parseArguments,
|
|
runCeremony,
|
|
runCli,
|
|
});
|