Files
qinglong/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs
T

670 lines
17 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const { spawnSync } = require('node:child_process');
const { createHash, webcrypto } = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
createClusterConsoleEvidenceBundle,
serializeClusterConsoleEvidenceBundle,
} = require('../packages/ql3-cluster-admin/assets/copilot-console/evidence-bundle.js');
const SCHEMA = 'qinglong/cluster-admin-release-workstation-ceremony@v1';
const WORKFLOW = '.github/workflows/ql3-image-release.yml';
const MAX_OUTPUT_BYTES = 1024 * 1024;
const MAX_TOOL_BYTES = 256 * 1024 * 1024;
const CONTROL = /[\u0000-\u001f\u007f]/u;
const SHA256 = /^sha256:[a-f0-9]{64}$/u;
class ReleaseWorkstationCeremonyError extends Error {
constructor() {
super('Cluster Admin release workstation ceremony failed');
this.name = 'ReleaseWorkstationCeremonyError';
}
}
function fail() {
throw new ReleaseWorkstationCeremonyError();
}
function digest(value) {
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
}
function canonicalize(value) {
if (value === null || typeof value !== 'object') {
return JSON.stringify(Object.is(value, -0) ? 0 : value);
}
if (Array.isArray(value)) {
return `[${value.map(canonicalize).join(',')}]`;
}
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`)
.join(',')}}`;
}
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();
values[match[1]] = match[2];
}
const expected = [
'image',
'repository',
'source-revision',
'source-ref',
'cosign',
'gh',
'docker',
'github-token-file',
'output',
];
if (
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify(expected.sort())
) {
fail();
}
const repository = values.repository;
if (!/^[a-z0-9][a-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/u.test(repository)) {
fail();
}
const owner = repository.slice(0, repository.indexOf('/'));
const imagePattern = new RegExp(
`^ghcr\\.io/${owner}/qinglong3-cluster-admin@sha256:[a-f0-9]{64}$`,
'u',
);
if (
!imagePattern.test(values.image) ||
!/^[a-f0-9]{40}$/u.test(values['source-revision']) ||
!/^refs\/tags\/v3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test(
values['source-ref'],
)
) {
fail();
}
return Object.freeze({
image: values.image,
repository,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
cosign: values.cosign,
gh: values.gh,
docker: values.docker,
githubTokenFile: values['github-token-file'],
output: values.output,
});
}
function canonicalRegularFile(filePath, options) {
if (
typeof filePath !== 'string' ||
!path.isAbsolute(filePath) ||
filePath.length > 4096 ||
CONTROL.test(filePath) ||
path.normalize(filePath) !== filePath
) {
fail();
}
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
fail();
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < options.minimumBytes ||
stat.size > options.maximumBytes ||
fs.realpathSync(filePath) !== filePath ||
(stat.mode & options.forbiddenMode) !== 0 ||
(options.executable && (stat.mode & 0o111) === 0) ||
(options.currentOwner &&
(typeof process.getuid !== 'function' || stat.uid !== process.getuid()))
) {
fail();
}
return stat;
}
function readToken(filePath) {
const before = canonicalRegularFile(filePath, {
minimumBytes: 1,
maximumBytes: 4096,
forbiddenMode: 0o077,
executable: false,
currentOwner: true,
});
let descriptor = -1;
let bytes;
try {
descriptor = fs.openSync(
filePath,
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();
}
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();
offset += count;
}
const token = bytes.toString('utf8');
if (
token.trim() !== token ||
token.length < 8 ||
CONTROL.test(token) ||
/\s/u.test(token)
) {
fail();
}
return Object.freeze({ bytes, token });
} catch (error) {
if (bytes) bytes.fill(0);
if (error instanceof ReleaseWorkstationCeremonyError) throw error;
fail();
} finally {
if (descriptor >= 0) fs.closeSync(descriptor);
}
}
function executable(filePath) {
const stat = canonicalRegularFile(filePath, {
minimumBytes: 2,
maximumBytes: MAX_TOOL_BYTES,
forbiddenMode: 0o022,
executable: true,
currentOwner: false,
});
return Object.freeze({
path: filePath,
dev: stat.dev,
ino: stat.ino,
size: stat.size,
sha256: digest(fs.readFileSync(filePath)),
});
}
function verifyExecutable(tool) {
const current = executable(tool.path);
if (
current.dev !== tool.dev ||
current.ino !== tool.ino ||
current.size !== tool.size ||
current.sha256 !== tool.sha256
) {
fail();
}
}
function privateUnusedOutput(filePath) {
if (
typeof filePath !== 'string' ||
!path.isAbsolute(filePath) ||
filePath.length > 4096 ||
CONTROL.test(filePath) ||
path.normalize(filePath) !== filePath ||
fs.existsSync(filePath)
) {
fail();
}
const parent = path.dirname(filePath);
let stat;
try {
stat = fs.lstatSync(parent);
} catch {
fail();
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
fs.realpathSync(parent) !== parent ||
typeof process.getuid !== 'function' ||
stat.uid !== process.getuid() ||
(stat.mode & 0o077) !== 0
) {
fail();
}
return parent;
}
function writeNoReplace(filePath, value) {
privateUnusedOutput(filePath);
const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
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();
offset += count;
}
fs.fsyncSync(descriptor);
} finally {
bytes.fill(0);
if (descriptor >= 0) fs.closeSync(descriptor);
}
}
function runStep(tool, name, args, env, timeoutMs) {
verifyExecutable(tool);
const result = spawnSync(tool.path, args, {
encoding: 'buffer',
env,
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_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_OUTPUT_BYTES ||
stderr.length > MAX_OUTPUT_BYTES
) {
stdout.fill(0);
stderr.fill(0);
fail();
}
return Object.freeze({
record: Object.freeze({
sequence: 0,
name,
tool: path.basename(tool.path),
executableSha256: tool.sha256,
argvSha256: digest(Buffer.from(JSON.stringify(args), 'utf8')),
stdoutBytes: stdout.length,
stdoutSha256: digest(stdout),
stderrBytes: stderr.length,
stderrSha256: digest(stderr),
exitCode: 0,
}),
stdout,
stderr,
});
}
function parseJson(bytes) {
try {
return JSON.parse(bytes.toString('utf8'));
} catch {
fail();
}
}
async function syntheticEvidenceVector() {
const bundle = await createClusterConsoleEvidenceBundle(
[
{
operation: 'run_read',
observedAtMs: 1_700_000_000_000,
request: {
schema: 'qinglong/cluster-copilot-console-read-request@v1',
operation: 'run_read',
projectId: 'ceremony-project',
requestId: 'ceremony-request',
runId: 'ceremony-run',
},
fact: {
schema: 'qinglong/bounded-run-projection@v1',
schemaVersion: 1,
status: 'succeeded',
projectId: 'ceremony-project',
runId: 'ceremony-run',
outputAvailable: false,
},
},
],
1_700_000_001_000,
webcrypto,
);
return Object.freeze({
bundle,
encoded: serializeClusterConsoleEvidenceBundle(bundle),
});
}
function expectedEvidenceVerification(bundle) {
return {
schema: 'qinglong/cluster-console-evidence-verification@v1',
status: 'verified',
bundle: {
schema: 'qinglong/cluster-console-redacted-evidence-bundle@v1',
contentDigest: bundle.contentDigest,
entryCount: 1,
totalRawCanonicalBytes: bundle.source.totalRawCanonicalBytes,
},
integrity: {
bundleDigest: 'verified',
rawFactDigests: 'not_recomputed_without_raw_facts',
},
claims: {
serverSignature: 'not_verified',
attestation: 'not_verified',
durableAudit: 'not_verified',
actionAuthority: 'none',
},
execution: { networkAccess: false, mutation: false, fileWrites: false },
};
}
function withSequence(steps) {
return steps.map((step, index) =>
Object.freeze({ ...step, sequence: index + 1 }),
);
}
async function runCeremony(options) {
const outputParent = privateUnusedOutput(options.output);
const tools = Object.freeze({
cosign: executable(options.cosign),
gh: executable(options.gh),
docker: executable(options.docker),
});
const token = readToken(options.githubTokenFile);
const publicEnv = Object.freeze({
LANG: 'C',
LC_ALL: 'C',
NO_COLOR: '1',
GH_PROMPT_DISABLED: '1',
});
const githubEnv = Object.freeze({
...publicEnv,
GH_TOKEN: token.token,
});
const temporary = fs.mkdtempSync(
path.join(outputParent, '.ql3-admin-release-ceremony-'),
);
fs.chmodSync(temporary, 0o700);
const vectorFile = path.join(temporary, 'synthetic-evidence.json');
const steps = [];
const transcripts = [];
const keep = (result) => {
steps.push(result.record);
transcripts.push(result.stdout, result.stderr);
return result;
};
const discard = (result) => {
keep(result);
result.stdout.fill(0);
result.stderr.fill(0);
};
try {
const vector = await syntheticEvidenceVector();
fs.writeFileSync(vectorFile, vector.encoded, { mode: 0o444, flag: 'wx' });
const before = fs.statSync(vectorFile);
const workflow = `${options.repository}/${WORKFLOW}`;
const identity = `https://github.com/${workflow}@${options.sourceRef}`;
discard(
runStep(
tools.cosign,
'keyless_signature',
[
'verify',
'--certificate-identity',
identity,
'--certificate-oidc-issuer',
'https://token.actions.githubusercontent.com',
options.image,
],
publicEnv,
300_000,
),
);
const attestation = (name, predicateType) => {
const args = [
'attestation',
'verify',
`oci://${options.image}`,
'--repo',
options.repository,
'--signer-workflow',
workflow,
'--source-digest',
options.sourceRevision,
'--source-ref',
options.sourceRef,
];
if (predicateType) args.push('--predicate-type', predicateType);
args.push('--deny-self-hosted-runners', '--bundle-from-oci');
discard(runStep(tools.gh, name, args, githubEnv, 300_000));
};
attestation('provenance_attestation', '');
attestation('cyclonedx_sbom_attestation', 'https://cyclonedx.org/bom');
attestation(
'os_vulnerability_attestation',
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
);
attestation(
'release_candidate_attestation',
'https://qinglong.dev/attestations/release-candidate-contract/v1',
);
discard(
runStep(
tools.docker,
'immutable_image_pull',
['pull', options.image],
publicEnv,
600_000,
),
);
const inspection = keep(
runStep(
tools.docker,
'local_digest_inspection',
['image', 'inspect', options.image],
publicEnv,
60_000,
),
);
const inspected = parseJson(inspection.stdout);
if (
!Array.isArray(inspected) ||
inspected.length !== 1 ||
inspected[0]?.Os !== 'linux' ||
!['amd64', 'arm64'].includes(inspected[0]?.Architecture) ||
!Array.isArray(inspected[0]?.RepoDigests) ||
!inspected[0].RepoDigests.includes(options.image)
) {
fail();
}
const mount = `type=bind,src=${vectorFile},dst=/evidence/bundle.json,readonly`;
const verified = keep(
runStep(
tools.docker,
'embedded_evidence_verifier',
[
'run',
'--rm',
'--read-only',
'--network',
'none',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'10001:10001',
'--pids-limit',
'32',
'--memory',
'128m',
'--cpus',
'0.25',
'--mount',
mount,
options.image,
'evidence-verify',
'--bundle=/evidence/bundle.json',
],
publicEnv,
60_000,
),
);
if (
verified.stderr.length !== 0 ||
JSON.stringify(parseJson(verified.stdout)) !==
JSON.stringify(expectedEvidenceVerification(vector.bundle))
) {
fail();
}
const after = fs.statSync(vectorFile);
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs ||
digest(fs.readFileSync(vectorFile)) !==
digest(Buffer.from(vector.encoded))
) {
fail();
}
for (const tool of Object.values(tools)) verifyExecutable(tool);
const unsigned = {
schema: SCHEMA,
schemaVersion: 1,
observedAt: new Date().toISOString(),
release: {
image: options.image,
repository: options.repository,
sourceRevision: options.sourceRevision,
sourceRef: options.sourceRef,
workflowIdentity: identity,
},
tools: ['cosign', 'gh', 'docker'].map((name) => ({
name,
sha256: tools[name].sha256,
sizeBytes: tools[name].size,
})),
verification: {
keylessSignature: true,
provenance: true,
cyclonedxSbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
imagePulled: true,
localRepoDigestBound: true,
embeddedEvidenceVerifier: true,
},
evidenceVector: {
schema: vector.bundle.schema,
contentDigest: vector.bundle.contentDigest,
entryCount: vector.bundle.source.entryCount,
totalRawCanonicalBytes: vector.bundle.source.totalRawCanonicalBytes,
classification: 'synthetic_non_sensitive',
},
isolation: {
network: 'none_for_embedded_verifier',
readOnlyRoot: true,
capabilities: 'none',
noNewPrivileges: true,
pids: 32,
memoryBytes: 134217728,
cpus: 0.25,
verifierMutation: false,
verifierFileWrites: false,
},
steps: withSequence(steps),
claims: {
externalToolResults: 'exit_zero_with_digest_only_transcript',
registryAvailability: 'observed_once',
offlineAudit: 'structure_and_digest_only',
workstationIdentityIncluded: false,
credentialIncluded: false,
reportAttestation: 'none',
actionAuthority: 'none',
},
};
const report = Object.freeze({
...unsigned,
contentDigest: digest(Buffer.from(canonicalize(unsigned), 'utf8')),
});
writeNoReplace(options.output, report);
return report;
} finally {
token.bytes.fill(0);
for (const transcript of transcripts) transcript.fill(0);
try {
fs.rmSync(temporary, { recursive: true, force: true });
} catch {
// A private synthetic-only directory is best-effort cleanup on failure.
}
}
}
async function runCli(argv) {
const report = await runCeremony(parseArguments(argv));
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-cluster-admin-release-workstation-ceremony',
compatible: true,
contentDigest: report.contentDigest,
})}\n`,
);
}
if (require.main === module) {
runCli(process.argv.slice(2)).catch(() => {
process.stderr.write(
'{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-workstation-ceremony","event":"ceremony_failed"}\n',
);
process.exitCode = 1;
});
}
module.exports = {
SCHEMA,
expectedEvidenceVerification,
parseArguments,
runCeremony,
runCli,
syntheticEvidenceVector,
};