From 3dc45df24ff0d1d869627d505cfc354fb2ad6fb4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 13:33:43 +0800 Subject: [PATCH 1/4] feat: Add a bounded GET-only console version comparator --- .../workflows/compare-console-versions.yml | 49 ++++ docs/console-execution-evidence.md | 41 ++++ scripts/console_version_comparator.mjs | 232 ++++++++++++++++++ tests/console_deploy_safety_validation.mjs | 3 + .../console_version_comparator_validation.mjs | 90 +++++++ 5 files changed, 415 insertions(+) create mode 100644 .github/workflows/compare-console-versions.yml create mode 100644 scripts/console_version_comparator.mjs create mode 100644 tests/console_version_comparator_validation.mjs diff --git a/.github/workflows/compare-console-versions.yml b/.github/workflows/compare-console-versions.yml new file mode 100644 index 00000000..5d374501 --- /dev/null +++ b/.github/workflows/compare-console-versions.yml @@ -0,0 +1,49 @@ +name: Compare Console Versions (GET only) + +on: + workflow_dispatch: + inputs: + candidate_commitment: + description: SHA-256 commitment of the exact candidate reference + required: true + type: string + baseline_mode: + description: Evidence source for the baseline + required: true + type: choice + options: [saved, derived, none] + default: none + baseline_commitment: + description: SHA-256 commitment (required only for saved mode) + required: false + type: string + default: "" + +permissions: + contents: read + +concurrency: + group: console-version-comparator + cancel-in-progress: false + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: runtime-strategy-switch + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Compare bounded metadata + env: + CANDIDATE_COMMITMENT: ${{ inputs.candidate_commitment }} + BASELINE_MODE: ${{ inputs.baseline_mode }} + BASELINE_COMMITMENT: ${{ inputs.baseline_commitment }} + RO_COMPARATOR_EXECUTOR_SHA: ${{ vars.RO_COMPARATOR_EXECUTOR_SHA }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_READ_ONLY_API_TOKEN: ${{ secrets.CLOUDFLARE_READ_ONLY_API_TOKEN }} + run: node scripts/console_version_comparator.mjs diff --git a/docs/console-execution-evidence.md b/docs/console-execution-evidence.md index 69140214..106e9680 100644 --- a/docs/console-execution-evidence.md +++ b/docs/console-execution-evidence.md @@ -44,3 +44,44 @@ This mode requires `release_frontend_assets=true`, `sync_secrets=false`, exclude backend-only, rollback, private-release and read-only modes. An arbitrary main commit is not an accepted release source. Deployment still requires separate scoped authorization; accepting a source does not grant deployment authority. + + +### Manual version comparison (GET only) + +`compare-console-versions.yml` is a separate, manual-only evidence executor. It +never uploads, activates or changes a candidate. A reviewed and merged executor +is a prerequisite, not permission to run it. A separate read-only card must bind +its exact revision, candidate commitment and baseline choice, with at most six +GET requests. No execution or Environment approval is implied by this document. + +Inputs are SHA-256 commitments of the exact UTF-8 provider references, with no +trimming or case conversion. `saved` requires a baseline commitment; `derived` +uses the newest deployment before the incident start, cross-checked against the +version immediately preceding the candidate on page one; `none` produces partial +candidate evidence. Missing derived evidence also produces partial evidence. +A missing saved baseline stops. Derivation never recovers an original saved +baseline. No pagination or retry is permitted. + +The Environment must supply a dedicated read-only credential through +`CLOUDFLARE_READ_ONLY_API_TOKEN`, the existing account binding, and +`RO_COMPARATOR_EXECUTOR_SHA` matching the card's executor revision. Their +availability and Environment review policy are unverified; provisioning or +changing bindings needs separate authority. The deploy-capable token is not +used. Checkout runs at the dispatch revision and does not persist credentials. +The required SG egress route and guard must be verified independently before any +future dispatch; hosted-runner egress is not established by this change. + +The fixed request order is deployments, version list, candidate, optional +baseline, candidate again, deployments again. Reads stop on schema, identity, +transport, size, timeout or drift failures. Each response is capped at 512 KiB, +with a six-GET / 3 MiB / 120-second ceiling. There is no persistent result cache +or resume path. Concurrent observations are separate; window drift fails. + +The output contains only fixed keys, enum kinds, booleans and the executor SHA. +Bindings and runtime groups expose equality booleans, never names, values or +provider references. Configuration equality uses the release workflow's exact +canonicalization, comparable projection and migration-tag normalization; etag +equality is emitted separately. Node major 22 is selected. Historical ICU and +`localeCompare` compatibility remain unproven. Secret metadata equality does not +prove secret-value equality. A stable read window does not prove historical +inactivity, recover transient conditions or authorize candidate reuse. diff --git a/scripts/console_version_comparator.mjs b/scripts/console_version_comparator.mjs new file mode 100644 index 00000000..c299156a --- /dev/null +++ b/scripts/console_version_comparator.mjs @@ -0,0 +1,232 @@ +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +// Exact release semantics; tests bind these functions to the deploy workflow. +const fail = () => { throw new Error('schema_unknown'); }; +const hash = value => createHash('sha256').update(value).digest('hex'); +const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value); +const canonical = value => JSON.stringify(value, function (_key, item) { + return item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item; +}); + function resources(version, id) { + if (version?.id !== id) fail(); + const value = version.resources, runtime = value?.script_runtime, script = value?.script; + if (!value || Object.keys(value).some(key => !['bindings', 'script', 'script_runtime'].includes(key)) + || !Array.isArray(value.bindings) || !value.bindings.length || value.bindings.length > 128 || !runtime || !script + || Object.keys(runtime).some(key => !['compatibility_date', 'compatibility_flags', 'usage_model', 'limits', 'migration_tag', 'exports'].includes(key)) + || Object.keys(script).some(key => !['etag', 'handlers', 'named_handlers', 'last_deployed_from', 'placement_mode'].includes(key)) + || typeof script.etag !== 'string' || !script.etag || script.etag.length > 256 + || !(script.handlers === null || (Array.isArray(script.handlers) && script.handlers.every(x => typeof x === 'string'))) + || !/^\d{4}-\d{2}-\d{2}$/.test(runtime.compatibility_date ?? '') + || (runtime.compatibility_flags !== undefined && (!Array.isArray(runtime.compatibility_flags) || runtime.compatibility_flags.some(x => typeof x !== 'string'))) + || !['standard', 'bundled', 'unbound'].includes(runtime.usage_model)) fail(); + // Current API also reports named exports (including DO classes). + // Validate their identity/handler shape and compare them below; do + // not discard this field merely to accept a newer response schema. + if (script.named_handlers !== undefined) { + if (!Array.isArray(script.named_handlers) || script.named_handlers.length > 128) fail(); + // A handler identifier occupies at least three JSON bytes. The + // 512 KiB transport limit bounds the complete list; 32 is not + // an API contract and rejects the existing unchanged export. + const handlerEntryLimit = Math.floor(524288 / 3); + const exports = new Set(); + for (const item of script.named_handlers) { + if (!item || Object.keys(item).some(key => !['name', 'handlers'].includes(key)) + || typeof item.name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(item.name) || exports.has(item.name) + || (item.handlers !== undefined && (!Array.isArray(item.handlers) || item.handlers.length > handlerEntryLimit + || item.handlers.some(handler => typeof handler !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(handler))))) fail(); + exports.add(item.name); + } + } + const names = new Set(); + for (const binding of value.bindings) { + if (!binding || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(binding.name ?? '') || names.has(binding.name) + || !['plain_text', 'json', 'secret_text', 'kv_namespace', 'durable_object_namespace'].includes(binding.type)) fail(); + if (binding.type === 'kv_namespace' && !/^[0-9a-f]{32}$/i.test(typeof binding.namespace_id === 'string' ? binding.namespace_id : '')) fail(); + if (binding.type === 'durable_object_namespace' && ( + !/^[0-9a-f]{32}$/i.test(typeof binding.namespace_id === 'string' ? binding.namespace_id : '') + || typeof binding.class_name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(binding.class_name) + || ['script_name', 'environment', 'dispatch_namespace'].some(key => binding[key] !== undefined && (typeof binding[key] !== 'string' || !binding[key] || binding[key].length > 128)))) fail(); + if (binding.type === 'plain_text' && (typeof binding.text !== 'string' || binding.text.length > 131072)) fail(); + if (binding.type === 'json' && !Object.hasOwn(binding, 'json')) fail(); + if (binding.type === 'secret_text' && Object.keys(binding).some(key => !['name', 'type'].includes(key))) fail(); + names.add(binding.name); + } + // Existing resources only: no resource provisioning or DO migration request. + if (!value.bindings.some(b => b.name === 'STRATEGY_SWITCH_CONFIG' && b.type === 'kv_namespace') + || !value.bindings.some(b => b.name === 'STRATEGY_SWITCH_RUNTIME_INSTANCES' && b.type === 'durable_object_namespace')) fail(); + return value; + } + const comparable = value => ({ bindings: [...value.bindings].sort((a, b) => a.name.localeCompare(b.name)), + script_runtime: value.script_runtime, handlers: value.script.handlers, + named_handlers: value.script.named_handlers === undefined ? null : [...value.script.named_handlers].sort((a,b) => a.name.localeCompare(b.name)), + placement_mode: value.script.placement_mode ?? null }); + const configHash = value => createHash('sha256').update(canonical(comparable(value))).digest('hex'); + +const types = ['plain_text', 'json', 'secret_text', 'kv_namespace', 'durable_object_namespace']; +const runtimeKeys = ['compatibility_date', 'compatibility_flags', 'usage_model', 'limits', 'migration_tag', 'exports']; +const groups = ['bindings', 'binding_types', 'script_runtime', 'handlers', 'named_handlers', 'placement_mode']; +const categories = ['FULL_COMPARISON', 'DERIVED_COMPARISON', 'CANDIDATE_ONLY_PARTIAL', 'STOPPED', 'configuration', 'budget', 'transport', 'http_error', 'response_invalid', 'oversize', 'unavailable', 'baseline_unavailable', 'identity_mismatch', 'unknown_field', 'schema_unknown', 'migration_tag_mismatch', 'drift_observed']; +const check = (ok, category) => { if (!ok) throw new Error(category); }; +const keysOnly = (object, keys) => check(object && typeof object === 'object' && !Array.isArray(object) && Object.keys(object).every(key => keys.includes(key)), 'unknown_field'); + +// No provider strings reach stdout: this is a typed, closed output projection. +// Unknown keys and values fail rather than being redacted into accepted evidence. +export function safeOutput(output) { + const booleans = ['candidate_commitment_matched', 'baseline_commitment_matched', 'normalized_config_equal', 'etag_equal', 'candidate_in_window_active_set', 'drift_observed']; + keysOnly(output, ['executor_sha', 'baseline_mode', 'gets_used', 'category', 'diff_key_path_kinds', 'group_equal', ...booleans]); + for (const [key, value] of Object.entries(output)) { + let ok = booleans.includes(key) ? value === null || typeof value === 'boolean' : false; + if (key === 'executor_sha') ok = value === null || /^[0-9a-f]{40}$/.test(value); + if (key === 'baseline_mode') ok = value === null || ['saved', 'derived', 'none'].includes(value); + if (key === 'gets_used') ok = Number.isInteger(value) && value >= 0 && value <= 6; + if (key === 'category') ok = categories.includes(value); + if (key === 'diff_key_path_kinds') ok = Array.isArray(value) && value.every(v => groups.includes(v)); + if (key === 'group_equal') { + keysOnly(value, groups); ok = true; + for (const [group, equal] of Object.entries(value)) { + if (['binding_types', 'script_runtime'].includes(group)) { + keysOnly(equal, group === 'binding_types' ? types : runtimeKeys); + ok &&= Object.values(equal).every(v => typeof v === 'boolean'); + } else ok &&= typeof equal === 'boolean'; + } + } + if (!ok) throw new Error('output_invalid'); + } + return JSON.stringify(output); +} + +export function boundedReader({account, token, fetchImpl, now = () => performance.now()}) { + const start = now(); let count = 0, total = 0; + const read = async path => { + check(count < 6 && now() - start < 120000, 'budget'); + check(path === '/deployments?page=1&per_page=10' || path === '/versions?page=1&per_page=10' || /^\/versions\/[0-9a-f-]{36}$/i.test(path), 'configuration'); + count++; + const signal = AbortSignal.timeout(Math.min(20000, 120000 - (now() - start))); + let response; + try { + response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/scripts/qsl-strategy-switch-console${path}`, { + method: 'GET', redirect: 'error', signal, + headers: {Authorization: `Bearer ${token}`, Accept: 'application/json', 'Accept-Encoding': 'identity'}, + }); + } catch { throw new Error('transport'); } + const responseCheck = async (ok, category) => { + if (!ok) { await response.body?.cancel(); check(false, category); } + }; + await responseCheck(response.ok && !response.redirected, 'http_error'); + await responseCheck(/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') && ['', 'identity'].includes(response.headers.get('content-encoding') ?? ''), 'response_invalid'); + const length = response.headers.get('content-length'); + await responseCheck(length === null || (/^\d+$/.test(length) && Number(length) <= 524288), 'oversize'); + const reader = response.body?.getReader(); check(reader, 'response_invalid'); + let size = 0, text = ''; const decoder = new TextDecoder('utf-8', {fatal:true}); + try { + for (;;) { + const {done, value} = await reader.read(); if (done) break; + size += value.byteLength; total += value.byteLength; + check(size <= 524288 && total <= 3145728, 'oversize'); + check(now() - start < 120000, 'budget'); + text += decoder.decode(value, {stream:true}); + } + text += decoder.decode(); + } finally { await reader.cancel(); } + check(!signal.aborted && now() - start < 120000, 'budget'); + check(length === null || Number(length) === size, 'response_invalid'); + let body; try { body = JSON.parse(text); } catch { throw new Error('response_invalid'); } + keysOnly(body, ['success', 'result', 'errors', 'messages', 'result_info']); + check(body.success === true && (body.errors == null || (Array.isArray(body.errors) && body.errors.length === 0)), 'response_invalid'); + return body.result; + }; + Object.defineProperty(read, 'count', {get:()=>count}); + return read; +} + +function validateVersion(version, id) { + keysOnly(version, ['id', 'number', 'metadata', 'annotations', 'resources']); + check(version.id === id, 'identity_mismatch'); + keysOnly(version.resources, ['bindings', 'script', 'script_runtime']); + keysOnly(version.resources.script_runtime, runtimeKeys); + keysOnly(version.resources.script, ['etag', 'handlers', 'named_handlers', 'last_deployed_from', 'placement_mode']); + const value = resources(version, id); + for (const binding of value.bindings) { + const allowed = {plain_text:['text'],json:['json'],secret_text:[],kv_namespace:['namespace_id'],durable_object_namespace:['namespace_id','class_name','script_name','environment','dispatch_namespace']}[binding.type]; + keysOnly(binding, ['name','type', ...allowed]); + } + return value; +} +function deployments(envelope) { + keysOnly(envelope, ['deployments']); + const result = envelope.deployments; + check(Array.isArray(result) && result.length > 0 && result.length <= 10, 'schema_unknown'); + const ids = new Set(); + for (const item of result) { + keysOnly(item, ['id', 'source', 'strategy', 'author_email', 'annotations', 'created_on', 'versions']); + check(typeof item.id === 'string' && !ids.has(item.id) && typeof item.created_on === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(item.created_on) && Number.isFinite(Date.parse(item.created_on)), 'schema_unknown'); + ids.add(item.id); + check(Array.isArray(item.versions) && item.versions.length > 0 && item.versions.length <= 10, 'schema_unknown'); + const versions = new Set(); + for (const entry of item.versions) { + keysOnly(entry, ['version_id', 'percentage']); + check(uuid(entry.version_id) && !versions.has(entry.version_id) && Number.isFinite(entry.percentage) && entry.percentage >= 0 && entry.percentage <= 100, 'schema_unknown'); + versions.add(entry.version_id); + } + check(Math.abs(item.versions.reduce((sum, v)=>sum+v.percentage,0)-100) < 0.000001, 'schema_unknown'); + } + // The provider contract orders deployments newest first. Reject contradictory order. + check(result.every((v,i)=>i===0 || Date.parse(result[i-1].created_on) > Date.parse(v.created_on)), 'schema_unknown'); + return result; +} +export async function compareVersions(env, fetchImpl = fetch) { + const output = {executor_sha:null,candidate_commitment_matched:false,baseline_mode:null,baseline_commitment_matched:null,normalized_config_equal:null,etag_equal:null,candidate_in_window_active_set:null,drift_observed:false,gets_used:0,category:'STOPPED'}; + let read; + try { + check(/^[0-9a-f]{40}$/.test(env.GITHUB_SHA ?? '') && env.GITHUB_SHA === env.RO_COMPARATOR_EXECUTOR_SHA, 'configuration'); + output.executor_sha = env.GITHUB_SHA; + check(['saved','derived','none'].includes(env.BASELINE_MODE), 'configuration'); output.baseline_mode=env.BASELINE_MODE; + check(/^[0-9a-f]{64}$/.test(env.CANDIDATE_COMMITMENT ?? '') && (env.BASELINE_MODE === 'saved' ? /^[0-9a-f]{64}$/.test(env.BASELINE_COMMITMENT ?? '') : !env.BASELINE_COMMITMENT), 'configuration'); + check(/^[0-9a-f]{32}$/i.test(env.CLOUDFLARE_ACCOUNT_ID ?? '') && env.CLOUDFLARE_READ_ONLY_API_TOKEN, 'configuration'); + read=boundedReader({account:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_READ_ONLY_API_TOKEN,fetchImpl}); + const start=deployments(await read('/deployments?page=1&per_page=10')); + const listed=await read('/versions?page=1&per_page=10'); + keysOnly(listed,['items']); check(Array.isArray(listed.items) && listed.items.length > 0 && listed.items.length <= 10, 'schema_unknown'); + const ids=listed.items.map(item=>{keysOnly(item,['id','number','metadata','annotations']);check(uuid(item.id),'schema_unknown');return item.id;}); + check(new Set(ids).size === ids.length,'unavailable'); + const matches=ids.filter(id=>hash(id) === env.CANDIDATE_COMMITMENT);check(matches.length === 1,'unavailable'); + const candidate=matches[0]; output.candidate_commitment_matched=true; + output.candidate_in_window_active_set=start[0].versions.some(v=>v.version_id === candidate && v.percentage > 0); + let baseline=null; + if(env.BASELINE_MODE === 'saved') { + const matches=ids.filter(id=>hash(id) === env.BASELINE_COMMITMENT);check(matches.length === 1,'baseline_unavailable');baseline=matches[0];output.baseline_commitment_matched=true; + } else if(env.BASELINE_MODE === 'derived') { + const prior=start.find(v=>Date.parse(v.created_on) < Date.parse('2026-11-07T01:29:40Z')); + if(prior?.versions.length === 1 && prior.versions[0].percentage === 100 && ids[ids.indexOf(candidate)+1] === prior.versions[0].version_id) baseline=prior.versions[0].version_id; + } + check(!baseline || baseline !== candidate,'baseline_unavailable'); + const next=validateVersion(await read('/versions/'+candidate),candidate); + if(baseline) { + const old=validateVersion(await read('/versions/'+baseline),baseline); + const oldTag=old.script_runtime.migration_tag,newTag=next.script_runtime.migration_tag; + check(typeof oldTag === 'string' && oldTag && (newTag === undefined || newTag === oldTag),'migration_tag_mismatch'); + const normalized=structuredClone(next);normalized.script_runtime.migration_tag=oldTag; + output.normalized_config_equal=configHash(normalized) === configHash(old); + output.etag_equal=next.script.etag === old.script.etag; + const before=comparable(old),after=comparable(normalized),equal=(a,b)=>canonical(a) === canonical(b); + output.group_equal={bindings:equal(before.bindings,after.bindings),binding_types:Object.fromEntries(types.map(type=>[type,equal(before.bindings.filter(b=>b.type===type),after.bindings.filter(b=>b.type===type))])),script_runtime:Object.fromEntries(runtimeKeys.map(key=>[key,equal(before.script_runtime[key],after.script_runtime[key])])),handlers:equal(before.handlers,after.handlers),named_handlers:equal(before.named_handlers,after.named_handlers),placement_mode:equal(before.placement_mode,after.placement_mode)}; + output.diff_key_path_kinds=groups.filter(key=> typeof output.group_equal[key] === 'boolean' ? !output.group_equal[key] : Object.values(output.group_equal[key]).some(v=>!v)); + } + const repeat=validateVersion(await read('/versions/'+candidate),candidate); + if(configHash(next) !== configHash(repeat) || next.script.etag !== repeat.script.etag) {output.drift_observed=true;throw new Error('drift_observed');} + const end=deployments(await read('/deployments?page=1&per_page=10')); + if(canonical(start) !== canonical(end)) {output.drift_observed=true;throw new Error('drift_observed');} + output.category=baseline ? (env.BASELINE_MODE === 'saved' ? 'FULL_COMPARISON':'DERIVED_COMPARISON'):'CANDIDATE_ONLY_PARTIAL'; + output.gets_used=read.count; safeOutput(output);return {output,exitCode:0}; + } catch(error) { + output.gets_used=read?.count ?? 0; + output.category=categories.includes(error?.message) ? error.message:'STOPPED'; + safeOutput(output);return {output,exitCode:1}; + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result=await compareVersions(process.env); + console.log(safeOutput(result.output));process.exitCode=result.exitCode; +} diff --git a/tests/console_deploy_safety_validation.mjs b/tests/console_deploy_safety_validation.mjs index c9bc63cd..92edc128 100644 --- a/tests/console_deploy_safety_validation.mjs +++ b/tests/console_deploy_safety_validation.mjs @@ -1394,3 +1394,6 @@ const zeroTotal = await failure([{ success: true, result: { deployments: [{ ...d ] }] } }]); assert.equal(zeroTotal.checkpoint, "deployment_percentage_total", "all-zero traffic is still invalid"); console.log(`console_deploy_safety_validation: PASS (manual-only; 16 original write gates; exclusive preflight/deploy/restore; 2 exact Binance gates; ${cases} offline reader cases; ${sourceCases} source cases; ${restoreCases} restore cases; ${overlayCases} overlay cases; ${configCases} config cases; ${releaseCases} isolated release cases; ${schemaCases} schema readback cases)`); + +// Offline-only comparator coverage is part of the existing release safety gate. +await import('./console_version_comparator_validation.mjs'); diff --git a/tests/console_version_comparator_validation.mjs b/tests/console_version_comparator_validation.mjs new file mode 100644 index 00000000..8aabad1b --- /dev/null +++ b/tests/console_version_comparator_validation.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { compareVersions, boundedReader, safeOutput } from '../scripts/console_version_comparator.mjs'; +const hash = x => createHash('sha256').update(x).digest('hex'); +const C = '11111111-1111-4111-8111-111111111111', B = '22222222-2222-4222-8222-222222222222'; +const sha = 'a'.repeat(40), secret = 'PRIVATE_SENTINEL'; +const env = { GITHUB_SHA: sha, RO_COMPARATOR_EXECUTOR_SHA: sha, CLOUDFLARE_ACCOUNT_ID: 'a'.repeat(32), CLOUDFLARE_READ_ONLY_API_TOKEN: secret, CANDIDATE_COMMITMENT: hash(C), BASELINE_MODE: 'saved', BASELINE_COMMITMENT: hash(B) }; +const resources = () => ({ bindings: [{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'b'.repeat(32)}, {name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'c'.repeat(32),class_name:'FakeClass'}, {name:'PRIVATE_BINDING',type:'plain_text',text:secret}], script_runtime:{compatibility_date:'2026-01-01',usage_model:'standard',migration_tag:'fake-tag'}, script:{etag:'fake-etag',handlers:['fetch'],named_handlers:[]} }); +const deployment = () => ({deployments:[{id: 'fake-deployment',created_on:'2026-11-06T00:00:00Z',versions:[{version_id:B,percentage:100}]}]}); +const fixtures = () => [deployment(),{items:[{id:C},{id:B}]},{id:C,resources:resources()},{id:B,resources:resources()},{id:C,resources:resources()},deployment()]; +let cases = 0; +async function run({change, envDelta={}, stop, expectedGets, expectedCode=1}={}) { + const bodies=fixtures(); change?.(bodies); let calls=0; + const result=await compareVersions({...env,...envDelta},async(url,options)=>{ + calls++; assert.equal(options.method,'GET'); assert.equal(options.redirect,'error'); assert.equal(new URL(url).hostname,'api.cloudflare.com'); + if(stop) return stop(calls, bodies); + return new Response(JSON.stringify({success:true,errors:[],result:bodies[calls-1]}),{headers:{'content-type':'application/json'}}); + }); + assert.equal(result.exitCode,expectedCode, JSON.stringify(result.output)); + if(expectedGets !== undefined) assert.equal(calls,expectedGets); + const text=safeOutput(result.output); for(const value of [secret,C,B,'PRIVATE_BINDING','fake-tag','fake-etag'])assert.ok(!text.includes(value)); + cases++; return result.output; +} +assert.equal((await run({expectedCode:0,expectedGets:6})).normalized_config_equal,true); +assert.equal((await run({expectedCode:0})).etag_equal,true); +assert.equal((await run({envDelta:{BASELINE_MODE:'derived',BASELINE_COMMITMENT:''},expectedCode:0})).category,'DERIVED_COMPARISON'); +assert.equal((await run({envDelta:{BASELINE_MODE:'none',BASELINE_COMMITMENT:''},change:b=>b.splice(3,1),expectedCode:0,expectedGets:5})).category,'CANDIDATE_ONLY_PARTIAL'); +await run({envDelta:{RO_COMPARATOR_EXECUTOR_SHA:'b'.repeat(40)},expectedGets:0}); +await run({envDelta:{BASELINE_COMMITMENT:''},expectedGets:0}); +await run({envDelta:{BASELINE_MODE:'bad'},expectedGets:0}); +await run({envDelta:{CANDIDATE_COMMITMENT:'bad'},expectedGets:0}); +await run({envDelta:{CLOUDFLARE_READ_ONLY_API_TOKEN:''},expectedGets:0}); +await run({change:b=>b[1].items=[{id:B}],expectedGets:2}); +await run({change:b=>b[1].items.push({id:C}),expectedGets:2}); +await run({change:b=>b[2].id=B,expectedGets:3}); +await run({change:b=>b[2].resources.script_runtime.PRIVATE_UNKNOWN=secret,expectedGets:3}); +await run({change:b=>b[2].resources.bindings[0].PRIVATE_UNKNOWN=secret,expectedGets:3}); +await run({change:b=>b[2].resources.bindings.push(b[2].resources.bindings[0]),expectedGets:3}); +await run({change:b=>delete b[2].resources.script.etag,expectedGets:3}); +await run({change:b=>b[2].resources.bindings.push({name:'FAKE_SECRET',type:'secret_text',text:secret}),expectedGets:3}); +await run({change:b=>b[2].resources.script_runtime.migration_tag='different',expectedGets:4}); +await run({change:b=>b[4].resources.script.etag='drift',expectedGets:5}); +await run({change:b=>b[5].deployments[0].versions[0].version_id=C,expectedGets:6}); +await run({stop:()=>new Response('',{status:302,headers:{location:'https://example.invalid'}}),expectedGets:1}); +await run({stop:()=>{throw new Error(secret)},expectedGets:1}); +await run({stop:()=>new Response('{}',{status:403}),expectedGets:1}); +await run({stop:()=>new Response('{}',{headers:{'content-type':'text/html'}}),expectedGets:1}); +await run({stop:()=>new Response('{bad',{headers:{'content-type':'application/json'}}),expectedGets:1}); +await run({stop:()=>new Response('{}',{headers:{'content-type':'application/json','content-length':'524289'}}),expectedGets:1}); +await run({stop:()=>new Response('x'.repeat(524289),{headers:{'content-type':'application/json'}}),expectedGets:1}); +await run({stop:()=>new Response(JSON.stringify({success:true,errors:[{}],result:[]}),{headers:{'content-type':'application/json'}}),expectedGets:1}); +await run({change:b=>b[2].resources.script_runtime.compatibility_flags='bad',expectedGets:3}); +await run({change:b=>b[2].resources.script.named_handlers=[{name:'FakeClass',handlers:'bad'}],expectedGets:3}); +await run({change:b=>b[0].deployments[0].created_on='not-a-date',expectedGets:1}); +await run({change:b=>b[0].deployments[0].versions[0].percentage=90,expectedGets:1}); +assert.equal((await run({envDelta:{BASELINE_MODE:'derived',BASELINE_COMMITMENT:''},change:b=>{b[0].deployments[0].created_on='2026-11-07T02:00:00Z';b[5].deployments[0].created_on='2026-11-07T02:00:00Z';b.splice(3,1);},expectedCode:0,expectedGets:5})).category,'CANDIDATE_ONLY_PARTIAL'); +assert.equal((await run({change:b=>b[2].resources.bindings[2].text='changed-fake-value',expectedCode:1,expectedGets:5})).drift_observed,true); +const different=await run({change:b=>{b[2].resources.bindings[2].text='changed-fake-value';b[4].resources.bindings[2].text='changed-fake-value';b[2].resources.script.etag='different';b[4].resources.script.etag='different';},expectedCode:0}); +assert.equal(different.normalized_config_equal,false);assert.equal(different.etag_equal,false); +let clock=0,calls=0; +const deadlineRead=boundedReader({account:'a'.repeat(32),token:secret,now:()=>clock,fetchImpl:async()=>{calls++;return new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}}); +clock=120000;await assert.rejects(()=>deadlineRead('/deployments?page=1&per_page=10'),/budget/);assert.equal(calls,0); +const read=boundedReader({account:'a'.repeat(32),token:secret,fetchImpl:async()=>new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}); +for(let i=0;i<6;i++)await read('/deployments?page=1&per_page=10'); +await assert.rejects(()=>read('/deployments?page=1&per_page=10'),/budget/); +assert.throws(()=>safeOutput({category:secret}),/output/); +assert.throws(()=>safeOutput({executor_sha:secret}),/output/); +assert.throws(()=>safeOutput({...awaitedOutput(),PRIVATE_UNKNOWN:secret}),/unknown_field/); +function awaitedOutput(){return {category:'STOPPED'}} +const script=readFileSync(new URL('../scripts/console_version_comparator.mjs',import.meta.url),'utf8'); +assert.deepEqual([...script.matchAll(/method:\s*['"]([^'"]+)/g)].map(m=>m[1]),['GET']); +assert.match(script,/redirect: 'error'/); +const workflow=readFileSync(new URL('../.github/workflows/compare-console-versions.yml',import.meta.url),'utf8'); +assert.deepEqual([...workflow.slice(workflow.indexOf('\non:'),workflow.indexOf('\npermissions:')).matchAll(/^ (\w+):/gm)].map(m=>m[1]),['workflow_dispatch']); +assert.equal((workflow.match(/runs-on:/g)||[]).length,1); +assert.match(workflow,/contents: read/); +assert.ok(!/secrets\.CLOUDFLARE_API_TOKEN\b|upload-artifact|wrangler|curl|sync_secrets|sync_strategy_profiles/.test(workflow)); +console.log(`console version comparator: ${cases} provider cases and budget/output/static assertions passed`); + +// Pin the copied primitives to the exact release source, including validation. +const release=readFileSync(new URL('../.github/workflows/deploy-strategy-switch-console.yml',import.meta.url),'utf8'); +for(const [start,end] of [ + ['const canonical = value => JSON.stringify(value, function','let stage ='], + ['function resources(version, id) {',"stage = 'pre_release_active'"], + ['const comparable = value =>','const desired ='], +]) { + const primitive=release.slice(release.indexOf(start),release.indexOf(end,release.indexOf(start))).split('\n').map(line=>line.trim()).join('\n').trim(); + assert.ok(script.split('\n').map(line=>line.trim()).join('\n').includes(primitive)); +} From e8339a5738d9eb04251b099b0a474110be4433a5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 13:38:08 +0800 Subject: [PATCH 2/4] fix: Reject unknown deployment comparison strategies --- scripts/console_version_comparator.mjs | 1 + tests/console_version_comparator_validation.mjs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/console_version_comparator.mjs b/scripts/console_version_comparator.mjs index c299156a..99957bcd 100644 --- a/scripts/console_version_comparator.mjs +++ b/scripts/console_version_comparator.mjs @@ -162,6 +162,7 @@ function deployments(envelope) { for (const item of result) { keysOnly(item, ['id', 'source', 'strategy', 'author_email', 'annotations', 'created_on', 'versions']); check(typeof item.id === 'string' && !ids.has(item.id) && typeof item.created_on === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(item.created_on) && Number.isFinite(Date.parse(item.created_on)), 'schema_unknown'); + check(item.strategy === 'percentage', 'schema_unknown'); ids.add(item.id); check(Array.isArray(item.versions) && item.versions.length > 0 && item.versions.length <= 10, 'schema_unknown'); const versions = new Set(); diff --git a/tests/console_version_comparator_validation.mjs b/tests/console_version_comparator_validation.mjs index 8aabad1b..27a7540f 100644 --- a/tests/console_version_comparator_validation.mjs +++ b/tests/console_version_comparator_validation.mjs @@ -7,7 +7,7 @@ const C = '11111111-1111-4111-8111-111111111111', B = '22222222-2222-4222-8222-2 const sha = 'a'.repeat(40), secret = 'PRIVATE_SENTINEL'; const env = { GITHUB_SHA: sha, RO_COMPARATOR_EXECUTOR_SHA: sha, CLOUDFLARE_ACCOUNT_ID: 'a'.repeat(32), CLOUDFLARE_READ_ONLY_API_TOKEN: secret, CANDIDATE_COMMITMENT: hash(C), BASELINE_MODE: 'saved', BASELINE_COMMITMENT: hash(B) }; const resources = () => ({ bindings: [{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'b'.repeat(32)}, {name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'c'.repeat(32),class_name:'FakeClass'}, {name:'PRIVATE_BINDING',type:'plain_text',text:secret}], script_runtime:{compatibility_date:'2026-01-01',usage_model:'standard',migration_tag:'fake-tag'}, script:{etag:'fake-etag',handlers:['fetch'],named_handlers:[]} }); -const deployment = () => ({deployments:[{id: 'fake-deployment',created_on:'2026-11-06T00:00:00Z',versions:[{version_id:B,percentage:100}]}]}); +const deployment = () => ({deployments:[{id: 'fake-deployment',strategy:'percentage',created_on:'2026-11-06T00:00:00Z',versions:[{version_id:B,percentage:100}]}]}); const fixtures = () => [deployment(),{items:[{id:C},{id:B}]},{id:C,resources:resources()},{id:B,resources:resources()},{id:C,resources:resources()},deployment()]; let cases = 0; async function run({change, envDelta={}, stop, expectedGets, expectedCode=1}={}) { @@ -58,6 +58,7 @@ assert.equal((await run({envDelta:{BASELINE_MODE:'derived',BASELINE_COMMITMENT:' assert.equal((await run({change:b=>b[2].resources.bindings[2].text='changed-fake-value',expectedCode:1,expectedGets:5})).drift_observed,true); const different=await run({change:b=>{b[2].resources.bindings[2].text='changed-fake-value';b[4].resources.bindings[2].text='changed-fake-value';b[2].resources.script.etag='different';b[4].resources.script.etag='different';},expectedCode:0}); assert.equal(different.normalized_config_equal,false);assert.equal(different.etag_equal,false); +await run({change:b=>{b[0].deployments[0].strategy='unknown';b[5].deployments[0].strategy='unknown';},expectedGets:1}); let clock=0,calls=0; const deadlineRead=boundedReader({account:'a'.repeat(32),token:secret,now:()=>clock,fetchImpl:async()=>{calls++;return new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}}); clock=120000;await assert.rejects(()=>deadlineRead('/deployments?page=1&per_page=10'),/budget/);assert.equal(calls,0); From c3c0c8593d0eae4207852d0097ae7cadf24a675d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 13:43:39 +0800 Subject: [PATCH 3/4] fix: Keep comparator account bindings out of step headers --- .github/workflows/compare-console-versions.yml | 2 +- docs/console-execution-evidence.md | 6 ++++-- tests/console_version_comparator_validation.mjs | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/compare-console-versions.yml b/.github/workflows/compare-console-versions.yml index 5d374501..81822463 100644 --- a/.github/workflows/compare-console-versions.yml +++ b/.github/workflows/compare-console-versions.yml @@ -44,6 +44,6 @@ jobs: BASELINE_MODE: ${{ inputs.baseline_mode }} BASELINE_COMMITMENT: ${{ inputs.baseline_commitment }} RO_COMPARATOR_EXECUTOR_SHA: ${{ vars.RO_COMPARATOR_EXECUTOR_SHA }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_READ_ONLY_API_TOKEN: ${{ secrets.CLOUDFLARE_READ_ONLY_API_TOKEN }} run: node scripts/console_version_comparator.mjs diff --git a/docs/console-execution-evidence.md b/docs/console-execution-evidence.md index 106e9680..70648ae9 100644 --- a/docs/console-execution-evidence.md +++ b/docs/console-execution-evidence.md @@ -63,11 +63,13 @@ A missing saved baseline stops. Derivation never recovers an original saved baseline. No pagination or retry is permitted. The Environment must supply a dedicated read-only credential through -`CLOUDFLARE_READ_ONLY_API_TOKEN`, the existing account binding, and +`CLOUDFLARE_READ_ONLY_API_TOKEN`, the existing Secret-backed account binding, and `RO_COMPARATOR_EXECUTOR_SHA` matching the card's executor revision. Their availability and Environment review policy are unverified; provisioning or changing bindings needs separate authority. The deploy-capable token is not -used. Checkout runs at the dispatch revision and does not persist credentials. +used. Account variables are not used as a fallback because runner step headers +can print ordinary variables before the executor starts. Missing account Secrets +stop before any GET. Checkout runs at the dispatch revision and does not persist credentials. The required SG egress route and guard must be verified independently before any future dispatch; hosted-runner egress is not established by this change. diff --git a/tests/console_version_comparator_validation.mjs b/tests/console_version_comparator_validation.mjs index 27a7540f..e1d327ab 100644 --- a/tests/console_version_comparator_validation.mjs +++ b/tests/console_version_comparator_validation.mjs @@ -31,6 +31,7 @@ await run({envDelta:{BASELINE_COMMITMENT:''},expectedGets:0}); await run({envDelta:{BASELINE_MODE:'bad'},expectedGets:0}); await run({envDelta:{CANDIDATE_COMMITMENT:'bad'},expectedGets:0}); await run({envDelta:{CLOUDFLARE_READ_ONLY_API_TOKEN:''},expectedGets:0}); +await run({envDelta:{CLOUDFLARE_ACCOUNT_ID:''},expectedGets:0}); await run({change:b=>b[1].items=[{id:B}],expectedGets:2}); await run({change:b=>b[1].items.push({id:C}),expectedGets:2}); await run({change:b=>b[2].id=B,expectedGets:3}); @@ -76,6 +77,7 @@ const workflow=readFileSync(new URL('../.github/workflows/compare-console-versio assert.deepEqual([...workflow.slice(workflow.indexOf('\non:'),workflow.indexOf('\npermissions:')).matchAll(/^ (\w+):/gm)].map(m=>m[1]),['workflow_dispatch']); assert.equal((workflow.match(/runs-on:/g)||[]).length,1); assert.match(workflow,/contents: read/); +assert.ok(!workflow.includes('vars.CLOUDFLARE_ACCOUNT_ID'), 'account binding must be a masked Secret in runner step headers'); assert.ok(!/secrets\.CLOUDFLARE_API_TOKEN\b|upload-artifact|wrangler|curl|sync_secrets|sync_strategy_profiles/.test(workflow)); console.log(`console version comparator: ${cases} provider cases and budget/output/static assertions passed`); From 9f3d2d1ae8d344ccf152c8332d148b79b546ce52 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 14:21:19 +0800 Subject: [PATCH 4/4] fix: Protect comparator resource and round timeout budget --- .../workflows/compare-console-versions.yml | 1 + docs/console-execution-evidence.md | 9 ++-- scripts/console_version_comparator.mjs | 9 ++-- .../console_version_comparator_validation.mjs | 49 +++++++++++++++++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.github/workflows/compare-console-versions.yml b/.github/workflows/compare-console-versions.yml index 81822463..14e65290 100644 --- a/.github/workflows/compare-console-versions.yml +++ b/.github/workflows/compare-console-versions.yml @@ -44,6 +44,7 @@ jobs: BASELINE_MODE: ${{ inputs.baseline_mode }} BASELINE_COMMITMENT: ${{ inputs.baseline_commitment }} RO_COMPARATOR_EXECUTOR_SHA: ${{ vars.RO_COMPARATOR_EXECUTOR_SHA }} + CLOUDFLARE_WORKER_NAME: ${{ secrets.CLOUDFLARE_WORKER_NAME }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_READ_ONLY_API_TOKEN: ${{ secrets.CLOUDFLARE_READ_ONLY_API_TOKEN }} run: node scripts/console_version_comparator.mjs diff --git a/docs/console-execution-evidence.md b/docs/console-execution-evidence.md index 70648ae9..19e2fed6 100644 --- a/docs/console-execution-evidence.md +++ b/docs/console-execution-evidence.md @@ -63,13 +63,16 @@ A missing saved baseline stops. Derivation never recovers an original saved baseline. No pagination or retry is permitted. The Environment must supply a dedicated read-only credential through -`CLOUDFLARE_READ_ONLY_API_TOKEN`, the existing Secret-backed account binding, and +`CLOUDFLARE_READ_ONLY_API_TOKEN`, the existing Secret-backed account binding, +`CLOUDFLARE_WORKER_NAME` as a Secret-backed Worker binding, and `RO_COMPARATOR_EXECUTOR_SHA` matching the card's executor revision. Their availability and Environment review policy are unverified; provisioning or changing bindings needs separate authority. The deploy-capable token is not used. Account variables are not used as a fallback because runner step headers -can print ordinary variables before the executor starts. Missing account Secrets -stop before any GET. Checkout runs at the dispatch revision and does not persist credentials. +can print ordinary variables before the executor starts. Missing account or Worker Secrets +stop before any GET. Worker names must be 1–63 lowercase letters, digits or +hyphens, starting and ending with a letter or digit; invalid names stop before +URL construction. Checkout runs at the dispatch revision and does not persist credentials. The required SG egress route and guard must be verified independently before any future dispatch; hosted-runner egress is not established by this change. diff --git a/scripts/console_version_comparator.mjs b/scripts/console_version_comparator.mjs index 99957bcd..c1dc6462 100644 --- a/scripts/console_version_comparator.mjs +++ b/scripts/console_version_comparator.mjs @@ -97,16 +97,17 @@ export function safeOutput(output) { return JSON.stringify(output); } -export function boundedReader({account, token, fetchImpl, now = () => performance.now()}) { +export function boundedReader({account, token, worker, fetchImpl, now = () => performance.now()}) { + check(typeof worker === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(worker), 'configuration'); const start = now(); let count = 0, total = 0; const read = async path => { check(count < 6 && now() - start < 120000, 'budget'); check(path === '/deployments?page=1&per_page=10' || path === '/versions?page=1&per_page=10' || /^\/versions\/[0-9a-f-]{36}$/i.test(path), 'configuration'); count++; - const signal = AbortSignal.timeout(Math.min(20000, 120000 - (now() - start))); + const signal = AbortSignal.timeout(Math.max(0, Math.floor(Math.min(20000, 120000 - (now() - start))))); let response; try { - response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/scripts/qsl-strategy-switch-console${path}`, { + response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/scripts/${worker}${path}`, { method: 'GET', redirect: 'error', signal, headers: {Authorization: `Bearer ${token}`, Accept: 'application/json', 'Accept-Encoding': 'identity'}, }); @@ -186,7 +187,7 @@ export async function compareVersions(env, fetchImpl = fetch) { check(['saved','derived','none'].includes(env.BASELINE_MODE), 'configuration'); output.baseline_mode=env.BASELINE_MODE; check(/^[0-9a-f]{64}$/.test(env.CANDIDATE_COMMITMENT ?? '') && (env.BASELINE_MODE === 'saved' ? /^[0-9a-f]{64}$/.test(env.BASELINE_COMMITMENT ?? '') : !env.BASELINE_COMMITMENT), 'configuration'); check(/^[0-9a-f]{32}$/i.test(env.CLOUDFLARE_ACCOUNT_ID ?? '') && env.CLOUDFLARE_READ_ONLY_API_TOKEN, 'configuration'); - read=boundedReader({account:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_READ_ONLY_API_TOKEN,fetchImpl}); + read=boundedReader({worker:env.CLOUDFLARE_WORKER_NAME,account:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_READ_ONLY_API_TOKEN,fetchImpl}); const start=deployments(await read('/deployments?page=1&per_page=10')); const listed=await read('/versions?page=1&per_page=10'); keysOnly(listed,['items']); check(Array.isArray(listed.items) && listed.items.length > 0 && listed.items.length <= 10, 'schema_unknown'); diff --git a/tests/console_version_comparator_validation.mjs b/tests/console_version_comparator_validation.mjs index e1d327ab..9a4a70ff 100644 --- a/tests/console_version_comparator_validation.mjs +++ b/tests/console_version_comparator_validation.mjs @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; +import test from 'node:test'; import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { compareVersions, boundedReader, safeOutput } from '../scripts/console_version_comparator.mjs'; const hash = x => createHash('sha256').update(x).digest('hex'); const C = '11111111-1111-4111-8111-111111111111', B = '22222222-2222-4222-8222-222222222222'; const sha = 'a'.repeat(40), secret = 'PRIVATE_SENTINEL'; -const env = { GITHUB_SHA: sha, RO_COMPARATOR_EXECUTOR_SHA: sha, CLOUDFLARE_ACCOUNT_ID: 'a'.repeat(32), CLOUDFLARE_READ_ONLY_API_TOKEN: secret, CANDIDATE_COMMITMENT: hash(C), BASELINE_MODE: 'saved', BASELINE_COMMITMENT: hash(B) }; +const worker = 'fake-console-worker'; +const env = { CLOUDFLARE_WORKER_NAME: worker, GITHUB_SHA: sha, RO_COMPARATOR_EXECUTOR_SHA: sha, CLOUDFLARE_ACCOUNT_ID: 'a'.repeat(32), CLOUDFLARE_READ_ONLY_API_TOKEN: secret, CANDIDATE_COMMITMENT: hash(C), BASELINE_MODE: 'saved', BASELINE_COMMITMENT: hash(B) }; const resources = () => ({ bindings: [{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'b'.repeat(32)}, {name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'c'.repeat(32),class_name:'FakeClass'}, {name:'PRIVATE_BINDING',type:'plain_text',text:secret}], script_runtime:{compatibility_date:'2026-01-01',usage_model:'standard',migration_tag:'fake-tag'}, script:{etag:'fake-etag',handlers:['fetch'],named_handlers:[]} }); const deployment = () => ({deployments:[{id: 'fake-deployment',strategy:'percentage',created_on:'2026-11-06T00:00:00Z',versions:[{version_id:B,percentage:100}]}]}); const fixtures = () => [deployment(),{items:[{id:C},{id:B}]},{id:C,resources:resources()},{id:B,resources:resources()},{id:C,resources:resources()},deployment()]; @@ -19,7 +21,7 @@ async function run({change, envDelta={}, stop, expectedGets, expectedCode=1}={}) }); assert.equal(result.exitCode,expectedCode, JSON.stringify(result.output)); if(expectedGets !== undefined) assert.equal(calls,expectedGets); - const text=safeOutput(result.output); for(const value of [secret,C,B,'PRIVATE_BINDING','fake-tag','fake-etag'])assert.ok(!text.includes(value)); + const text=safeOutput(result.output); for(const value of [secret,worker,C,B,'PRIVATE_BINDING','fake-tag','fake-etag'])assert.ok(!text.includes(value)); cases++; return result.output; } assert.equal((await run({expectedCode:0,expectedGets:6})).normalized_config_equal,true); @@ -61,9 +63,9 @@ const different=await run({change:b=>{b[2].resources.bindings[2].text='changed-f assert.equal(different.normalized_config_equal,false);assert.equal(different.etag_equal,false); await run({change:b=>{b[0].deployments[0].strategy='unknown';b[5].deployments[0].strategy='unknown';},expectedGets:1}); let clock=0,calls=0; -const deadlineRead=boundedReader({account:'a'.repeat(32),token:secret,now:()=>clock,fetchImpl:async()=>{calls++;return new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}}); +const deadlineRead=boundedReader({worker,account:'a'.repeat(32),token:secret,now:()=>clock,fetchImpl:async()=>{calls++;return new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}}); clock=120000;await assert.rejects(()=>deadlineRead('/deployments?page=1&per_page=10'),/budget/);assert.equal(calls,0); -const read=boundedReader({account:'a'.repeat(32),token:secret,fetchImpl:async()=>new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}); +const read=boundedReader({worker,account:'a'.repeat(32),token:secret,fetchImpl:async()=>new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}); for(let i=0;i<6;i++)await read('/deployments?page=1&per_page=10'); await assert.rejects(()=>read('/deployments?page=1&per_page=10'),/budget/); assert.throws(()=>safeOutput({category:secret}),/output/); @@ -91,3 +93,42 @@ for(const [start,end] of [ const primitive=release.slice(release.indexOf(start),release.indexOf(end,release.indexOf(start))).split('\n').map(line=>line.trim()).join('\n').trim(); assert.ok(script.split('\n').map(line=>line.trim()).join('\n').includes(primitive)); } + +// Regression coverage for protected resource configuration and fractional budgets. +test('configured Worker identity is used only for the bounded request URL', async () => { + const read = boundedReader({worker, account:'a'.repeat(32), token:secret, fetchImpl:async url => { + assert.equal(new URL(url).pathname, '/client/v4/accounts/'+'a'.repeat(32)+'/workers/scripts/'+worker+'/versions'); + return new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}}); + }}); + await read('/versions?page=1&per_page=10'); +}); +test('missing or unsafe Worker identity fails before any request', async () => { + for (const value of [undefined, '', 'bad/name', '../other', 'bad?name', 'bad#name', 'bad name', 'bad\nname', 'bad\n', 'bad\r\n', '-bad', 'a'.repeat(64)]) { + assert.throws(()=>boundedReader({worker:value, account:'a'.repeat(32), token:secret, fetchImpl:()=>assert.fail('must not fetch')}), /configuration/); + assert.equal((await run({envDelta:{CLOUDFLARE_WORKER_NAME:value},expectedGets:0})).category,'configuration'); + } +}); +test('workflow passes the protected Worker binding', () => { + assert.match(workflow, /CLOUDFLARE_WORKER_NAME: \$\{\{ secrets\.CLOUDFLARE_WORKER_NAME \}\}/); +}); +test('fractional remaining budget issues a GET with integer timeout', async () => { + for (const elapsed of [100000.25, 119999.75]) { + let clock=0, calls=0; + const read=boundedReader({worker,account:'a'.repeat(32),token:secret,now:()=>clock,fetchImpl:async()=>{ + calls++; return new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}}); + }}); + clock=elapsed; + await read('/versions?page=1&per_page=10'); + assert.equal(calls,1); + } +}); +test('budget crossing between checks never supplies a negative timeout', async () => { + const ticks=[0,119999.75,120000.25]; let timeout; + const original=AbortSignal.timeout; + AbortSignal.timeout=ms=>{ timeout=ms; return original(ms); }; + try { + const read=boundedReader({worker,account:'a'.repeat(32),token:secret,now:()=>ticks.shift() ?? 120000.25,fetchImpl:async()=>new Response('{"success":true,"result":[]}',{headers:{'content-type':'application/json'}})}); + await assert.rejects(()=>read('/versions?page=1&per_page=10'),/budget/); + assert.equal(timeout,0); + } finally { AbortSignal.timeout=original; } +});