diff --git a/scripts/audit-actions-workflow-registry.mjs b/scripts/audit-actions-workflow-registry.mjs new file mode 100644 index 00000000..777f1aec --- /dev/null +++ b/scripts/audit-actions-workflow-registry.mjs @@ -0,0 +1,420 @@ +#!/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 { isUtf8 } from 'node:buffer'; +import { closeSync, fstatSync, openSync, readSync } 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/'; +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) { + 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) { + 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. */ +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 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 = readBoundedInput(inputPath); + if (!isUtf8(bytes)) { + throw new Error('input is not valid UTF-8'); + } + 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'], + ['defaultBranchShaAfter', 'ownedActiveRepairs'], + ) + ) { + 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 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 ( + !isRecord(value) || + !hasExactKeys(value, ['id', 'path', 'state']) || + !Number.isSafeInteger(value.id) || + value.id <= 0 || + !isBoundedPath(value.path) || + typeof value.state !== 'string' || + !WORKFLOW_STATES.has(value.state) + ) { + 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'); + } + 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); + 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), + }); +} + +/** 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, + ownedRepairsByPath, + foldedPaths, + ambiguousActivePaths, +) { + if (state !== 'active') { + return 'disabled'; + } + if (path.startsWith(GITHUB_DYNAMIC_PREFIX)) { + return 'github_dynamic'; + } + if ( + path.startsWith(REPOSITORY_WORKFLOW_PREFIX) && + !isCanonicalRepositoryWorkflowPath(path) + ) { + return 'unresolved_path'; + } + if (ambiguousActivePaths.has(path)) { + return 'unresolved_identity'; + } + if (presentPaths.has(path)) { + return 'present'; + } + if (ownedRepairsByPath.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 ( + 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)) || + new Date(fixture.observedAt).toISOString() !== fixture.observedAt + ) { + throw new Error('observation time is invalid'); + } + + const presentList = readPathList( + fixture.presentWorkflowPaths, + 'present workflow paths', + true, + ); + const ownedRepairs = readOwnedActiveRepairs(fixture.ownedActiveRepairs); + const presentPaths = new Set(presentList); + const ownedRepairsByPath = new Map( + ownedRepairs.map((repair) => [ + repair.path, + Object.freeze({ prNumber: repair.prNumber, headSha: repair.headSha }), + ]), + ); + const foldedPaths = new Set( + [...presentList, ...ownedRepairs.map((repair) => repair.path)].map((path) => + path.toLocaleLowerCase('en-US'), + ), + ); + const pages = readCompletePages(fixture.pages); + const ambiguousActivePaths = findAmbiguousActiveRepositoryPaths(pages.items); + + return Object.freeze({ + defaultBranchSha: fixture.defaultBranchSha, + observedAt: fixture.observedAt, + complete: true, + paginationReceipts: pages.receipts, + workflows: 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, + ...(classification === 'owned_active_repair' && repairOwner !== undefined + ? { repairOwner } + : {}), + }); + }), + ), + }); +} + +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'); +} diff --git a/src/actionsWorkflowRegistryAudit.test.ts b/src/actionsWorkflowRegistryAudit.test.ts new file mode 100644 index 00000000..c08ffb1f --- /dev/null +++ b/src/actionsWorkflowRegistryAudit.test.ts @@ -0,0 +1,277 @@ +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[] = []; +const REPAIR_HEAD_SHA = '6b54294c360c77916f9956837a41cea3833adcbe'; + +interface WorkflowFixture { + readonly defaultBranchSha: string; + readonly observedAt: string; + readonly presentWorkflowPaths: readonly string[]; + readonly ownedActiveRepairs?: readonly { + readonly path: string; + readonly prNumber: number; + readonly headSha: 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', + ], + ownedActiveRepairs: [ + { + path: '.github/workflows/current-once.yml', + prNumber: 279, + headSha: REPAIR_HEAD_SHA, + }, + ], + 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; + repairOwner?: { prNumber: number; headSha: 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', + repairOwner: { prNumber: 279, headSha: REPAIR_HEAD_SHA }, + }, + { + 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('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({ + ...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; + 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', + }, + ]); + }); +}); 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 }); + } + }); +}); 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 }); + } + }); +}); 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 }); + } + }); +}); 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 }); + } + }); +}); 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 }, + }), + ]); + }); +}); 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'); + }); +}); 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 }); + } + }); +});