diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index ed73c00ab..99dc608a5 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -167,10 +167,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} - name: Install gh-aw extension - # Required so `gh aw --version` succeeds and the compile-gate test in - # test/gh-aw-quality.test.ts is NOT skipped. Without this step the test - # uses `it.skipIf(!ghAwAvailable)` and always skips — which is - # indistinguishable from having no gate at all. See #1732. + # Required so `gh aw --version` succeeds and the compiled-workflow gates in + # test/gh-aw-quality.test.ts can compile squad.md. Those gates FAIL CLOSED: + # if gh aw is absent they hard-fail with this exact install command, rather + # than skipping — a skipped gate is indistinguishable from no gate. This + # includes the shell input security contract gate (#1834) and the compile + # contract (#1732). run: gh extension install github/gh-aw env: GH_TOKEN: ${{ github.token }} diff --git a/test/fixtures/gh-aw-shell-contract/violating.lock.yml b/test/fixtures/gh-aw-shell-contract/violating.lock.yml new file mode 100644 index 000000000..4acd7cb1b --- /dev/null +++ b/test/fixtures/gh-aw-shell-contract/violating.lock.yml @@ -0,0 +1,42 @@ +# Positive-control fixture for the gh-aw shell input security contract gate (#1834). +# +# This is a compiled-shaped GitHub Actions lock fragment whose `run:` blocks each +# violate one anti-pattern from workflows/squad.md §"Shell input security contract". +# It exists so the gate is PROVEN capable of failing, not assumed to be: the gate's +# own test feeds this file through the same extractRunBlocks()/scanRunBlocks() used +# on the real squad.lock.yml and asserts each token is named with its file and line. +# +# RETRO's acceptance bar (issue #1834): "A gate that cannot turn red on a fixture +# containing `run: printf '%s\n' \"${{ github.event.issue.body }}\"` is not a valid +# gate." That exact line is the first block below. +# +# DO NOT install or reference this file as a workflow. It lives under test/fixtures/ +# so actionlint and gh-aw never see it; only the gate's unit test reads it. +name: shell-contract-positive-control +on: + issues: + types: [opened] +jobs: + violations: + runs-on: ubuntu-latest + steps: + - name: UNTRUSTED_TEMPLATE_IN_RUN — raw event expression inside run + run: printf '%s\n' "${{ github.event.issue.body }}" + - name: UNTRUSTED_PRINTF_FORMAT — body in printf's format slot + env: + SQUAD_TRIGGER_BODY: ${{ github.event.issue.body }} + run: | + body="${SQUAD_TRIGGER_BODY-}" + printf "$body" + - name: UNTRUSTED_COMMAND_STRING — body built into a command string + env: + SQUAD_TRIGGER_BODY: ${{ github.event.issue.body }} + run: | + eval "$SQUAD_TRIGGER_BODY" + bash -c "handle $SQUAD_TRIGGER_BODY" + - name: UNTRUSTED_AWK_PROGRAM_OR_VAR — body through awk -v and awk program + env: + SQUAD_TRIGGER_BODY: ${{ github.event.issue.body }} + run: | + awk -v body="$SQUAD_TRIGGER_BODY" 'BEGIN { print body }' + awk "/$SQUAD_TRIGGER_BODY/ { print }" diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index ac2951994..8a0337d0b 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -14,6 +14,14 @@ import { join, dirname } from 'node:path'; import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { minimatch } from 'minimatch'; import { POSIX_SHELL, NO_POSIX_SHELL_MESSAGE, requirePosixShell } from './posix-shell'; +import { + extractRunBlocks, + scanRunBlocks, + extractBodyHandlingShell, + scanShellLines, + formatViolations, + type ContractToken, +} from './gh-aw-shell-contract'; const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); @@ -1081,55 +1089,408 @@ describe('gh-aw: inline skill extraction', () => { }); // --------------------------------------------------------------------------- -// Test: gh-aw compilation retains durable state and Auto-Cast contracts +// Test: gh-aw compilation retains durable state, Auto-Cast contracts, AND the +// shell input security contract over compiled output (#1834). // --------------------------------------------------------------------------- +// +// workflows/squad.md §"Shell input security contract [MANDATORY]" declares that +// attacker-controlled GitHub event text may reach the shell only through named +// env: vars read via quoted expansion, and names four greppable anti-patterns. +// That contract was declared but unenforced. This suite is the gate. +// +// The gate FAILS CLOSED. It used to gate on `it.skipIf(!ghAwAvailable)`, which is +// the exact "silently skipped while the suite reports green" defect this file's +// header (see #1833) complains about: a check that never runs is indistinguishable +// from no check. So `gh aw` missing, a lock that fails to compile, an absent lock, +// or zero inspected surfaces are all FAILURES, never skips. CI installs gh aw for +// exactly this reason (.github/workflows/squad-ci.yml, #1732/#1834). + +const GH_AW_INSTALL_HINT = + '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + + '`gh extension install github/gh-aw` (matches .github/workflows/squad-ci.yml). This ' + + 'gate fails closed rather than skipping: an unmeasured contract is indistinguishable ' + + 'from a violated one (#1834).'; + +const CONTRACT_FIXTURE = join( + process.cwd(), + 'test', + 'fixtures', + 'gh-aw-shell-contract', + 'violating.lock.yml' +); + +describe('gh-aw: compiled workflow shell input security contract', () => { + // Compile the real workflow once and memoize. The top-level afterAll wipes + // TEST_WORKSPACES_DIR, so the ephemeral workspace is cleaned globally. + let compiledLock: string | null = null; + + function lockText(): string { + if (compiledLock !== null) return compiledLock; + + const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); + if (versionProbe.status !== 0) { + throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); + } -describe('gh-aw: compiled workflow contract', () => { - const ghAwAvailable = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }).status === 0; + const workspace = createTestWorkspace('gh-aw-contract-'); + execFileSync('git', ['init', '--quiet'], { cwd: workspace }); + // gh-aw's dispatch-workflow validation resolves its dispatch target against a + // `.github/workflows/` directory located relative to the compiled file. This + // repo ships the gh-aw *source* from a top-level `workflows/` dir; downstream + // consumers install it into `.github/workflows/` via `gh aw add`. Mirror that + // real deployment layout so `squad-implement-worker` resolves as it will in + // every real install. + cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); + execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict'], { + cwd: workspace, + encoding: 'utf8', + stdio: 'pipe', + }); - // Explicit timeout: this test shells out to the real `gh aw compile` binary, - // which reliably finishes in ~2-3s in isolation but can exceed the 5s vitest - // default under full-suite parallel load/contention. Bumping this avoids - // spurious CI flakiness unrelated to the assertions themselves. - it.skipIf(!ghAwAvailable)('strict-compiles and preserves prompt/config behavior', () => { - const workspace = createTestWorkspace('gh-aw-compile-'); - try { - execFileSync('git', ['init', '--quiet'], { cwd: workspace }); - // gh-aw's dispatch-workflow validation (added alongside #1682's - // safe-outputs.dispatch-workflow config) resolves its dispatch target against - // a `.github/workflows/` directory that it locates relative to the compiled - // file's path, assuming the standard `/.github/workflows/.md` - // layout. This repo distributes the gh-aw *source* one level shallower, from a - // top-level `workflows/` directory (see docs/src/content/docs/guide/gh-aw.md), - // which downstream consumers install into their own `.github/workflows/` via - // `gh aw add owner/squad/workflows/squad-implement-worker.md@dev - // owner/squad/workflows/squad.md@dev` — landing both files side-by-side there. - // Mirror that real deployment layout in the ephemeral test workspace (instead - // of a bare `workflows/` copy) so the dispatch target `squad-implement-worker` - // resolves the same way it will for every real downstream install. - cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict'], { - cwd: workspace, - encoding: 'utf8', - stdio: 'pipe', + const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); + if (!existsSync(lockPath)) { + throw new Error( + `gh aw compile produced no squad.lock.yml — the compiled artifact this gate ` + + `inspects is absent, so the contract is unmeasured. ${GH_AW_INSTALL_HINT}` + ); + } + compiledLock = readText(lockPath); + return compiledLock; + } + + // Explicit timeout: shells out to the real `gh aw compile`, ~2-3s in isolation + // but can exceed the 5s vitest default under full-suite parallel contention. + it('strict-compiles and preserves prompt/config behavior', () => { + const compiled = lockText(); + expect(compiled).toContain('"auto_close_issue":false'); + expect(compiled).toContain('"data_enabled":true'); + expect(compiled).toContain('"required":["origin_issue","phases","schema_version","squad_artifact"]'); + expect(compiled).toContain('"enum":["research","plan","plan-accepted"'); + // gh-aw records runtime-import paths relative to the repo root, so with the + // real `.github/workflows/` deployment layout these are prefixed accordingly. + expect(compiled).toContain('{{#runtime-import .github/workflows/shared/squad-planning-ontology.md}}'); + expect(compiled).toContain('{{#runtime-import .github/workflows/squad.md}}'); + expect(compiled).not.toMatch(//); + }, 20000); + + it('emits no attacker-controlled event text in any compiled run: block', () => { + const compiled = lockText(); + const blocks = extractRunBlocks(compiled); + + // Fail closed: a scanner with nothing to scan is a permanently green gate. The + // real lock has dozens of run: blocks; zero means compilation changed shape and + // the extractor no longer sees them. + expect( + blocks.length, + 'No run: blocks found in compiled squad.lock.yml. A scanner that finds nothing ' + + 'to scan is a permanently green gate (#1834). Re-check extractRunBlocks against ' + + 'the current `gh aw compile` output shape.' + ).toBeGreaterThan(0); + + const violations = scanRunBlocks(blocks, '.github/workflows/squad.lock.yml'); + expect( + violations, + `Compiled run: blocks violate the shell input security contract ` + + `(workflows/squad.md §"Shell input security contract [MANDATORY]"). Actions ` + + `expands \${{ … }} before the shell starts, so event text in a run: block is ` + + `unsafe even inside quotes. Each entry names the anti-pattern token, file, and ` + + `line:\n${formatViolations(violations)}\n\n` + + `Reproduce the finding directly against compiled output:\n` + + ` gh aw compile .github/workflows/squad.md --strict\n` + + ` grep -nE '\\$\\{\\{[^}]*github\\.event\\.[a-z_]+\\.(body|title)' ` + + `.github/workflows/squad.lock.yml` + ).toEqual([]); + }, 20000); + + it('routes the /squad parser body only through contract-safe shell', () => { + // The printf/eval/awk hops live in the parser one-liners of workflows/squad.md, + // which gh-aw pulls in verbatim at runtime via {{#runtime-import … squad.md}} — + // never inlined into the lock. This is the only surface on which those hops can + // be observed, so it is scanned directly (see gh-aw-shell-contract.ts header). + const source = readText(SQUAD_WORKFLOW); + const shell = extractBodyHandlingShell(source); + + expect( + shell.length, + 'No body-handling shell found in workflows/squad.md. The /squad parser reads ' + + 'SQUAD_TRIGGER_BODY through fenced bash; zero matches means the extractor lost ' + + 'the parser code and the printf/eval/awk hops are unmeasured (#1834).' + ).toBeGreaterThan(0); + + const violations = scanShellLines(shell, 'workflows/squad.md'); + expect( + violations, + `The /squad parser passes attacker body text into a forbidden shell construct ` + + `(workflows/squad.md §"Shell input security contract [MANDATORY]"). Each entry ` + + `names the anti-pattern token, file, and line:\n${formatViolations(violations)}\n\n` + + `Reproduce: inspect the parser one-liners that read the body variable:\n` + + ` grep -nE 'printf +"?\\$|awk +-v|eval|bash +-c' workflows/squad.md` + ).toEqual([]); + }); + + it('positive control: turns red on a known-violating compiled fixture, naming token, file, and line', () => { + // RETRO's acceptance bar (#1834): "A gate that cannot turn red on a fixture + // containing `run: printf '%s\n' "${{ github.event.issue.body }}"` is not a valid + // gate." This proves the gate CAN fail, using the same extractor/scanner the real + // lock goes through. It needs no gh aw, so it runs everywhere — the "can it turn + // red" proof must never itself be skippable. + expect(existsSync(CONTRACT_FIXTURE), `positive-control fixture missing: ${CONTRACT_FIXTURE}`).toBe(true); + + const fixture = readText(CONTRACT_FIXTURE); + const violations = scanRunBlocks(extractRunBlocks(fixture), 'violating.lock.yml'); + + const tokens = new Set(violations.map(v => v.token)); + for (const expected of [ + 'UNTRUSTED_TEMPLATE_IN_RUN', + 'UNTRUSTED_PRINTF_FORMAT', + 'UNTRUSTED_COMMAND_STRING', + 'UNTRUSTED_AWK_PROGRAM_OR_VAR', + ] as ContractToken[]) { + expect( + tokens.has(expected), + `Positive-control fixture did not trip ${expected}. The gate cannot observe ` + + `that anti-pattern, so it is a status-only gate for it (#1834). Found tokens: ` + + `${[...tokens].join(', ') || '(none)'}` + ).toBe(true); + } + + // The diagnostic must name token AND file AND line — a status-only failure passes + // a generic-shaped assertion clean (#1832 mutation-testing finding). + const mandated = violations.find(v => v.token === 'UNTRUSTED_TEMPLATE_IN_RUN'); + expect(mandated, 'the mandated RETRO line must trip UNTRUSTED_TEMPLATE_IN_RUN').toBeDefined(); + expect(mandated!.file).toBe('violating.lock.yml'); + expect(mandated!.line, 'the violation must carry a concrete 1-based line number').toBeGreaterThan(0); + expect(mandated!.evidence).toContain('github.event.issue.body'); + + // And the rendered diagnostic actually contains token, file, and line together. + expect(formatViolations(violations)).toMatch( + /UNTRUSTED_TEMPLATE_IN_RUN\s+violating\.lock\.yml:\d+/ + ); + }); + + it('does not flag the sanctioned body-handling forms (guards against a permanent red)', () => { + // The contract's own correct forms must stay green, or the gate is a permanent + // red — equally worthless per the 2026-08-20 test bar. Runner-owned expansions + // ($PATH, ${RUNNER_TEMP}) are not attacker text and must not trip the gate. + const safe = [ + `printf '%s\\n' "$SQUAD_TRIGGER_BODY" | awk '{sub(/\\r$/,"")}' | grep -F -- '/squad'`, + `body="\${SQUAD_TRIGGER_BODY-}"`, + `printf '%s\\n' "$body" | tr -d '\\r' | grep -n -i -m 3 -F -- '/squad'`, + // `--` ends printf's option parsing; the format slot is still the literal that + // follows it, so the body remains an argument. The `--` handling added for the + // bypass below must not turn this sanctioned form red. + `printf -- '%s\\n' "$body"`, + `printf -- '%s\\n' "$GITHUB_EVENT_ISSUE_BODY"`, + `bash -c 'set +o histexpand; export PATH="$PATH"'`, + `source "\${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh"`, + `awk -v n=3 'BEGIN { print n }'`, + ].map((text, i) => ({ line: i + 1, text })); + + expect( + scanShellLines(safe, 'sanctioned-forms'), + 'The scanner flagged a contract-COMPLIANT form. A gate that is always red is as ' + + 'worthless as one that is always green (2026-08-20 test bar). Tighten the ' + + 'detectors in gh-aw-shell-contract.ts.' + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Test: scanner-level regressions in the shell contract gate (#1834) +// --------------------------------------------------------------------------- +// +// The gate above proves the CONTRACT holds against the real workflow. These tests +// prove the DETECTOR holds — that the scanner actually observes what its own doc +// comments claim it observes. Three bypasses were found by review after the gate +// landed; each is a case where the gate reported green because the detector never +// looked, which is the same "unmeasured is indistinguishable from clean" failure +// mode the gate itself exists to eliminate. +// +// These are pure unit tests over the exported scanner: no `gh aw`, no compile, so +// the proof that the detector can see each bypass is never skippable. + +describe('gh-aw: shell contract detector regressions (#1834)', () => { + const scan = (text: string) => scanShellLines([{ line: 1, text }], 'regression'); + const tokensFor = (text: string) => scan(text).map(v => v.token); + + describe('BODY_VAR matches multi-segment carrier names', () => { + // The doc comment promised "any `*_BODY` / `*_TITLE` variable", but the pattern + // allowed only ONE `[A-Za-z0-9]+` segment before the suffix. Event-carrier env + // vars are conventionally multi-segment SCREAMING_SNAKE, so the *unenforced* + // shape was the idiomatic one — every detector went blind on it. + const multiSegment = [ + 'SQUAD_EVENT_BODY', + 'GITHUB_EVENT_ISSUE_BODY', + 'GITHUB_EVENT_COMMENT_BODY', + 'SQUAD_EVENT_ISSUE_TITLE', + 'GITHUB_EVENT_PULL_REQUEST_TITLE', + ]; + + for (const name of multiSegment) { + it(`taints $${name} in printf's format slot`, () => { + expect( + tokensFor(`printf "$${name}"`), + `$${name} is a *_BODY/*_TITLE carrier the scanner claims to taint, but a ` + + `single-segment-only pattern let it reach printf's format slot unflagged.` + ).toContain('UNTRUSTED_PRINTF_FORMAT'); }); - const compiled = readText(join(workspace, '.github', 'workflows', 'squad.lock.yml')); - expect(compiled).toContain('"auto_close_issue":false'); - expect(compiled).toContain('"data_enabled":true'); - expect(compiled).toContain('"required":["origin_issue","phases","schema_version","squad_artifact"]'); - expect(compiled).toContain('"enum":["research","plan","plan-accepted"'); - // gh-aw records runtime-import paths relative to the repo root (not the - // compiled file's own directory), so with the real `.github/workflows/` - // deployment layout these are prefixed accordingly — verified against an - // isolated compile outside this repo/worktree entirely. - expect(compiled).toContain('{{#runtime-import .github/workflows/shared/squad-planning-ontology.md}}'); - expect(compiled).toContain('{{#runtime-import .github/workflows/squad.md}}'); - expect(compiled).not.toMatch(//); - } finally { - rmSync(workspace, { recursive: true, force: true }); + it(`taints $${name} in a command string`, () => { + expect(tokensFor(`eval "\${${name}-}"`)).toContain('UNTRUSTED_COMMAND_STRING'); + }); + + it(`taints $${name} passed through awk -v`, () => { + expect(tokensFor(`awk -v v="$${name}" 'BEGIN { print v }'`)).toContain( + 'UNTRUSTED_AWK_PROGRAM_OR_VAR' + ); + }); } - }, 20000); + + it('still taints the single-segment and sanctioned carrier names', () => { + // Widening the prefix must not lose the names that already worked. + for (const text of [ + 'printf "$SQUAD_TRIGGER_BODY"', + 'printf "$ISSUE_BODY"', + 'printf "$PR_TITLE"', + 'printf "$body"', + ]) { + expect(tokensFor(text), `${text} must stay tainted`).toContain( + 'UNTRUSTED_PRINTF_FORMAT' + ); + } + }); + + it('does not taint runner-owned variables that merely contain the segments', () => { + // A widened prefix must not start flagging runner-owned values, or the gate + // goes permanently red — as worthless as permanently green. + for (const text of [ + 'printf "$RUNNER_TEMP"', + 'bash -c "export PATH=$PATH"', + 'source "${GITHUB_WORKSPACE}/setup.sh"', + 'printf "$BODYGUARD_HOME"', + ]) { + expect(tokensFor(text), `${text} carries no attacker text and must stay green`).toEqual( + [] + ); + } + }); + }); + + describe("printf's -- end-of-options separator does not shield the format slot", () => { + // `printf -- "$body"` is the idiom used precisely when a body might begin with + // `-`. The arg reader treated `--` as the format operand and returned it, so the + // body — sitting in the real format slot — was never inspected. + for (const name of ['body', 'SQUAD_TRIGGER_BODY', 'GITHUB_EVENT_ISSUE_BODY']) { + it(`flags printf -- "$${name}"`, () => { + expect( + tokensFor(`printf -- "$${name}"`), + `\`--\` ends option parsing; the body after it IS the format string. ` + + `Reading \`--\` as the first argument bypasses UNTRUSTED_PRINTF_FORMAT.` + ).toContain('UNTRUSTED_PRINTF_FORMAT'); + }); + } + + it('flags -- combined with other option forms', () => { + expect(tokensFor('printf -v out -- "$body"')).toContain('UNTRUSTED_PRINTF_FORMAT'); + }); + + it('leaves the format slot green when -- is followed by a literal format', () => { + expect(tokensFor(`printf -- '%s\\n' "$body"`)).toEqual([]); + expect(tokensFor(`printf -- "%s\\n" "$SQUAD_TRIGGER_BODY"`)).toEqual([]); + }); + + it('still flags a body glued to -- as one word', () => { + // `--"$body"` is a single word, so it is the format string, not a separator. + expect(tokensFor('printf --"$body"')).toContain('UNTRUSTED_PRINTF_FORMAT'); + }); + }); + + describe('extractRunBlocks does not re-read block scalar content as workflow keys', () => { + // A `run:` line inside a heredoc or echoed YAML is shell text, not a key. The + // outer cursor never advanced past a consumed scalar, so that line re-entered + // the header branch: a phantom block appeared, its lines were already owned by + // the real block, and every violation inside it was reported twice. + const NESTED = [ + 'jobs:', // 1 + ' a:', // 2 + ' steps:', // 3 + ' - name: writes a workflow', // 4 + ' run: |', // 5 + " cat <<'YAML' > generated.yml", // 6 + ' run: |', // 7 + ' printf "$SQUAD_EVENT_BODY"', // 8 + ' YAML', // 9 + ' echo done', // 10 + ' - name: tail', // 11 + ' run: echo tail', // 12 + ].join('\n'); + + it('opens no phantom block for a run: line nested in a heredoc', () => { + const blocks = extractRunBlocks(NESTED); + expect( + blocks.map(b => b.headerLine), + 'Line 7 is heredoc payload owned by the block scalar opened on line 5, not a ' + + 'workflow key. A header at line 7 means scalar content was misparsed.' + ).toEqual([5, 12]); + }); + + it('keeps the whole heredoc inside the enclosing block', () => { + const outer = extractRunBlocks(NESTED).find(b => b.headerLine === 5); + expect(outer, 'the real run: block on line 5 must be extracted').toBeDefined(); + expect( + outer!.lines.map(l => l.line), + 'the enclosing block owns every deeper-indented line, heredoc payload included' + ).toEqual([6, 7, 8, 9, 10]); + }); + + it('reports a violation inside nested YAML exactly once', () => { + const violations = scanRunBlocks(extractRunBlocks(NESTED), 'nested.yml'); + expect( + violations.map(v => `${v.token}@${v.line}`), + 'Duplicate blocks produce duplicate findings, so the same defect is counted ' + + 'twice in the diagnostic and in any violation total.' + ).toEqual(['UNTRUSTED_PRINTF_FORMAT@8']); + }); + + it('does not skip a sibling run: that terminates the previous block', () => { + // Guards the off-by-one in the cursor advance: the line that ENDS a scalar is + // not part of it and must still be examined as a header. + const siblings = [ + 'steps:', // 1 + ' run: |', // 2 + ' printf "$A_BODY"', // 3 + ' run: |', // 4 + ' printf "$B_BODY"', // 5 + ].join('\n'); + + const blocks = extractRunBlocks(siblings); + expect( + blocks.map(b => b.headerLine), + 'Advancing the cursor past a scalar must not swallow the line that ended it.' + ).toEqual([2, 4]); + expect(scanRunBlocks(blocks, 'siblings.yml').map(v => v.line)).toEqual([3, 5]); + }); + + it('still extracts the real compiled lock shape (inline and block runs)', () => { + // Non-regression: the shapes the gate depends on are unchanged by the advance. + const mixed = [ + 'steps:', // 1 + ' - name: inline', // 2 + ' run: echo hi', // 3 + ' - name: block', // 4 + ' run: |', // 5 + ' echo one', // 6 + '', // 7 + ' echo two', // 8 + ' - name: after', // 9 + ' run: echo bye', // 10 + ].join('\n'); + + const blocks = extractRunBlocks(mixed); + expect(blocks.map(b => b.headerLine)).toEqual([3, 5, 10]); + expect(blocks[1].lines.map(l => l.line)).toEqual([6, 7, 8]); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/test/gh-aw-shell-contract.ts b/test/gh-aw-shell-contract.ts new file mode 100644 index 000000000..c5c391a82 --- /dev/null +++ b/test/gh-aw-shell-contract.ts @@ -0,0 +1,349 @@ +/** + * Shell input security contract scanner (#1834). + * + * `workflows/squad.md` §"Shell input security contract [MANDATORY]" declares that + * attacker-controlled GitHub event text (issue/PR bodies and titles, comment + * bodies) may reach the shell only through named `env:` variables read via quoted + * parameter expansion. It names four greppable anti-patterns. That contract was + * declared but unenforced; this module is the detection half of the gate. + * + * The gate has two surfaces, because the two halves of the contract live in two + * different artifacts: + * + * 1. UNTRUSTED_TEMPLATE_IN_RUN is a property of the COMPILED workflow: an Actions + * `${{ github.event.*.body }}` expression inside a `run:` block is expanded by + * Actions *before* the shell starts, so shell quoting cannot save it. This can + * only be observed after `gh aw compile`, in `squad.lock.yml`'s `run:` blocks. + * + * 2. The printf / eval / bash -c / awk hops are properties of the PARSER CODE — + * the `/squad` command parser one-liners in `workflows/squad.md`. gh-aw pulls + * that markdown in verbatim at runtime via `{{#runtime-import ... squad.md}}`; + * it is never inlined into the lock, so the lock cannot observe it. Those hops + * are therefore scanned on the runtime-imported source, which is the exact + * bytes the agent runs. + * + * The detectors key on *attacker body references* — a `${{ github.event.*.body }}` + * expression, or a shell expansion of a body-carrying variable — not on the mere + * presence of `$`. gh-aw's own machinery legitimately runs `bash -c 'set +o … + * export PATH="$PATH"'` and `source "${RUNNER_TEMP}/…"`; those carry runner-owned + * values, never attacker text, and must not trip the gate. Keying on body + * references is both what the contract actually forbids and what keeps the gate + * from going permanently red. + */ + +export type ContractToken = + | 'UNTRUSTED_TEMPLATE_IN_RUN' + | 'UNTRUSTED_COMMAND_STRING' + | 'UNTRUSTED_PRINTF_FORMAT' + | 'UNTRUSTED_AWK_PROGRAM_OR_VAR'; + +export interface ContractViolation { + token: ContractToken; + file: string; + /** 1-based line number of the offending line. */ + line: number; + /** The offending source line, trimmed, for the diagnostic. */ + evidence: string; +} + +/** One `run:` block extracted from a compiled workflow, with absolute line numbers. */ +export interface RunBlock { + /** 1-based line where the `run:` key appears. */ + headerLine: number; + /** Body lines with absolute 1-based line numbers (inline runs yield one entry). */ + lines: Array<{ line: number; text: string }>; +} + +// Attacker-controlled GitHub event *text* fields: bodies and titles. Numbers and +// ids (issue.number, comment.id) are not attacker text and are deliberately excluded. +const ATTACKER_EVENT_FIELD = + 'github\\.event\\.(?:issue|comment|pull_request|discussion)\\.(?:body|title)\\b'; + +/** A `${{ … }}` Actions expression that references attacker event text. */ +const TEMPLATE_ATTACKER_EVENT = new RegExp( + `\\$\\{\\{[^]*?${ATTACKER_EVENT_FIELD}[^]*?\\}\\}` +); + +/** + * A shell expansion of a variable that carries attacker body/title text. The + * sanctioned channel names it `SQUAD_TRIGGER_BODY` / `SQUAD_DISPATCH_COMMAND`; any + * `*_BODY` / `*_TITLE` variable, or a lower-case `$body`, is treated as tainted so + * a renamed carrier cannot slip the gate. Matching requires a `$`/`${` sigil, so + * the literal word "body" in prose is not flagged. + * + * The carrier prefix admits underscores. An earlier `[A-Za-z0-9]+_BODY` matched only + * a SINGLE segment before the suffix, so multi-segment carriers — `$SQUAD_EVENT_BODY`, + * `$GITHUB_EVENT_ISSUE_BODY`, `$PULL_REQUEST_TITLE` — silently bypassed every detector + * while the doc comment above claimed "any `*_BODY` / `*_TITLE`". Since the env-var + * naming convention for event carriers is exactly multi-segment SCREAMING_SNAKE, the + * unenforced case was the *likely* one, not an exotic one. + */ +const BODY_VAR = + /\$\{?\s*(?:SQUAD_TRIGGER_BODY|SQUAD_DISPATCH_COMMAND|body|[A-Za-z0-9_]*_(?:BODY|TITLE))\b/i; + +/** Does this text carry attacker body text, via an Actions expression or a body variable? */ +function carriesBody(text: string): boolean { + return TEMPLATE_ATTACKER_EVENT.test(text) || BODY_VAR.test(text); +} + +/** + * Extract `run:` blocks from GitHub Actions workflow YAML, tracking absolute line + * numbers so violations can be reported as file:line. Handles both block scalars + * (`run: |`, `run: >`, with optional chomping indicators) and inline `run: cmd`. + * + * This is a deliberately line-oriented reader rather than a YAML parse: the gate + * must name the offending line, and a parsed scalar loses its line origin. + * + * A block scalar's body is opaque shell text, never workflow keys, so the outer + * cursor is advanced past it. Without that, a `run:` line embedded in a heredoc or + * echoed YAML (`cat <<'YAML' … run: | … YAML`) re-entered the header branch and + * opened a phantom block, double-reporting the same line and inflating the block + * count that the fail-closed "found something to scan" assertion depends on. + */ +export function extractRunBlocks(yamlText: string): RunBlock[] { + const lines = yamlText.replace(/\r\n/g, '\n').split('\n'); + const blocks: RunBlock[] = []; + + for (let i = 0; i < lines.length; i++) { + const header = lines[i].match(/^(\s*)run:(\s*)(.*)$/); + if (!header) continue; + + const indent = header[1].length; + const value = header[3]; + + if (/^[|>][+-]?\s*$/.test(value)) { + // Block scalar: body is the following lines indented deeper than `run:`. + const body: Array<{ line: number; text: string }> = []; + let j = i + 1; + for (; j < lines.length; j++) { + if (lines[j].trim() === '') { + body.push({ line: j + 1, text: lines[j] }); + continue; + } + const bodyIndent = lines[j].match(/^(\s*)/)![1].length; + if (bodyIndent <= indent) break; + body.push({ line: j + 1, text: lines[j] }); + } + // Trim trailing blank lines that belong to the next key, not the block. + while (body.length && body[body.length - 1].text.trim() === '') body.pop(); + blocks.push({ headerLine: i + 1, lines: body }); + // `j` is the first line the scalar does NOT own; resume scanning there so + // scalar content is never re-read as a workflow key. `-1` offsets the `i++`. + // Popped trailing blanks were still consumed by `j` and cannot be headers. + i = j - 1; + } else if (value !== '') { + // Inline run: the command is on the same line as the key. + blocks.push({ headerLine: i + 1, lines: [{ line: i + 1, text: value }] }); + } + } + + return blocks; +} + +/** + * After a `printf`, return the first non-flag argument token (with its quotes). + * + * Option stripping loops so any order of `-v NAME`, plain `-x` flags, and the `--` + * end-of-options separator is consumed. `--` matters specifically: it is not an + * operand, so reading it as the format slot made `printf -- "$body"` — the exact + * idiom used to stop a body starting with `-` from being parsed as a flag — return + * the harmless token `--` and bypass UNTRUSTED_PRINTF_FORMAT entirely. After `--` + * every remaining word is an operand, so option scanning stops there. + */ +function firstPrintfArg(afterPrintf: string): string { + let s = afterPrintf.replace(/^\s+/, ''); + + for (;;) { + // `printf -v NAME …` consumes a variable-name operand; skip it. + const vFlag = s.match(/^-v\s+\S+\s+/); + if (vFlag) { + s = s.slice(vFlag[0].length); + continue; + } + // `--` ends option parsing: the next word is the format slot, not a flag. + const endOfOptions = s.match(/^--\s+/); + if (endOfOptions) { + s = s.slice(endOfOptions[0].length); + break; + } + const flag = s.match(/^-[A-Za-z]+\s+/); + if (flag) { + s = s.slice(flag[0].length); + continue; + } + break; + } + + if (s[0] === "'") { + const end = s.indexOf("'", 1); + return end === -1 ? s : s.slice(0, end + 1); + } + if (s[0] === '"') { + // Read to the next unescaped double quote. + for (let k = 1; k < s.length; k++) { + if (s[k] === '\\') { k++; continue; } + if (s[k] === '"') return s.slice(0, k + 1); + } + return s; + } + // Bare word: read to the next shell separator. + const m = s.match(/^[^\s;&|)]+/); + return m ? m[0] : s; +} + +/** + * UNTRUSTED_PRINTF_FORMAT — the body must never be printf's format (first) slot; + * the format must be a literal and the body an argument. Flags a `printf` whose + * first argument carries body text. `printf '%s\n' "$body"` is safe (literal + * format, body in the argument slot); `printf "$body"` is not. + */ +function detectPrintfFormat(text: string): boolean { + const re = /\bprintf\b/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const arg = firstPrintfArg(text.slice(m.index + m[0].length)); + if (carriesBody(arg)) return true; + } + return false; +} + +/** + * UNTRUSTED_COMMAND_STRING — never build shell syntax from attacker text. Flags + * `eval`, `source`/`.`, or `bash -c`/`sh -c` whose argument carries body text. + * Runner-owned expansions (`$PATH`, `${RUNNER_TEMP}`) are not body and stay green. + */ +function detectCommandString(text: string): boolean { + const patterns = [ + /\beval\b([^\n]*)/g, + /\b(?:bash|sh)\s+-c\b([^\n]*)/g, + /(?:^|[\s;&|(])(?:source|\.)\s+([^\n]*)/g, + ]; + for (const re of patterns) { + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (carriesBody(m[1])) return true; + } + } + return false; +} + +/** + * UNTRUSTED_AWK_PROGRAM_OR_VAR — never interpolate attacker text into an awk + * program, and never pass the raw body through `awk -v` (which applies escape + * processing). Flags `awk -v name=` and a double-quoted awk program that + * carries body text. A static single-quoted program (`awk '…$0…'`) is safe: the + * `$0` there is an awk field reference, not a shell expansion. + */ +function detectAwk(text: string): boolean { + if (!/\bawk\b/.test(text)) return false; + + // awk -v name= + const vRe = /-v\s+[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\S+)/g; + let m: RegExpExecArray | null; + while ((m = vRe.exec(text)) !== null) { + if (carriesBody(m[1])) return true; + } + + // Double-quoted awk program that interpolates body text. Single-quoted programs + // cannot be interpolated by the shell and are the sanctioned form. + const progRe = /\bawk\b(?:\s+-[A-Za-z]\S*|\s+-v\s+\S+)*\s+("[^"]*")/g; + while ((m = progRe.exec(text)) !== null) { + if (carriesBody(m[1])) return true; + } + + return false; +} + +/** + * Scan a single line of shell for the three code-shape anti-patterns (printf, + * command-string, awk). UNTRUSTED_TEMPLATE_IN_RUN is intentionally NOT included + * here: it is only meaningful inside a compiled `run:` block and is applied by + * {@link scanRunBlocks}. + */ +function detectShellShapeTokens(text: string): ContractToken[] { + const tokens: ContractToken[] = []; + if (detectPrintfFormat(text)) tokens.push('UNTRUSTED_PRINTF_FORMAT'); + if (detectCommandString(text)) tokens.push('UNTRUSTED_COMMAND_STRING'); + if (detectAwk(text)) tokens.push('UNTRUSTED_AWK_PROGRAM_OR_VAR'); + return tokens; +} + +/** + * Scan compiled `run:` blocks for every contract anti-pattern, including + * UNTRUSTED_TEMPLATE_IN_RUN (an `${{ github.event.*.body }}` expression that + * survived into a `run:` block). Returns one violation per (line, token). + */ +export function scanRunBlocks(blocks: RunBlock[], file: string): ContractViolation[] { + const violations: ContractViolation[] = []; + for (const block of blocks) { + for (const { line, text } of block.lines) { + if (TEMPLATE_ATTACKER_EVENT.test(text)) { + violations.push({ token: 'UNTRUSTED_TEMPLATE_IN_RUN', file, line, evidence: text.trim() }); + } + for (const token of detectShellShapeTokens(text)) { + violations.push({ token, file, line, evidence: text.trim() }); + } + } + } + return violations; +} + +/** + * Scan runtime-imported parser shell (the `/squad` parser one-liners in + * `workflows/squad.md`) for the code-shape anti-patterns. These snippets are the + * exact bytes gh-aw imports at runtime; they never reach the lock, so this is the + * only surface on which the printf/eval/awk hops can be observed. + */ +export function scanShellLines( + lines: Array<{ line: number; text: string }>, + file: string +): ContractViolation[] { + const violations: ContractViolation[] = []; + for (const { line, text } of lines) { + for (const token of detectShellShapeTokens(text)) { + violations.push({ token, file, line, evidence: text.trim() }); + } + } + return violations; +} + +/** + * Extract the body lines of every fenced ```bash / ```sh block that references a + * body-carrying variable, with absolute line numbers. This isolates the parser + * code that actually handles attacker text from surrounding prose and correct-form + * documentation examples. + */ +export function extractBodyHandlingShell( + markdown: string +): Array<{ line: number; text: string }> { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const out: Array<{ line: number; text: string }> = []; + let inFence = false; + let fenceLines: Array<{ line: number; text: string }> = []; + + for (let i = 0; i < lines.length; i++) { + const open = lines[i].match(/^[ \t]*```(bash|sh)[ \t]*$/); + if (!inFence && open) { + inFence = true; + fenceLines = []; + continue; + } + if (inFence && /^[ \t]*```[ \t]*$/.test(lines[i])) { + inFence = false; + if (fenceLines.some(fl => BODY_VAR.test(fl.text) || TEMPLATE_ATTACKER_EVENT.test(fl.text))) { + out.push(...fenceLines); + } + continue; + } + if (inFence) fenceLines.push({ line: i + 1, text: lines[i] }); + } + + return out; +} + +/** Render violations into a single, actionable diagnostic naming token, file, and line. */ +export function formatViolations(violations: ContractViolation[]): string { + return violations + .map(v => ` ${v.token} ${v.file}:${v.line}\n ${v.evidence}`) + .join('\n'); +} diff --git a/workflows/squad.md b/workflows/squad.md index fdc629b6f..4c9606e2b 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -292,10 +292,26 @@ expressions, or when parser code passes a body variable as a `printf` format, into `eval`/`bash -c`, or into an awk program/`awk -v`. A gate that cannot turn red on a fixture whose `run:` prints a raw issue-body expression is not valid. -That gate is **not implemented**; it is tracked in #1834. This contract is -normative today but reviewed by hand, not enforced by CI. The steps below -satisfy hops 2–6 as written; hop 1 is unverifiable here, since this repository -ships no compiled gh-aw output. +That gate is **implemented** (#1834) in `test/gh-aw-quality.test.ts` (describe +`gh-aw: compiled workflow shell input security contract`), backed by the scanner +in `test/gh-aw-shell-contract.ts` and the positive-control fixture +`test/fixtures/gh-aw-shell-contract/violating.lock.yml`. It runs in CI, which +installs `gh aw` and compiles this workflow (see `.github/workflows/squad-ci.yml`). +The gate fails closed: a missing compiler, an absent lock, or zero inspected +surfaces are failures, never skips. + +Because the contract spans two artifacts, it is verified on two surfaces: + +- **Hop 1 (`UNTRUSTED_TEMPLATE_IN_RUN`)** is a property of the compiled lock, so + it is scanned there. Actions expands template expressions before the shell + starts, so an attacker-controlled event expression left in a compiled `run:` + block is the observable failure. +- **Hops 2–6 (`printf`/`eval`/`bash -c`/`awk`)** live in the `/squad` parser + one-liners below, which gh-aw pulls in verbatim at runtime via a + runtime-import of this file and never inlines into the lock. That + runtime-imported source is therefore the only surface on which those hops can + be observed, and the gate scans it directly. The steps below satisfy hops 2–6 + as written. ### Step PC-0: Normalize a dispatched command [MANDATORY on `workflow_dispatch`]