Skip to content

Harden publish workflow against expression-interpolation injection - #760

Merged
fpigeonjr merged 1 commit into
masterfrom
gh-759-security-publish-yml-interpolates-actions-expressi
Oct 6, 2026
Merged

fpigeonjr merged 1 commit into
masterfrom
gh-759-security-publish-yml-interpolates-actions-expressi

Conversation

@fpigeonjr

Copy link
Copy Markdown
Contributor

Description

.github/workflows/publish.yml interpolated GitHub Actions expressions
(${{ github.event_name }}, ${{ inputs.dry-run }}, ${{ vars.DRY_RUN }})
directly into the text of the Determine dry-run mode step's run: body.
Expression interpolation is substituted into the script text before bash
ever parses it, so a DRY_RUN value containing shell metacharacters would
execute as a command — and would do so before the step's own true/false
validation could reject it. This step lives in the publish job, the only
job holding id-token: write for npm Trusted Publishing, so the blast
radius is publish credentials for a public package.

This change moves those three values into the step's env: block and reads
them as quoted shell variables ("$EVENT_NAME", "$DISPATCH_DRY_RUN",
"$VARS_DRY_RUN"), so they arrive as data, never as script text. It also
adds 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 a run: body.

I audited every other run: body in the file:

  • Verify release tag matches package.json version reads GITHUB_REF_NAME
    and GITHUB_SHA as runner shell environment variables (e.g.
    "${GITHUB_REF_NAME#v}", $GITHUB_SHA), not as ${{ }} Actions-expression
    interpolation — already safe, confirmed with the proof script below, left
    unchanged.
  • Ensure npm supports Trusted Publishing and the two Publish (...) steps
    contain no ${{ }} in their run: bodies at all.
  • The three if: conditions (github.event_name == 'release',
    steps.mode.outputs['dry-run'] == 'true', the combined one on the live
    publish 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 in env: blocks and if:
conditions in this file — never inside a run: script body.

Motivation and Context

Closes #759

Type of Change (Select One and Apply Label)

  • Bug fix (non-breaking change which fixes an issue) → Apply bugfix label
  • New feature (non-breaking change which adds functionality) → Apply enhancement label
  • Breaking change (fix or feature that would cause existing functionality to change) → Apply breaking label
  • Documentation / configuration update → Apply maintenance label

(Also tagged security, since this closes a shell-injection vulnerability tracked under the security label — both labels applied.)

How to Test

  1. 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.
  2. Run the throwaway proof script below (not part of this PR, written to /tmp) that extracts the exact shell logic of the "before" (interpolated) and "after" (env-passed) forms of the Determine dry-run mode step and executes both against the same battery of inputs.
  3. Confirm the injection payload DRY_RUN = true"; echo PWNED; # executes PWNED and exits 0 in the before form, and is rejected with ::error::DRY_RUN must be 'true' or 'false' / exits 1 in the after form.
  4. Confirm every legitimate input (unset, true, false, TRUE, yes, workflow_dispatch with dry-run=true, workflow_dispatch with dry-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)

--- [1] injection payload DRY_RUN='true"; echo PWNED; #' (release/push event path) ---
-- before:
PWNED
dry-run=true
Publish dry-run mode: true
exit=0
-- after:
::error::DRY_RUN must be 'true' or 'false' (got 'true"; echo pwned; #')
exit=1

--- [2] unset DRY_RUN (release path) -> should default to dry-run ---
-- before:
dry-run=true
Publish dry-run mode: true
exit=0
-- after:
dry-run=true
Publish dry-run mode: true
exit=0

--- [3] DRY_RUN=true (release path) ---
-- before:
dry-run=true
Publish dry-run mode: true
exit=0
-- after:
dry-run=true
Publish dry-run mode: true
exit=0

--- [4] DRY_RUN=false (release path) ---
-- before:
dry-run=false
Publish dry-run mode: false
exit=0
-- after:
dry-run=false
Publish dry-run mode: false
exit=0

--- [5] DRY_RUN=TRUE (release path, case normalization) ---
-- before:
dry-run=true
Publish dry-run mode: true
exit=0
-- after:
dry-run=true
Publish dry-run mode: true
exit=0

--- [6] DRY_RUN=yes (release path, invalid non-boolean) ---
-- before:
::error::DRY_RUN must be 'true' or 'false' (got 'yes')
exit=1
-- after:
::error::DRY_RUN must be 'true' or 'false' (got 'yes')
exit=1

--- [7] workflow_dispatch with dry-run=true ---
-- before:
dry-run=true
Publish dry-run mode: true
exit=0
-- after:
dry-run=true
Publish dry-run mode: true
exit=0

--- [8] workflow_dispatch with dry-run=false (must be rejected, rehearsal-only) ---
-- before:
::error::workflow_dispatch runs must use dry-run=true. Publish a GitHub Release to run a live publish.
exit=1
-- after:
::error::workflow_dispatch runs must use dry-run=true. Publish a GitHub Release to run a live publish.
exit=1

--- [9] workflow_dispatch with injection payload in dry-run input ---
-- before:
PWNED
dry-run=true
Publish dry-run mode: true
exit=0
-- after:
::error::workflow_dispatch runs must use dry-run=true. Publish a GitHub Release to run a live publish.
exit=1

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 /tmp script (not committed to the repo) and runs it with bash -c; it does not trigger the workflow, call npm publish, or touch package.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

  • Branch name follows convention (gh-759-security-publish-yml-interpolates-actions-expressi)
  • PR title starts with a verb in the imperative mood
  • I have self-reviewed my own code
  • format:check passes (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 file
  • lint passes (npm run lint) — not applicable; this change touches only .github/workflows/publish.yml, which ng lint does not cover
  • build passes (cd test-app && npm run build) — not applicable; no source files changed
  • Tests pass and coverage is reported (cd test-app && npm test) — not applicable; no source files changed, coverage floor untouched
  • If this change requires a documentation update, I have updated it accordingly — added a security-model note to the workflow's own header comment and inline comments at the changed step
  • If there are dependent changes, they have been merged and published in downstream modules — N/A, no dependent changes

Acceptance Criteria (from #759)

  • "No ${{ }} interpolation remains inside any run: body in publish.yml" — Verified by grep -n '\${{' .github/workflows/publish.yml: the only remaining matches are in the header comment (prose, not executable), the inline comment above the env: block, and the three env: key assignments (EVENT_NAME, DISPATCH_DRY_RUN, VARS_DRY_RUN). Zero occurrences inside any run: | body.
  • "Expressions are passed via env: and read as quoted shell variables" — EVENT_NAME, DISPATCH_DRY_RUN, and VARS_DRY_RUN are declared in the step's env: block and read as "$EVENT_NAME", "$DISPATCH_DRY_RUN", "$VARS_DRY_RUN" in the script.
  • "The DRY_RUN boolean 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 to true), and [6] (yes → hard-fail, identical before/after) above.
  • "workflow_dispatch remains rehearsal-only" — Proof item [8]: dry-run=false on workflow_dispatch is rejected with the rehearsal-only error, identically before and after.
  • "A metacharacter payload is proven inert, with the before/after output recorded" — Proof items [1] and [9] above: the payload executes PWNED and exits 0 before the fix; it is rejected with the DRY_RUN must be 'true' or 'false' error and exits 1 after.
  • "id-token: write remains scoped to the publish job alone; all actions stay SHA-pinned" — Unchanged by this PR; confirmed by grep -n 'id-token\|uses:' .github/workflows/publish.yml: id-token: write appears only under the publish job's permissions:, and both uses: lines (actions/checkout, actions/setup-node) remain pinned to a full commit SHA with a version comment.
  • "Nothing published to npm by the fix; no version bump" — No package.json/package-lock.json changes; 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 passes env: 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.

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 fpigeonjr self-assigned this Oct 6, 2026
@fpigeonjr fpigeonjr added security Security vulnerabilities and advisories maintenance Repo maintenance / tooling labels Oct 6, 2026
@fpigeonjr
fpigeonjr marked this pull request as ready for review October 6, 2026 21:03
@fpigeonjr
fpigeonjr requested a review from a team as a code owner October 6, 2026 21:03
@fpigeonjr
fpigeonjr merged commit 07d7683 into master Oct 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintenance Repo maintenance / tooling security Security vulnerabilities and advisories

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: publish.yml interpolates Actions expressions into run: bodies — shell injection in the OIDC publish job

2 participants