mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:25:29 +08:00
670 lines
17 KiB
JavaScript
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,
|
|
};
|