mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
522 lines
15 KiB
JavaScript
522 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const {
|
|
auditWorkerCredentialManagementReleaseGate,
|
|
} = require('./ql3-worker-credential-management-release-gate.cjs');
|
|
const {
|
|
validateCloudNativePgDrReleaseEvidence,
|
|
} = require('./ql3-cloudnativepg-dr-evidence-audit.cjs');
|
|
const {
|
|
auditCloudNativePgBackup,
|
|
} = require('./ql3-cloudnativepg-backup-audit.cjs');
|
|
const {
|
|
auditBarmanCloudSupplyChain,
|
|
} = require('./ql3-barman-cloud-supply-chain-audit.cjs');
|
|
const {
|
|
auditCertManagerSelection,
|
|
} = require('./ql3-cert-manager-selection-audit.cjs');
|
|
|
|
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2';
|
|
const EVIDENCE_KINDS = Object.freeze([
|
|
'worker-management',
|
|
'cloudnativepg-disaster-recovery',
|
|
]);
|
|
const EVIDENCE_FIXTURES = Object.freeze({
|
|
'worker-management': 'qinglong/worker-credential-management-release-gate@v1',
|
|
'cloudnativepg-disaster-recovery':
|
|
'qinglong/cloudnativepg-disaster-recovery@v1',
|
|
});
|
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
const MAX_EVIDENCE_AGE_SECONDS = 24 * 60 * 60;
|
|
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
const REVISION_PATTERN = /^[a-f0-9]{40}$/u;
|
|
const VERSION_PATTERN =
|
|
/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u;
|
|
const STATIC_AUDIT_NAMES = Object.freeze([
|
|
'cloudnativepg-backup',
|
|
'barman-cloud-supply-chain',
|
|
'cert-manager-selection',
|
|
]);
|
|
|
|
class QingLong3PrivateReleaseEvidenceReceiptError extends Error {
|
|
constructor(message) {
|
|
super(`QingLong 3 private release evidence receipt failed: ${message}`);
|
|
this.name = 'QingLong3PrivateReleaseEvidenceReceiptError';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new QingLong3PrivateReleaseEvidenceReceiptError(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 canonicalAbsolute(filePath, label) {
|
|
if (
|
|
typeof filePath !== 'string' ||
|
|
!path.isAbsolute(filePath) ||
|
|
path.resolve(filePath) !== filePath
|
|
) {
|
|
fail(`${label} path must be canonical and absolute`);
|
|
}
|
|
if (fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)) {
|
|
fail(`${label} parent must be canonical`);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function readJsonFile(filePath, label, privateInput = false) {
|
|
const resolved = canonicalAbsolute(filePath, label);
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
const descriptor = fs.openSync(resolved, flags);
|
|
try {
|
|
const before = fs.fstatSync(descriptor);
|
|
if (
|
|
!before.isFile() ||
|
|
before.nlink !== 1 ||
|
|
before.size < 2 ||
|
|
before.size > MAX_JSON_BYTES ||
|
|
fs.realpathSync(resolved) !== resolved ||
|
|
(privateInput &&
|
|
(before.uid !== process.getuid() || (before.mode & 0o777) !== 0o600))
|
|
) {
|
|
fail(`${label} must be one bounded canonical regular file`);
|
|
}
|
|
const bytes = fs.readFileSync(descriptor);
|
|
const after = fs.fstatSync(descriptor);
|
|
if (
|
|
before.dev !== after.dev ||
|
|
before.ino !== after.ino ||
|
|
before.size !== after.size ||
|
|
before.mtimeMs !== after.mtimeMs ||
|
|
bytes.length !== before.size
|
|
) {
|
|
bytes.fill(0);
|
|
fail(`${label} changed while being read`);
|
|
}
|
|
let value;
|
|
try {
|
|
value = JSON.parse(bytes.toString('utf8'));
|
|
} catch {
|
|
bytes.fill(0);
|
|
fail(`${label} must contain valid JSON`);
|
|
}
|
|
if (canonicalJson(value) !== bytes.toString('utf8')) {
|
|
bytes.fill(0);
|
|
fail(`${label} must use exact canonical JSON encoding`);
|
|
}
|
|
return Object.freeze({ value, bytes });
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function writeNoReplace(filePath, value) {
|
|
const resolved = canonicalAbsolute(filePath, 'output');
|
|
if (fs.existsSync(resolved)) fail('output must not already exist');
|
|
fs.writeFileSync(resolved, canonicalJson(value), {
|
|
encoding: 'utf8',
|
|
mode: 0o600,
|
|
flag: 'wx',
|
|
});
|
|
}
|
|
|
|
function validateIdentity(options) {
|
|
if (
|
|
!VERSION_PATTERN.test(options.version || '') ||
|
|
!REVISION_PATTERN.test(options.sourceRevision || '') ||
|
|
options.sourceRef !== `refs/tags/v${options.version}` ||
|
|
!['cluster', 'all'].includes(options.releaseScope)
|
|
) {
|
|
fail('release identity must be one exact Cluster-capable QingLong 3 tag');
|
|
}
|
|
if (!EVIDENCE_KINDS.includes(options.evidenceKind)) {
|
|
fail('evidence kind is invalid');
|
|
}
|
|
}
|
|
|
|
function staticAuditReceipts(root) {
|
|
const reports = [
|
|
['cloudnativepg-backup', auditCloudNativePgBackup({ root })],
|
|
['barman-cloud-supply-chain', auditBarmanCloudSupplyChain({ root })],
|
|
['cert-manager-selection', auditCertManagerSelection({ root })],
|
|
];
|
|
if (reports.some(([, report]) => report.compatible !== true)) {
|
|
fail('one or more static disaster-recovery locks are incompatible');
|
|
}
|
|
return Object.freeze(
|
|
reports.map(([name, report]) =>
|
|
Object.freeze({
|
|
name,
|
|
auditDigest: sha256(JSON.stringify(report)),
|
|
compatible: true,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
function assembleReceipt(options) {
|
|
validateIdentity(options);
|
|
if (!DIGEST_PATTERN.test(options.reportDigest || '')) {
|
|
fail('private evidence report digest is invalid');
|
|
}
|
|
const observedAtMs = Date.parse(options.observedAt);
|
|
const validationClockMs = options.nowMs;
|
|
if (
|
|
!Number.isSafeInteger(validationClockMs) ||
|
|
!Number.isFinite(observedAtMs) ||
|
|
observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS ||
|
|
validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
|
) {
|
|
fail('private evidence is outside the release freshness window');
|
|
}
|
|
const staticAudits =
|
|
options.evidenceKind === 'cloudnativepg-disaster-recovery'
|
|
? staticAuditReceipts(options.root)
|
|
: Object.freeze([]);
|
|
const unsigned = {
|
|
schemaVersion: 2,
|
|
schema: RECEIPT_SCHEMA,
|
|
release: {
|
|
version: options.version,
|
|
sourceRevision: options.sourceRevision,
|
|
sourceRef: options.sourceRef,
|
|
scope: options.releaseScope,
|
|
},
|
|
evidenceKind: options.evidenceKind,
|
|
evidence: {
|
|
fixture: options.fixture,
|
|
observedAt: options.observedAt,
|
|
maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS,
|
|
reportDigest: options.reportDigest,
|
|
sourceReportsUploaded: false,
|
|
},
|
|
staticAudits,
|
|
verification: {
|
|
sourceAwareAudit: true,
|
|
privateEvidenceReplayed: true,
|
|
freshnessValidatedAtCreation: true,
|
|
durableValidationClockPublished: false,
|
|
publicConsumerReplay: 'not_possible_without_private_reports',
|
|
privateReportContentPublished: false,
|
|
compatible: true,
|
|
},
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
receiptDigest: sha256(JSON.stringify(unsigned)),
|
|
});
|
|
}
|
|
|
|
function createWorkerReceipt(
|
|
options,
|
|
dependencies = {
|
|
now: Date.now,
|
|
auditGate: auditWorkerCredentialManagementReleaseGate,
|
|
},
|
|
) {
|
|
const nowMs = dependencies.now();
|
|
const gate = dependencies.auditGate(
|
|
{
|
|
reportFile: options.reportFile,
|
|
ceremonyReportFile: options.ceremonyReportFile,
|
|
durableAuditReportFile: options.durableAuditReportFile,
|
|
pkiRotationReportFile: options.pkiRotationReportFile,
|
|
caRolloverReportFile: options.caRolloverReportFile,
|
|
sourceCommit: options.sourceRevision,
|
|
releaseVersion: options.version,
|
|
},
|
|
{ now: () => nowMs },
|
|
);
|
|
const report = readJsonFile(
|
|
options.reportFile,
|
|
'Worker management evidence',
|
|
true,
|
|
);
|
|
try {
|
|
const receipt = assembleReceipt({
|
|
...options,
|
|
evidenceKind: 'worker-management',
|
|
fixture: gate.fixture,
|
|
observedAt: report.value.observedAt,
|
|
reportDigest: gate.evidenceReportSha256,
|
|
nowMs,
|
|
});
|
|
writeNoReplace(options.outputFile, receipt);
|
|
return receipt;
|
|
} finally {
|
|
report.bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function createCloudNativePgReceipt(
|
|
options,
|
|
dependencies = {
|
|
now: Date.now,
|
|
validateEvidence: validateCloudNativePgDrReleaseEvidence,
|
|
},
|
|
) {
|
|
const nowMs = dependencies.now();
|
|
const report = readJsonFile(
|
|
options.reportFile,
|
|
'CloudNativePG disaster-recovery evidence',
|
|
true,
|
|
);
|
|
try {
|
|
const audit = dependencies.validateEvidence(report.value, {
|
|
sourceCommit: options.sourceRevision,
|
|
releaseVersion: options.version,
|
|
nowMs,
|
|
});
|
|
if (!audit.compatible) {
|
|
fail('CloudNativePG disaster-recovery evidence is incompatible');
|
|
}
|
|
const receipt = assembleReceipt({
|
|
...options,
|
|
evidenceKind: 'cloudnativepg-disaster-recovery',
|
|
fixture: audit.fixture,
|
|
observedAt: report.value.observedAt,
|
|
reportDigest: sha256(report.bytes),
|
|
nowMs,
|
|
});
|
|
writeNoReplace(options.outputFile, receipt);
|
|
return receipt;
|
|
} finally {
|
|
report.bytes.fill(0);
|
|
}
|
|
}
|
|
|
|
function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
|
validateIdentity(options);
|
|
const expectedStaticNames =
|
|
options.evidenceKind === 'cloudnativepg-disaster-recovery'
|
|
? STATIC_AUDIT_NAMES
|
|
: [];
|
|
if (
|
|
!exactKeys(actual, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'release',
|
|
'evidenceKind',
|
|
'evidence',
|
|
'staticAudits',
|
|
'verification',
|
|
'receiptDigest',
|
|
]) ||
|
|
actual.schemaVersion !== 2 ||
|
|
actual.schema !== RECEIPT_SCHEMA ||
|
|
!exactKeys(actual.release, [
|
|
'version',
|
|
'sourceRevision',
|
|
'sourceRef',
|
|
'scope',
|
|
]) ||
|
|
JSON.stringify(actual.release) !==
|
|
JSON.stringify({
|
|
version: options.version,
|
|
sourceRevision: options.sourceRevision,
|
|
sourceRef: options.sourceRef,
|
|
scope: options.releaseScope,
|
|
}) ||
|
|
actual.evidenceKind !== options.evidenceKind ||
|
|
!exactKeys(actual.evidence, [
|
|
'fixture',
|
|
'observedAt',
|
|
'maximumAgeSeconds',
|
|
'reportDigest',
|
|
'sourceReportsUploaded',
|
|
]) ||
|
|
actual.evidence.fixture !== EVIDENCE_FIXTURES[options.evidenceKind] ||
|
|
actual.evidence.maximumAgeSeconds !== MAX_EVIDENCE_AGE_SECONDS ||
|
|
!DIGEST_PATTERN.test(actual.evidence.reportDigest || '') ||
|
|
actual.evidence.sourceReportsUploaded !== false ||
|
|
!Array.isArray(actual.staticAudits) ||
|
|
JSON.stringify(actual.staticAudits.map((entry) => entry?.name)) !==
|
|
JSON.stringify(expectedStaticNames) ||
|
|
actual.staticAudits.some(
|
|
(entry) =>
|
|
!exactKeys(entry, ['name', 'auditDigest', 'compatible']) ||
|
|
!DIGEST_PATTERN.test(entry.auditDigest || '') ||
|
|
entry.compatible !== true,
|
|
) ||
|
|
!exactKeys(actual.verification, [
|
|
'sourceAwareAudit',
|
|
'privateEvidenceReplayed',
|
|
'freshnessValidatedAtCreation',
|
|
'durableValidationClockPublished',
|
|
'publicConsumerReplay',
|
|
'privateReportContentPublished',
|
|
'compatible',
|
|
]) ||
|
|
JSON.stringify(actual.verification) !==
|
|
JSON.stringify({
|
|
sourceAwareAudit: true,
|
|
privateEvidenceReplayed: true,
|
|
freshnessValidatedAtCreation: true,
|
|
durableValidationClockPublished: false,
|
|
publicConsumerReplay: 'not_possible_without_private_reports',
|
|
privateReportContentPublished: false,
|
|
compatible: true,
|
|
})
|
|
) {
|
|
fail('receipt shape or release binding is invalid');
|
|
}
|
|
const observedAtMs = Date.parse(actual.evidence.observedAt);
|
|
if (!Number.isFinite(observedAtMs)) {
|
|
fail('receipt freshness binding is invalid');
|
|
}
|
|
if (options.validationClockMs !== undefined) {
|
|
const validationClockMs = options.validationClockMs;
|
|
if (
|
|
!Number.isSafeInteger(validationClockMs) ||
|
|
observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS ||
|
|
validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
|
) {
|
|
fail('receipt is outside the release freshness window');
|
|
}
|
|
}
|
|
const { receiptDigest, ...unsigned } = actual;
|
|
if (
|
|
!DIGEST_PATTERN.test(receiptDigest || '') ||
|
|
receiptDigest !== sha256(JSON.stringify(unsigned))
|
|
) {
|
|
fail('receipt digest is invalid');
|
|
}
|
|
return Object.freeze({
|
|
compatible: true,
|
|
evidenceKind: actual.evidenceKind,
|
|
receiptDigest,
|
|
reportDigest: actual.evidence.reportDigest,
|
|
publicConsumerReplay: actual.verification.publicConsumerReplay,
|
|
});
|
|
}
|
|
|
|
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 = [
|
|
'release-scope',
|
|
'source-ref',
|
|
'source-revision',
|
|
'version',
|
|
];
|
|
const expected =
|
|
values.mode === 'worker-create'
|
|
? [
|
|
'mode',
|
|
...identity,
|
|
'report',
|
|
'ceremony-report',
|
|
'durable-audit-report',
|
|
'pki-rotation-report',
|
|
'ca-rollover-report',
|
|
'output',
|
|
]
|
|
: values.mode === 'dr-create'
|
|
? ['mode', ...identity, 'report', 'output']
|
|
: values.mode === 'audit'
|
|
? ['mode', ...identity, 'evidence-kind', 'receipt']
|
|
: [];
|
|
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'],
|
|
...(values['evidence-kind']
|
|
? { evidenceKind: values['evidence-kind'] }
|
|
: {}),
|
|
...(values.report ? { reportFile: values.report } : {}),
|
|
...(values['ceremony-report']
|
|
? { ceremonyReportFile: values['ceremony-report'] }
|
|
: {}),
|
|
...(values['durable-audit-report']
|
|
? { durableAuditReportFile: values['durable-audit-report'] }
|
|
: {}),
|
|
...(values['pki-rotation-report']
|
|
? { pkiRotationReportFile: values['pki-rotation-report'] }
|
|
: {}),
|
|
...(values['ca-rollover-report']
|
|
? { caRolloverReportFile: values['ca-rollover-report'] }
|
|
: {}),
|
|
...(values.output ? { outputFile: values.output } : {}),
|
|
...(values.receipt ? { receiptFile: values.receipt } : {}),
|
|
});
|
|
}
|
|
|
|
function runCli(
|
|
argv,
|
|
root = path.resolve(__dirname, '..'),
|
|
output = process.stdout,
|
|
) {
|
|
const options = parseArguments(argv);
|
|
if (options.mode === 'audit') {
|
|
const receipt = readJsonFile(options.receiptFile, 'receipt').value;
|
|
const result = inspectPrivateReleaseEvidenceReceipt(receipt, options);
|
|
output.write(canonicalJson(result));
|
|
return result;
|
|
}
|
|
const createOptions = { ...options, root };
|
|
const receipt =
|
|
options.mode === 'worker-create'
|
|
? createWorkerReceipt(createOptions)
|
|
: createCloudNativePgReceipt(createOptions);
|
|
output.write(canonicalJson(receipt));
|
|
return receipt;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
runCli(process.argv.slice(2));
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${error instanceof Error ? error.message : 'receipt contract failed'}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = Object.freeze({
|
|
EVIDENCE_KINDS,
|
|
RECEIPT_SCHEMA,
|
|
QingLong3PrivateReleaseEvidenceReceiptError,
|
|
createCloudNativePgReceipt,
|
|
createWorkerReceipt,
|
|
inspectPrivateReleaseEvidenceReceipt,
|
|
parseArguments,
|
|
runCli,
|
|
});
|