Context
Follow-up hardening item (internal tracking id: SEC-8) surfaced by the security review that produced PR #28.
That PR moved caller-controlled inputs out of run: script bodies and into env: blocks for the
python-setup/pip, python-setup/uv, python-setup/pixi, and release/pypi actions, and removed eval.
actions/release/github/action.yml was intentionally left out of that PR's scope, so it still interpolates
inputs.* directly into shell script text — the same anti-pattern PR #28 fixed everywhere else.
Problem / Current Behaviour
GitHub Actions substitutes ${{ ... }} expressions into the run: script text before bash executes.
A value containing shell metacharacters ($( ), backticks, ;, |) therefore becomes part of the script
and runs as code. release/github interpolates several caller-controlled inputs directly into run: bodies,
including into executed command strings and a command substitution.
These inputs come from the calling workflow (normally a trusted maintainer), so this is not directly
attacker-controlled today — severity is defense-in-depth. It becomes exploitable if a consumer wires one of
these inputs from untrusted data (e.g. config-file: ${{ github.event.client_payload.path }}). It is flagged
for consistency: the same anti-pattern was already removed from the sibling actions.
Affected locations
All in actions/release/github/action.yml (line numbers as of the security-hardening branch / post-PR #28):
| Step |
Lines |
Sink |
Input(s) |
| Generate changelog and bump version |
340, 344, 368, 371, 400, 402 |
$CZ_CMD bump ... --increment X --prerelease Y (executed, unquoted) |
increment, prerelease-type |
| Validate changelog / Generate changelog |
226, 286, 288 |
CZ_CMD="pixi run -e <install-groups> cz" (later executed) |
install-groups |
| Validate changelog / Generate changelog |
228, 290 |
CZ_CMD="<package-manager> run cz" (later executed) |
package-manager |
| Validate changelog / Generate changelog |
233, 298 |
WORK_DIR=$(dirname "<config-file>") then cd "$WORK_DIR" |
config-file |
| Display release configuration |
157–174 |
echo of inputs (command-substitution-in-echo) |
multiple |
| Fold refreshed lockfile |
476 |
case "<package-manager>" in |
package-manager |
| Create GitHub Release / Release summary |
550, 623–627 |
echo of inputs |
draft, increment, prerelease-type |
Safe and out of scope: if: conditions (e.g. lines 183, 190, 198, 446, 603) and with: inputs
(186–203, 606–610) are GitHub-expression contexts, not shell — they need no change.
Steps to Reproduce / Motivation Example
# A consumer workflow that pipes untrusted data into an input reaches a shell context:
- uses: serapeum-org/github-actions/actions/release/github@release/github/v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
config-file: ${{ github.event.client_payload.path }} # attacker-influenced -> RCE on the runner
With config-file set to $(malicious), the WORK_DIR=$(dirname "$(malicious)") line executes it.
Proposed Solution
Same mechanical treatment applied in PR #28: for every step that references ${{ inputs.* }} inside its
run: body, add the value to that step's env: block and reference the quoted shell variable instead.
# Before
- name: Generate changelog and bump version with commitizen
shell: bash
run: |
NEXT_VERSION=$($CZ_CMD bump --yes --increment ${{ inputs.increment }} \
--prerelease ${{ inputs.prerelease-type }} ...)
# After
- name: Generate changelog and bump version with commitizen
shell: bash
env:
INCREMENT: ${{ inputs.increment }}
PRERELEASE_TYPE: ${{ inputs.prerelease-type }}
PACKAGE_MANAGER: ${{ inputs.package-manager }}
INSTALL_GROUPS: ${{ inputs.install-groups }}
CONFIG_FILE: ${{ inputs.config-file }}
run: |
NEXT_VERSION=$($CZ_CMD bump --yes --increment "$INCREMENT" \
--prerelease "$PRERELEASE_TYPE" ...)
Behavior is unchanged; only the substitution mechanism changes.
Out of Scope
Effort Estimate
Size: S
Rationale: single file; ~6 run: steps get an env: block and quoted-variable substitutions, plus a
CI re-run — no logic changes.
Definition of Done
Context
Follow-up hardening item (internal tracking id: SEC-8) surfaced by the security review that produced PR #28.
That PR moved caller-controlled inputs out of
run:script bodies and intoenv:blocks for thepython-setup/pip,python-setup/uv,python-setup/pixi, andrelease/pypiactions, and removedeval.actions/release/github/action.ymlwas intentionally left out of that PR's scope, so it still interpolatesinputs.*directly into shell script text — the same anti-pattern PR #28 fixed everywhere else.Problem / Current Behaviour
GitHub Actions substitutes
${{ ... }}expressions into therun:script text before bash executes.A value containing shell metacharacters (
$( ), backticks,;,|) therefore becomes part of the scriptand runs as code.
release/githubinterpolates several caller-controlled inputs directly intorun:bodies,including into executed command strings and a command substitution.
These inputs come from the calling workflow (normally a trusted maintainer), so this is not directly
attacker-controlled today — severity is defense-in-depth. It becomes exploitable if a consumer wires one of
these inputs from untrusted data (e.g.
config-file: ${{ github.event.client_payload.path }}). It is flaggedfor consistency: the same anti-pattern was already removed from the sibling actions.
Affected locations
All in
actions/release/github/action.yml(line numbers as of thesecurity-hardeningbranch / post-PR #28):$CZ_CMD bump ... --increment X --prerelease Y(executed, unquoted)increment,prerelease-typeCZ_CMD="pixi run -e <install-groups> cz"(later executed)install-groupsCZ_CMD="<package-manager> run cz"(later executed)package-managerWORK_DIR=$(dirname "<config-file>")thencd "$WORK_DIR"config-filecase "<package-manager>" inpackage-managerdraft,increment,prerelease-typeSafe and out of scope:
if:conditions (e.g. lines 183, 190, 198, 446, 603) andwith:inputs(186–203, 606–610) are GitHub-expression contexts, not shell — they need no change.
Steps to Reproduce / Motivation Example
With
config-fileset to$(malicious), theWORK_DIR=$(dirname "$(malicious)")line executes it.Proposed Solution
Same mechanical treatment applied in PR #28: for every step that references
${{ inputs.* }}inside itsrun:body, add the value to that step'senv:block and reference the quoted shell variable instead.Behavior is unchanged; only the substitution mechanism changes.
Out of Scope
if:conditions andwith:inputs (already safe GitHub-expression contexts).python-setup/*andrelease/pypiactions (already hardened in PR Harden actions against script injection and pin third-party actions #28).actions/*(checkout/setup-python/cache) — separate consideration.Effort Estimate
Size:
SRationale: single file; ~6
run:steps get anenv:block and quoted-variable substitutions, plus aCI re-run — no logic changes.
Definition of Done
${{ inputs.* }}expression remains inside anyrun:body inactions/release/github/action.yml(grep-verifiable;
if:/with:occurrences excluded).actions/release/github/action.ymlparses as valid YAML and all rewrittenrun:blocks passbash -n.test-release-github.ymlsuite passes unchanged (behavior preserved).