mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): validate release tags before registry mutation
This commit is contained in:
@@ -78,6 +78,15 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
],
|
||||
[
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) =>
|
||||
source.replaceAll(
|
||||
'scripts/ql3-release-tag-finalizer.cjs',
|
||||
'scripts/unvalidated-inline-publisher.cjs',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
],
|
||||
];
|
||||
for (const [target, transform, code] of fixtures) {
|
||||
const report = auditClusterCopilotConsoleDistribution({
|
||||
|
||||
@@ -136,9 +136,13 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
receiptAttested: true,
|
||||
},
|
||||
finalPublicationClosure: {
|
||||
finalizer: 'scripts/ql3-release-tag-finalizer.cjs',
|
||||
planSchema: 'qinglong/release-publication-plan@v2',
|
||||
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
|
||||
planValidatedBeforeRegistryAccess: true,
|
||||
hermeticResponseLossRehearsal: true,
|
||||
liveTerminalAuditBeforeClosure: true,
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
deploymentReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
@@ -219,7 +223,7 @@ test('rejects removal of the durable release-catalog contract tests', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
/durable catalog, deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -230,7 +234,7 @@ test('rejects removal of the final publication closure contract tests', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
/durable catalog, deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,7 +245,18 @@ test('rejects removal of the deployment readiness contract tests', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
/durable catalog, deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the fail-closed tag finalizer tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3ReleaseTagFinalizer.test.cjs',
|
||||
'test/back/tag-finalizer-tests-removed.test.cjs',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/tag finalizer.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -263,7 +278,7 @@ test('rejects removal of the deployment-lock materialization contract tests', ()
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/deployment-lock and workflow negative tests/,
|
||||
/deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1170,14 +1185,29 @@ test('rejects deployment readiness without its own pre-promotion attestation', (
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without bounded repository inventory', () => {
|
||||
test('rejects final tag promotion that bypasses the validated finalizer', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
" 'tag',\n 'ls',\n image.registryRepository,",
|
||||
" 'image',\n 'digest',\n image.registryRepository,",
|
||||
' --mode=finalize \\',
|
||||
' --mode=audit \\',
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without a live read-only terminal audit', () => {
|
||||
const audited = [
|
||||
' --mode=audit \\',
|
||||
' --plan="${PUBLICATION_PLAN}" \\',
|
||||
' --regctl="${REGCTL}"',
|
||||
].join('\n');
|
||||
const unaudited = [
|
||||
' --mode=finalize \\',
|
||||
' --plan="${PUBLICATION_PLAN}" \\',
|
||||
' --regctl="${REGCTL}"',
|
||||
].join('\n');
|
||||
const mutated = releaseSource.replace(audited, unaudited);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects omission of the final publication closure audit', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' --mode=close \\',
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
PUBLICATION_PLAN_SCHEMA,
|
||||
} = require('../../scripts/ql3-release-publication-closure-contract.cjs');
|
||||
const {
|
||||
SCHEMA: DEPLOYMENT_READINESS_SCHEMA,
|
||||
} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs');
|
||||
const {
|
||||
RELEASE_SET_SCHEMA,
|
||||
} = require('../../scripts/ql3-release-set-contract.cjs');
|
||||
const {
|
||||
MAX_INVENTORY_BYTES,
|
||||
auditReleaseTags,
|
||||
createRegctlAdapter,
|
||||
finalizeReleaseTags,
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = require('../../scripts/ql3-release-tag-finalizer.cjs');
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function publicationPlan() {
|
||||
const version = '3.0.0-alpha.0';
|
||||
const sourceRevision = 'a'.repeat(40);
|
||||
const repository = 'ghcr.io/qinglong-release/qinglong3-local-application';
|
||||
const imageDigest = `sha256:${'1'.repeat(64)}`;
|
||||
const manifestDigest = `sha256:${'2'.repeat(64)}`;
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: PUBLICATION_PLAN_SCHEMA,
|
||||
release: {
|
||||
version,
|
||||
sourceRevision,
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
scope: 'local',
|
||||
},
|
||||
releaseSet: {
|
||||
schema: RELEASE_SET_SCHEMA,
|
||||
releaseSetDigest: `sha256:${'3'.repeat(64)}`,
|
||||
contentDigest: `sha256:${'4'.repeat(64)}`,
|
||||
},
|
||||
catalog: {
|
||||
planDigest: `sha256:${'5'.repeat(64)}`,
|
||||
receiptDigest: `sha256:${'6'.repeat(64)}`,
|
||||
manifestDigest,
|
||||
immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`,
|
||||
},
|
||||
deploymentReadiness: {
|
||||
schema: DEPLOYMENT_READINESS_SCHEMA,
|
||||
receiptDigest: `sha256:${'7'.repeat(64)}`,
|
||||
finalizerConsumptionDigest: `sha256:${'8'.repeat(64)}`,
|
||||
requiredDeploymentFamilies: ['local'],
|
||||
},
|
||||
requiredPrerequisites: {
|
||||
releaseSetProvenance: 'attested_before_catalog_publication',
|
||||
catalogSignature: 'verified_exact_workflow_identity',
|
||||
catalogProvenance: 'verified_source_tag_and_revision',
|
||||
catalogReceipt: 'attested_before_deployment_gates',
|
||||
deploymentReadiness: 'scope_exact_receipt_attested_before_tag_promotion',
|
||||
},
|
||||
promotionPolicy: {
|
||||
authority: 'verified_catalog_bound_deployments',
|
||||
inventory: 'bounded_exact_repository_tags',
|
||||
conflict: 'fail_closed_before_any_tag_mutation',
|
||||
recovery: 'reuse_exact_digest_only',
|
||||
finalVerification: 'all_tags_exact_digest',
|
||||
crossRepositoryAtomicity: false,
|
||||
registryTagCas: false,
|
||||
},
|
||||
images: [
|
||||
{
|
||||
name: 'local',
|
||||
registryRepository: repository,
|
||||
immutableReference: `${repository}@${imageDigest}`,
|
||||
digest: imageDigest,
|
||||
tags: [
|
||||
{ kind: 'version', reference: `${repository}:${version}` },
|
||||
{
|
||||
kind: 'source',
|
||||
reference: `${repository}:sha-${sourceRevision}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function splitTag(reference) {
|
||||
const separator = reference.lastIndexOf(':');
|
||||
return [reference.slice(0, separator), reference.slice(separator + 1)];
|
||||
}
|
||||
|
||||
class FakeRegistry {
|
||||
constructor(plan) {
|
||||
this.sources = new Map(
|
||||
plan.images.map((image) => [image.immutableReference, image.digest]),
|
||||
);
|
||||
this.repositories = new Map(
|
||||
plan.images.map((image) => [image.registryRepository, new Map()]),
|
||||
);
|
||||
this.calls = [];
|
||||
this.inventoryOverride = undefined;
|
||||
this.copyFailureAfterWrite = false;
|
||||
this.copyDrift = false;
|
||||
}
|
||||
|
||||
resolveDigest(reference) {
|
||||
this.calls.push(['resolveDigest', reference]);
|
||||
if (reference.includes('@')) return this.sources.get(reference);
|
||||
const [repository, tag] = splitTag(reference);
|
||||
return this.repositories.get(repository)?.get(tag);
|
||||
}
|
||||
|
||||
listTags(repository) {
|
||||
this.calls.push(['listTags', repository]);
|
||||
if (this.inventoryOverride !== undefined) return this.inventoryOverride;
|
||||
const tags = [...(this.repositories.get(repository)?.keys() ?? [])].sort();
|
||||
return tags.length === 0 ? '' : `${tags.join('\n')}\n`;
|
||||
}
|
||||
|
||||
copyImage(source, target) {
|
||||
this.calls.push(['copyImage', source, target]);
|
||||
const [repository, tag] = splitTag(target);
|
||||
const digest = this.copyDrift
|
||||
? `sha256:${'f'.repeat(64)}`
|
||||
: this.sources.get(source);
|
||||
this.repositories.get(repository).set(tag, digest);
|
||||
if (this.copyFailureAfterWrite) {
|
||||
this.copyFailureAfterWrite = false;
|
||||
throw new Error('simulated response loss');
|
||||
}
|
||||
}
|
||||
|
||||
seed(reference, digest) {
|
||||
const [repository, tag] = splitTag(reference);
|
||||
this.repositories.get(repository).set(tag, digest);
|
||||
}
|
||||
|
||||
copyCalls() {
|
||||
return this.calls.filter(([operation]) => operation === 'copyImage');
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-tag-finalizer-')),
|
||||
);
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function writeCanonical(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
|
||||
test('finalizes every exact tag and audits the live terminal state', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
const observation = finalizeReleaseTags(plan, registry);
|
||||
assert.equal(registry.copyCalls().length, 2);
|
||||
assert.equal(observation.tags.length, 2);
|
||||
assert.equal(
|
||||
observation.tags.every((tag) => tag.digest === plan.images[0].digest),
|
||||
true,
|
||||
);
|
||||
const copiesBeforeAudit = registry.copyCalls().length;
|
||||
assert.deepEqual(auditReleaseTags(plan, observation, registry), {
|
||||
schemaVersion: 1,
|
||||
planDigest: plan.planDigest,
|
||||
observationDigest: observation.observationDigest,
|
||||
tagCount: 2,
|
||||
allTagsExactDigest: true,
|
||||
registryMutation: false,
|
||||
compatible: true,
|
||||
});
|
||||
assert.equal(registry.copyCalls().length, copiesBeforeAudit);
|
||||
});
|
||||
|
||||
test('validates the complete plan before any registry observation or mutation', () => {
|
||||
const plan = structuredClone(publicationPlan());
|
||||
plan.images[0].tags[0].reference =
|
||||
'ghcr.io/attacker/other-repository:3.0.0-alpha.0';
|
||||
const { planDigest: _discarded, ...unsigned } = plan;
|
||||
plan.planDigest = sha256(JSON.stringify(unsigned));
|
||||
const registry = new FakeRegistry(publicationPlan());
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/publication plan tag is invalid/,
|
||||
);
|
||||
assert.deepEqual(registry.calls, []);
|
||||
});
|
||||
|
||||
test('preflights every conflict before the first tag mutation', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.seed(plan.images[0].tags[1].reference, `sha256:${'e'.repeat(64)}`);
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/already points at another digest/,
|
||||
);
|
||||
assert.equal(registry.copyCalls().length, 0);
|
||||
});
|
||||
|
||||
test('recovers copy response loss by reusing exact tags and only filling absence', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.copyFailureAfterWrite = true;
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/simulated response loss/,
|
||||
);
|
||||
assert.equal(registry.copyCalls().length, 1);
|
||||
const observation = finalizeReleaseTags(plan, registry);
|
||||
assert.equal(registry.copyCalls().length, 2);
|
||||
assert.equal(observation.tags.length, 2);
|
||||
assert.equal(
|
||||
registry.resolveDigest(plan.images[0].tags[0].reference),
|
||||
plan.images[0].digest,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed or unbounded inventory before mutation', () => {
|
||||
for (const inventory of [
|
||||
'duplicate\nduplicate\n',
|
||||
'not canonical',
|
||||
`${'x'.repeat(MAX_INVENTORY_BYTES + 1)}\n`,
|
||||
]) {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.inventoryOverride = inventory;
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/inventory is malformed|inventory is invalid or unbounded/,
|
||||
);
|
||||
assert.equal(registry.copyCalls().length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails terminal observation when a copy resolves to the wrong digest', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.copyDrift = true;
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/promoted tag does not resolve/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runs canonical private no-replace finalize and read-only audit stages', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
const planPath = path.join(directory, 'plan.json');
|
||||
const observationPath = path.join(directory, 'observation.json');
|
||||
writeCanonical(planPath, plan);
|
||||
const output = { write() {} };
|
||||
const observation = runCli(
|
||||
[
|
||||
'--mode=finalize',
|
||||
`--plan=${planPath}`,
|
||||
'--regctl=/unused/injected-regctl',
|
||||
`--output=${observationPath}`,
|
||||
],
|
||||
output,
|
||||
{ registry },
|
||||
);
|
||||
assert.equal(fs.statSync(observationPath).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
fs.readFileSync(observationPath, 'utf8'),
|
||||
`${JSON.stringify(observation)}\n`,
|
||||
);
|
||||
const audit = runCli(
|
||||
[
|
||||
'--mode=audit',
|
||||
`--plan=${planPath}`,
|
||||
'--regctl=/unused/injected-regctl',
|
||||
`--observation=${observationPath}`,
|
||||
],
|
||||
output,
|
||||
{ registry },
|
||||
);
|
||||
assert.equal(audit.compatible, true);
|
||||
assert.equal(audit.registryMutation, false);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[
|
||||
'--mode=finalize',
|
||||
`--plan=${planPath}`,
|
||||
'--regctl=/unused/injected-regctl',
|
||||
`--output=${observationPath}`,
|
||||
],
|
||||
output,
|
||||
{ registry },
|
||||
),
|
||||
/output must be published once/,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts only closed CLI modes and a hardened regctl executable', (t) => {
|
||||
assert.deepEqual(
|
||||
parseArguments([
|
||||
'--mode=finalize',
|
||||
'--plan=/private/plan.json',
|
||||
'--regctl=/private/regctl',
|
||||
'--output=/private/observation.json',
|
||||
]),
|
||||
{
|
||||
mode: 'finalize',
|
||||
plan: '/private/plan.json',
|
||||
regctl: '/private/regctl',
|
||||
output: '/private/observation.json',
|
||||
},
|
||||
);
|
||||
for (const argv of [
|
||||
['--mode=finalize', '--plan=/p', '--regctl=/r'],
|
||||
[
|
||||
'--mode=audit',
|
||||
'--plan=/p',
|
||||
'--regctl=/r',
|
||||
'--observation=/o',
|
||||
'--force=true',
|
||||
],
|
||||
['--mode=unknown', '--plan=/p', '--regctl=/r', '--output=/o'],
|
||||
]) {
|
||||
assert.throws(() => parseArguments(argv), /usage/);
|
||||
}
|
||||
|
||||
const directory = temporaryDirectory(t);
|
||||
const executable = path.join(directory, 'regctl');
|
||||
fs.writeFileSync(executable, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const pinnedAdapter = createRegctlAdapter(executable);
|
||||
assert.equal(typeof pinnedAdapter.resolveDigest, 'function');
|
||||
fs.chmodSync(executable, 0o700);
|
||||
assert.throws(
|
||||
() => pinnedAdapter.resolveDigest('ghcr.io/example/image@sha256:digest'),
|
||||
/identity changed/,
|
||||
);
|
||||
fs.chmodSync(executable, 0o775);
|
||||
assert.throws(() => createRegctlAdapter(executable), /non-writable/);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
const alias = path.join(directory, 'regctl-alias');
|
||||
fs.symlinkSync(executable, alias);
|
||||
assert.throws(() => createRegctlAdapter(alias), /non-writable/);
|
||||
});
|
||||
Reference in New Issue
Block a user