/** * pr-policy.test.mjs * * Extracts the exact `script: |` block from callable-pr-policy.yaml and * exercises the pure validation functions via the PR_POLICY_TEST=1 escape. * No npm dependencies — uses only Node.js built-ins. * * Run: node --test test/pr-policy.test.mjs */ import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); // ── Script extraction ──────────────────────────────────────────────────────── // Reads the YAML file and extracts the literal block scalar under `script: |`. // The strip amount is determined from the indentation of the first non-empty // line after the `script: |` marker. function extractScriptBlock(yamlText) { const lines = yamlText.split('\n'); let state = 'looking'; let strip = 0; const scriptLines = []; for (let i = 0; i < lines.length; i++) { if (state === 'looking') { if (/^\s+script:\s*\|/.test(lines[i])) { state = 'content'; } } else { const line = lines[i]; if (line.trim() === '') { scriptLines.push(''); continue; } const indent = (line.match(/^(\s*)/) || ['', ''])[1].length; if (strip === 0) { strip = indent; } if (indent >= strip) { scriptLines.push(line.slice(strip)); } else { break; } } } while (scriptLines.length && !scriptLines[scriptLines.length - 1].trim()) { scriptLines.pop(); } return scriptLines.join('\n'); } // ── Pure-function loader ───────────────────────────────────────────────────── // Runs the extracted script with PR_POLICY_TEST=1, which causes the script to // return the pure validator functions before making any API calls. let v; // shared validator object async function loadValidators() { const yamlPath = join(__dirname, '../.github/workflows/callable-pr-policy.yaml'); const yamlText = readFileSync(yamlPath, 'utf8'); const scriptCode = extractScriptBlock(yamlText); assert.ok(scriptCode.length > 100, 'script block must be non-trivially long'); assert.ok(scriptCode.includes('PR_POLICY_TEST'), 'script block must contain PR_POLICY_TEST escape'); process.env.PR_POLICY_TEST = '1'; try { // Wrap in an async IIFE matching how actions/github-script executes it. // Pure functions do not call github/context/core, so empty mocks suffice. const asyncFn = new Function( 'github', 'context', 'core', 'process', 'return (async function() {\n' + scriptCode + '\n})()' ); const mockCore = { error: () => {}, setFailed: () => {}, warning: () => {}, summary: { addRaw: () => ({ write: async () => {} }) }, }; v = await asyncFn({}, {}, mockCore, process); } finally { delete process.env.PR_POLICY_TEST; } assert.ok(v && typeof v === 'object', 'PR_POLICY_TEST escape must return an object'); for (const fn of [ 'validateTitle', 'getJiraKey', 'validateBranch', 'validateBody', 'validateCommitSubject', 'detectAiFooter', 'isWorkflowFilename', 'validateWorkflowContent', 'parseRetryAfterMs', 'checkCommitLimit', 'checkFilesLimit', 'normalizeViolationFingerprint', 'filterNewViolations', 'fnv1a32', 'stripHash', 'classifyFileStatus', ]) { assert.equal(typeof v[fn], 'function', fn + ' must be exported'); } } // ── Test suite ─────────────────────────────────────────────────────────────── before(async () => { await loadValidators(); }); // ── validateTitle ──────────────────────────────────────────────────────────── describe('validateTitle', () => { it('accepts a valid non-Dependabot title', () => { assert.deepEqual(v.validateTitle('feat(auth): add login endpoint (DEV-123)', false), []); }); it('accepts a valid title without scope', () => { assert.deepEqual(v.validateTitle('fix: resolve null pointer (PLAT-42)', false), []); }); it('accepts a valid Dependabot title without Jira key', () => { assert.deepEqual(v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21', true), []); }); it('rejects missing Jira key for non-Dependabot', () => { const errs = v.validateTitle('fix: resolve null pointer', false); assert.ok(errs.length > 0, 'should have errors'); assert.ok(errs.some(e => e.includes('Jira')), 'should mention Jira: ' + errs.join('; ')); }); it('rejects unknown type', () => { const errs = v.validateTitle('update: something (DEV-1)', false); assert.ok(errs.length > 0, 'should have errors for unknown type'); assert.ok(errs.some(e => e.includes('type') || e.includes('match')), 'should mention type'); }); it('rejects title over 72 chars', () => { const long = 'feat: ' + 'a'.repeat(60) + ' (DEV-1)'; const errs = v.validateTitle(long, false); assert.ok(errs.some(e => e.includes('72')), 'should mention 72 char limit'); }); it('rejects title ending with a period (Dependabot, no Jira required)', () => { // Use a Dependabot title so only the period error fires. const errs = v.validateTitle('chore(deps): bump lodash from 4.17.20 to 4.17.21.', true); assert.ok(errs.some(e => e.includes('period')), 'should mention period: ' + errs.join('; ')); }); it('rejects description ending with period before Jira suffix', () => { const errs = v.validateTitle('fix: resolve issue. (DEV-1)', false); assert.ok(errs.some(e => e.includes('period')), 'desc period before Jira: ' + errs.join('; ')); }); it('rejects description starting with uppercase', () => { const errs = v.validateTitle('fix: Resolve issue (DEV-1)', false); assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); }); it('accepts PLAT and SEC Jira keys', () => { assert.deepEqual(v.validateTitle('chore: update deps (PLAT-99)', false), []); assert.deepEqual(v.validateTitle('docs: add runbook (SEC-7)', false), []); }); it('rejects inactive Jira projects (INFRA)', () => { const errs = v.validateTitle('fix: patch (INFRA-1)', false); assert.ok(errs.length > 0, 'INFRA is inactive and should fail'); }); it('accepts all valid types', () => { const types = ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert','release']; for (const t of types) { const errs = v.validateTitle(t + ': do something (DEV-1)', false); assert.deepEqual(errs, [], t + ' should be a valid type'); } }); // Emergency-revert candidate: jiraMaybeExempt skips the Jira key requirement. it('accepts revert title without Jira when jiraMaybeExempt is true', () => { assert.deepEqual(v.validateTitle('revert: emergency rollback of payment service', false, true), []); }); it('rejects revert title without Jira when jiraMaybeExempt is false', () => { const errs = v.validateTitle('revert: emergency rollback of payment service', false, false); assert.ok(errs.some(e => e.includes('Jira')), 'missing Jira should fail without exemption: ' + errs.join('; ')); }); it('rejects revert title without Jira when jiraMaybeExempt is omitted (default false)', () => { const errs = v.validateTitle('revert: emergency rollback of payment service', false); assert.ok(errs.some(e => e.includes('Jira')), 'default should behave like false: ' + errs.join('; ')); }); }); // ── getJiraKey ─────────────────────────────────────────────────────────────── describe('getJiraKey', () => { it('extracts DEV key', () => assert.equal(v.getJiraKey('fix: thing (DEV-42)'), 'DEV-42')); it('extracts PLAT key', () => assert.equal(v.getJiraKey('chore: thing (PLAT-1)'), 'PLAT-1')); it('extracts SEC key', () => assert.equal(v.getJiraKey('docs: thing (SEC-999)'), 'SEC-999')); it('returns null when no key', () => assert.equal(v.getJiraKey('chore: thing'), null)); it('returns null for mid-title key', () => assert.equal(v.getJiraKey('fix (DEV-1): something'), null)); }); // ── validateBranch ─────────────────────────────────────────────────────────── describe('validateBranch', () => { it('accepts valid feature branch', () => { assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); }); it('accepts all valid prefixes', () => { const prefixes = ['feature','fix','hotfix','chore','docs','refactor','release']; for (const p of prefixes) { assert.deepEqual(v.validateBranch(p + '/my-thing', false), [], p + '/ should be valid'); } }); it('skips validation for Dependabot', () => { assert.deepEqual(v.validateBranch('dependabot/npm_and_yarn/lodash-4.17.21', true), []); }); it('rejects unknown prefix', () => { const errs = v.validateBranch('bugfix/my-thing', false); assert.ok(errs.length > 0, 'bugfix/ is not a valid prefix'); }); it('rejects nested path (two slashes)', () => { const errs = v.validateBranch('feature/team/my-thing', false); assert.ok(errs.length > 0, 'nested paths should be rejected'); }); it('rejects uppercase in segment', () => { const errs = v.validateBranch('feature/myThing', false); assert.ok(errs.length > 0, 'uppercase in segment should be rejected'); }); it('rejects Jira key in segment (case-insensitive)', () => { const errs = v.validateBranch('fix/dev-123', false); assert.ok(errs.length > 0, 'Jira-key pattern in segment should be rejected'); }); it('rejects uppercase Jira key in segment', () => { const errs = v.validateBranch('fix/DEV-123', false); assert.ok(errs.length > 0, 'uppercase Jira key should be rejected'); }); it('rejects branch with no segment after prefix', () => { const errs = v.validateBranch('feature/', false); assert.ok(errs.length > 0, 'empty segment should be rejected'); }); }); // ── validateBody ───────────────────────────────────────────────────────────── describe('validateBody', () => { const goodBody = '## Summary\nSomething changed.\n\n## Validation\nRan tests.\n\n## Tests\nUnit tests pass.\n\n## Notes\nNone.'; it('accepts a valid body', () => { assert.deepEqual(v.validateBody(goodBody, false), []); }); it('accepts None. under Notes', () => { assert.deepEqual(v.validateBody(goodBody, false), []); }); it('skips validation for Dependabot', () => { assert.deepEqual(v.validateBody('', true), []); }); it('rejects empty body', () => { const errs = v.validateBody('', false); assert.ok(errs.length > 0, 'empty body should fail'); }); it('rejects wrong heading order', () => { const body = '## Validation\nOK\n\n## Summary\nOK\n\n## Tests\nOK\n\n## Notes\nNone.'; const errs = v.validateBody(body, false); assert.ok(errs.some(e => e.includes('Validation') || e.includes('Summary')), 'wrong order: ' + errs.join('; ')); }); it('rejects fifth H2 heading', () => { const body = goodBody + '\n\n## Extra\nwhoops'; const errs = v.validateBody(body, false); assert.ok(errs.some(e => e.includes('5') || e.includes('4')), 'fifth heading: ' + errs.join('; ')); }); it('rejects empty section', () => { const body = '## Summary\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; const errs = v.validateBody(body, false); assert.ok(errs.some(e => e.includes('Summary') && e.includes('empty')), 'empty summary: ' + errs.join('; ')); }); it('strips HTML comments before heading scan', () => { const body = '\n## Summary\nreal.\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; assert.deepEqual(v.validateBody(body, false), [], 'HTML comment heading should not count'); }); it('strips fenced code blocks before heading scan', () => { const TICK = String.fromCharCode(0x60); const body = `## Summary\nSee below.\n\n${TICK.repeat(3)}\n## Fake heading inside fence\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; assert.deepEqual(v.validateBody(body, false), [], 'fenced heading should not count'); }); it('handles tilde fences', () => { const body = '## Summary\nSee below.\n\n~~~\n## Fake inside tilde fence\n~~~\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.'; assert.deepEqual(v.validateBody(body, false), [], 'tilde-fenced heading should not count'); }); it('handles fences with 1 leading space', () => { const TICK = String.fromCharCode(0x60); const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; assert.deepEqual(v.validateBody(body, false), [], '1-space indented fence should strip fake heading'); }); it('handles fences with 3 leading spaces', () => { const TICK = String.fromCharCode(0x60); const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Fake\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; assert.deepEqual(v.validateBody(body, false), [], '3-space indented fence should strip fake heading'); }); it('does not treat 4-space-indented fence as a code fence', () => { const TICK = String.fromCharCode(0x60); const body = `## Summary\nSee below.\n\n ${TICK.repeat(3)}\n## Extra Heading\n${TICK.repeat(3)}\n\n## Validation\nOK.\n\n## Tests\nOK.\n\n## Notes\nNone.`; // 4-space indent exceeds the 0-3 space fence rule; heading is not stripped → error. const errs = v.validateBody(body, false); assert.ok(errs.length > 0, '4-space fence should not strip heading (counted as extra heading)'); }); it('rejects missing Notes heading', () => { const body = '## Summary\nOK.\n\n## Validation\nOK.\n\n## Tests\nOK.'; const errs = v.validateBody(body, false); assert.ok(errs.length > 0, 'missing Notes should fail'); }); }); // ── validateCommitSubject ───────────────────────────────────────────────────── describe('validateCommitSubject', () => { it('accepts a valid commit subject', () => { assert.deepEqual(v.validateCommitSubject('feat(auth): add login endpoint'), []); }); it('accepts subject without scope', () => { assert.deepEqual(v.validateCommitSubject('fix: resolve null pointer'), []); }); it('accepts breaking change marker', () => { assert.deepEqual(v.validateCommitSubject('feat!: remove deprecated api'), []); }); it('rejects subject with Jira key suffix', () => { const errs = v.validateCommitSubject('fix: patch (DEV-123)'); assert.ok(errs.some(e => e.includes('Jira')), 'should reject Jira suffix: ' + errs.join('; ')); }); it('rejects subject with lowercase Jira key suffix (case-insensitive)', () => { const errs = v.validateCommitSubject('fix: patch (dev-123)'); assert.ok(errs.some(e => e.includes('Jira')), 'lowercase Jira suffix should also be rejected: ' + errs.join('; ')); }); it('rejects subject with plat Jira key suffix', () => { const errs = v.validateCommitSubject('chore: update config (plat-5)'); assert.ok(errs.some(e => e.includes('Jira')), 'plat Jira suffix should be rejected: ' + errs.join('; ')); }); it('rejects non-conventional subject', () => { const errs = v.validateCommitSubject('Update readme'); assert.ok(errs.length > 0, 'should reject non-conventional subject'); }); it('rejects uppercase description start', () => { const errs = v.validateCommitSubject('fix: Resolve issue'); assert.ok(errs.some(e => e.includes('lowercase')), 'should mention lowercase: ' + errs.join('; ')); }); it('rejects subject over 72 chars', () => { const long = 'feat: ' + 'a'.repeat(70); const errs = v.validateCommitSubject(long); assert.ok(errs.some(e => e.includes('72')), 'should mention 72: ' + errs.join('; ')); }); it('rejects description ending with a period', () => { const errs = v.validateCommitSubject('fix: resolve issue.'); assert.ok(errs.some(e => e.includes('period')), 'trailing period in description: ' + errs.join('; ')); }); }); // ── detectAiFooter ──────────────────────────────────────────────────────────── describe('detectAiFooter', () => { it('detects Claude Co-authored-by', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Claude ')); }); it('detects ChatGPT Co-authored-by', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: ChatGPT ')); }); it('detects "Generated with Claude Code"', () => { assert.ok(v.detectAiFooter('feat: add feature\n\nGenerated with Claude Code')); }); it('detects "Generated by GitHub Copilot"', () => { assert.ok(v.detectAiFooter('feat: add feature\n\nGenerated by GitHub Copilot')); }); it('detects "Generated by Codex"', () => { assert.ok(v.detectAiFooter('feat: add\n\nGenerated by Codex')); }); it('detects emoji robot marker', () => { assert.ok(v.detectAiFooter('fix: thing\n\n🤖 Generated by tool')); }); it('returns false for clean commit', () => { assert.ok(!v.detectAiFooter('fix: resolve null pointer\n\nThis was hand-written.')); }); it('returns false for unrelated Co-authored-by', () => { assert.ok(!v.detectAiFooter('fix: thing\n\nCo-authored-by: Alice ')); }); it('detects AI footer in PR body', () => { assert.ok(v.detectAiFooter('## Summary\nDone.\n\nCo-authored-by: Gemini ')); }); it('detects Co-authored-by case-insensitively (all-caps header)', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCO-AUTHORED-BY: Claude '), 'all-caps header should match'); }); it('detects Co-authored-by case-insensitively (all-caps identity)', () => { assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: CLAUDE '), 'all-caps identity should match'); }); it('detects Cursor identity', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Cursor '), 'Cursor co-authored-by'); assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Cursor'), 'Generated by Cursor'); }); it('detects Anthropic identity', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Anthropic '), 'Anthropic co-authored-by'); }); it('detects Codeium identity', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codeium '), 'Codeium co-authored-by'); assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Codeium'), 'Generated by Codeium'); }); it('detects OpenAI identity', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: OpenAI '), 'OpenAI co-authored-by'); }); it('does not flag generic AI discussion in prose', () => { assert.ok(!v.detectAiFooter('fix: thing\n\nThis uses AI to improve performance.'), 'generic AI mention'); assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated AI model configuration.'), 'AI model config mention'); assert.ok(!v.detectAiFooter('feat: add AI-powered search (DEV-1)\n\n## Summary\nAI search.'), 'AI in title/body prose'); }); }); // ── isWorkflowFilename ──────────────────────────────────────────────────────── describe('isWorkflowFilename', () => { it('matches .github/workflows/*.yaml', () => { assert.ok(v.isWorkflowFilename('.github/workflows/ci.yaml')); }); it('matches .github/workflows/*.yml', () => { assert.ok(v.isWorkflowFilename('.github/workflows/deploy.yml')); }); it('matches workflow-templates/*.yml', () => { assert.ok(v.isWorkflowFilename('workflow-templates/ci-node.yml')); }); it('rejects nested path in workflows', () => { assert.ok(!v.isWorkflowFilename('.github/workflows/subdir/ci.yaml')); }); it('rejects non-YAML files', () => { assert.ok(!v.isWorkflowFilename('.github/workflows/ci.json')); }); it('rejects unrelated files', () => { assert.ok(!v.isWorkflowFilename('src/foo.yaml')); }); }); // ── validateWorkflowContent ─────────────────────────────────────────────────── describe('validateWorkflowContent', () => { const BASE = '.github/workflows/test.yaml'; const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; const ZERO_SHA = '0'.repeat(40); function wf(uses, extra = '') { return [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - ' + uses, extra, ].join('\n'); } it('accepts a fully pinned remote action', () => { const content = wf(`uses: actions/checkout@${VALID_SHA} # v9.0.0`); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('rejects local ./ ref (unsupported until recursive action-manifest validation)', () => { const content = wf('uses: ./.github/workflows/sub.yaml'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('local action') || e.includes('not supported')), 'local ref must fail: ' + errs.join('; ')); }); it('accepts docker:// ref with valid sha256 digest', () => { const digest = 'a' .repeat(64); const content = wf('uses: docker://alpine@sha256:' + digest); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('rejects floating tag ref (v1, v2)', () => { const content = wf('uses: actions/checkout@v4'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('40-char SHA')), 'floating tag should fail: ' + errs.join('; ')); }); it('rejects SHA without version comment', () => { const content = wf(`uses: actions/checkout@${VALID_SHA}`); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'no comment should fail: ' + errs.join('; ')); }); it('rejects SHA with wrong comment format', () => { const content = wf(`uses: actions/checkout@${VALID_SHA} # latest`); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'wrong comment should fail'); }); it('rejects all-zero placeholder SHA', () => { const content = wf(`uses: actions/checkout@${ZERO_SHA} # v1.0.0`); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('placeholder') || e.includes('zero') || e.includes('all-zero')), 'all-zero SHA should fail: ' + errs.join('; ')); }); it('rejects v0.0.0 placeholder version', () => { const content = wf(`uses: actions/checkout@${VALID_SHA} # v0.0.0`); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('v0.0.0') || e.includes('placeholder')), 'v0.0.0 should fail: ' + errs.join('; ')); }); it('rejects both all-zero SHA and v0.0.0', () => { const content = wf(`uses: actions/checkout@${ZERO_SHA} # v0.0.0`); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.length >= 2, 'should report two errors (zero SHA + v0.0.0): ' + errs.join('; ')); }); it('rejects missing top-level permissions', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ./.github/workflows/sub.yaml', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions should fail: ' + errs.join('; ')); }); it('accepts top-level permissions: {} (explicit empty)', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions: {}', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('accepts quoted uses: value with valid SHA', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ` - uses: "${VALID_SHA} # v9.0.0"`, ].join('\n'); // The quoted value doesn't have an owner/repo@ prefix — just validate that // the quote-stripping doesn't break the parser. A quoted SHA without an owner // won't parse as a remote action at all (no @ before sha). Confirm no crash. // Use a proper quoted action ref: const content2 = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ` - uses: "actions/checkout@${VALID_SHA} # v9.0.0"`, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content2, BASE), [], 'double-quoted valid ref should pass'); }); it('accepts single-quoted uses: value with valid SHA', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ` - uses: 'actions/checkout@${VALID_SHA} # v9.0.0'`, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted valid ref should pass'); }); it('does not treat uses: inside a run: block as an action reference', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - name: shell-step', ' run: |', ' echo "uses: actions/checkout@v4"', ' uses: some-other@v1', ' echo done', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block must not be flagged'); }); it('rejects ${{ }} in run: inline value', () => { const EXPR = '$' + '{{ github.token }}'; const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - name: bad', ' run: echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'inline ${{ }} should fail: ' + errs.join('; ')); }); it('rejects ${{ }} in run: block scalar', () => { const EXPR = '$' + '{{ secrets.OTHER }}'; const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - name: bad', ' env:', ' TOKEN: $' + '{{ secrets.TOKEN }}', ' run: |', ' echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'block ${{ }} should fail: ' + errs.join('; ')); }); it('does not flag ${{ }} in env: above run:', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - name: ok', ' env:', ' MY_VAR: $' + '{{ secrets.TOKEN }}', ' run: |', ' echo "$MY_VAR"', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('accumulates multiple violations', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4', ' - uses: actions/setup-node@v4', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.length >= 3, 'should have at least 3 errors (no perms + 2 bad pins): ' + errs.join('; ')); }); }); // ── validateWorkflowContent — docker digest pinning ────────────────────────── describe('validateWorkflowContent — docker digest pinning', () => { const BASE = 'f.yaml'; const GOOD_DIGEST = 'b'.repeat(64); function wf(uses) { return [ 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - ' + uses, ].join('\n'); } it('accepts docker:// ref with valid lowercase 64-hex sha256 digest', () => { assert.deepEqual(v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + GOOD_DIGEST), BASE), []); }); it('accepts docker:// ref for image with tag+digest', () => { assert.deepEqual(v.validateWorkflowContent(wf('uses: docker://ubuntu:22.04@sha256:' + GOOD_DIGEST), BASE), []); }); it('rejects docker:// ref with mutable tag only', () => { const errs = v.validateWorkflowContent(wf('uses: docker://alpine:3.19'), BASE); assert.ok(errs.some(e => e.includes('sha256')), 'mutable tag must fail: ' + errs.join('; ')); }); it('rejects docker:// ref with :latest tag', () => { const errs = v.validateWorkflowContent(wf('uses: docker://ubuntu:latest'), BASE); assert.ok(errs.some(e => e.includes('sha256')), ':latest must fail: ' + errs.join('; ')); }); it('rejects bare docker:// ref with no tag or digest', () => { const errs = v.validateWorkflowContent(wf('uses: docker://ubuntu'), BASE); assert.ok(errs.some(e => e.includes('sha256')), 'bare image must fail: ' + errs.join('; ')); }); it('rejects docker:// ref with uppercase in sha256 digest', () => { const errs = v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + 'A'.repeat(64)), BASE); assert.ok(errs.some(e => e.includes('sha256')), 'uppercase hex must fail: ' + errs.join('; ')); }); it('rejects docker:// ref with short (63-char) sha256 digest', () => { const errs = v.validateWorkflowContent(wf('uses: docker://alpine@sha256:' + 'a'.repeat(63)), BASE); assert.ok(errs.some(e => e.includes('sha256')), 'short digest must fail: ' + errs.join('; ')); }); it('rejects docker:// ref with wrong digest prefix (md5:)', () => { const errs = v.validateWorkflowContent(wf('uses: docker://alpine@md5:' + 'a'.repeat(32)), BASE); assert.ok(errs.some(e => e.includes('sha256')), 'non-sha256 digest must fail: ' + errs.join('; ')); }); }); // ── validateWorkflowContent — anchored flow step ───────────────────────────── describe('validateWorkflowContent — anchored flow step', () => { const BASE = 'f.yaml'; function wf(step) { return 'permissions: {}\n' + step; } it('rejects anchored flow-style step with uses (- &step { uses: ... })', () => { const errs = v.validateWorkflowContent(wf(' - &step { uses: actions/checkout@v4 }'), BASE); assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'anchored flow uses must fail: ' + errs.join('; ')); }); it('rejects anchored flow-style step with run (- &step { run: ... })', () => { const errs = v.validateWorkflowContent(wf(' - &step { run: echo hi }'), BASE); assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'anchored flow run must fail: ' + errs.join('; ')); }); it('rejects anchored flow-style even for non-structural keys', () => { const errs = v.validateWorkflowContent(wf(' - &data { os: ubuntu, node: 24 }'), BASE); assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'any anchored flow step must fail: ' + errs.join('; ')); }); it('still rejects plain flow-style step with structural uses key', () => { const errs = v.validateWorkflowContent(wf(' - { uses: actions/checkout@v4 }'), BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'plain flow uses must still fail: ' + errs.join('; ')); }); it('rejects block-style step with standalone sequence-item anchor (- &ref)', () => { const content = [ 'permissions: {}', 'steps:', ' - &ref', ' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'standalone - &anchor step must fail: ' + errs.join('; ')); }); it('rejects multiline anchored uses: - &step / { uses: ... }', () => { const content = [ 'permissions: {}', 'steps:', ' - &step', ' { uses: actions/checkout@v4 }', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'multiline - &step / { uses } must fail: ' + errs.join('; ')); }); it('rejects multiline anchored run: - &step / { run: ... }', () => { const content = [ 'permissions: {}', 'steps:', ' - &step', ' { run: echo hi }', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('anchor') || e.includes('not supported')), 'multiline - &step / { run } must fail: ' + errs.join('; ')); }); it('no regression: plain block-style step without anchor passes pin checks normally', () => { const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; const content = [ 'permissions: {}', 'steps:', ' - uses: ' + PIN, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('non-sequence mapping-value anchor is not flagged (foo: &anchor value)', () => { const content = [ 'permissions: {}', 'env:', ' TOKEN: &tok my-value', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(!errs.some(e => e.includes('sequence-item anchor')), 'mapping-value anchor must not trigger sequence-item rule: ' + errs.join('; ')); }); }); // ── checkCommitLimit ────────────────────────────────────────────────────────── describe('checkCommitLimit', () => { it('returns null for exactly 250 commits', () => { assert.equal(v.checkCommitLimit(250), null); }); it('returns null for fewer than 250 commits', () => { assert.equal(v.checkCommitLimit(1), null); assert.equal(v.checkCommitLimit(100), null); }); it('returns an infra error string for 251 commits', () => { const result = v.checkCommitLimit(251); assert.ok(result !== null, 'should return error for >250'); assert.ok(result.includes('250'), 'error should mention the limit: ' + result); }); it('returns an infra error string for large commit count', () => { const result = v.checkCommitLimit(1000); assert.ok(result !== null, 'should return error for large count'); assert.ok(result.includes('1000'), 'error should include the actual count: ' + result); }); }); // ── checkFilesLimit ─────────────────────────────────────────────────────────── describe('checkFilesLimit', () => { it('returns null for exactly 3000 files', () => { assert.equal(v.checkFilesLimit(3000), null); }); it('returns null for fewer than 3000 files', () => { assert.equal(v.checkFilesLimit(1), null); assert.equal(v.checkFilesLimit(500), null); }); it('returns an infra error string for 3001 files', () => { const result = v.checkFilesLimit(3001); assert.ok(result !== null, 'should return error for >3000'); assert.ok(result.includes('3000'), 'error should mention the limit: ' + result); }); it('returns an infra error string for large file count', () => { const result = v.checkFilesLimit(5000); assert.ok(result !== null, 'should return error for large count'); assert.ok(result.includes('5000'), 'error should include the actual count: ' + result); }); }); // ── parseRetryAfterMs ───────────────────────────────────────────────────────── describe('parseRetryAfterMs', () => { it('parses integer seconds', () => { assert.equal(v.parseRetryAfterMs('30'), 30000); assert.equal(v.parseRetryAfterMs('1'), 1000); }); it('returns 0 for zero seconds', () => { assert.equal(v.parseRetryAfterMs('0'), 0); }); it('caps at 60000ms for large integer values', () => { assert.equal(v.parseRetryAfterMs('999'), 60000); assert.equal(v.parseRetryAfterMs('61'), 60000); }); it('parses HTTP-date format and returns positive ms', () => { const nowMs = Date.now(); const futureDate = new Date(nowMs + 10000).toUTCString(); const result = v.parseRetryAfterMs(futureDate, nowMs); assert.ok(result > 9000 && result <= 10000, 'HTTP-date ~10s in future should produce ~10000ms, got ' + result); }); it('returns 0 for past HTTP-date', () => { const nowMs = Date.now(); const pastDate = new Date(nowMs - 5000).toUTCString(); assert.equal(v.parseRetryAfterMs(pastDate, nowMs), 0); }); it('returns 0 for invalid header string', () => { assert.equal(v.parseRetryAfterMs('not-a-number'), 0); assert.equal(v.parseRetryAfterMs('banana'), 0); }); it('returns 0 for empty string', () => { assert.equal(v.parseRetryAfterMs(''), 0); }); it('returns 0 for missing header (falsy)', () => { assert.equal(v.parseRetryAfterMs(undefined), 0); assert.equal(v.parseRetryAfterMs(null), 0); }); it('caps HTTP-date result at 60000ms', () => { const nowMs = Date.now(); const farFutureDate = new Date(nowMs + 120000).toUTCString(); assert.equal(v.parseRetryAfterMs(farFutureDate, nowMs), 60000); }); }); // ── Additional branch-segment hygiene tests (fix 7) ────────────────────────── describe('validateBranch — consecutive and trailing hyphens', () => { it('rejects consecutive hyphens in segment', () => { const errs = v.validateBranch('feature/my--feature', false); assert.ok(errs.length > 0, 'consecutive hyphens should be rejected'); assert.ok(errs.some(e => e.includes('feature/my--feature')), 'error should name the bad branch: ' + errs.join('; ')); }); it('rejects trailing hyphen in segment', () => { const errs = v.validateBranch('feature/my-feature-', false); assert.ok(errs.length > 0, 'trailing hyphen should be rejected'); }); it('rejects segment that is just a hyphen', () => { const errs = v.validateBranch('feature/-', false); assert.ok(errs.length > 0, 'bare hyphen segment should be rejected'); }); it('accepts proper kebab-case with multiple words', () => { assert.deepEqual(v.validateBranch('feature/my-new-feature', false), []); assert.deepEqual(v.validateBranch('fix/patch-null-check', false), []); assert.deepEqual(v.validateBranch('chore/update-deps', false), []); }); it('accepts single-word segment', () => { assert.deepEqual(v.validateBranch('feature/auth', false), []); assert.deepEqual(v.validateBranch('fix/login', false), []); }); }); // ── AI-footer extended patterns (fix 6) ────────────────────────────────────── describe('detectAiFooter — extended AI identities', () => { it('detects Codex in Co-authored-by', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: Codex '), 'Codex Co-authored-by'); assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: codex '), 'lowercase codex'); }); it('detects Generated by Codex', () => { assert.ok(v.detectAiFooter('fix: thing\n\nGenerated by Codex'), 'Generated by Codex'); assert.ok(v.detectAiFooter('fix: thing\n\nGenerated with Codex'), 'Generated with Codex'); }); it('detects GPT-5 Co-authored-by', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-5 '), 'GPT-5 Co-authored-by'); assert.ok(v.detectAiFooter('fix: thing\n\nco-authored-by: gpt-5 '), 'lowercase gpt-5'); }); it('detects GPT-4o Co-authored-by', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-4o '), 'GPT-4o Co-authored-by'); }); it('detects Generated by GPT-5', () => { assert.ok(v.detectAiFooter('feat: add\n\nGenerated by GPT-5'), 'Generated by GPT-5'); assert.ok(v.detectAiFooter('feat: add\n\nGenerated by gpt-5'), 'lowercase generated by gpt-5'); }); it('detects Generated by GPT-4o', () => { assert.ok(v.detectAiFooter('feat: add\n\nGenerated by GPT-4o'), 'Generated by GPT-4o'); }); it('detects GPT-3 and GPT-4 (existing coverage preserved)', () => { assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-3 '), 'GPT-3'); assert.ok(v.detectAiFooter('fix: thing\n\nCo-authored-by: GPT-4 '), 'GPT-4'); }); it('does not flag "GPT" without version as generic prose', () => { // "GPT" alone as a word in prose should not be flagged — there must be a dash+version. assert.ok(!v.detectAiFooter('fix: thing\n\nUpdated GPT configuration.'), 'bare GPT in prose is not a trailer'); }); }); // ── validateWorkflowContent — quoted keys, block-scalar improvements (fixes 1-3) ── describe('validateWorkflowContent — quoted keys and block-scalar tracking', () => { const BASE = '.github/workflows/test.yaml'; const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; function wfHeader() { return [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ].join('\n'); } it('recognises double-quoted "uses" key and validates pin', () => { // "uses": floating-tag should still be rejected const content = wfHeader() + '\n - "uses": actions/checkout@v4'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('40-char SHA')), 'quoted "uses" floating tag must fail: ' + errs.join('; ')); }); it('accepts double-quoted "uses" key with valid pinned SHA', () => { const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted "uses" with valid SHA should pass'); }); it('recognises single-quoted uses key and validates pin', () => { const content = wfHeader() + "\n - 'uses': actions/checkout@v4"; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('40-char SHA')), "single-quoted 'uses' floating tag must fail: " + errs.join('; ')); }); it('accepts single-quoted uses key with valid pinned SHA', () => { const content = wfHeader() + `\n - 'uses': actions/checkout@${VALID_SHA} # v9.0.0`; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted 'uses' with valid SHA should pass"); }); it('rejects uses block scalar before opaque block handling can hide it', () => { const content = [ ...wfHeader().split('\n'), ' - uses: >-', ' actions/checkout@v4', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('uses: block scalar')), 'uses: folded block scalar must fail: ' + errs.join('; ')); }); it('rejects quoted uses block scalar before pin validation can be bypassed', () => { const content = [ ...wfHeader().split('\n'), ' - "uses": |-', ' actions/checkout@v4', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('uses: block scalar')), 'quoted uses: literal block scalar must fail: ' + errs.join('; ')); }); it('recognises sequence-form "- run: |" and does not flag uses: inside as action ref', () => { // The sequence item `- run: |` opens a run block scalar. // uses: lines inside must not be treated as action references. const content = [ ...wfHeader().split('\n'), ' - name: build', ' run: |', ' echo "uses: actions/checkout@v4"', ' uses: some/action@v1', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run block (sequence step) must not be flagged'); }); it('recognises sequence-form "- run: echo hi" inline and does not flag uses: on next step', () => { const content = [ ...wfHeader().split('\n'), ` - run: echo hello`, ` - uses: actions/checkout@${VALID_SHA} # v9.0.0`, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'run: inline does not swallow uses: on next step'); }); it('does not flag uses: inside a non-run block scalar (e.g. script: |)', () => { // script: | is a block scalar that is NOT a run block. // uses: lines inside must not be treated as action references. // Expressions inside script: | must not be flagged as run: injection. const EXPR = '$' + '{{ github.token }}'; const content = [ ...wfHeader().split('\n'), ' - name: js-step', ' uses: actions/github-script@' + VALID_SHA + ' # v9.0.0', ' with:', ' script: |', ' // uses: actions/checkout@v4 (this is JS comment, not YAML)', ' uses: some/action@v1', ' const tok = ' + EXPR + ';', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: and expressions inside script: block must not be flagged'); }); it('accepts quoted action ref with version comment OUTSIDE the quotes', () => { // uses: "owner/repo@sha" # vX.Y.Z — comment is a YAML comment, not inside quotes. const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}" # v9.0.0`; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'quoted ref with external comment should pass'); }); it('accepts single-quoted action ref with version comment OUTSIDE the quotes', () => { const content = wfHeader() + `\n - uses: 'actions/checkout@${VALID_SHA}' # v9.0.0`; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'single-quoted ref with external comment should pass'); }); it('rejects quoted action ref with no comment at all', () => { const content = wfHeader() + `\n - uses: "actions/checkout@${VALID_SHA}"`; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('vX.Y.Z') || e.includes('40-char SHA')), 'quoted ref with no comment must fail: ' + errs.join('; ')); }); it('recognises quoted "permissions" key at column 0 for top-level check', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', '"permissions":', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'double-quoted "permissions": at col 0 should count'); }); }); // ── validateTitle — Jira-backed revert is not an emergency candidate (fix 5) ── describe('validateTitle — Jira-backed revert semantics', () => { it('accepts revert title WITH Jira key regardless of jiraMaybeExempt', () => { // A revert that already carries a Jira key needs no emergency exemption. assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, false), []); assert.deepEqual(v.validateTitle('revert: rollback payment service (DEV-42)', false, true), []); }); it('getJiraKey extracts key from Jira-backed revert title', () => { // Confirms that getJiraKey correctly identifies a revert title with Jira key, // which is what the main logic uses to decide isEmergencyCandidate = false. assert.equal(v.getJiraKey('revert: rollback payment service (DEV-42)'), 'DEV-42'); }); it('getJiraKey returns null for revert title without Jira key', () => { // Null result means isEmergencyCandidate COULD be true (if label is also present). assert.equal(v.getJiraKey('revert: emergency rollback of payment service'), null); }); }); // ── Scanner fail-closed cases (fixes: |2, flow, escaped keys, aliases) ─────── describe('validateWorkflowContent — scanner fail-closed cases', () => { const BASE = '.github/workflows/test.yaml'; const VALID_SHA = '3a2844b7e9c422d3c10d287c895573f7108da1b3'; function wfHeader() { return [ 'name: Test', 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ].join('\n'); } // ── Fix 1: explicit block indent/chomp indicators |2, |2-, |-2, >+2 ────── it('enters run block on "run: |2" and detects expression injection inside', () => { const EXPR = '$' + '{{ github.token }}'; const content = [ ...wfHeader().split('\n'), ' - name: x', ' run: |2', ' echo hi', ' echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'run |2 block should flag expression: ' + errs.join('; ')); }); it('enters run block on "run: |2-" and detects expression injection', () => { const EXPR = '$' + '{{ secrets.TOKEN }}'; const content = [ ...wfHeader().split('\n'), ' - name: x', ' run: |2-', ' echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'run |2- block should flag expression: ' + errs.join('; ')); }); it('enters run block on "run: |-2" and detects expression injection', () => { const EXPR = '$' + '{{ github.ref }}'; const content = [ ...wfHeader().split('\n'), ' - name: x', ' run: |-2', ' echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'run |-2 block should flag expression: ' + errs.join('; ')); }); it('enters run block on "run: >+2" and detects expression injection', () => { const EXPR = '$' + '{{ github.actor }}'; const content = [ ...wfHeader().split('\n'), ' - name: x', ' run: >+2', ' echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'run >+2 block should flag expression: ' + errs.join('; ')); }); it('does NOT flag uses: inside run: |2 block as an action reference', () => { const content = [ ...wfHeader().split('\n'), ' - name: x', ' run: |2', ' uses: actions/checkout@v4', ' echo done', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'uses: inside run |2 must not be flagged'); }); it('sequence-form "- run: |2" correctly enters run block', () => { const EXPR = '$' + '{{ github.sha }}'; const content = [ ...wfHeader().split('\n'), ' - run: |2', ' echo ' + EXPR, ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('env:')), 'sequence - run: |2 should detect expression: ' + errs.join('; ')); }); // ── Fix 2: flow-style sequence steps ───────────────────────────────────── it('rejects flow-style step "- { uses: ... }"', () => { const content = wfHeader() + '\n - { uses: actions/checkout@v4, with: { token: x } }'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'flow uses step should fail: ' + errs.join('; ')); }); it('rejects flow-style step "- { run: ... }"', () => { const content = wfHeader() + '\n - { run: echo hello }'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'flow run step should fail: ' + errs.join('; ')); }); it('rejects flow-style step with any nonempty mapping', () => { const content = wfHeader() + '\n - { name: my-step, uses: actions/checkout@v4 }'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'any nonempty flow step should fail: ' + errs.join('; ')); }); it('does NOT reject permissions: {} (mapping value, not sequence item)', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions: {}', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1', ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'permissions: {} must not be rejected'); }); // ── Fix 3: escaped/encoded structural keys ───────────────────────────────── it('rejects double-quoted key with Unicode escape "u\\u0073es" (encodes "uses")', () => { // In the YAML source, the key is literally "u\u0073es": — the scanner sees \u const escapedUses = '"u\\u0073es": actions/checkout@v4'; const content = wfHeader() + '\n - ' + escapedUses; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped uses key must be rejected: ' + errs.join('; ')); }); it('rejects double-quoted key with Unicode escape "r\\u0075n" (encodes "run")', () => { const escapedRun = '"r\\u0075n": echo hello'; const content = wfHeader() + '\n ' + escapedRun; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('escaped key') || e.includes('not supported')), 'escaped run key must be rejected: ' + errs.join('; ')); }); it('does NOT reject literal double-quoted "uses" key (no backslash)', () => { const content = wfHeader() + `\n - "uses": actions/checkout@${VALID_SHA} # v9.0.0`; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "uses" key should pass'); }); it('does NOT reject literal double-quoted "run" key (no backslash)', () => { const content = wfHeader() + '\n "run": echo hello'; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'literal "run" key should pass'); }); // ── Fix 4: YAML aliases/anchors on structural values ────────────────────── it('rejects YAML alias in "run:" value (run: *command)', () => { const content = wfHeader() + '\n run: *deploy_script'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must fail: ' + errs.join('; ')); }); it('rejects YAML alias in "uses:" value (uses: *action_ref)', () => { const content = wfHeader() + '\n - uses: *checkout_action'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: *alias must fail: ' + errs.join('; ')); }); it('rejects YAML anchor definition in "run:" value (run: &anchor |)', () => { // &anchor before the block indicator means the run block has an anchor definition. // The line scanner cannot safely resolve what aliases to *anchor will execute. const content = wfHeader() + '\n run: &deploy_script |'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must fail: ' + errs.join('; ')); }); it('rejects YAML anchor definition in "uses:" value (uses: &anchor ref)', () => { const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must fail: ' + errs.join('; ')); }); it('rejects YAML alias for top-level permissions: value', () => { const content = [ 'name: Test', 'on:', ' workflow_call:', 'permissions: *read_perms', 'jobs:', ' job:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ./.github/workflows/sub.yaml', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor') || e.includes('permissions')), 'permissions: *alias must fail: ' + errs.join('; ')); }); // ── Fix: flow mapping false-positive — non-step data must pass ──────────── it('allows flow-style sequence item without structural uses/run key', () => { const content = wfHeader() + '\n - { os: ubuntu-latest, node: 24 }'; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'matrix data object must not be rejected'); }); it('allows flow-style sequence item with only name key', () => { const content = wfHeader() + '\n - { name: my-step, timeout: 10 }'; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'non-step flow object with name/timeout must pass'); }); it('still rejects flow-style step with uses: key inside', () => { const content = wfHeader() + '\n - { uses: actions/checkout@v4 }'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), '- { uses: ... } must still fail: ' + errs.join('; ')); }); it('still rejects flow-style step with run: key inside', () => { const content = wfHeader() + '\n - { run: echo hi }'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), '- { run: ... } must still fail: ' + errs.join('; ')); }); it('still rejects flow-style step with uses: after a comma', () => { const content = wfHeader() + '\n - { name: checkout, uses: actions/checkout@v4 }'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), '- { name: x, uses: ... } must still fail: ' + errs.join('; ')); }); // ── Fix: anchor/alias false-positive — ordinary shell & must pass ───────── it('allows run: value containing & in a shell command (not a YAML anchor)', () => { const content = wfHeader() + '\n run: echo "R&D build"'; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell & in run value must not be rejected'); }); it('allows run: value with && (shell AND operator)', () => { const content = wfHeader() + '\n run: make build && make test'; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'shell && must not be rejected'); }); it('allows single-quoted run: value with & inside', () => { const content = wfHeader() + "\n run: 'echo R&D'"; assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted run with & must pass"); }); it('still rejects run: *alias (YAML alias token)', () => { const content = wfHeader() + '\n run: *deploy_script'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: *alias must still fail: ' + errs.join('; ')); }); it('still rejects run: &anchor | (YAML anchor before block indicator)', () => { const content = wfHeader() + '\n run: &deploy_script |'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'run: &anchor | must still fail: ' + errs.join('; ')); }); it('still rejects uses: &anchor ref (YAML anchor in action ref)', () => { const content = wfHeader() + `\n - uses: &checkout actions/checkout@${VALID_SHA} # v9.0.0`; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('alias') || e.includes('anchor')), 'uses: &anchor must still fail: ' + errs.join('; ')); }); }); // ── Static assertions on the YAML file ─────────────────────────────────────── describe('static YAML assertions', () => { let yamlText; before(() => { yamlText = readFileSync( join(__dirname, '../.github/workflows/callable-pr-policy.yaml'), 'utf8' ); }); it('does not use pull_request_target as a trigger', () => { // The word may appear in comments, but must never be a YAML on: trigger key. assert.ok( !/^\s*pull_request_target\s*:/m.test(yamlText), 'callable-pr-policy.yaml must not declare pull_request_target as an on: trigger' ); }); it('pins github-script to the exact SHA', () => { assert.ok( yamlText.includes('actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'), 'must pin github-script to 3a2844b7e9c422d3c10d287c895573f7108da1b3' ); }); it('includes the v9.0.0 version comment', () => { assert.ok(yamlText.includes('# v9.0.0'), 'must have # v9.0.0 comment'); }); it('declares job id "pr"', () => { assert.ok(/^ pr:$/m.test(yamlText), 'job id must be "pr"'); }); }); // ── normalizeViolationFingerprint ──────────────────────────────────────────── describe('normalizeViolationFingerprint', () => { it('strips :LINE: from per-line errors', () => { const err = 'f.yaml:42: run: block contains expression — expressions must go through env:'; assert.equal(v.normalizeViolationFingerprint(err), 'f.yaml: run: block contains expression — expressions must go through env:'); }); it('leaves uses: violations unchanged (no line number in error)', () => { const err = 'f.yaml: "uses: actions/checkout@v4" must be pinned to a 40-char SHA with "# vX.Y.Z" comment'; assert.equal(v.normalizeViolationFingerprint(err), err); }); it('leaves missing-permissions unchanged (no line number)', () => { const err = 'f.yaml: missing top-level "permissions:" key'; assert.equal(v.normalizeViolationFingerprint(err), err); }); it('removes only the first :LINE: occurrence', () => { const err = 'f.yaml:10: escaped key: something'; assert.equal(v.normalizeViolationFingerprint(err), 'f.yaml: escaped key: something'); }); it('line 10 and line 200 normalize to the same fingerprint', () => { const a = v.normalizeViolationFingerprint('f.yaml:10: run: block contains expression'); const b = v.normalizeViolationFingerprint('f.yaml:200: run: block contains expression'); assert.equal(a, b); }); }); // ── filterNewViolations ─────────────────────────────────────────────────────── describe('filterNewViolations', () => { const FP_UNPIN = (f, ref) => `${f}: "uses: ${ref}" must be pinned to a 40-char SHA with "# vX.Y.Z" comment`; const FP_PERM = (f) => `${f}: missing top-level "permissions:" key`; const FP_EXPR = (f, ln) => `${f}:${ln}: run: block contains expression — expressions must go through env:`; it('unchanged floating action is ignored', () => { const err = FP_UNPIN('w.yaml', 'actions/checkout@v4'); assert.deepEqual(v.filterNewViolations([err], [err]), []); }); it('changed floating ref is treated as new', () => { const head = FP_UNPIN('w.yaml', 'actions/setup-node@v4'); const base = FP_UNPIN('w.yaml', 'actions/checkout@v4'); assert.deepEqual(v.filterNewViolations([head], [base]), [head]); }); it('new floating action (no base violation) is blocked', () => { const err = FP_UNPIN('w.yaml', 'actions/checkout@v4'); assert.deepEqual(v.filterNewViolations([err], []), [err]); }); it('unchanged missing permissions is ignored', () => { const err = FP_PERM('w.yaml'); assert.deepEqual(v.filterNewViolations([err], [err]), []); }); it('added file missing permissions is blocked (empty base)', () => { const err = FP_PERM('w.yaml'); assert.deepEqual(v.filterNewViolations([err], []), [err]); }); it('unchanged run expression at same line is ignored', () => { const err = FP_EXPR('w.yaml', 10); assert.deepEqual(v.filterNewViolations([err], [err]), []); }); it('line shift ignored — same run expression moved to different line', () => { const head = FP_EXPR('w.yaml', 20); const base = FP_EXPR('w.yaml', 10); assert.deepEqual(v.filterNewViolations([head], [base]), []); }); it('newly added run expression is blocked', () => { const e1 = FP_EXPR('w.yaml', 10); const e2 = FP_EXPR('w.yaml', 20); const result = v.filterNewViolations([e1, e2], [e1]); assert.equal(result.length, 1); }); it('unchanged run expression ignored; a second new one blocked', () => { const base = FP_EXPR('w.yaml', 10); const head1 = FP_EXPR('w.yaml', 12); const head2 = FP_EXPR('w.yaml', 50); const result = v.filterNewViolations([head1, head2], [base]); assert.equal(result.length, 1); assert.equal(result[0], head2); }); it('multiple violation types: unchanged ones are ignored', () => { const perm = FP_PERM('w.yaml'); const unpin = FP_UNPIN('w.yaml', 'actions/checkout@v4'); const newUnpin = FP_UNPIN('w.yaml', 'actions/upload-artifact@v4'); const result = v.filterNewViolations([perm, unpin, newUnpin], [perm, unpin]); assert.deepEqual(result, [newUnpin]); }); it('renamed file: fingerprints use head filename for both sides', () => { const headV = FP_PERM('new-name.yaml'); const baseV = FP_PERM('new-name.yaml'); assert.deepEqual(v.filterNewViolations([headV], [baseV]), []); }); it('returns empty array when head has no violations', () => { const base = FP_UNPIN('w.yaml', 'actions/checkout@v4'); assert.deepEqual(v.filterNewViolations([], [base]), []); }); it('returns all head violations when base is empty (added file)', () => { const v1 = FP_PERM('w.yaml'); const v2 = FP_UNPIN('w.yaml', 'actions/checkout@v4'); assert.deepEqual(v.filterNewViolations([v1, v2], []), [v1, v2]); }); }); // ── fnv1a32 ─────────────────────────────────────────────────────────────────── describe('fnv1a32', () => { it('returns 8 hex chars', () => { assert.match(v.fnv1a32('hello'), /^[0-9a-f]{8}$/); }); it('is deterministic', () => { assert.equal(v.fnv1a32('run: echo hi'), v.fnv1a32('run: echo hi')); }); it('different strings produce different hashes', () => { assert.notEqual(v.fnv1a32('run: echo ${{ github.ref }}'), v.fnv1a32('run: echo ${{ github.event.pull_request.title }}')); }); it('empty string returns known value', () => { assert.equal(v.fnv1a32(''), '811c9dc5'); }); }); // ── stripHash ───────────────────────────────────────────────────────────────── describe('stripHash', () => { it('strips [fnv:XXXXXXXX] at end of message', () => { assert.equal( v.stripHash('f.yaml:42: run: block contains ${{ }} [fnv:a1b2c3d4]'), 'f.yaml:42: run: block contains ${{ }}' ); }); it('is a no-op when no hash suffix present', () => { const msg = 'f.yaml: missing top-level "permissions:" key'; assert.equal(v.stripHash(msg), msg); }); it('does not strip [fnv:...] appearing in the middle of a message', () => { const msg = 'f.yaml: some [fnv:a1b2c3d4] middle text'; assert.equal(v.stripHash(msg), msg); }); }); // ── Content fingerprint integration (Finding 1) ─────────────────────────────── describe('content fingerprint: changed payload is new', () => { const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; const BASE_WF = (runLine) => [ 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ' - run: ' + runLine, ].join('\n'); it('changed run expression is treated as new (block scalar)', () => { const wfRef = [ 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ' - run: |', ' echo ${{ github.ref }}', ].join('\n'); const wfTitle = wfRef.replace('echo ${{ github.ref }}', 'echo ${{ github.event.pull_request.title }}'); const headErrs = v.validateWorkflowContent(wfTitle, 'f.yaml'); const baseErrs = v.validateWorkflowContent(wfRef, 'f.yaml'); assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed expression must be reported as new'); }); it('unchanged run expression at a shifted line is grandfathered', () => { const wfA = BASE_WF('echo ${{ github.ref }}'); // wfB inserts a blank step before the run step, shifting its line number const wfB = [ 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ' - name: placeholder', ' run: echo noop', ' - run: echo ${{ github.ref }}', ].join('\n'); const headErrs = v.validateWorkflowContent(wfB, 'f.yaml'); const baseErrs = v.validateWorkflowContent(wfA, 'f.yaml'); assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same expression on a different line must be grandfathered'); }); it('flow-style run changed to flow-style uses is new', () => { const wfRun = 'permissions: {}\n - { run: echo hi }'; const wfUses = 'permissions: {}\n - { uses: actions/checkout@v4 }'; const headErrs = v.validateWorkflowContent(wfUses, 'f.yaml'); const baseErrs = v.validateWorkflowContent(wfRun, 'f.yaml'); assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow mapping must be reported as new'); }); }); // ── Renamed from non-workflow path (Finding 2) ─────────────────────────────── describe('isWorkflowFilename: rename routing', () => { it('non-workflow source path is not a workflow filename', () => { assert.equal(v.isWorkflowFilename('scripts/deploy.yaml'), false); assert.equal(v.isWorkflowFilename('infra/template.yml'), false); assert.equal(v.isWorkflowFilename('.github/deploy.yaml'), false); }); it('workflow target paths are workflow filenames', () => { assert.equal(v.isWorkflowFilename('.github/workflows/deploy.yaml'), true); assert.equal(v.isWorkflowFilename('workflow-templates/ci.yml'), true); }); it('rename from non-workflow treated as added: filterNewViolations with empty base captures all', () => { // When previous_filename is not a workflow file, the runtime uses [] as baseErrs. // This test verifies that all head violations are surfaced (same as added file). const noncompliantWf = [ 'on:', ' workflow_call:', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4', ].join('\n'); const headErrs = v.validateWorkflowContent(noncompliantWf, '.github/workflows/new.yaml'); assert.ok(headErrs.length > 0, 'noncompliant file must have violations'); // With empty base (simulating rename from non-workflow), all violations are new assert.deepEqual(v.filterNewViolations(headErrs, []), headErrs); }); }); // ── classifyFileStatus ──────────────────────────────────────────────────────── describe('classifyFileStatus', () => { function isWf(f) { return v.isWorkflowFilename(f); } const wfFile = '.github/workflows/deploy.yaml'; const nonWfFile = 'scripts/setup.yaml'; function file(status, filename, previous_filename) { return { status, filename: filename || wfFile, previous_filename }; } it('removed → skip', () => { assert.deepEqual(v.classifyFileStatus(file('removed'), isWf), { action: 'skip' }); }); it('unchanged → skip', () => { assert.deepEqual(v.classifyFileStatus(file('unchanged'), isWf), { action: 'skip' }); }); it('added → full', () => { assert.deepEqual(v.classifyFileStatus(file('added'), isWf), { action: 'full' }); }); it('copied → full (even with previous_filename)', () => { assert.deepEqual(v.classifyFileStatus(file('copied', wfFile, wfFile), isWf), { action: 'full' }); }); it('modified → diff with same filename as basePath', () => { assert.deepEqual(v.classifyFileStatus(file('modified'), isWf), { action: 'diff', basePath: wfFile }); }); it('changed → diff with same filename as basePath', () => { assert.deepEqual(v.classifyFileStatus(file('changed'), isWf), { action: 'diff', basePath: wfFile }); }); it('renamed from workflow path → diff with previous_filename as basePath', () => { const prev = '.github/workflows/old.yaml'; assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, prev), isWf), { action: 'diff', basePath: prev }); }); it('renamed from non-workflow path → full (treat as added)', () => { assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, nonWfFile), isWf), { action: 'full' }); }); it('renamed with no previous_filename → full', () => { assert.deepEqual(v.classifyFileStatus(file('renamed', wfFile, undefined), isWf), { action: 'full' }); }); it('unknown status → infra with reason string', () => { const result = v.classifyFileStatus(file('bogus'), isWf); assert.equal(result.action, 'infra'); assert.ok(result.reason.includes('bogus'), 'reason must name the unknown status: ' + result.reason); assert.ok(result.reason.includes(wfFile), 'reason must include filename: ' + result.reason); }); it('copied noncompliant workflow gets full validation (no baseline)', () => { // Simulate: copied file has violations in head, previous_filename also exists. // classifyFileStatus returns full, so filterNewViolations is called with empty base. const noncompliantWf = [ 'on:', ' workflow_call:', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4', ].join('\n'); const cls = v.classifyFileStatus({ status: 'copied', filename: wfFile, previous_filename: wfFile }, isWf); assert.equal(cls.action, 'full', 'copied must be full'); const headErrs = v.validateWorkflowContent(noncompliantWf, wfFile); assert.ok(headErrs.length > 0, 'noncompliant file must have violations'); assert.deepEqual(v.filterNewViolations(headErrs, []), headErrs, 'all violations reported with empty base'); }); it('copied compliant workflow produces no violations', () => { const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; const compliantWf = [ 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ].join('\n'); const cls = v.classifyFileStatus({ status: 'copied', filename: wfFile }, isWf); assert.equal(cls.action, 'full'); assert.deepEqual(v.validateWorkflowContent(compliantWf, wfFile), []); }); it('unknown status result reason is usable as POLICY-INFRA message', () => { const result = v.classifyFileStatus(file('merge'), isWf); assert.equal(result.action, 'infra'); const infra = 'POLICY-INFRA: ' + result.reason + '; skipping workflow validation'; assert.ok(infra.includes('POLICY-INFRA'), 'infra message must have prefix: ' + infra); }); }); // ── validateWorkflowContent — local action refs ─────────────────────────────── describe('validateWorkflowContent — local action refs', () => { const BASE = 'f.yaml'; const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; function wf(uses) { return [ 'on:', ' workflow_call:', 'permissions:', ' contents: read', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + uses, ].join('\n'); } it('new local ref is blocked', () => { const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); assert.ok(errs.some(e => e.includes('local action')), 'local ref must fail: ' + errs.join('; ')); }); it('local ref error includes the path for content fingerprinting', () => { const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); assert.ok(errs.some(e => e.includes('./.github/actions/my-action')), 'path must appear in error: ' + errs.join('; ')); }); it('changed local path fingerprints differently from original', () => { const headErrs = v.validateWorkflowContent(wf('./.github/actions/new-action'), BASE); const baseErrs = v.validateWorkflowContent(wf('./.github/actions/old-action'), BASE); // Different paths → different fingerprints → changed path is new assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed local path must be reported as new'); }); it('unchanged local ref is grandfathered by diff mode', () => { const headErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); const baseErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE); assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same local ref must be grandfathered'); }); it('remote pinned ref is still accepted', () => { assert.deepEqual(v.validateWorkflowContent(wf(PIN), BASE), []); }); }); // ── validateWorkflowContent — flow steps array ──────────────────────────────── describe('validateWorkflowContent — flow steps array', () => { const BASE = 'f.yaml'; const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; function wfSteps(stepsLine) { return [ 'permissions: {}', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' ' + stepsLine, ].join('\n'); } it('rejects steps: [{ uses: unpinned }] flow array', () => { const errs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'inline uses flow steps must fail: ' + errs.join('; ')); }); it('rejects steps: [{ run: echo }] flow array with run', () => { const errs = v.validateWorkflowContent(wfSteps('steps: [{ run: echo hi }]'), BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'inline run flow steps must fail: ' + errs.join('; ')); }); it('rejects "steps": [...] with double-quoted key', () => { const errs = v.validateWorkflowContent(wfSteps('"steps": [{ uses: actions/checkout@v4 }]'), BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'double-quoted steps flow array must fail: ' + errs.join('; ')); }); it('rejects single-quoted \'steps\': [...] key', () => { const errs = v.validateWorkflowContent(wfSteps("'steps': [{ uses: actions/checkout@v4 }]"), BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'single-quoted steps flow array must fail: ' + errs.join('; ')); }); it('rejects multiline flow opener steps: [', () => { const content = [ 'permissions: {}', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps: [', ' { uses: actions/checkout@v4 }', ' ]', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('flow-style')), 'multiline flow steps opener must fail: ' + errs.join('; ')); }); it('allows steps: [] (empty array, no execution)', () => { const errs = v.validateWorkflowContent(wfSteps('steps: []'), BASE); assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'empty steps: [] must pass: ' + errs.join('; ')); }); it('allows steps: [] with trailing comment', () => { const errs = v.validateWorkflowContent(wfSteps('steps: [] # placeholder'), BASE); assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'steps: [] with comment must pass: ' + errs.join('; ')); }); it('block-style steps list is not affected', () => { const content = [ 'permissions: {}', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('changed flow steps payload fingerprints differently (content hash)', () => { // Two different flow steps lines produce different FNV hashes → different fingerprints const headErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE); const baseErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/setup-node@v4 }]'), BASE); assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow payload must be new'); }); }); // ── isActionManifestFilename ────────────────────────────────────────────────── describe('isActionManifestFilename', () => { it('matches action.yml at repo root', () => { assert.ok(v.isActionManifestFilename('action.yml')); }); it('matches action.yaml at repo root', () => { assert.ok(v.isActionManifestFilename('action.yaml')); }); it('matches .github/actions/my-action/action.yml', () => { assert.ok(v.isActionManifestFilename('.github/actions/my-action/action.yml')); }); it('matches nested .github/actions/sub/deep/action.yaml', () => { assert.ok(v.isActionManifestFilename('.github/actions/sub/deep/action.yaml')); }); it('rejects workflow files', () => { assert.ok(!v.isActionManifestFilename('.github/workflows/ci.yaml')); }); it('rejects action-like filenames that are not action.yml/yaml', () => { assert.ok(!v.isActionManifestFilename('.github/actions/my-action/entrypoint.js')); assert.ok(!v.isActionManifestFilename('actions.yml')); }); }); // ── isPolicyFilename ────────────────────────────────────────────────────────── describe('isPolicyFilename', () => { it('accepts workflow files', () => { assert.ok(v.isPolicyFilename('.github/workflows/ci.yaml')); assert.ok(v.isPolicyFilename('workflow-templates/pr-policy.yml')); }); it('accepts action manifests', () => { assert.ok(v.isPolicyFilename('action.yml')); assert.ok(v.isPolicyFilename('.github/actions/setup/action.yaml')); }); it('rejects unrelated files', () => { assert.ok(!v.isPolicyFilename('src/index.js')); assert.ok(!v.isPolicyFilename('.github/actions/setup/entrypoint.js')); }); }); // ── validateWorkflowContent — action manifests ──────────────────────────────── describe('validateWorkflowContent — action manifests', () => { const BASE = '.github/actions/my-action/action.yml'; const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; function manifest(usesLine) { return [ 'name: My Action', 'description: Does something.', 'runs:', ' using: composite', ' steps:', ' - uses: ' + usesLine, ].join('\n'); } it('does NOT require top-level permissions: in action manifests', () => { const content = manifest(PIN); assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []); }); it('still requires permissions: in workflow files (default)', () => { const content = [ 'on: workflow_call', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ].join('\n'); const errs = v.validateWorkflowContent(content, '.github/workflows/test.yaml'); assert.ok(errs.some(e => e.includes('permissions')), 'workflow must require permissions: ' + errs.join('; ')); }); it('rejects unpinned remote uses in action manifest', () => { const content = manifest('actions/checkout@v4'); const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); assert.ok(errs.some(e => e.includes('40-char SHA') || e.includes('pinned')), 'unpinned must fail: ' + errs.join('; ')); }); it('accepts fully pinned remote uses in action manifest', () => { const content = manifest(PIN); assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []); }); it('rejects unsafe run expression in action manifest', () => { const content = [ 'name: My Action', 'description: Does something.', 'runs:', ' using: composite', ' steps:', ' - run: echo ${{ github.event.pull_request.title }}', ].join('\n'); const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); assert.ok(errs.some(e => e.includes('expression')), 'run expression must fail: ' + errs.join('; ')); }); it('rejects local action ref in action manifest (unsupported)', () => { const content = manifest('./.github/actions/nested'); const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); assert.ok(errs.some(e => e.includes('local action')), 'local ref in manifest must fail: ' + errs.join('; ')); }); it('diff mode: modified manifest adding unpinned ref is blocked', () => { const headContent = manifest('actions/checkout@v4'); const baseContent = manifest(PIN); const headErrs = v.validateWorkflowContent(headContent, BASE, { requirePermissions: false }); const baseErrs = v.validateWorkflowContent(baseContent, BASE, { requirePermissions: false }); assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'new unpinned ref must block'); }); it('diff mode: unchanged unpinned ref in manifest is grandfathered', () => { const content = manifest('actions/checkout@v4'); const headErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); const baseErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false }); assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged violation must be grandfathered'); }); }); // ── classifyFileStatus — action manifests ───────────────────────────────────── describe('classifyFileStatus — action manifests', () => { function isWf(f) { return v.isPolicyFilename(f); } it('added action manifest → full', () => { const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'added' }, isWf); assert.equal(result.action, 'full'); }); it('copied action manifest → full', () => { const result = v.classifyFileStatus({ filename: '.github/actions/new/action.yml', status: 'copied', previous_filename: '.github/actions/old/action.yml' }, isWf); assert.equal(result.action, 'full'); }); it('modified action manifest → diff with same path', () => { const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'modified' }, isWf); assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/setup/action.yml' }); }); it('renamed from action manifest path → diff with previous_filename', () => { const result = v.classifyFileStatus({ filename: '.github/actions/new-name/action.yml', status: 'renamed', previous_filename: '.github/actions/old-name/action.yml', }, isWf); assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old-name/action.yml' }); }); it('renamed from non-policy path → full (treat as added)', () => { const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'renamed', previous_filename: 'docs/action-template.yml', }, isWf); assert.equal(result.action, 'full'); }); }); // ── Exact bypass scenario ───────────────────────────────────────────────────── // A modified composite action.yml that adds an unpinned uses: must block even // when the referencing workflow file and its local uses: ./... line are unchanged. describe('bypass scenario: modified action manifest adds unpinned ref', () => { const ACTION_FILE = '.github/actions/setup/action.yml'; const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; const baseManifest = [ 'name: Setup', 'description: Sets up the environment.', 'runs:', ' using: composite', ' steps:', ' - uses: ' + PIN, ].join('\n'); const headManifest = [ 'name: Setup', 'description: Sets up the environment.', 'runs:', ' using: composite', ' steps:', ' - uses: ' + PIN, ' - uses: actions/setup-node@v4', ].join('\n'); it('base manifest (pinned only) has no violations', () => { assert.deepEqual(v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false }), []); }); it('head manifest (adds unpinned step) has a violation', () => { const errs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false }); assert.ok(errs.length > 0, 'head must have violations: ' + errs.join('; ')); }); it('diff mode reports the new unpinned ref as a new violation', () => { const headErrs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false }); const baseErrs = v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false }); const newViolations = v.filterNewViolations(headErrs, baseErrs); assert.equal(newViolations.length, 1, 'exactly one new violation expected: ' + newViolations.join('; ')); assert.ok(newViolations[0].includes('setup-node'), 'violation must name the offending ref: ' + newViolations[0]); }); it('new local ref inside composite action also fails closed', () => { const manifest = [ 'name: Setup', 'description: Sets up the environment.', 'runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/nested', ].join('\n'); const errs = v.validateWorkflowContent(manifest, ACTION_FILE, { requirePermissions: false }); assert.ok(errs.some(e => e.includes('local action')), 'local ref in composite must fail: ' + errs.join('; ')); }); }); // ── policyFileKind ──────────────────────────────────────────────────────────── describe('policyFileKind', () => { it('returns "workflow" for workflow files', () => { assert.equal(v.policyFileKind('.github/workflows/ci.yaml'), 'workflow'); assert.equal(v.policyFileKind('workflow-templates/pr.yml'), 'workflow'); }); it('returns "action" for action manifests', () => { assert.equal(v.policyFileKind('action.yml'), 'action'); assert.equal(v.policyFileKind('.github/actions/setup/action.yaml'), 'action'); }); it('returns null for non-policy files', () => { assert.equal(v.policyFileKind('src/index.js'), null); assert.equal(v.policyFileKind('.github/actions/setup/entrypoint.js'), null); }); }); // ── classifyFileStatus — cross-type rename grandfathering ───────────────────── describe('classifyFileStatus — cross-type rename grandfathering', () => { function isWf(f) { return v.isPolicyFilename(f); } it('action -> workflow rename → full (cross-kind, not grandfathered)', () => { const result = v.classifyFileStatus({ filename: '.github/workflows/imported.yml', status: 'renamed', previous_filename: 'action.yml', }, isWf); assert.equal(result.action, 'full', 'action→workflow must be full, got: ' + JSON.stringify(result)); }); it('workflow -> action rename → full (cross-kind, not grandfathered)', () => { const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'renamed', previous_filename: '.github/workflows/ci.yml', }, isWf); assert.equal(result.action, 'full', 'workflow→action must be full, got: ' + JSON.stringify(result)); }); it('workflow -> workflow rename → diff (same kind)', () => { const result = v.classifyFileStatus({ filename: '.github/workflows/new.yml', status: 'renamed', previous_filename: '.github/workflows/old.yml', }, isWf); assert.deepEqual(result, { action: 'diff', basePath: '.github/workflows/old.yml' }); }); it('action -> action rename → diff (same kind)', () => { const result = v.classifyFileStatus({ filename: '.github/actions/new/action.yml', status: 'renamed', previous_filename: '.github/actions/old/action.yml', }, isWf); assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old/action.yml' }); }); it('non-policy -> workflow rename → full', () => { const result = v.classifyFileStatus({ filename: '.github/workflows/imported.yml', status: 'renamed', previous_filename: 'docs/template.yml', }, isWf); assert.equal(result.action, 'full'); }); }); // ── Exact bypass reproduction: action.yml renamed to workflow ───────────────── describe('bypass reproduction: action renamed to workflow reports missing permissions', () => { const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; // Simulates: base is action.yml (no permissions, valid for action), head is // .github/workflows/imported.yml (same content, but now a workflow file). // Full validation must run because the policy kind changed (action → workflow). it('action.yml content re-validated as workflow reports missing permissions', () => { const actionContent = [ 'name: Setup', 'description: Sets up the environment.', 'runs:', ' using: composite', ' steps:', ' - uses: ' + PIN, ].join('\n'); // classifyFileStatus returns full → no baseline. const isWf = f => v.isPolicyFilename(f); const cls = v.classifyFileStatus({ filename: '.github/workflows/imported.yml', status: 'renamed', previous_filename: 'action.yml', }, isWf); assert.equal(cls.action, 'full', 'cross-kind rename must be full'); // Full validation as a workflow file — no opts.requirePermissions: false. const errs = v.validateWorkflowContent(actionContent, '.github/workflows/imported.yml'); assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions must be reported: ' + errs.join('; ')); }); it('workflow.yml renamed to workflow.yml stays in diff mode and grandfathers unchanged violations', () => { const content = [ 'permissions: {}', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ].join('\n'); const isWf = f => v.isPolicyFilename(f); const cls = v.classifyFileStatus({ filename: '.github/workflows/new.yml', status: 'renamed', previous_filename: '.github/workflows/old.yml', }, isWf); assert.deepEqual(cls, { action: 'diff', basePath: '.github/workflows/old.yml' }, 'same-kind rename must be diff'); // Use file.filename for both head/base so fingerprints match. const headErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml'); const baseErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml'); assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged content must be grandfathered'); }); }); // ── Whitespace-before-colon bypass ─────────────────────────────────────────── describe('validateWorkflowContent — whitespace before colon', () => { const BASE = '.github/workflows/test.yaml'; const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; function wfHeader() { return 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps:'; } it('rejects "uses : actions/checkout@v4" (space before colon)', () => { const content = wfHeader() + '\n - uses : actions/checkout@v4'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('whitespace before')), 'must reject uses with space: ' + errs.join('; ')); }); it('rejects sequence-form "- uses : ..." (space before colon)', () => { const content = wfHeader() + '\n - uses : actions/checkout@v4'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'sequence uses space must fail: ' + errs.join('; ')); }); it('rejects "run : echo ${{ github.token }}" (space before colon with expression)', () => { const content = wfHeader() + '\n - run : echo ${{ github.token }}'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'run with space must fail: ' + errs.join('; ')); }); it('rejects "steps : [{ uses : actions/checkout@v4 }]" (space before colon on steps)', () => { const content = 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps : [{ uses : actions/checkout@v4 }]'; const errs = v.validateWorkflowContent(content, BASE); assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'steps with space must fail: ' + errs.join('; ')); }); it('normal keys without space still work correctly', () => { const content = [ 'permissions: {}', 'jobs:', ' j:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: ' + PIN, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, BASE), []); }); it('changed whitespace-before-colon payload fingerprints differently', () => { const h = wfHeader() + '\n - uses : actions/checkout@v4'; const b = wfHeader() + '\n - uses : actions/setup-node@v4'; const headErrs = v.validateWorkflowContent(h, BASE); const baseErrs = v.validateWorkflowContent(b, BASE); assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed payload must be new'); }); }); // ── Workflow/action overlap: .github/workflows/action.yml ───────────────────── describe('policyFileKind: workflow takes precedence over action-manifest pattern', () => { it('policyFileKind returns "workflow" for .github/workflows/action.yml', () => { assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow'); }); it('.github/workflows/action.yml requires permissions (workflow semantics)', () => { const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; const content = [ 'name: Inline Action', 'description: Does something.', 'runs:', ' using: composite', ' steps:', ' - uses: ' + PIN, ].join('\n'); // Default opts (requirePermissions: true) because policyFileKind === 'workflow' const errs = v.validateWorkflowContent(content, '.github/workflows/action.yml'); assert.ok(errs.some(e => e.includes('permissions')), 'workflow-path action.yml must require permissions: ' + errs.join('; ')); }); it('.github/actions/foo/action.yml does NOT require permissions (action semantics)', () => { const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1'; const content = [ 'name: Inline Action', 'description: Does something.', 'runs:', ' using: composite', ' steps:', ' - uses: ' + PIN, ].join('\n'); assert.deepEqual(v.validateWorkflowContent(content, '.github/actions/foo/action.yml', { requirePermissions: false }), []); }); it('isActionManifestFilename still matches .github/workflows/action.yml (pattern overlap acknowledged)', () => { assert.ok(v.isActionManifestFilename('.github/workflows/action.yml'), 'pattern overlap exists'); assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow', 'but kind is workflow'); }); });