Skip to content

Harden actions against script injection and pin third-party actions#28

Merged
MAfarrag merged 16 commits into
mainfrom
security-hardening
Jul 21, 2026
Merged

Harden actions against script injection and pin third-party actions#28
MAfarrag merged 16 commits into
mainfrom
security-hardening

Conversation

@MAfarrag

@MAfarrag MAfarrag commented Jul 20, 2026

Copy link
Copy Markdown
Member

Description

Hardens the composite actions and test workflows against the security issues found in a review of the repository.
No inputs, outputs, or observable behavior change — every generated command is identical; the values are simply passed
through env: (and quoted) instead of being interpolated into run: script text.

What changed:

  • Script injection (High): mkdocs-deploy read github.event.release.tag_name and the mike alias directly inside a
    run: body. A release published with a tag name containing shell metacharacters ($(…), backticks, ;) would run as
    code on the runner with access to the deploy token. Both are now passed via env: and quoted.
  • Supply chain (Medium): softprops/action-gh-release (which receives the release token) was pinned to the mutable
    @v3 tag; now pinned to commit 3d0d9888cb7fd7b750713d6e236d1fcb99157228 (v3.0.2), matching the SHA pinning already
    used for setup-uv and setup-pixi.
  • Defense-in-depth (Low): removed both eval calls in python-setup/pip (argv arrays instead); routed all
    caller-controlled inputs (install-groups, package, activate-environment, pypi-repository-url, cache,
    skip-publish, increment, prerelease-type, config-file, draft, …) and github.actor through env: across
    the pip, uv, pixi, release/pypi, mkdocs-deploy, release/github, and deploy-docs definitions. In
    release/github the commitizen command is also rebuilt as an argv array ("${CZ_CMD[@]}") rather than an
    interpolated string.
  • Review follow-ups: strengthened the mkdocs-deploy release-tag negative test into a paired positive/negative
    assertion, dropped an unused pages: write, and documented the contents: readskip-github-release coupling.
  • CI hardening (Low): defaulted the token to contents: read in the test-mkdocs-deploy and test-release/github
    workflows (their jobs push only to the local fake remote), and pinned astral-sh/setup-uv from @v7 to the SHA
    already used by the uv action (v8.3.2).

Issues

Type of change

Check relevant points.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Breaking change: release/github's install-groups input is now required with no default (was
default: 'groups: docs dev'). Callers that omitted it and relied on that default must now pass it explicitly.
This warrants a major version bump when release/github is next tagged.

How Has This Been Tested?

  • All 10 edited YAML files parse cleanly (yaml.safe_load).

  • Every non-templated run: block passes bash -n (native git-bash).

  • Simulated the rewritten pip/pypi argv construction to confirm the commands are unchanged:
    pip install .[aws,viz], pip install . --group dev --group docs, twine upload --repository-url <url> dist/*, and
    twine upload dist/* when no repository URL is set.

  • End-to-end validation runs via the existing test-*.yml workflows triggered by this PR (local execution is limited
    since these are GitHub Actions, per the repo's testing notes).

  • YAML validity + bash -n on all rewritten run blocks

  • pip/pypi argv-building simulation (behavior unchanged)

Checklist:

  • updated version number in pyproject.toml. — N/A: actions are versioned via namespaced tags (mkdocs-deploy/vX.Y.Z,
    release/github/vX.Y.Z, pip/vX.Y.Z) after merge, not a repo-level version.
  • added changes to History.rst. — N/A: repo has no History.rst.
  • updated the latest version in README file. — N/A: no version bump in this PR.
  • I have added tests that prove my fix is effective or that my feature works. — Existing test-*.yml suites exercise
    every affected action; changes are behavior-preserving hardening, so no new fixtures were added.
  • New and existing unit tests pass locally with my changes. — Validated via YAML parse + bash -n + argv simulation
    (full CI runs on this PR).
  • documentation are updated. — N/A: no user-facing input/output or usage changes.

MAfarrag added 4 commits July 20, 2026 21:49
Interpolating ${{ ... }} expressions directly into a run: body substitutes
the value into the script text before bash runs, so a value with shell
metacharacters (command substitution, ;, |, backticks) executes as code.

- mkdocs-deploy: read github.event.release.tag_name and the mike alias via
  env instead of inline interpolation, and quote them.
- python-setup/pip: drop eval for the extras/groups install (build argv arrays)
  and read install-groups/cache via env.
- python-setup/uv, pixi: read install-groups / activate-environment via env and
  quote the value passed to pixi list.
- release/pypi: read package, package-manager, install-groups, repository-url and
  skip-publish via env; build the twine --repository-url argument as an array.
- deploy-docs workflow: set the git user from github.actor via env.

The generated commands are unchanged.
…ia env

- Pin softprops/action-gh-release to commit 3d0d988 (v3.0.2) instead of the
  mutable @V3 tag. This action receives the release token, so a moved tag would
  run untrusted code with it. Matches the SHA pinning already used for setup-uv
  and setup-pixi.
- Configure the git user from github.actor via env rather than inline
  interpolation.
- test-mkdocs-deploy, test-release/github: default the workflow token to
  contents: read. The jobs push only to the local fake remote, so no write
  scope is needed.
- test-release/pypi: pin astral-sh/setup-uv to the SHA already used by the uv
  action (v8.3.2) instead of the mutable @v7 tag.
…e tag

Quoting RELEASE_TAG_VERSION in the mike deploy command (part of the injection
hardening) exposed a latent bug: when the release tag resolves to empty, the
previous unquoted expansion silently collapsed the empty word and shifted the
mike alias into the version slot, deploying a bogus version. Quoted, it now
passes an empty version and mike errors with '' is not a valid version.

Add an explicit guard so a release trigger with no resolvable tag (no release-tag
input and no github.event.release.tag_name) fails fast with a clear message, and
convert the corresponding test into a negative test that asserts this failure.
MAfarrag added 12 commits July 21, 2026 18:22
The negative test previously asserted only outcome == failure, so it could go
green even if the action failed for a reason other than the tag-resolution guard.
Pair it with a positive control (same fixture/setup, WITH a tag) and assert that
adding the tag is exactly what flips failure to success. Also drop the inert
GITHUB_EVENT_NAME env (the guard reads github.event.release.tag_name from the real
push event, not that variable).
mike deploys by pushing to the gh-pages branch over git (contents: write); it
never calls the GitHub Pages deployment API, so pages: write was unused. Drop it
for least privilege.
…lease

Document that the reduced token scope is safe only because every job sets
skip-github-release: 'true' (Releases API never called) and pushes go to the fake
remote, so a future real-release job would need contents: write.
…njection (SEC-8)

Mirror the hardening applied to the sibling actions: move every caller-controlled
input referenced inside a run: body (package-manager, install-groups, config-file,
increment, prerelease-type, draft, package-manager-version, python-version) into an
env: block and reference the quoted variable. Convert the commitizen command from an
interpolated string (CZ_CMD="pixi run -e <install-groups> cz", executed via $CZ_CMD)
into an argv array ("${CZ_CMD[@]}"), and drop the dead CZ_CMD build in the changelog-
validation step. This removes the two real injection sinks (unquoted install-groups in
an executed command string, and config-file inside dirname "$(...)") plus the free-form
increment/prerelease-type flags. No behavior change for valid inputs; the generated
commands are identical.

Closes #29
…bodies

Round-2 review found a second-order injection the SEC-8 fix left open: the
config-file input is sanitized at the bump step, but the derived WORK_DIR is
written to a step output and then re-interpolated as
WORK_DIR="${{ steps.bump_version.outputs.WORK_DIR }}" in a later run body, where a
config-file like "$(cmd)/pyproject.toml" would surface as WORK_DIR="$(cmd)" and
execute. Route every steps.bump_version.outputs.* (plus github.server_url /
github.repository) through env: and reference the quoted variable. No run body in
this action interpolates ${{ }} anymore.
The install-groups input previously defaulted to 'groups: docs dev' (pip/uv
format). For the pixi path that value is meaningless as an environment name, so a
pixi caller who relied on the default silently got an invalid environment. Make the
input required with no default so every caller must supply the correct value for its
package manager (pip/uv: a groups:/extras: string; pixi: a single environment name).

Update the five test jobs that relied on the old default (prerelease, python-versions,
custom-branch, cross-platform, git-configuration) to pass install-groups: 'groups: docs
dev' explicitly, preserving their prior behavior (commitizen lives in the dev group).

BREAKING CHANGE: install-groups is now required; callers that omitted it and relied on
the 'groups: docs dev' default must pass it explicitly.
…to security-hardening

# Conflicts:
#	actions/mkdocs-deploy/action.yml
… unavailable

install-groups is required with no default, but required is advisory for
composite actions (not runtime-enforced), so a caller that omits it silently gets
'' and the release fails deep in the bump step with a raw 'cz: not found' (or
'pixi run -e ""'). Add a preflight in the bump step that checks commitizen is
available and, if not, emits a clear ::error:: pointing at install-groups. Checks
availability (not emptiness), so the valid core-only case (empty install-groups
when commitizen is a core dependency) still works.
… inputs tables

install-groups is now required with no default (breaking). Update the hand-written
README inputs table and the generated reference doc's install-groups row + usage
snippet to match. Left the pre-existing description drift in the other reference
docs alone (unrelated to this change; regenerated on the next docs deploy).
…urface its error

Move the preflight to after 'cd "$WORK_DIR"' so it checks commitizen in the same
directory every real cz command runs from — a monorepo config-file in a sub-package
would otherwise be checked at the repo root and could false-positive for pixi. Also
stop suppressing stderr (>/dev/null only) so a uv/pixi sync or network failure is not
misreported as 'commitizen missing', and soften the comment: for uv the preflight
rarely fires (uv run re-syncs default groups); it mainly guards pip and non-default
groups.
install-groups is now required with no default, but the examples (all uv-based)
omitted it — copying them would fail the new commitizen preflight for pip/pixi and
silently rely on uv's auto-sync otherwise. Add install-groups: 'groups: dev' (the
group that provides commitizen) to all 27 example invocations in EXAMPLES.md and
getting-started.md. The version-pin snippet (no with: block) is left as-is. Kept the
@github-release/v1 refs — this ships as a minor bump, not v2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment