mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add external release workstation ceremony
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { TextDecoder } = require('node:util');
|
||||
|
||||
const SCHEMA = 'qinglong/cluster-admin-release-workstation-ceremony@v1';
|
||||
const AUDIT_SCHEMA =
|
||||
'qinglong/cluster-admin-release-workstation-ceremony-audit@v1';
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
||||
const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
||||
const MAX_REPORT_BYTES = 512 * 1024;
|
||||
const EMPTY_DIGEST =
|
||||
'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
class ReleaseWorkstationCeremonyAuditError extends Error {
|
||||
constructor(message) {
|
||||
super(
|
||||
`Cluster Admin release workstation ceremony audit failed: ${message}`,
|
||||
);
|
||||
this.name = 'ReleaseWorkstationCeremonyAuditError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new ReleaseWorkstationCeremonyAuditError(message);
|
||||
}
|
||||
|
||||
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 exactKeys(value, keys) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) ===
|
||||
JSON.stringify([...keys].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function exactObject(value, keys, label) {
|
||||
if (!exactKeys(value, keys)) fail(`${label} shape is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
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 expected = [
|
||||
'report',
|
||||
'image',
|
||||
'repository',
|
||||
'source-revision',
|
||||
'source-ref',
|
||||
];
|
||||
if (
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(expected.sort())
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
reportFile: values.report,
|
||||
image: values.image,
|
||||
repository: values.repository,
|
||||
sourceRevision: values['source-revision'],
|
||||
sourceRef: values['source-ref'],
|
||||
});
|
||||
}
|
||||
|
||||
function readReport(filePath) {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!path.isAbsolute(filePath) ||
|
||||
filePath.length > 4096 ||
|
||||
CONTROL.test(filePath) ||
|
||||
path.normalize(filePath) !== filePath
|
||||
) {
|
||||
fail('report path is invalid');
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
fail('report is unavailable');
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_REPORT_BYTES ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
typeof process.getuid !== 'function' ||
|
||||
stat.uid !== process.getuid() ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
fail('report must be one canonical owner-private bounded file');
|
||||
}
|
||||
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 !== stat.dev ||
|
||||
opened.ino !== stat.ino ||
|
||||
opened.mode !== stat.mode ||
|
||||
opened.uid !== stat.uid ||
|
||||
opened.size !== stat.size
|
||||
) {
|
||||
fail('report changed before open');
|
||||
}
|
||||
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('report read was incomplete');
|
||||
offset += count;
|
||||
}
|
||||
const after = fs.fstatSync(descriptor);
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeMs !== opened.mtimeMs
|
||||
) {
|
||||
fail('report changed while read');
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
);
|
||||
} catch {
|
||||
fail('report must contain UTF-8 JSON');
|
||||
}
|
||||
if (`${JSON.stringify(value, null, 2)}\n` !== bytes.toString('utf8')) {
|
||||
fail('report encoding is not canonical');
|
||||
}
|
||||
return Object.freeze({ bytes, value });
|
||||
} catch (error) {
|
||||
if (bytes) bytes.fill(0);
|
||||
if (error instanceof ReleaseWorkstationCeremonyAuditError) throw error;
|
||||
fail('report could not be read safely');
|
||||
} finally {
|
||||
if (descriptor >= 0) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function validIsoTime(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) &&
|
||||
Number.isFinite(Date.parse(value))
|
||||
);
|
||||
}
|
||||
|
||||
function same(value, expected, label) {
|
||||
if (JSON.stringify(value) !== JSON.stringify(expected)) {
|
||||
fail(`${label} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRelease(value, expected) {
|
||||
exactObject(
|
||||
value,
|
||||
['image', 'repository', 'sourceRevision', 'sourceRef', 'workflowIdentity'],
|
||||
'release',
|
||||
);
|
||||
const owner = expected.repository.split('/')[0];
|
||||
if (
|
||||
!/^[a-z0-9][a-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/u.test(
|
||||
expected.repository,
|
||||
) ||
|
||||
!new RegExp(
|
||||
`^ghcr\\.io/${owner}/qinglong3-cluster-admin@sha256:[a-f0-9]{64}$`,
|
||||
'u',
|
||||
).test(expected.image) ||
|
||||
!/^[a-f0-9]{40}$/u.test(expected.sourceRevision) ||
|
||||
!/^refs\/tags\/v3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test(
|
||||
expected.sourceRef,
|
||||
)
|
||||
) {
|
||||
fail('expected release identity is invalid');
|
||||
}
|
||||
const workflow = `${expected.repository}/.github/workflows/ql3-image-release.yml`;
|
||||
same(
|
||||
value,
|
||||
{
|
||||
image: expected.image,
|
||||
repository: expected.repository,
|
||||
sourceRevision: expected.sourceRevision,
|
||||
sourceRef: expected.sourceRef,
|
||||
workflowIdentity: `https://github.com/${workflow}@${expected.sourceRef}`,
|
||||
},
|
||||
'release identity',
|
||||
);
|
||||
}
|
||||
|
||||
function auditReport(value, expected) {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'observedAt',
|
||||
'release',
|
||||
'tools',
|
||||
'verification',
|
||||
'evidenceVector',
|
||||
'isolation',
|
||||
'steps',
|
||||
'claims',
|
||||
'contentDigest',
|
||||
],
|
||||
'report',
|
||||
);
|
||||
if (
|
||||
value.schema !== SCHEMA ||
|
||||
value.schemaVersion !== 1 ||
|
||||
!validIsoTime(value.observedAt) ||
|
||||
typeof value.contentDigest !== 'string' ||
|
||||
!DIGEST.test(value.contentDigest)
|
||||
) {
|
||||
fail('report identity is invalid');
|
||||
}
|
||||
validateRelease(value.release, expected);
|
||||
if (!Array.isArray(value.tools) || value.tools.length !== 3) {
|
||||
fail('tool evidence is invalid');
|
||||
}
|
||||
const expectedTools = ['cosign', 'gh', 'docker'];
|
||||
value.tools.forEach((tool, index) => {
|
||||
exactObject(tool, ['name', 'sha256', 'sizeBytes'], 'tool');
|
||||
if (
|
||||
tool.name !== expectedTools[index] ||
|
||||
typeof tool.sha256 !== 'string' ||
|
||||
!DIGEST.test(tool.sha256) ||
|
||||
!Number.isSafeInteger(tool.sizeBytes) ||
|
||||
tool.sizeBytes < 2 ||
|
||||
tool.sizeBytes > 256 * 1024 * 1024
|
||||
) {
|
||||
fail('tool evidence is invalid');
|
||||
}
|
||||
});
|
||||
same(
|
||||
value.verification,
|
||||
{
|
||||
keylessSignature: true,
|
||||
provenance: true,
|
||||
cyclonedxSbom: true,
|
||||
osVulnerabilityEvidence: true,
|
||||
imagePulled: true,
|
||||
localRepoDigestBound: true,
|
||||
embeddedEvidenceVerifier: true,
|
||||
},
|
||||
'verification claims',
|
||||
);
|
||||
exactObject(
|
||||
value.evidenceVector,
|
||||
[
|
||||
'schema',
|
||||
'contentDigest',
|
||||
'entryCount',
|
||||
'totalRawCanonicalBytes',
|
||||
'classification',
|
||||
],
|
||||
'evidence vector',
|
||||
);
|
||||
if (
|
||||
value.evidenceVector.schema !==
|
||||
'qinglong/cluster-console-redacted-evidence-bundle@v1' ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.evidenceVector.contentDigest) ||
|
||||
value.evidenceVector.entryCount !== 1 ||
|
||||
!Number.isSafeInteger(value.evidenceVector.totalRawCanonicalBytes) ||
|
||||
value.evidenceVector.totalRawCanonicalBytes < 2 ||
|
||||
value.evidenceVector.totalRawCanonicalBytes > 8 * 1024 * 1024 ||
|
||||
value.evidenceVector.classification !== 'synthetic_non_sensitive'
|
||||
) {
|
||||
fail('evidence vector is invalid');
|
||||
}
|
||||
same(
|
||||
value.isolation,
|
||||
{
|
||||
network: 'none_for_embedded_verifier',
|
||||
readOnlyRoot: true,
|
||||
capabilities: 'none',
|
||||
noNewPrivileges: true,
|
||||
pids: 32,
|
||||
memoryBytes: 134217728,
|
||||
cpus: 0.25,
|
||||
verifierMutation: false,
|
||||
verifierFileWrites: false,
|
||||
},
|
||||
'verifier isolation',
|
||||
);
|
||||
const expectedSteps = [
|
||||
['keyless_signature', 'cosign'],
|
||||
['provenance_attestation', 'gh'],
|
||||
['cyclonedx_sbom_attestation', 'gh'],
|
||||
['os_vulnerability_attestation', 'gh'],
|
||||
['immutable_image_pull', 'docker'],
|
||||
['local_digest_inspection', 'docker'],
|
||||
['embedded_evidence_verifier', 'docker'],
|
||||
];
|
||||
if (
|
||||
!Array.isArray(value.steps) ||
|
||||
value.steps.length !== expectedSteps.length
|
||||
) {
|
||||
fail('step evidence is invalid');
|
||||
}
|
||||
const toolDigests = Object.fromEntries(
|
||||
value.tools.map((tool) => [tool.name, tool.sha256]),
|
||||
);
|
||||
value.steps.forEach((step, index) => {
|
||||
exactObject(
|
||||
step,
|
||||
[
|
||||
'sequence',
|
||||
'name',
|
||||
'tool',
|
||||
'executableSha256',
|
||||
'argvSha256',
|
||||
'stdoutBytes',
|
||||
'stdoutSha256',
|
||||
'stderrBytes',
|
||||
'stderrSha256',
|
||||
'exitCode',
|
||||
],
|
||||
'step',
|
||||
);
|
||||
const [name, tool] = expectedSteps[index];
|
||||
if (
|
||||
step.sequence !== index + 1 ||
|
||||
step.name !== name ||
|
||||
step.tool !== tool ||
|
||||
step.executableSha256 !== toolDigests[tool] ||
|
||||
!DIGEST.test(step.argvSha256) ||
|
||||
!Number.isSafeInteger(step.stdoutBytes) ||
|
||||
step.stdoutBytes < 0 ||
|
||||
step.stdoutBytes > 1024 * 1024 ||
|
||||
!DIGEST.test(step.stdoutSha256) ||
|
||||
!Number.isSafeInteger(step.stderrBytes) ||
|
||||
step.stderrBytes < 0 ||
|
||||
step.stderrBytes > 1024 * 1024 ||
|
||||
!DIGEST.test(step.stderrSha256) ||
|
||||
step.exitCode !== 0
|
||||
) {
|
||||
fail('step evidence is invalid');
|
||||
}
|
||||
});
|
||||
const verifierStep = value.steps[6];
|
||||
if (
|
||||
verifierStep.stdoutBytes < 2 ||
|
||||
verifierStep.stderrBytes !== 0 ||
|
||||
verifierStep.stderrSha256 !== EMPTY_DIGEST
|
||||
) {
|
||||
fail('embedded verifier transcript is invalid');
|
||||
}
|
||||
same(
|
||||
value.claims,
|
||||
{
|
||||
externalToolResults: 'exit_zero_with_digest_only_transcript',
|
||||
registryAvailability: 'observed_once',
|
||||
offlineAudit: 'structure_and_digest_only',
|
||||
workstationIdentityIncluded: false,
|
||||
credentialIncluded: false,
|
||||
reportAttestation: 'none',
|
||||
actionAuthority: 'none',
|
||||
},
|
||||
'claim boundary',
|
||||
);
|
||||
const unsigned = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key !== 'contentDigest') unsigned[key] = entry;
|
||||
}
|
||||
if (
|
||||
digest(Buffer.from(canonicalize(unsigned), 'utf8')) !== value.contentDigest
|
||||
) {
|
||||
fail('report digest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
reportContentDigest: value.contentDigest,
|
||||
releaseImage: value.release.image,
|
||||
verificationSteps: value.steps.length,
|
||||
externalResults: 'not_replayed',
|
||||
actionAuthority: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv) {
|
||||
const options = parseArguments(argv);
|
||||
const report = readReport(options.reportFile);
|
||||
try {
|
||||
const result = auditReport(report.value, options);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
schema: AUDIT_SCHEMA,
|
||||
...result,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
report.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
runCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'release workstation ceremony audit failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIT_SCHEMA,
|
||||
SCHEMA,
|
||||
auditReport,
|
||||
parseArguments,
|
||||
readReport,
|
||||
runCli,
|
||||
};
|
||||
@@ -0,0 +1,664 @@
|
||||
#!/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',
|
||||
);
|
||||
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,
|
||||
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,
|
||||
};
|
||||
@@ -6,6 +6,9 @@ const path = require('node:path');
|
||||
const FILES = Object.freeze({
|
||||
launcher: 'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||
verifier: 'deploy/console/ql3-cluster-copilot/verify-release.sh',
|
||||
ceremony: 'scripts/ql3-cluster-admin-release-workstation-ceremony.cjs',
|
||||
ceremonyAudit:
|
||||
'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs',
|
||||
environment:
|
||||
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
@@ -107,10 +110,72 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
);
|
||||
rejectFragments(
|
||||
'verifier',
|
||||
[':latest', 'refs/heads/', '--insecure-ignore-tlog', '--certificate-identity-regexp'],
|
||||
[
|
||||
':latest',
|
||||
'refs/heads/',
|
||||
'--insecure-ignore-tlog',
|
||||
'--certificate-identity-regexp',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_WIDENED',
|
||||
);
|
||||
|
||||
requireFragments(
|
||||
'ceremony',
|
||||
[
|
||||
"'qinglong/cluster-admin-release-workstation-ceremony@v1'",
|
||||
'GH_TOKEN: token.token',
|
||||
"'--certificate-identity'",
|
||||
"'--certificate-oidc-issuer'",
|
||||
"'https://token.actions.githubusercontent.com'",
|
||||
"'--signer-workflow'",
|
||||
"'--source-digest'",
|
||||
"'--source-ref'",
|
||||
"'--deny-self-hosted-runners'",
|
||||
"'--bundle-from-oci'",
|
||||
"'https://cyclonedx.org/bom'",
|
||||
"'https://qinglong.dev/attestations/image-os-vulnerability/v1'",
|
||||
"'immutable_image_pull'",
|
||||
"'local_digest_inspection'",
|
||||
"'embedded_evidence_verifier'",
|
||||
"'--read-only'",
|
||||
"'--network',\n 'none'",
|
||||
"'--cap-drop'",
|
||||
"'no-new-privileges'",
|
||||
"'10001:10001'",
|
||||
"'128m'",
|
||||
"'0.25'",
|
||||
"'evidence-verify'",
|
||||
"reportAttestation: 'none'",
|
||||
'writeNoReplace(options.output, report)',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_CEREMONY_DRIFT',
|
||||
);
|
||||
rejectFragments(
|
||||
'ceremony',
|
||||
[
|
||||
'shell: true',
|
||||
'env: process.env',
|
||||
':latest',
|
||||
'--privileged',
|
||||
'GH_TOKEN: token.token,\n ...publicEnv',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_CEREMONY_WIDENED',
|
||||
);
|
||||
requireFragments(
|
||||
'ceremonyAudit',
|
||||
[
|
||||
"'qinglong/cluster-admin-release-workstation-ceremony-audit@v1'",
|
||||
'report must be one canonical owner-private bounded file',
|
||||
"externalResults: 'not_replayed'",
|
||||
"offlineAudit: 'structure_and_digest_only'",
|
||||
"reportAttestation: 'none'",
|
||||
"actionAuthority: 'none'",
|
||||
'O_NOFOLLOW',
|
||||
'canonicalize(unsigned)',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_AUDIT_DRIFT',
|
||||
);
|
||||
|
||||
let environment;
|
||||
try {
|
||||
environment = JSON.parse(source.environment);
|
||||
@@ -128,8 +193,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
const expectedEnvironment = {
|
||||
QL3_COPILOT_CONSOLE_IMAGE:
|
||||
'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64),
|
||||
QL3_COPILOT_CONSOLE_PRIVATE_ROOT:
|
||||
'/absolute/private/ql3-copilot-console',
|
||||
QL3_COPILOT_CONSOLE_PRIVATE_ROOT: '/absolute/private/ql3-copilot-console',
|
||||
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-copilot-console-egress',
|
||||
QL3_COPILOT_CONSOLE_PORT: '5701',
|
||||
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
|
||||
@@ -225,6 +289,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
hostPublication: '127.0.0.1',
|
||||
kubernetesResident: false,
|
||||
additionalWorkspacePackages: 0,
|
||||
externalWorkstationCeremony: 'source-tag-private-report',
|
||||
ceremonyStatus: 'implementation-ready-public-release-pending',
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user