Hardening: release workflow supply-chain and robustness #18

Closed
opened 2026-10-15 22:38:58 -07:00 by eric · 1 comment
Owner

Type: hardening. Found by security audit run 3 (commit 01195c8) in .forgejo/workflows/release.yml and .forgejo/workflows/ci.yml. No lower-trust principal of this project can currently influence a release (only tag pushers trigger it), so these are not vulnerabilities.

  • Unpinned, unverified build tool. release.yml:16-18 downloads wp-cli.phar from raw.githubusercontent.com/wp-cli/builds/gh-pages with no version pin or checksum, then runs it to build the published zip. Pin a versioned release URL (github.com/wp-cli/wp-cli/releases/download/vX.Y.Z/wp-cli-X.Y.Z.phar) and verify a SHA-512 committed in the workflow (sha512sum -c), or build with git archive + .gitattributes export-ignore so no third-party executable is in the release path.
  • Floating actions. actions/checkout@v4 and shivammathur/setup-php@v2 (release.yml:13, ci.yml:13-14,34-35) are pinned to tags. Pin to full commit SHAs (keeping the full-URL form Forgejo needs) and set persist-credentials: false on the release checkout so later steps cannot read the job token from .git/config.
  • No release gating. Any v* tag on any commit is built and published. Consider Forgejo protected tags for v*, and a step that checks the tagged commit is on main (git merge-base --is-ancestor) and that ${TAG#v} matches the plugin header Version, MULTI_DOMAIN_REDUX_VERSION and readme.txt Stable tag.
  • Shell-built JSON. release.yml:31-41 interpolates TAG into a JSON body and API path in the shell. Build the JSON with an encoder (e.g. python3 -c 'import json,os;print(json.dumps({"tag_name":os.environ["TAG"],"name":os.environ["TAG"][1:]}))') and URL-encode TAG.
  • Possible functional bug (not verified): wp dist-archive . may write the zip to the parent directory by default, so ZIP=$(ls multi-domain-redux.*.zip) (release.yml:28) could find nothing. Pass an explicit target (wp dist-archive . ./) and add set -euo pipefail.
  • Reproducible CI inputs. composer.lock is gitignored, so CI resolves dev dependencies fresh each run. Committing it makes CI inputs reviewable (no effect on the shipped zip, which excludes vendor/).
  • Fork PRs on a self-hosted runner. ci.yml runs contributor code on pull_request. If outside forks can open PRs, require maintainer approval for fork workflow runs and make sure job containers cannot reach the DinD daemon the release job also uses.
**Type:** hardening. Found by security audit run 3 (commit `01195c8`) in `.forgejo/workflows/release.yml` and `.forgejo/workflows/ci.yml`. No lower-trust principal of this project can currently influence a release (only tag pushers trigger it), so these are not vulnerabilities. - **Unpinned, unverified build tool.** `release.yml:16-18` downloads `wp-cli.phar` from `raw.githubusercontent.com/wp-cli/builds/gh-pages` with no version pin or checksum, then runs it to build the published zip. Pin a versioned release URL (`github.com/wp-cli/wp-cli/releases/download/vX.Y.Z/wp-cli-X.Y.Z.phar`) and verify a SHA-512 committed in the workflow (`sha512sum -c`), or build with `git archive` + `.gitattributes export-ignore` so no third-party executable is in the release path. - **Floating actions.** `actions/checkout@v4` and `shivammathur/setup-php@v2` (`release.yml:13`, `ci.yml:13-14,34-35`) are pinned to tags. Pin to full commit SHAs (keeping the full-URL form Forgejo needs) and set `persist-credentials: false` on the release checkout so later steps cannot read the job token from `.git/config`. - **No release gating.** Any `v*` tag on any commit is built and published. Consider Forgejo protected tags for `v*`, and a step that checks the tagged commit is on `main` (`git merge-base --is-ancestor`) and that `${TAG#v}` matches the plugin header `Version`, `MULTI_DOMAIN_REDUX_VERSION` and `readme.txt` Stable tag. - **Shell-built JSON.** `release.yml:31-41` interpolates `TAG` into a JSON body and API path in the shell. Build the JSON with an encoder (e.g. `python3 -c 'import json,os;print(json.dumps({"tag_name":os.environ["TAG"],"name":os.environ["TAG"][1:]}))'`) and URL-encode `TAG`. - **Possible functional bug (not verified):** `wp dist-archive .` may write the zip to the parent directory by default, so `ZIP=$(ls multi-domain-redux.*.zip)` (`release.yml:28`) could find nothing. Pass an explicit target (`wp dist-archive . ./`) and add `set -euo pipefail`. - **Reproducible CI inputs.** `composer.lock` is gitignored, so CI resolves dev dependencies fresh each run. Committing it makes CI inputs reviewable (no effect on the shipped zip, which excludes `vendor/`). - **Fork PRs on a self-hosted runner.** `ci.yml` runs contributor code on `pull_request`. If outside forks can open PRs, require maintainer approval for fork workflow runs and make sure job containers cannot reach the DinD daemon the release job also uses.
eric closed this issue 2026-10-16 13:54:43 -07:00
Author
Owner

Resolved in 4bbe64d.

  • wp-cli phar, shell-built JSON, dist-archive bug: already fixed by the tag-driven release rewrite (191e78a). No third-party binary builds the zip, payloads go through json.dumps, and every step uses set -euo pipefail.
  • Release gating: the version-string and CHANGELOG checks were already in place. The on-main check and protected tags were skipped: single maintainer, single tagger.
  • Floating actions: kept on major tags on purpose so fixes roll in without pin maintenance. Instead, the workflow token is read-only, only the publish job gets contents: write, and that job uses plain git (no actions) and checks the fetched commit matches the tested SHA. All other checkouts set persist-credentials: false.
  • composer.lock: left uncommitted for the same rolling-update reason. It's dev-only and never shipped.
  • Fork PRs: not applicable, since instance registration is closed.

Rationale recorded in CLAUDE.md.

Resolved in 4bbe64d. - **wp-cli phar, shell-built JSON, dist-archive bug**: already fixed by the tag-driven release rewrite (191e78a). No third-party binary builds the zip, payloads go through json.dumps, and every step uses set -euo pipefail. - **Release gating**: the version-string and CHANGELOG checks were already in place. The on-main check and protected tags were skipped: single maintainer, single tagger. - **Floating actions**: kept on major tags on purpose so fixes roll in without pin maintenance. Instead, the workflow token is read-only, only the publish job gets contents: write, and that job uses plain git (no actions) and checks the fetched commit matches the tested SHA. All other checkouts set persist-credentials: false. - **composer.lock**: left uncommitted for the same rolling-update reason. It's dev-only and never shipped. - **Fork PRs**: not applicable, since instance registration is closed. Rationale recorded in CLAUDE.md.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eric/multi-domain-redux#18
No description provided.