From d20cb198b95963779838966e81ff909b2f0aecc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:47:37 +0900 Subject: [PATCH 01/20] test(ci): define Actions registry recurrence audit --- src/actionsWorkflowRegistryAudit.test.ts | 192 +++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/actionsWorkflowRegistryAudit.test.ts diff --git a/src/actionsWorkflowRegistryAudit.test.ts b/src/actionsWorkflowRegistryAudit.test.ts new file mode 100644 index 00000000..47f2b7a9 --- /dev/null +++ b/src/actionsWorkflowRegistryAudit.test.ts @@ -0,0 +1,192 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; + +const temporaryRoots: string[] = []; + +interface WorkflowFixture { + readonly defaultBranchSha: string; + readonly observedAt: string; + readonly presentWorkflowPaths: readonly string[]; + readonly ownedActiveRepairPaths?: readonly string[]; + readonly pages: readonly { + readonly page: number; + readonly perPage: number; + readonly totalCount: number; + readonly items: readonly { + readonly id: number; + readonly path: string; + readonly state: string; + }[]; + }[]; +} + +function runAudit(fixture: WorkflowFixture) { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-audit-')); + temporaryRoots.push(root); + const inputPath = join(root, 'input.json'); + writeFileSync(inputPath, JSON.stringify(fixture), 'utf8'); + + return spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); +} + +function baseFixture(): WorkflowFixture { + return { + defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940', + observedAt: '2026-08-12T11:42:20.000Z', + presentWorkflowPaths: [ + '.github/workflows/ci.yml', + '.github/workflows/release.yml', + ], + ownedActiveRepairPaths: ['.github/workflows/current-once.yml'], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 5, + items: [ + { id: 1, path: '.github/workflows/ci.yml', state: 'active' }, + { id: 2, path: '.github/workflows/release.yml', state: 'active' }, + { + id: 3, + path: '.github/workflows/historical-finalizer.yml', + state: 'active', + }, + { + id: 4, + path: '.github/workflows/current-once.yml', + state: 'active', + }, + { + id: 5, + path: 'dynamic/dependabot/dependabot-updates', + state: 'active', + }, + ], + }, + ], + }; +} + +afterEach(() => { + while (temporaryRoots.length > 0) { + rmSync(temporaryRoots.pop()!, { recursive: true, force: true }); + } +}); + +describe('Actions workflow registry audit', () => { + it('classifies source-backed, orphaned, explicitly owned repair, and GitHub dynamic identities without name heuristics', () => { + const result = runAudit(baseFixture()); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const evidence = JSON.parse(result.stdout) as { + defaultBranchSha: string; + observedAt: string; + complete: boolean; + paginationReceipts: Array<{ page: number; itemCount: number; totalCount: number }>; + workflows: Array<{ id: number; path: string; state: string; classification: string }>; + }; + expect(evidence.defaultBranchSha).toBe( + 'a430b1c153702de3b6439def801732d7453b4940', + ); + expect(evidence.observedAt).toBe('2026-08-12T11:42:20.000Z'); + expect(evidence.complete).toBe(true); + expect(evidence.paginationReceipts).toEqual([ + { page: 1, itemCount: 5, totalCount: 5 }, + ]); + expect(evidence.workflows).toEqual([ + { + id: 1, + path: '.github/workflows/ci.yml', + state: 'active', + classification: 'present', + }, + { + id: 2, + path: '.github/workflows/release.yml', + state: 'active', + classification: 'present', + }, + { + id: 3, + path: '.github/workflows/historical-finalizer.yml', + state: 'active', + classification: 'active_orphan', + }, + { + id: 4, + path: '.github/workflows/current-once.yml', + state: 'active', + classification: 'owned_active_repair', + }, + { + id: 5, + path: 'dynamic/dependabot/dependabot-updates', + state: 'active', + classification: 'github_dynamic', + }, + ]); + }); + + it('fails closed when pagination does not account for the advertised registry total', () => { + const fixture = baseFixture(); + const result = runAudit({ + ...fixture, + pages: [ + { + ...fixture.pages[0], + totalCount: 6, + }, + ], + }); + + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('workflow registry pagination is incomplete'); + }); + + it('does not silently treat path case or percent-encoding drift as an orphan match', () => { + const fixture = baseFixture(); + const result = runAudit({ + ...fixture, + pages: [ + { + page: 1, + perPage: 100, + totalCount: 2, + items: [ + { id: 8, path: '.github/workflows/CI.yml', state: 'active' }, + { + id: 9, + path: '.github/workflows%2Frelease.yml', + state: 'active', + }, + ], + }, + ], + }); + + expect(result.status).toBe(0); + const workflows = ( + JSON.parse(result.stdout) as { + workflows: Array<{ id: number; classification: string }>; + } + ).workflows; + expect(workflows).toEqual([ + { id: 8, path: '.github/workflows/CI.yml', state: 'active', classification: 'path_mismatch' }, + { id: 9, path: '.github/workflows%2Frelease.yml', state: 'active', classification: 'unresolved_path' }, + ]); + }); +}); From a57990bf4ffc49e9be71a2441c2fb7c7d3c701a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:48:16 +0900 Subject: [PATCH 02/20] test(ci): keep registry audit RED type-safe --- src/actionsWorkflowRegistryAudit.test.ts | 36 ++++++++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/actionsWorkflowRegistryAudit.test.ts b/src/actionsWorkflowRegistryAudit.test.ts index 47f2b7a9..e9e49f23 100644 --- a/src/actionsWorkflowRegistryAudit.test.ts +++ b/src/actionsWorkflowRegistryAudit.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -95,8 +95,17 @@ describe('Actions workflow registry audit', () => { defaultBranchSha: string; observedAt: string; complete: boolean; - paginationReceipts: Array<{ page: number; itemCount: number; totalCount: number }>; - workflows: Array<{ id: number; path: string; state: string; classification: string }>; + paginationReceipts: Array<{ + page: number; + itemCount: number; + totalCount: number; + }>; + workflows: Array<{ + id: number; + path: string; + state: string; + classification: string; + }>; }; expect(evidence.defaultBranchSha).toBe( 'a430b1c153702de3b6439def801732d7453b4940', @@ -181,12 +190,27 @@ describe('Actions workflow registry audit', () => { expect(result.status).toBe(0); const workflows = ( JSON.parse(result.stdout) as { - workflows: Array<{ id: number; classification: string }>; + workflows: Array<{ + id: number; + path: string; + state: string; + classification: string; + }>; } ).workflows; expect(workflows).toEqual([ - { id: 8, path: '.github/workflows/CI.yml', state: 'active', classification: 'path_mismatch' }, - { id: 9, path: '.github/workflows%2Frelease.yml', state: 'active', classification: 'unresolved_path' }, + { + id: 8, + path: '.github/workflows/CI.yml', + state: 'active', + classification: 'path_mismatch', + }, + { + id: 9, + path: '.github/workflows%2Frelease.yml', + state: 'active', + classification: 'unresolved_path', + }, ]); }); }); From 88c3f72b6e7d86bf27819d9593f0162a711acdf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:53:33 +0900 Subject: [PATCH 03/20] fix(ci): classify Actions workflow registry drift --- scripts/audit-actions-workflow-registry.mjs | 286 ++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 scripts/audit-actions-workflow-registry.mjs diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs new file mode 100644 index 00000000..3fa19343 --- /dev/null +++ b/scripts/audit-actions-workflow-registry.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node + +/** + * Classify a bounded, already-fetched GitHub Actions workflow-registry snapshot. + * + * This detector is deliberately read-only. A trusted caller gathers the exact + * default-branch tree and every paginated Actions workflow page, writes the + * bounded JSON fixture, and invokes this script. The script never calls GitHub, + * reads credentials, disables a workflow, restores source, or mutates a ref. + */ +import { readFileSync } from 'node:fs'; + +const MAX_INPUT_BYTES = 1024 * 1024; +const MAX_PATH_CODE_UNITS = 1024; +const MAX_PAGES = 1000; +const MAX_WORKFLOWS = 100_000; +const SHA_1 = /^[0-9a-f]{40}$/u; +const REPOSITORY_WORKFLOW_PREFIX = '.github/workflows/'; +const GITHUB_DYNAMIC_PREFIX = 'dynamic/'; + +/** Write one bounded operator-facing failure and terminate without JSON output. */ +function fail(message) { + process.stderr.write(`workflow registry audit failed: ${message}\n`); + process.exitCode = 1; +} + +/** Return whether a value is a plain JSON object. */ +function isRecord(value) { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +/** Return whether an object has exactly the documented keys. */ +function hasExactKeys(record, requiredKeys, optionalKeys = []) { + const allowed = new Set([...requiredKeys, ...optionalKeys]); + const keys = Object.keys(record); + return ( + requiredKeys.every((key) => Object.hasOwn(record, key)) && + keys.every((key) => allowed.has(key)) + ); +} + +/** Validate one bounded opaque path supplied by GitHub or the tree collector. */ +function isBoundedPath(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_PATH_CODE_UNITS && + !value.includes('\u0000') && + !value.includes('\\') && + !value.startsWith('/') + ); +} + +/** Validate one canonical workflow source path from the protected tree. */ +function isCanonicalRepositoryWorkflowPath(value) { + return ( + isBoundedPath(value) && + value.startsWith(REPOSITORY_WORKFLOW_PREFIX) && + !value.includes('%') && + !value.split('/').includes('..') + ); +} + +/** Parse and strictly validate command-line arguments. */ +function readInputArgument(argv) { + if (argv.length !== 2 || argv[0] !== '--input' || argv[1].length === 0) { + throw new Error('usage: audit-actions-workflow-registry.mjs --input '); + } + return argv[1]; +} + +/** Read one bounded JSON fixture without following any repository-controlled URL. */ +function readFixture(inputPath) { + const bytes = readFileSync(inputPath); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_INPUT_BYTES) { + throw new Error('input size is outside the supported bound'); + } + let value; + try { + value = JSON.parse(bytes.toString('utf8')); + } catch { + throw new Error('input is not valid JSON'); + } + if ( + !isRecord(value) || + !hasExactKeys( + value, + ['defaultBranchSha', 'observedAt', 'presentWorkflowPaths', 'pages'], + ['ownedActiveRepairPaths'], + ) + ) { + throw new Error('input has an invalid top-level contract'); + } + return value; +} + +/** Validate and detach one bounded unique path list. */ +function readPathList(value, label, canonicalOnly) { + if (!Array.isArray(value) || value.length > MAX_WORKFLOWS) { + throw new Error(`${label} is invalid`); + } + const result = []; + const seen = new Set(); + for (const path of value) { + const valid = canonicalOnly + ? isCanonicalRepositoryWorkflowPath(path) + : isBoundedPath(path); + if (!valid || seen.has(path)) { + throw new Error(`${label} contains an invalid or duplicate path`); + } + seen.add(path); + result.push(path); + } + return Object.freeze(result); +} + +/** Validate one exact GitHub workflow registry item. */ +function readWorkflowItem(value) { + if ( + !isRecord(value) || + !hasExactKeys(value, ['id', 'path', 'state']) || + !Number.isSafeInteger(value.id) || + value.id <= 0 || + !isBoundedPath(value.path) || + typeof value.state !== 'string' || + value.state.length === 0 || + value.state.length > 32 + ) { + throw new Error('workflow registry item is invalid'); + } + return Object.freeze({ id: value.id, path: value.path, state: value.state }); +} + +/** Validate all pages and prove that pagination accounts for the advertised total. */ +function readCompletePages(value) { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PAGES) { + throw new Error('workflow registry pages are invalid'); + } + + const items = []; + const receipts = []; + const workflowIds = new Set(); + let expectedTotal = null; + + for (let index = 0; index < value.length; index += 1) { + const page = value[index]; + if ( + !isRecord(page) || + !hasExactKeys(page, ['page', 'perPage', 'totalCount', 'items']) || + !Number.isSafeInteger(page.page) || + page.page !== index + 1 || + !Number.isSafeInteger(page.perPage) || + page.perPage < 1 || + page.perPage > 100 || + !Number.isSafeInteger(page.totalCount) || + page.totalCount < 0 || + page.totalCount > MAX_WORKFLOWS || + !Array.isArray(page.items) || + page.items.length > page.perPage + ) { + throw new Error('workflow registry page is invalid'); + } + if (expectedTotal === null) { + expectedTotal = page.totalCount; + } else if (page.totalCount !== expectedTotal) { + throw new Error('workflow registry total changed between pages'); + } + + for (const rawItem of page.items) { + const item = readWorkflowItem(rawItem); + if (workflowIds.has(item.id)) { + throw new Error('workflow registry contains a duplicate workflow id'); + } + workflowIds.add(item.id); + items.push(item); + } + receipts.push( + Object.freeze({ + page: page.page, + itemCount: page.items.length, + totalCount: page.totalCount, + }), + ); + } + + if (items.length !== expectedTotal) { + throw new Error('workflow registry pagination is incomplete'); + } + return Object.freeze({ + items: Object.freeze(items), + receipts: Object.freeze(receipts), + }); +} + +/** Classify one registry record without filename or workflow-name heuristics. */ +function classifyWorkflow(path, state, presentPaths, ownedRepairPaths, foldedPaths) { + if (state !== 'active') { + return 'disabled'; + } + if (path.startsWith(GITHUB_DYNAMIC_PREFIX)) { + return 'github_dynamic'; + } + if (path.includes('%')) { + return 'unresolved_path'; + } + if (presentPaths.has(path)) { + return 'present'; + } + if (ownedRepairPaths.has(path)) { + return 'owned_active_repair'; + } + if (foldedPaths.has(path.toLocaleLowerCase('en-US'))) { + return 'path_mismatch'; + } + if (path.startsWith(REPOSITORY_WORKFLOW_PREFIX)) { + return 'active_orphan'; + } + return 'unresolved_path'; +} + +/** Produce frozen deterministic audit evidence from one validated fixture. */ +function auditFixture(fixture) { + if (!SHA_1.test(fixture.defaultBranchSha)) { + throw new Error('default branch SHA is invalid'); + } + if ( + typeof fixture.observedAt !== 'string' || + Number.isNaN(Date.parse(fixture.observedAt)) || + new Date(fixture.observedAt).toISOString() !== fixture.observedAt + ) { + throw new Error('observation time is invalid'); + } + + const presentList = readPathList( + fixture.presentWorkflowPaths, + 'present workflow paths', + true, + ); + const ownedRepairList = readPathList( + fixture.ownedActiveRepairPaths ?? [], + 'owned active repair paths', + true, + ); + const presentPaths = new Set(presentList); + const ownedRepairPaths = new Set(ownedRepairList); + const foldedPaths = new Set( + [...presentList, ...ownedRepairList].map((path) => + path.toLocaleLowerCase('en-US'), + ), + ); + const pages = readCompletePages(fixture.pages); + + return Object.freeze({ + defaultBranchSha: fixture.defaultBranchSha, + observedAt: fixture.observedAt, + complete: true, + paginationReceipts: pages.receipts, + workflows: Object.freeze( + pages.items.map((item) => + Object.freeze({ + ...item, + classification: classifyWorkflow( + item.path, + item.state, + presentPaths, + ownedRepairPaths, + foldedPaths, + ), + }), + ), + ), + }); +} + +try { + const inputPath = readInputArgument(process.argv.slice(2)); + const evidence = auditFixture(readFixture(inputPath)); + process.stdout.write(`${JSON.stringify(evidence)}\n`); +} catch (error) { + fail(error instanceof Error ? error.message : 'unexpected validation failure'); +} From d4f700c488ca4b6033e425d9c6dde1107ddd5b69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:38:40 +0900 Subject: [PATCH 04/20] test(ci): reject undocumented workflow registry states --- ...actionsWorkflowRegistryAuditStates.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditStates.test.ts diff --git a/src/actionsWorkflowRegistryAuditStates.test.ts b/src/actionsWorkflowRegistryAuditStates.test.ts new file mode 100644 index 00000000..3e8c8915 --- /dev/null +++ b/src/actionsWorkflowRegistryAuditStates.test.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; + +const temporaryRoots: string[] = []; + +function runAudit(state: string) { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-state-')); + temporaryRoots.push(root); + const inputPath = join(root, 'input.json'); + writeFileSync( + inputPath, + JSON.stringify({ + defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940', + observedAt: '2026-08-12T13:26:00.000Z', + presentWorkflowPaths: ['.github/workflows/ci.yml'], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 1, + items: [{ id: 1, path: '.github/workflows/ci.yml', state }], + }, + ], + }), + 'utf8', + ); + + return spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); +} + +afterEach(() => { + while (temporaryRoots.length > 0) { + rmSync(temporaryRoots.pop()!, { recursive: true, force: true }); + } +}); + +describe('Actions workflow registry state boundary', () => { + it('accepts the documented inactive workflow states as non-active evidence', () => { + for (const state of [ + 'deleted', + 'disabled_fork', + 'disabled_inactivity', + 'disabled_manually', + ]) { + const result = runAudit(state); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + workflows: [{ state, classification: 'disabled' }], + }); + } + }); + + it('fails closed instead of treating an undocumented workflow state as disabled', () => { + const result = runAudit('suspended_future_state'); + + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('workflow registry item is invalid'); + }); +}); From 914bbf3f5fb49f434c1fada85e1e613e6cb33408 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:46:28 +0900 Subject: [PATCH 05/20] fix(ci): fail closed on unknown workflow states --- scripts/audit-actions-workflow-registry.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index 3fa19343..765c1106 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -17,6 +17,13 @@ const MAX_WORKFLOWS = 100_000; const SHA_1 = /^[0-9a-f]{40}$/u; const REPOSITORY_WORKFLOW_PREFIX = '.github/workflows/'; const GITHUB_DYNAMIC_PREFIX = 'dynamic/'; +const WORKFLOW_STATES = new Set([ + 'active', + 'deleted', + 'disabled_fork', + 'disabled_inactivity', + 'disabled_manually', +]); /** Write one bounded operator-facing failure and terminate without JSON output. */ function fail(message) { @@ -128,8 +135,7 @@ function readWorkflowItem(value) { value.id <= 0 || !isBoundedPath(value.path) || typeof value.state !== 'string' || - value.state.length === 0 || - value.state.length > 32 + !WORKFLOW_STATES.has(value.state) ) { throw new Error('workflow registry item is invalid'); } From 6790ebb60971be0d2733e7793b00cada5748e99c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:33:54 +0900 Subject: [PATCH 06/20] test(ci): reject malformed workflow registry UTF-8 --- src/actionsWorkflowRegistryAuditUtf8.test.ts | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditUtf8.test.ts diff --git a/src/actionsWorkflowRegistryAuditUtf8.test.ts b/src/actionsWorkflowRegistryAuditUtf8.test.ts new file mode 100644 index 00000000..8f390ef4 --- /dev/null +++ b/src/actionsWorkflowRegistryAuditUtf8.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +function baseFixture() { + return { + defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940', + observedAt: '2026-08-12T11:42:20.000Z', + presentWorkflowPaths: ['.github/workflows/ci.yml'], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 1, + items: [ + { id: 1, path: '.github/workflows/ci.yml', state: 'active' }, + ], + }, + ], + }; +} + +describe('Actions workflow registry audit UTF-8 boundary', () => { + it('fails closed before JSON parsing when the fixture contains malformed UTF-8', () => { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-utf8-')); + try { + const inputPath = join(root, 'input.json'); + const bytes = Buffer.from(JSON.stringify(baseFixture()), 'utf8'); + const needle = Buffer.from('ci.yml', 'utf8'); + const offset = bytes.indexOf(needle); + expect(offset).toBeGreaterThanOrEqual(0); + bytes[offset] = 0x80; + writeFileSync(inputPath, bytes); + + const result = spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('input is not valid UTF-8'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From aee34390231d02d0d4751146f5c59b0b51103541 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:40:43 +0900 Subject: [PATCH 07/20] fix(ci): reject malformed workflow registry UTF-8 --- scripts/audit-actions-workflow-registry.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index 765c1106..d60750f5 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -8,6 +8,7 @@ * bounded JSON fixture, and invokes this script. The script never calls GitHub, * reads credentials, disables a workflow, restores source, or mutates a ref. */ +import { isUtf8 } from 'node:buffer'; import { readFileSync } from 'node:fs'; const MAX_INPUT_BYTES = 1024 * 1024; @@ -87,6 +88,9 @@ function readFixture(inputPath) { if (bytes.byteLength === 0 || bytes.byteLength > MAX_INPUT_BYTES) { throw new Error('input size is outside the supported bound'); } + if (!isUtf8(bytes)) { + throw new Error('input is not valid UTF-8'); + } let value; try { value = JSON.parse(bytes.toString('utf8')); From f6a19a91033914aacac3f30282fbb7d09b0a0ccc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:41:51 +0900 Subject: [PATCH 08/20] test(ci): fail closed on short registry pages --- ...rkflowRegistryAuditPaginationShape.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditPaginationShape.test.ts diff --git a/src/actionsWorkflowRegistryAuditPaginationShape.test.ts b/src/actionsWorkflowRegistryAuditPaginationShape.test.ts new file mode 100644 index 00000000..687124d9 --- /dev/null +++ b/src/actionsWorkflowRegistryAuditPaginationShape.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +function workflow(id: number) { + return { + id, + path: `.github/workflows/workflow-${id}.yml`, + state: 'active', + }; +} + +describe('Actions workflow registry audit pagination shape', () => { + it('fails closed when a non-final page is short even if later items fill the advertised total', () => { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-pages-')); + try { + const inputPath = join(root, 'input.json'); + writeFileSync( + inputPath, + JSON.stringify({ + defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940', + observedAt: '2026-08-12T11:42:20.000Z', + presentWorkflowPaths: [], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 101, + items: [workflow(1)], + }, + { + page: 2, + perPage: 100, + totalCount: 101, + items: Array.from({ length: 100 }, (_, index) => workflow(index + 2)), + }, + ], + }), + 'utf8', + ); + + const result = spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('workflow registry pagination is incomplete'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From c98165003f97e13fd7212b99d92ce33e681c980f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:45:56 +0900 Subject: [PATCH 09/20] fix(ci): reject incomplete registry pagination shape --- scripts/audit-actions-workflow-registry.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index d60750f5..ca8d97eb 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -180,6 +180,9 @@ function readCompletePages(value) { } else if (page.totalCount !== expectedTotal) { throw new Error('workflow registry total changed between pages'); } + if (index < value.length - 1 && page.items.length !== page.perPage) { + throw new Error('workflow registry pagination is incomplete'); + } for (const rawItem of page.items) { const item = readWorkflowItem(rawItem); From 11319f5036ea3192c286869d3217c5cd2175c165 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:50:50 +0900 Subject: [PATCH 10/20] test(ci): detect default-branch movement during registry audit --- ...orkflowRegistryAuditBranchMovement.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditBranchMovement.test.ts diff --git a/src/actionsWorkflowRegistryAuditBranchMovement.test.ts b/src/actionsWorkflowRegistryAuditBranchMovement.test.ts new file mode 100644 index 00000000..fc05ca46 --- /dev/null +++ b/src/actionsWorkflowRegistryAuditBranchMovement.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +describe('Actions workflow registry audit branch binding', () => { + it('fails closed when the default branch moves during snapshot collection', () => { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-branch-')); + try { + const inputPath = join(root, 'input.json'); + writeFileSync( + inputPath, + JSON.stringify({ + defaultBranchSha: 'a430b1c153702de3b6439def801732d7453b4940', + defaultBranchShaAfter: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + observedAt: '2026-08-12T11:42:20.000Z', + presentWorkflowPaths: ['.github/workflows/ci.yml'], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 1, + items: [ + { id: 1, path: '.github/workflows/ci.yml', state: 'active' }, + ], + }, + ], + }), + 'utf8', + ); + + const result = spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('default branch moved during observation'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 371205b6e16d3194c8325a4e731bf7097da5020c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:14:51 +0900 Subject: [PATCH 11/20] fix(ci): fail closed on workflow audit branch movement --- scripts/audit-actions-workflow-registry.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index ca8d97eb..0047aa66 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -102,7 +102,7 @@ function readFixture(inputPath) { !hasExactKeys( value, ['defaultBranchSha', 'observedAt', 'presentWorkflowPaths', 'pages'], - ['ownedActiveRepairPaths'], + ['defaultBranchShaAfter', 'ownedActiveRepairPaths'], ) ) { throw new Error('input has an invalid top-level contract'); @@ -241,6 +241,18 @@ function auditFixture(fixture) { if (!SHA_1.test(fixture.defaultBranchSha)) { throw new Error('default branch SHA is invalid'); } + if ( + fixture.defaultBranchShaAfter !== undefined && + !SHA_1.test(fixture.defaultBranchShaAfter) + ) { + throw new Error('ending default branch SHA is invalid'); + } + if ( + fixture.defaultBranchShaAfter !== undefined && + fixture.defaultBranchShaAfter !== fixture.defaultBranchSha + ) { + throw new Error('default branch moved during observation'); + } if ( typeof fixture.observedAt !== 'string' || Number.isNaN(Date.parse(fixture.observedAt)) || From 7effc38e059c9089524257a9845712a9b55f0efa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:41:58 +0900 Subject: [PATCH 12/20] test(ci): preflight registry audit input size --- src/actionsWorkflowRegistryAudit.test.ts | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/actionsWorkflowRegistryAudit.test.ts b/src/actionsWorkflowRegistryAudit.test.ts index e9e49f23..7319f17e 100644 --- a/src/actionsWorkflowRegistryAudit.test.ts +++ b/src/actionsWorkflowRegistryAudit.test.ts @@ -166,6 +166,54 @@ describe('Actions workflow registry audit', () => { expect(result.stderr).toContain('workflow registry pagination is incomplete'); }); + it('rejects an oversized fixture before whole-file materialization', () => { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-audit-')); + temporaryRoots.push(root); + const inputPath = join(root, 'oversized-input.json'); + const preloadPath = join(root, 'guard-read-file-sync.cjs'); + writeFileSync(inputPath, Buffer.alloc(1024 * 1024 + 1, 0x20)); + writeFileSync( + preloadPath, + [ + "const fs = require('node:fs');", + "const { syncBuiltinESMExports } = require('node:module');", + 'const originalReadFileSync = fs.readFileSync;', + 'fs.readFileSync = function guardedReadFileSync(path, ...args) {', + " if (String(path) === process.env.INKSPAN_GUARDED_INPUT) throw new Error('whole-file fixture materialization reached');", + ' return originalReadFileSync.call(this, path, ...args);', + '};', + 'syncBuiltinESMExports();', + ].join('\n'), + 'utf8', + ); + + const nodeOptions = [ + process.env.NODE_OPTIONS, + `--require=${preloadPath}`, + ] + .filter((value): value is string => Boolean(value)) + .join(' '); + const result = spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + INKSPAN_GUARDED_INPUT: inputPath, + NODE_OPTIONS: nodeOptions, + }, + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('input size is outside the supported bound'); + expect(result.stderr).not.toContain('whole-file fixture materialization reached'); + }); + it('does not silently treat path case or percent-encoding drift as an orphan match', () => { const fixture = baseFixture(); const result = runAudit({ From 6ee2628a6feff4e288672fb0021f32e58f540bfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:48:36 +0900 Subject: [PATCH 13/20] fix(ci): bound registry audit fixture reads --- scripts/audit-actions-workflow-registry.mjs | 42 ++++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index 0047aa66..96d92ee3 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -9,7 +9,7 @@ * reads credentials, disables a workflow, restores source, or mutates a ref. */ import { isUtf8 } from 'node:buffer'; -import { readFileSync } from 'node:fs'; +import { closeSync, fstatSync, openSync, readSync } from 'node:fs'; const MAX_INPUT_BYTES = 1024 * 1024; const MAX_PATH_CODE_UNITS = 1024; @@ -82,12 +82,44 @@ function readInputArgument(argv) { return argv[1]; } +/** Read at most one bounded regular-file snapshot from a trusted local path. */ +function readBoundedInput(inputPath) { + const descriptor = openSync(inputPath, 'r'); + try { + const metadata = fstatSync(descriptor); + if ( + !metadata.isFile() || + metadata.size === 0 || + metadata.size > MAX_INPUT_BYTES + ) { + throw new Error('input size is outside the supported bound'); + } + + const buffer = Buffer.allocUnsafe(MAX_INPUT_BYTES + 1); + let bytesRead = 0; + while (bytesRead < buffer.byteLength) { + const count = readSync( + descriptor, + buffer, + bytesRead, + buffer.byteLength - bytesRead, + null, + ); + if (count === 0) break; + bytesRead += count; + } + if (bytesRead === 0 || bytesRead > MAX_INPUT_BYTES) { + throw new Error('input size is outside the supported bound'); + } + return buffer.subarray(0, bytesRead); + } finally { + closeSync(descriptor); + } +} + /** Read one bounded JSON fixture without following any repository-controlled URL. */ function readFixture(inputPath) { - const bytes = readFileSync(inputPath); - if (bytes.byteLength === 0 || bytes.byteLength > MAX_INPUT_BYTES) { - throw new Error('input size is outside the supported bound'); - } + const bytes = readBoundedInput(inputPath); if (!isUtf8(bytes)) { throw new Error('input is not valid UTF-8'); } From eb467f96376f69ef47a4fc7df5355f6872e56e91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 02:15:47 +0900 Subject: [PATCH 14/20] test(ci): reject non-canonical workflow registry paths --- ...WorkflowRegistryAuditCanonicalPath.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditCanonicalPath.test.ts diff --git a/src/actionsWorkflowRegistryAuditCanonicalPath.test.ts b/src/actionsWorkflowRegistryAuditCanonicalPath.test.ts new file mode 100644 index 00000000..2cd57372 --- /dev/null +++ b/src/actionsWorkflowRegistryAuditCanonicalPath.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +describe('Actions workflow registry audit canonical paths', () => { + it('keeps non-canonical repository paths unresolved instead of actionable orphans', () => { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-paths-')); + try { + const inputPath = join(root, 'input.json'); + writeFileSync( + inputPath, + JSON.stringify({ + defaultBranchSha: 'e8109ec2a17de8bd6594487aa12c8c8a93cb2c03', + observedAt: '2026-08-14T02:09:41.000Z', + presentWorkflowPaths: [], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 3, + items: [ + { + id: 101, + path: '.github/workflows/../ci.yml', + state: 'active', + }, + { + id: 102, + path: '.github/workflows/./ci.yml', + state: 'active', + }, + { + id: 103, + path: '.github/workflows//ci.yml', + state: 'active', + }, + ], + }, + ], + }), + 'utf8', + ); + + const result = spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const evidence = JSON.parse(result.stdout) as { + workflows: Array<{ classification: string }>; + }; + expect(evidence.workflows.map(({ classification }) => classification)).toEqual([ + 'unresolved_path', + 'unresolved_path', + 'unresolved_path', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From fbc30a5f3a571d3d751a5ea333731aca06e507b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 02:29:40 +0900 Subject: [PATCH 15/20] fix(ci): keep non-canonical workflow paths unresolved --- scripts/audit-actions-workflow-registry.mjs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index 96d92ee3..8a132151 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -66,12 +66,16 @@ function isBoundedPath(value) { /** Validate one canonical workflow source path from the protected tree. */ function isCanonicalRepositoryWorkflowPath(value) { - return ( - isBoundedPath(value) && - value.startsWith(REPOSITORY_WORKFLOW_PREFIX) && - !value.includes('%') && - !value.split('/').includes('..') - ); + if ( + !isBoundedPath(value) || + !value.startsWith(REPOSITORY_WORKFLOW_PREFIX) || + value.includes('%') + ) { + return false; + } + return value + .split('/') + .every((segment) => segment.length > 0 && segment !== '.' && segment !== '..'); } /** Parse and strictly validate command-line arguments. */ @@ -250,7 +254,10 @@ function classifyWorkflow(path, state, presentPaths, ownedRepairPaths, foldedPat if (path.startsWith(GITHUB_DYNAMIC_PREFIX)) { return 'github_dynamic'; } - if (path.includes('%')) { + if ( + path.startsWith(REPOSITORY_WORKFLOW_PREFIX) && + !isCanonicalRepositoryWorkflowPath(path) + ) { return 'unresolved_path'; } if (presentPaths.has(path)) { From 504e094d7bb152f743b919207f7527093a46b285 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 02:35:20 +0900 Subject: [PATCH 16/20] test(ci): reject ambiguous reused workflow identities --- ...WorkflowRegistryAuditIdentityReuse.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditIdentityReuse.test.ts diff --git a/src/actionsWorkflowRegistryAuditIdentityReuse.test.ts b/src/actionsWorkflowRegistryAuditIdentityReuse.test.ts new file mode 100644 index 00000000..a37b466f --- /dev/null +++ b/src/actionsWorkflowRegistryAuditIdentityReuse.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +describe('Actions workflow registry audit identity reuse', () => { + it('keeps duplicate active identities for one canonical path unresolved', () => { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-identity-')); + try { + const inputPath = join(root, 'input.json'); + writeFileSync( + inputPath, + JSON.stringify({ + defaultBranchSha: 'e8109ec2a17de8bd6594487aa12c8c8a93cb2c03', + observedAt: '2026-08-14T17:42:00.000Z', + presentWorkflowPaths: ['.github/workflows/ci.yml'], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 3, + items: [ + { + id: 201, + path: '.github/workflows/ci.yml', + state: 'active', + }, + { + id: 202, + path: '.github/workflows/ci.yml', + state: 'active', + }, + { + id: 203, + path: '.github/workflows/ci.yml', + state: 'disabled_manually', + }, + ], + }, + ], + }), + 'utf8', + ); + + const result = spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const evidence = JSON.parse(result.stdout) as { + workflows: Array<{ id: number; classification: string }>; + }; + expect(evidence.workflows).toEqual([ + { + id: 201, + path: '.github/workflows/ci.yml', + state: 'active', + classification: 'unresolved_identity', + }, + { + id: 202, + path: '.github/workflows/ci.yml', + state: 'active', + classification: 'unresolved_identity', + }, + { + id: 203, + path: '.github/workflows/ci.yml', + state: 'disabled_manually', + classification: 'disabled', + }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 6b54294c360c77916f9956837a41cea3833adcbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 02:39:00 +0900 Subject: [PATCH 17/20] fix(ci): fail closed on reused workflow identities --- scripts/audit-actions-workflow-registry.mjs | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index 8a132151..eaf3e0f7 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -246,8 +246,31 @@ function readCompletePages(value) { }); } +/** Find active canonical repository paths represented by more than one registry id. */ +function findAmbiguousActiveRepositoryPaths(items) { + const counts = new Map(); + for (const item of items) { + if (item.state !== 'active' || !isCanonicalRepositoryWorkflowPath(item.path)) { + continue; + } + counts.set(item.path, (counts.get(item.path) ?? 0) + 1); + } + return new Set( + [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([path]) => path), + ); +} + /** Classify one registry record without filename or workflow-name heuristics. */ -function classifyWorkflow(path, state, presentPaths, ownedRepairPaths, foldedPaths) { +function classifyWorkflow( + path, + state, + presentPaths, + ownedRepairPaths, + foldedPaths, + ambiguousActivePaths, +) { if (state !== 'active') { return 'disabled'; } @@ -260,6 +283,9 @@ function classifyWorkflow(path, state, presentPaths, ownedRepairPaths, foldedPat ) { return 'unresolved_path'; } + if (ambiguousActivePaths.has(path)) { + return 'unresolved_identity'; + } if (presentPaths.has(path)) { return 'present'; } @@ -318,6 +344,7 @@ function auditFixture(fixture) { ), ); const pages = readCompletePages(fixture.pages); + const ambiguousActivePaths = findAmbiguousActiveRepositoryPaths(pages.items); return Object.freeze({ defaultBranchSha: fixture.defaultBranchSha, @@ -334,6 +361,7 @@ function auditFixture(fixture) { presentPaths, ownedRepairPaths, foldedPaths, + ambiguousActivePaths, ), }), ), From a9f207cccad000521bcbca9a2a170ca96f55fd1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:05:52 +0900 Subject: [PATCH 18/20] test(ci): bind repair workflow ownership evidence --- ...rkflowRegistryAuditRepairOwnership.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/actionsWorkflowRegistryAuditRepairOwnership.test.ts diff --git a/src/actionsWorkflowRegistryAuditRepairOwnership.test.ts b/src/actionsWorkflowRegistryAuditRepairOwnership.test.ts new file mode 100644 index 00000000..fe83f7a3 --- /dev/null +++ b/src/actionsWorkflowRegistryAuditRepairOwnership.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +const DEFAULT_BRANCH_SHA = 'e8109ec2a17de8bd6594487aa12c8c8a93cb2c03'; +const REPAIR_HEAD_SHA = '6b54294c360c77916f9956837a41cea3833adcbe'; +const REPAIR_PATH = '.github/workflows/current-once.yml'; + +function runAudit(fixture: unknown) { + const root = mkdtempSync(join(tmpdir(), 'inkspan-actions-registry-repair-owner-')); + try { + const inputPath = join(root, 'input.json'); + writeFileSync(inputPath, JSON.stringify(fixture), 'utf8'); + return spawnSync( + process.execPath, + [resolve('scripts/audit-actions-workflow-registry.mjs'), '--input', inputPath], + { + cwd: resolve('.'), + encoding: 'utf8', + timeout: 10_000, + env: process.env, + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function baseFixture() { + return { + defaultBranchSha: DEFAULT_BRANCH_SHA, + observedAt: '2026-08-14T18:00:00.000Z', + presentWorkflowPaths: ['.github/workflows/ci.yml'], + pages: [ + { + page: 1, + perPage: 100, + totalCount: 1, + items: [{ id: 501, path: REPAIR_PATH, state: 'active' }], + }, + ], + }; +} + +describe('Actions workflow registry repair ownership evidence', () => { + it('rejects path-only repair exemptions that are not bound to an exact active PR head', () => { + const result = runAudit({ + ...baseFixture(), + ownedActiveRepairPaths: [REPAIR_PATH], + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('invalid top-level contract'); + }); + + it('retains exact PR and head identity for a validated active repair exemption', () => { + const result = runAudit({ + ...baseFixture(), + ownedActiveRepairs: [ + { + path: REPAIR_PATH, + prNumber: 279, + headSha: REPAIR_HEAD_SHA, + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const evidence = JSON.parse(result.stdout) as { + workflows: Array<{ + classification: string; + repairOwner?: { prNumber: number; headSha: string }; + }>; + }; + expect(evidence.workflows).toEqual([ + expect.objectContaining({ + classification: 'owned_active_repair', + repairOwner: { prNumber: 279, headSha: REPAIR_HEAD_SHA }, + }), + ]); + }); +}); From 81bccd014ef5231219887420d61283d73a597b66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:08:54 +0900 Subject: [PATCH 19/20] test(ci): require exact repair owner evidence --- src/actionsWorkflowRegistryAudit.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/actionsWorkflowRegistryAudit.test.ts b/src/actionsWorkflowRegistryAudit.test.ts index 7319f17e..c08ffb1f 100644 --- a/src/actionsWorkflowRegistryAudit.test.ts +++ b/src/actionsWorkflowRegistryAudit.test.ts @@ -5,12 +5,17 @@ import { spawnSync } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; const temporaryRoots: string[] = []; +const REPAIR_HEAD_SHA = '6b54294c360c77916f9956837a41cea3833adcbe'; interface WorkflowFixture { readonly defaultBranchSha: string; readonly observedAt: string; readonly presentWorkflowPaths: readonly string[]; - readonly ownedActiveRepairPaths?: readonly string[]; + readonly ownedActiveRepairs?: readonly { + readonly path: string; + readonly prNumber: number; + readonly headSha: string; + }[]; readonly pages: readonly { readonly page: number; readonly perPage: number; @@ -49,7 +54,13 @@ function baseFixture(): WorkflowFixture { '.github/workflows/ci.yml', '.github/workflows/release.yml', ], - ownedActiveRepairPaths: ['.github/workflows/current-once.yml'], + ownedActiveRepairs: [ + { + path: '.github/workflows/current-once.yml', + prNumber: 279, + headSha: REPAIR_HEAD_SHA, + }, + ], pages: [ { page: 1, @@ -105,6 +116,7 @@ describe('Actions workflow registry audit', () => { path: string; state: string; classification: string; + repairOwner?: { prNumber: number; headSha: string }; }>; }; expect(evidence.defaultBranchSha).toBe( @@ -139,6 +151,7 @@ describe('Actions workflow registry audit', () => { path: '.github/workflows/current-once.yml', state: 'active', classification: 'owned_active_repair', + repairOwner: { prNumber: 279, headSha: REPAIR_HEAD_SHA }, }, { id: 5, From 955e5228446926f0583ef08f434431a757584f79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:10:03 +0900 Subject: [PATCH 20/20] fix(ci): bind repair workflow exemptions to exact owners --- scripts/audit-actions-workflow-registry.mjs | 86 +++++++++++++++------ 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs index eaf3e0f7..777f1aec 100644 --- a/scripts/audit-actions-workflow-registry.mjs +++ b/scripts/audit-actions-workflow-registry.mjs @@ -138,7 +138,7 @@ function readFixture(inputPath) { !hasExactKeys( value, ['defaultBranchSha', 'observedAt', 'presentWorkflowPaths', 'pages'], - ['defaultBranchShaAfter', 'ownedActiveRepairPaths'], + ['defaultBranchShaAfter', 'ownedActiveRepairs'], ) ) { throw new Error('input has an invalid top-level contract'); @@ -166,6 +166,42 @@ function readPathList(value, label, canonicalOnly) { return Object.freeze(result); } +/** Validate exact owner evidence for active repair workflow exemptions. */ +function readOwnedActiveRepairs(value) { + if (value === undefined) { + return Object.freeze([]); + } + if (!Array.isArray(value) || value.length > MAX_WORKFLOWS) { + throw new Error('owned active repair evidence is invalid'); + } + + const result = []; + const seenPaths = new Set(); + for (const repair of value) { + if ( + !isRecord(repair) || + !hasExactKeys(repair, ['path', 'prNumber', 'headSha']) || + !isCanonicalRepositoryWorkflowPath(repair.path) || + !Number.isSafeInteger(repair.prNumber) || + repair.prNumber <= 0 || + typeof repair.headSha !== 'string' || + !SHA_1.test(repair.headSha) || + seenPaths.has(repair.path) + ) { + throw new Error('owned active repair evidence is invalid'); + } + seenPaths.add(repair.path); + result.push( + Object.freeze({ + path: repair.path, + prNumber: repair.prNumber, + headSha: repair.headSha, + }), + ); + } + return Object.freeze(result); +} + /** Validate one exact GitHub workflow registry item. */ function readWorkflowItem(value) { if ( @@ -267,7 +303,7 @@ function classifyWorkflow( path, state, presentPaths, - ownedRepairPaths, + ownedRepairsByPath, foldedPaths, ambiguousActivePaths, ) { @@ -289,7 +325,7 @@ function classifyWorkflow( if (presentPaths.has(path)) { return 'present'; } - if (ownedRepairPaths.has(path)) { + if (ownedRepairsByPath.has(path)) { return 'owned_active_repair'; } if (foldedPaths.has(path.toLocaleLowerCase('en-US'))) { @@ -331,15 +367,16 @@ function auditFixture(fixture) { 'present workflow paths', true, ); - const ownedRepairList = readPathList( - fixture.ownedActiveRepairPaths ?? [], - 'owned active repair paths', - true, - ); + const ownedRepairs = readOwnedActiveRepairs(fixture.ownedActiveRepairs); const presentPaths = new Set(presentList); - const ownedRepairPaths = new Set(ownedRepairList); + const ownedRepairsByPath = new Map( + ownedRepairs.map((repair) => [ + repair.path, + Object.freeze({ prNumber: repair.prNumber, headSha: repair.headSha }), + ]), + ); const foldedPaths = new Set( - [...presentList, ...ownedRepairList].map((path) => + [...presentList, ...ownedRepairs.map((repair) => repair.path)].map((path) => path.toLocaleLowerCase('en-US'), ), ); @@ -352,19 +389,24 @@ function auditFixture(fixture) { complete: true, paginationReceipts: pages.receipts, workflows: Object.freeze( - pages.items.map((item) => - Object.freeze({ + pages.items.map((item) => { + const classification = classifyWorkflow( + item.path, + item.state, + presentPaths, + ownedRepairsByPath, + foldedPaths, + ambiguousActivePaths, + ); + const repairOwner = ownedRepairsByPath.get(item.path); + return Object.freeze({ ...item, - classification: classifyWorkflow( - item.path, - item.state, - presentPaths, - ownedRepairPaths, - foldedPaths, - ambiguousActivePaths, - ), - }), - ), + classification, + ...(classification === 'owned_active_repair' && repairOwner !== undefined + ? { repairOwner } + : {}), + }); + }), ), }); }