From e2b5d49985e760e2472053ae6a7992f79e49e7c2 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:52:37 -0500 Subject: [PATCH] Harden publish workflow against expression-interpolation injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address GSA/sam-ui-elements#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. --- .github/workflows/publish.yml | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 609a89670..c7b5bc986 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,16 @@ name: Publish to npm # Until registration is complete, DRY_RUN defaults to true. DevSecOps must set # the release environment/repository variable DRY_RUN=false to enable a live # Release publish. No long-lived npm token is supported. +# +# Security model: values that originate outside this workflow file +# (`vars.DRY_RUN`, `inputs.dry-run`, event metadata) are never interpolated +# with `${{ }}` directly into a `run:` script body. Expression interpolation +# is substituted into the script *text* before bash parses it, so a value +# containing shell metacharacters would execute as code before any +# validation in the script could reject it. Such values are passed through +# a step's `env:` block instead and read as quoted shell variables. This +# matters most in the `publish` job, which holds `id-token: write` for npm +# Trusted Publishing. on: release: @@ -91,17 +101,29 @@ jobs: - name: Determine dry-run mode id: mode + env: + # NEVER interpolate these with `${{ }}` inside the script body: + # expression interpolation is substituted into the script *text* + # before bash parses it, so a DRY_RUN value such as + # `true"; echo PWNED; #` would execute as a command before the + # boolean validation below could reject it. This job holds + # `id-token: write` for npm Trusted Publishing, so the blast + # radius is publish credentials. Read these as quoted shell + # variables instead. + EVENT_NAME: ${{ github.event_name }} + DISPATCH_DRY_RUN: ${{ inputs.dry-run }} + VARS_DRY_RUN: ${{ vars.DRY_RUN }} run: | # Manual dispatch is rehearsal-only. Only a full GitHub Release may # perform a live publish, controlled by repo/environment DRY_RUN. - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - DRY="${{ inputs.dry-run }}" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + DRY="$DISPATCH_DRY_RUN" if [ "$DRY" != "true" ]; then echo "::error::workflow_dispatch runs must use dry-run=true. Publish a GitHub Release to run a live publish." exit 1 fi else - DRY="${{ vars.DRY_RUN }}" + DRY="$VARS_DRY_RUN" fi DRY="${DRY:-true}" DRY="$(echo "$DRY" | tr '[:upper:]' '[:lower:]')"