feat(ql3): bind private release evidence receipts

This commit is contained in:
whyour
2026-08-18 01:48:27 +08:00
parent fd433c41cd
commit 3e7bfa8087
17 changed files with 1452 additions and 66 deletions
+97 -35
View File
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
);
requirePattern(
source,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
);
requirePattern(
source,
@@ -537,11 +537,16 @@ function auditReleaseWorkflow(source) {
publishJob.if,
) ||
JSON.stringify(releaseSetJob?.needs) !==
JSON.stringify(['release-candidate', 'publish']) ||
JSON.stringify([
'release-candidate',
'worker-management-release-evidence',
'cluster-dr-release-evidence',
'publish',
]) ||
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
releaseSetJob?.['timeout-minutes'] !== 20 ||
typeof releaseSetJob?.if !== 'string' ||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'[\s\S]*cluster-evidence-required != 'true'[\s\S]*worker-management-release-evidence\.result == 'success'[\s\S]*cluster-dr-release-evidence\.result == 'success'/.test(
releaseSetJob.if,
)
) {
@@ -630,7 +635,7 @@ function auditReleaseWorkflow(source) {
}
if (
!Array.isArray(drEvidenceJob?.steps) ||
drEvidenceJob.steps.length !== 3 ||
drEvidenceJob.steps.length !== 4 ||
drEvidenceJob.steps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
drEvidenceJob.steps[0]?.with?.['persist-credentials'] !== false ||
@@ -640,7 +645,7 @@ function auditReleaseWorkflow(source) {
typeof drEvidenceJob.steps[2]?.run !== 'string'
) {
throw new Error(
'private disaster-recovery evidence job must contain only the reviewed credential-free checkout, Node setup and audit steps',
'private disaster-recovery evidence job must contain only the reviewed checkout, audit, content-free receipt and upload steps',
);
}
const publishSteps = publishJob?.steps;
@@ -707,7 +712,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps;
if (
!Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 15 ||
releaseSetSteps.length !== 16 ||
releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -723,54 +728,65 @@ function auditReleaseWorkflow(source) {
path: '${{ runner.temp }}/release-records',
'merge-multiple': true,
}) ||
releaseSetSteps[3]?.id !== 'release-set' ||
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[3]?.run ?? '',
) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
releaseSetSteps[3]?.if !==
"needs.release-candidate.outputs.cluster-evidence-required == 'true'" ||
releaseSetSteps[3]?.uses !==
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
JSON.stringify(releaseSetSteps[3]?.with) !==
JSON.stringify({
pattern:
'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-*',
path: '${{ runner.temp }}/private-release-evidence-receipts',
'merge-multiple': true,
}) ||
releaseSetSteps[4]?.id !== 'release-set' ||
!/evidence_receipts="\$\{RUNNER_TEMP\}\/private-release-evidence-receipts"[\s\S]*install -d -m 0700 "\$\{evidence_receipts\}"[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*--evidence-receipts="\$\{evidence_receipts\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--evidence-receipts="\$\{evidence_receipts\}"[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[4]?.run ?? '',
) ||
releaseSetSteps[5]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
releaseSetSteps[5]?.run ?? '',
) ||
releaseSetSteps[6]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
releaseSetSteps[7]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
!/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test(
releaseSetSteps[7]?.run ?? '',
releaseSetSteps[8]?.run ?? '',
) ||
releaseSetSteps[8]?.uses !==
releaseSetSteps[9]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[8]?.with) !==
JSON.stringify(releaseSetSteps[9]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.release-set.outputs.report }}',
}) ||
releaseSetSteps[9]?.id !== 'catalog' ||
releaseSetSteps[10]?.id !== 'catalog' ||
!/artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*image digest "\$\{discovery_tag\}"[\s\S]*artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[9]?.run ?? '',
) ||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
releaseSetSteps[10]?.run ?? '',
) ||
releaseSetSteps[11]?.uses !==
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
releaseSetSteps[11]?.run ?? '',
) ||
releaseSetSteps[12]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[11]?.with) !==
JSON.stringify(releaseSetSteps[12]?.with) !==
JSON.stringify({
'subject-name': '${{ steps.catalog.outputs.repository }}',
'subject-digest': '${{ steps.catalog.outputs.digest }}',
'push-to-registry': true,
}) ||
releaseSetSteps[12]?.id !== 'catalog-receipt' ||
releaseSetSteps[13]?.id !== 'catalog-receipt' ||
!/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[12]?.run ?? '',
releaseSetSteps[13]?.run ?? '',
) ||
releaseSetSteps[13]?.uses !==
releaseSetSteps[14]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[13]?.with) !==
JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
}) ||
releaseSetSteps[14]?.uses !==
releaseSetSteps[15]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify(releaseSetSteps[15]?.with) !==
JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
path: '${{ steps.release-set.outputs.bundle }}',
@@ -928,17 +944,50 @@ function auditReleaseWorkflow(source) {
}
if (
!Array.isArray(evidenceJob?.steps) ||
evidenceJob.steps.length !== 3 ||
evidenceJob.steps.length !== 4 ||
evidenceJob.steps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
evidenceJob.steps[0]?.with?.['persist-credentials'] !== false ||
evidenceJob.steps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
evidenceJob.steps[1]?.with?.['node-version'] !== '24.18.0' ||
typeof evidenceJob.steps[2]?.run !== 'string'
typeof evidenceJob.steps[2]?.run !== 'string' ||
evidenceJob.steps[3]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(evidenceJob.steps[3]?.with) !==
JSON.stringify({
name: 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-worker-management',
path: '${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json',
'if-no-files-found': 'error',
'retention-days': 1,
'compression-level': 9,
overwrite: false,
'include-hidden-files': false,
}) ||
!Array.isArray(drEvidenceJob?.steps) ||
drEvidenceJob.steps.length !== 4 ||
drEvidenceJob.steps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
drEvidenceJob.steps[0]?.with?.['persist-credentials'] !== false ||
drEvidenceJob.steps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
drEvidenceJob.steps[1]?.with?.['node-version'] !== '24.18.0' ||
typeof drEvidenceJob.steps[2]?.run !== 'string' ||
drEvidenceJob.steps[3]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(drEvidenceJob.steps[3]?.with) !==
JSON.stringify({
name: 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-cloudnativepg-disaster-recovery',
path: '${{ runner.temp }}/ql3-private-release-evidence-receipts/cloudnativepg-disaster-recovery.json',
'if-no-files-found': 'error',
'retention-days': 1,
'compression-level': 9,
overwrite: false,
'include-hidden-files': false,
})
) {
throw new Error(
'private evidence job must contain only the reviewed credential-free checkout, Node setup and audit steps',
'private evidence jobs must contain only reviewed credential-free checkout, audit, content-free receipt and upload steps',
);
}
if (/actions\/cache@/.test(source)) {
@@ -951,6 +1000,16 @@ function auditReleaseWorkflow(source) {
/GITHUB_REF_TYPE}" != "tag"[\s\S]*GITHUB_REF_NAME}" != "v\$\{RELEASE_VERSION\}"/,
'private evidence gate must bind dispatch version to the exact tag',
);
requirePattern(
source,
/ql3-private-release-evidence-receipt-contract\.cjs[\s\S]*--mode=worker-create[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--output="\$\{receipt\}"[\s\S]*--mode=audit[\s\S]*--evidence-kind=worker-management[\s\S]*--receipt="\$\{receipt\}"/,
'Worker evidence job must create and audit only one content-free source-bound receipt',
);
requirePattern(
source,
/ql3-private-release-evidence-receipt-contract\.cjs[\s\S]*--mode=dr-create[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--report="\$\{report\}"[\s\S]*--output="\$\{receipt\}"[\s\S]*--mode=audit[\s\S]*--evidence-kind=cloudnativepg-disaster-recovery[\s\S]*--receipt="\$\{receipt\}"/,
'disaster-recovery evidence job must create and audit only one content-free source-bound receipt',
);
requireExactOccurrences(
source,
/evidence_dir="\/run\/qinglong3-release-evidence\/\$\{GITHUB_SHA\}"/g,
@@ -1199,13 +1258,15 @@ function auditReleaseWorkflow(source) {
sourceAware: true,
privateEphemeralRunner: true,
maximumAgeSeconds: 86400,
artifactUpload: false,
privateReportUpload: false,
contentFreeReceiptPublished: true,
},
cloudNativePgDisasterRecoveryEvidence: {
sourceAware: true,
privateEphemeralRunner: true,
maximumAgeSeconds: 86400,
artifactUpload: false,
privateReportUpload: false,
contentFreeReceiptPublished: true,
staticLocksReaudited: true,
},
osVulnerabilityScan: {
@@ -1241,6 +1302,7 @@ function auditReleaseWorkflow(source) {
releaseSet: {
sourceDerived: true,
sameRunRecords: true,
sameRunPrivateEvidenceReceipts: true,
exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set',
@@ -1250,7 +1312,7 @@ function auditReleaseWorkflow(source) {
},
durableCatalog: {
repository: 'qinglong3-release-catalog',
artifactType: 'application/vnd.qinglong.release-set.v1+json',
artifactType: 'application/vnd.qinglong.release-set.v2+json',
basenameOnly: true,
crossRunnerDeterministic: true,
byteExactRoundTrip: true,
@@ -0,0 +1,513 @@
#!/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@v1';
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 validatedAtMs = options.nowMs;
if (
!Number.isSafeInteger(validatedAtMs) ||
!Number.isFinite(observedAtMs) ||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
validatedAtMs - 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: 1,
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,
validatedAt: new Date(validatedAtMs).toISOString(),
maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS,
reportDigest: options.reportDigest,
sourceReportsUploaded: false,
},
staticAudits,
verification: {
sourceAwareAudit: true,
privateEvidenceReplayed: true,
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 !== 1 ||
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',
'validatedAt',
'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',
'publicConsumerReplay',
'privateReportContentPublished',
'compatible',
]) ||
JSON.stringify(actual.verification) !==
JSON.stringify({
sourceAwareAudit: true,
privateEvidenceReplayed: true,
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);
const validatedAtMs = Date.parse(actual.evidence.validatedAt);
if (
!Number.isFinite(observedAtMs) ||
!Number.isFinite(validatedAtMs) ||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
) {
fail('receipt freshness binding is invalid');
}
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,
});
+1 -1
View File
@@ -12,7 +12,7 @@ const {
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v1+json';
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v2+json';
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
+113 -3
View File
@@ -12,11 +12,15 @@ const {
SCHEMA: RELEASE_CANDIDATE_SCHEMA,
auditReleaseCandidateContract,
} = require('./ql3-release-candidate-contract.cjs');
const {
EVIDENCE_KINDS,
inspectPrivateReleaseEvidenceReceipt,
} = require('./ql3-private-release-evidence-receipt-contract.cjs');
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v1';
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v2';
const MAX_JSON_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
@@ -237,9 +241,48 @@ function deploymentFamily(candidate, family, imageNames) {
});
}
function expectedEvidenceKinds(releaseScope) {
return releaseScope === 'local' ? [] : [...EVIDENCE_KINDS];
}
function validateEvidenceReceipts(receipts, candidate) {
if (!Array.isArray(receipts)) {
fail('private release evidence receipts must be an array');
}
const expectedKinds = expectedEvidenceKinds(candidate.release.scope);
if (receipts.length !== expectedKinds.length) {
fail('private release evidence receipt count differs from release scope');
}
const receiptsByKind = new Map();
for (const receipt of receipts) {
const evidenceKind = receipt?.evidenceKind;
if (
!expectedKinds.includes(evidenceKind) ||
receiptsByKind.has(evidenceKind)
) {
fail('private release evidence receipts must be exact and unique');
}
inspectPrivateReleaseEvidenceReceipt(receipt, {
version: candidate.release.version,
sourceRevision: candidate.release.sourceRevision,
sourceRef: candidate.release.sourceRef,
releaseScope: candidate.release.scope,
evidenceKind,
});
receiptsByKind.set(evidenceKind, receipt);
}
return Object.freeze(
expectedKinds.map((evidenceKind) => receiptsByKind.get(evidenceKind)),
);
}
function createReleaseSet(options) {
const candidate = verifyCandidate(options.candidate, options);
const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner);
const evidenceReceipts = validateEvidenceReceipts(
options.evidenceReceipts,
candidate,
);
if (!Array.isArray(options.records)) fail('image records must be an array');
if (options.records.length !== candidate.images.length) {
fail('image record count differs from the release candidate');
@@ -281,6 +324,7 @@ function createReleaseSet(options) {
local: deploymentFamily(candidate, 'local', localImages),
cluster: deploymentFamily(candidate, 'cluster', clusterImages),
},
evidenceReceipts,
images,
promotion: {
authority: 'complete_verified_release_set',
@@ -292,6 +336,9 @@ function createReleaseSet(options) {
requiredVerification: {
imageKeylessSignature: true,
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
privateReleaseEvidenceReceipts: expectedEvidenceKinds(
candidate.release.scope,
),
releaseSetBuildProvenance: true,
},
};
@@ -313,6 +360,9 @@ function auditReleaseSet(actual, options) {
imageCount: actual.images.length,
images: Object.freeze(actual.images.map((entry) => entry.name)),
references: Object.freeze(actual.images.map((entry) => entry.reference)),
evidenceReceiptDigests: Object.freeze(
actual.evidenceReceipts.map((entry) => entry.receiptDigest),
),
tagPromotionAuthority: actual.promotion.authority,
});
}
@@ -337,6 +387,7 @@ function inspectReleaseSet(actual, options) {
'repositoryOwner',
'platforms',
'deploymentFamilies',
'evidenceReceipts',
'images',
'promotion',
'requiredVerification',
@@ -361,6 +412,7 @@ function inspectReleaseSet(actual, options) {
JSON.stringify(actual.platforms) !==
JSON.stringify(['linux/amd64', 'linux/arm64']) ||
!exactKeys(actual.deploymentFamilies, ['local', 'cluster']) ||
!Array.isArray(actual.evidenceReceipts) ||
!Array.isArray(actual.images) ||
!exactKeys(actual.promotion, [
'authority',
@@ -380,12 +432,16 @@ function inspectReleaseSet(actual, options) {
!exactKeys(actual.requiredVerification, [
'imageKeylessSignature',
'imageAttestations',
'privateReleaseEvidenceReceipts',
'releaseSetBuildProvenance',
]) ||
JSON.stringify(actual.requiredVerification) !==
JSON.stringify({
imageKeylessSignature: true,
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
privateReleaseEvidenceReceipts: expectedEvidenceKinds(
options.releaseScope,
),
releaseSetBuildProvenance: true,
})
) {
@@ -448,6 +504,20 @@ function inspectReleaseSet(actual, options) {
) {
fail('standalone release set deployment families are invalid');
}
const expectedReceiptKinds = expectedEvidenceKinds(options.releaseScope);
if (actual.evidenceReceipts.length !== expectedReceiptKinds.length) {
fail('standalone release set evidence receipt count is invalid');
}
for (let index = 0; index < expectedReceiptKinds.length; index += 1) {
const evidenceKind = expectedReceiptKinds[index];
inspectPrivateReleaseEvidenceReceipt(actual.evidenceReceipts[index], {
version: options.version,
sourceRevision: options.sourceRevision,
sourceRef: options.sourceRef,
releaseScope: options.releaseScope,
evidenceKind,
});
}
const { releaseSetDigest, ...unsigned } = actual;
if (
!DIGEST_PATTERN.test(releaseSetDigest || '') ||
@@ -462,8 +532,12 @@ function inspectReleaseSet(actual, options) {
imageCount: actual.images.length,
images: Object.freeze([...expectedNames]),
references: Object.freeze(actual.images.map((entry) => entry.reference)),
evidenceReceiptDigests: Object.freeze(
actual.evidenceReceipts.map((entry) => entry.receiptDigest),
),
verification: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false,
privateEvidenceReplayed: false,
});
}
@@ -492,6 +566,33 @@ function readRecordDirectory(directoryPath, candidate) {
);
}
function readEvidenceReceiptDirectory(directoryPath, candidate) {
const resolved = resolveCanonicalAbsolute(
directoryPath,
'private evidence receipts',
);
const stat = fs.lstatSync(resolved);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
fs.realpathSync(resolved) !== resolved
) {
fail('private evidence receipts must be one canonical directory');
}
const expectedNames = expectedEvidenceKinds(candidate.release.scope).map(
(kind) => `${kind}.json`,
);
const actualNames = fs.readdirSync(resolved).sort();
if (
JSON.stringify(actualNames) !== JSON.stringify([...expectedNames].sort())
) {
fail('private evidence receipt directory differs from release scope');
}
return expectedNames.map((name) =>
readCanonicalJson(path.join(resolved, name), 'private evidence receipt'),
);
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
@@ -513,9 +614,9 @@ function parseArguments(argv) {
values.mode === 'record-image'
? [...common, 'digest', 'image', 'output']
: values.mode === 'aggregate'
? [...common, 'output', 'records']
? [...common, 'evidence-receipts', 'output', 'records']
: values.mode === 'audit'
? [...common, 'records', 'report']
? [...common, 'evidence-receipts', 'records', 'report']
: values.mode === 'inspect'
? [...identity, 'report']
: [];
@@ -537,6 +638,9 @@ function parseArguments(argv) {
...(values.image ? { image: values.image } : {}),
...(values.digest ? { digest: values.digest } : {}),
...(values.records ? { records: values.records } : {}),
...(values['evidence-receipts']
? { evidenceReceipts: values['evidence-receipts'] }
: {}),
...(values.output ? { output: values.output } : {}),
...(values.report ? { report: values.report } : {}),
});
@@ -558,11 +662,16 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
return record;
}
const records = readRecordDirectory(options.records, candidate);
const evidenceReceipts = readEvidenceReceiptDirectory(
options.evidenceReceipts,
candidate,
);
if (options.mode === 'aggregate') {
const releaseSet = createReleaseSet({
...options,
candidate,
records,
evidenceReceipts,
root,
});
writeNoReplace(options.output, releaseSet);
@@ -574,6 +683,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
...options,
candidate,
records,
evidenceReceipts,
root,
});
output.write(canonicalJson(audit));