|
| 1 | +import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | +import { createHash } from 'node:crypto'; |
| 3 | +import { spawnSync } from 'node:child_process'; |
| 4 | +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; |
| 5 | +import { dirname, join, resolve } from 'node:path'; |
| 6 | +import { |
| 7 | + CONTRACT_DESTINATION, |
| 8 | + OWNERSHIP_DESTINATION, |
| 9 | + PACKAGE_NAME, |
| 10 | +} from '../workflows/shared/squad-install-verifier.mjs'; |
| 11 | +import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js'; |
| 12 | + |
| 13 | +const revision = 'a'.repeat(40); |
| 14 | +const verifier = resolve('workflows/shared/squad-install-verifier.mjs'); |
| 15 | +const workspaceRoot = resolve('.test-workspaces'); |
| 16 | +const roots: string[] = []; |
| 17 | +const workflowMutations: ReadonlyArray< |
| 18 | + readonly [string, (text: string, source: string) => string] |
| 19 | +> = [ |
| 20 | + ['owner', (text, source) => |
| 21 | + text.replace(source, source.replace('bradygaster/', 'attacker/'))], |
| 22 | + ['repo', (text, source) => |
| 23 | + text.replace(source, source.replace('/squad/', '/other/'))], |
| 24 | + ['workflow', (text, source) => |
| 25 | + text.replace(source, source.replace('squad-review.md', 'squad.md'))], |
| 26 | + ['revision', (text, source) => |
| 27 | + text.replace(source, source.replace(revision, 'b'.repeat(40)))], |
| 28 | + ['suffix', (text, source) => text.replace(source, `${source}/extra`)], |
| 29 | + ['missing', (text, source) => text.replace(`${source}\n`, '')], |
| 30 | + ['duplicate', (text, source) => text.replace(source, `${source}\n${source}`)], |
| 31 | + ['misplaced', (text, source) => |
| 32 | + text.replace(`${source}\n---\n`, `---\n${source}\n`)], |
| 33 | + ['extra final newline', text => `${text}\n`], |
| 34 | + ['content', text => text.replace('Squad Review', 'Tampered Squad Review')], |
| 35 | +]; |
| 36 | + |
| 37 | +function write(root: string, path: string, content: string | Buffer): void { |
| 38 | + const destination = join(root, path); |
| 39 | + mkdirSync(dirname(destination), { recursive: true }); |
| 40 | + writeFileSync(destination, content); |
| 41 | +} |
| 42 | + |
| 43 | +function makeConsumer(sourceBinding: 'workflow' | 'package' = 'workflow'): string { |
| 44 | + mkdirSync(workspaceRoot, { recursive: true }); |
| 45 | + const root = mkdtempSync(join(workspaceRoot, 'gh-aw-verify-resource-')); |
| 46 | + roots.push(root); |
| 47 | + const fixture = createFirstInstallFixture(revision, sourceBinding); |
| 48 | + for (const [path, content] of fixture.consumerFiles) write(root, path, content); |
| 49 | + write(root, OWNERSHIP_DESTINATION, `${JSON.stringify(fixture.provenance, null, 2)}\n`); |
| 50 | + return root; |
| 51 | +} |
| 52 | + |
| 53 | +function runVerify(root: string, destination: string) { |
| 54 | + return spawnSync(process.execPath, [ |
| 55 | + verifier, |
| 56 | + '--root', root, |
| 57 | + '--verify-resource', destination, |
| 58 | + ], { encoding: 'utf8' }); |
| 59 | +} |
| 60 | + |
| 61 | +function updateOwnedDigest(root: string, destination: string): void { |
| 62 | + const ownershipPath = join(root, OWNERSHIP_DESTINATION); |
| 63 | + const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8')); |
| 64 | + const entry = ownership.files.find( |
| 65 | + (candidate: { destination: string }) => candidate.destination === destination, |
| 66 | + ); |
| 67 | + if (!entry) throw new Error(`Missing ownership entry for ${destination}`); |
| 68 | + entry.sha256 = createHash('sha256').update(readFileSync(join(root, destination))).digest('hex'); |
| 69 | + writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`); |
| 70 | +} |
| 71 | + |
| 72 | +afterEach(() => { |
| 73 | + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); |
| 74 | +}); |
| 75 | + |
| 76 | +describe('gh-aw: consumer resource verification', () => { |
| 77 | + it.each(['workflow', 'package'] as const)( |
| 78 | + 'exercises the shipped --verify-resource CLI against a clean %s-bound install', |
| 79 | + sourceBinding => { |
| 80 | + const root = makeConsumer(sourceBinding); |
| 81 | + const destination = '.github/workflows/squad-review.md'; |
| 82 | + const result = runVerify(root, destination); |
| 83 | + expect(result.status, result.stderr).toBe(0); |
| 84 | + expect(result.stdout.trim()).toMatch(/^[0-9a-f]{64}$/); |
| 85 | + }, |
| 86 | + ); |
| 87 | + |
| 88 | + it('accepts only the documented final-newline variation', () => { |
| 89 | + const root = makeConsumer(); |
| 90 | + const destination = '.github/workflows/squad-review.md'; |
| 91 | + const original = readFileSync(join(root, destination), 'utf8'); |
| 92 | + expect(original.endsWith('\n')).toBe(true); |
| 93 | + writeFileSync(join(root, destination), original.slice(0, -1)); |
| 94 | + updateOwnedDigest(root, destination); |
| 95 | + const result = runVerify(root, destination); |
| 96 | + expect(result.status, result.stderr).toBe(0); |
| 97 | + }); |
| 98 | + |
| 99 | + it.each(workflowMutations)('rejects %s workflow resource mutation through the CLI', (label, mutate) => { |
| 100 | + const root = makeConsumer(); |
| 101 | + const destination = '.github/workflows/squad-review.md'; |
| 102 | + const source = `source: ${PACKAGE_NAME}/package/squad-review.md@${revision}`; |
| 103 | + const original = readFileSync(join(root, destination), 'utf8'); |
| 104 | + const modified = mutate(original, source); |
| 105 | + expect(modified, label).not.toBe(original); |
| 106 | + writeFileSync(join(root, destination), modified); |
| 107 | + updateOwnedDigest(root, destination); |
| 108 | + const result = runVerify(root, destination); |
| 109 | + expect(result.status, `${label}: ${result.stdout}${result.stderr}`).toBe(1); |
| 110 | + expect(result.stderr, label).toMatch(/source binding|digest mismatch/); |
| 111 | + }); |
| 112 | + |
| 113 | + it.each([ |
| 114 | + ['shared runtime', '.github/workflows/shared/squad-review-guard.mjs'], |
| 115 | + ['skill', '.github/skills/gh-aw-enlistment/SKILL.md'], |
| 116 | + ] as const)('verifies %s by raw exact digest without ownership metadata', (_label, destination) => { |
| 117 | + const root = makeConsumer(); |
| 118 | + rmSync(join(root, OWNERSHIP_DESTINATION)); |
| 119 | + expect(runVerify(root, destination).status).toBe(0); |
| 120 | + writeFileSync(join(root, destination), Buffer.concat([ |
| 121 | + readFileSync(join(root, destination)), |
| 122 | + Buffer.from('\n'), |
| 123 | + ])); |
| 124 | + const result = runVerify(root, destination); |
| 125 | + expect(result.status).toBe(1); |
| 126 | + expect(result.stderr).toContain(`Squad resource digest mismatch for ${destination}.`); |
| 127 | + }); |
| 128 | + |
| 129 | + it('fails closed when installed provenance does not bind the installed revision', () => { |
| 130 | + const root = makeConsumer(); |
| 131 | + const ownershipPath = join(root, OWNERSHIP_DESTINATION); |
| 132 | + const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8')); |
| 133 | + ownership.source = `${PACKAGE_NAME}@${'b'.repeat(40)}`; |
| 134 | + writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`); |
| 135 | + const result = runVerify(root, '.github/workflows/squad-review.md'); |
| 136 | + expect(result.status).toBe(1); |
| 137 | + expect(result.stderr).toContain(`Package ownership source must be ${PACKAGE_NAME}@${revision}.`); |
| 138 | + }); |
| 139 | + |
| 140 | + it('fails closed when the installed contract is absent', () => { |
| 141 | + const root = makeConsumer(); |
| 142 | + rmSync(join(root, CONTRACT_DESTINATION)); |
| 143 | + const result = runVerify(root, '.github/workflows/squad-review.md'); |
| 144 | + expect(result.status).toBe(1); |
| 145 | + expect(result.stderr).toContain(`Required file is missing: ${CONTRACT_DESTINATION}`); |
| 146 | + }); |
| 147 | +}); |
0 commit comments