Repository navigation
Harden publish workflow against expression-interpolation injection - #760
Merged
fpigeonjr merged 1 commit intoOct 6, 2026
Merged
Conversation
Address #759. - Pass `github.event_name`, `inputs.dry-run` and `vars.DRY_RUN` into the `Determine dry-run mode` step via the step's `env:` block and read them as quoted shell variables. `${{ }}` expressions are substituted into the generated script *text* before bash parses it, so a `DRY_RUN` value such as `true"; echo PWNED; #` executed before the boolean validation could reject it — in the only job holding `id-token: write` for npm Trusted Publishing. - Add a security-model note to the workflow header explaining why values from outside the workflow file must never be interpolated with `${{ }}` inside a `run:` body. - Audited every other `run:` body in the file: the `Verify release tag matches package.json version` step already reads `GITHUB_REF_NAME` and `GITHUB_SHA` as runner shell env vars (e.g. `"${GITHUB_REF_NAME#v}"`), which is already safe and needed no change. All `${{ }}` usage now appears only in `if:` conditions and `env:` blocks, never inside a `run:` script body. Behaviour is unchanged: unset -> dry-run, case normalized, any non-boolean hard-fails, workflow_dispatch stays rehearsal-only. Verified with a before/after shell-logic extraction proof against unset, true, false, TRUE, yes, a shell-metacharacter injection payload, and both workflow_dispatch dry-run values. No version bump. Nothing published to npm by this change.
fpigeonjr
marked this pull request as ready for review
October 6, 2026 21:03
halprin
approved these changes
Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
.github/workflows/publish.ymlinterpolated GitHub Actions expressions(
${{ github.event_name }},${{ inputs.dry-run }},${{ vars.DRY_RUN }})directly into the text of the
Determine dry-run modestep'srun:body.Expression interpolation is substituted into the script text before bash
ever parses it, so a
DRY_RUNvalue containing shell metacharacters wouldexecute as a command — and would do so before the step's own
true/falsevalidation could reject it. This step lives in the
publishjob, the onlyjob holding
id-token: writefor npm Trusted Publishing, so the blastradius is publish credentials for a public package.
This change moves those three values into the step's
env:block and readsthem as quoted shell variables (
"$EVENT_NAME","$DISPATCH_DRY_RUN","$VARS_DRY_RUN"), so they arrive as data, never as script text. It alsoadds a security-model note to the workflow header and an inline comment at
the changed step explaining why these must never be interpolated with
${{ }}inside arun:body.I audited every other
run:body in the file:Verify release tag matches package.json versionreadsGITHUB_REF_NAMEand
GITHUB_SHAas runner shell environment variables (e.g."${GITHUB_REF_NAME#v}",$GITHUB_SHA), not as${{ }}Actions-expressioninterpolation — already safe, confirmed with the proof script below, left
unchanged.
Ensure npm supports Trusted Publishingand the twoPublish (...)stepscontain no
${{ }}in theirrun:bodies at all.if:conditions (github.event_name == 'release',steps.mode.outputs['dry-run'] == 'true', the combined one on the livepublish step) are evaluated by the Actions runner itself as a boolean
gate on whether the step runs, not substituted into shell text — this is
the intended, safe use of
${{ }}and is unchanged.After this change,
${{ }}appears only inenv:blocks andif:conditions in this file — never inside a
run:script body.Motivation and Context
Closes #759
Type of Change (Select One and Apply Label)
bugfixlabelenhancementlabelbreakinglabelmaintenancelabel(Also tagged
security, since this closes a shell-injection vulnerability tracked under thesecuritylabel — both labels applied.)How to Test
actionlint .github/workflows/publish.yml→ exits 0, no findings. Note: actionlint does not flag this class of issue (confirmed per the issue body); its clean exit is not evidence of the fix, only that the YAML/expression syntax is still well-formed./tmp) that extracts the exact shell logic of the "before" (interpolated) and "after" (env-passed) forms of theDetermine dry-run modestep and executes both against the same battery of inputs.DRY_RUN = true"; echo PWNED; #executesPWNEDand exits0in the before form, and is rejected with::error::DRY_RUN must be 'true' or 'false'/ exits1in the after form.unset,true,false,TRUE,yes,workflow_dispatchwithdry-run=true,workflow_dispatchwithdry-run=false) behaves identically before and after.Expected result: malicious input is inert after the fix; all legitimate behavior is unchanged.
Proof output (recorded, literal)
Important: nothing was published to npm, and no version was bumped, by producing this evidence. The proof script above re-implements the step's shell logic locally in a throwaway
/tmpscript (not committed to the repo) and runs it withbash -c; it does not trigger the workflow, callnpm publish, or touchpackage.json/package-lock.json. No GitHub Actions run was triggered, no repo/environment variable was set.Screenshots (if appropriate)
N/A — this is a CI/CD workflow-file change with no UI surface.
Checklist
gh-759-security-publish-yml-interpolates-actions-expressi)format:checkpasses (npm run format:check) — not applicable to YAML workflow files under this repo's Prettier config scope, but ran it; no changes reported for this filelintpasses (npm run lint) — not applicable; this change touches only.github/workflows/publish.yml, whichng lintdoes not coverbuildpasses (cd test-app && npm run build) — not applicable; no source files changedcd test-app && npm test) — not applicable; no source files changed, coverage floor untouchedAcceptance Criteria (from #759)
${{ }}interpolation remains inside anyrun:body inpublish.yml" — Verified bygrep -n '\${{' .github/workflows/publish.yml: the only remaining matches are in the header comment (prose, not executable), the inline comment above theenv:block, and the threeenv:key assignments (EVENT_NAME,DISPATCH_DRY_RUN,VARS_DRY_RUN). Zero occurrences inside anyrun: |body.env:and read as quoted shell variables" —EVENT_NAME,DISPATCH_DRY_RUN, andVARS_DRY_RUNare declared in the step'senv:block and read as"$EVENT_NAME","$DISPATCH_DRY_RUN","$VARS_DRY_RUN"in the script.DRY_RUNboolean gate still: defaults to dry-run when unset, normalizes case, and hard-fails on any non-boolean value" — Proof items [2] (unset →true), [5] (TRUE→ normalized totrue), and [6] (yes→ hard-fail, identical before/after) above.workflow_dispatchremains rehearsal-only" — Proof item [8]:dry-run=falseonworkflow_dispatchis rejected with the rehearsal-only error, identically before and after.PWNEDand exits0before the fix; it is rejected with theDRY_RUN must be 'true' or 'false'error and exits1after.id-token: writeremains scoped to the publish job alone; all actions stay SHA-pinned" — Unchanged by this PR; confirmed bygrep -n 'id-token\|uses:' .github/workflows/publish.yml:id-token: writeappears only under thepublishjob'spermissions:, and bothuses:lines (actions/checkout,actions/setup-node) remain pinned to a full commit SHA with a version comment.package.json/package-lock.jsonchanges; this PR touches only.github/workflows/publish.yml. No workflow run was triggered to produce the proof above (see "How to Test" note).Residual verification gap (disclosed honestly)
The proof above exercises the step's shell logic verbatim, extracted and run locally with
bash -c/env, which is a faithful reproduction of how the GitHub Actions runner would execute it (Actions does string substitution into the script text for${{ }}the same way the "before" harness does, and passesenv:values as real process environment variables the same way the "after" harness does). It does not exercise the actual GitHub Actions YAML parser/runner end-to-end, since doing so would require triggering a real workflow run (explicitly out of scope per this task's instructions — no workflow run was triggered). This is a scoped, intentional gap, not an oversight.