release workflow uses mutable action and tool versions with publish tokens #21

Closed
opened 2026-06-04 16:19:26 +00:00 by KofTwentyTwo · 2 comments
KofTwentyTwo commented 2026-06-04 16:19:26 +00:00 (Migrated from github.com)

Finding

The release workflow runs with publish-capable tokens while using mutable action tags and the latest GoReleaser binary.

Evidence

  • .github/workflows/release.yml:9 to .github/workflows/release.yml:10 grants write access to repository contents.
  • .github/workflows/release.yml:17 to .github/workflows/release.yml:39 uses version tags for actions and passes both release tokens to GoReleaser.
  • .github/workflows/release.yml:30 to .github/workflows/release.yml:33 requests the latest GoReleaser action tooling.
  • .github/workflows/ci.yml:25 to .github/workflows/ci.yml:72 also uses mutable action version tags in CI.
  • .goreleaser.yaml:62 to .goreleaser.yaml:70 publishes the Homebrew formula with the tap token.

Reproduction

Inspect the release workflow. Any unexpected upstream change in a referenced mutable action or the latest GoReleaser binary executes in a job that can publish GitHub release artifacts and update the Homebrew tap.

Why It Matters

The release job is the software supply-chain boundary for distributed binaries and the Homebrew formula. Mutable tooling in that token context increases the blast radius of compromised or unexpectedly changed upstream release tooling.

Suggested Fix

Pin actions and GoReleaser to immutable versions or SHAs, keep permissions minimal per job, and document a regular pin-refresh process.

## Finding The release workflow runs with publish-capable tokens while using mutable action tags and the latest GoReleaser binary. ## Evidence - `.github/workflows/release.yml:9` to `.github/workflows/release.yml:10` grants write access to repository contents. - `.github/workflows/release.yml:17` to `.github/workflows/release.yml:39` uses version tags for actions and passes both release tokens to GoReleaser. - `.github/workflows/release.yml:30` to `.github/workflows/release.yml:33` requests the latest GoReleaser action tooling. - `.github/workflows/ci.yml:25` to `.github/workflows/ci.yml:72` also uses mutable action version tags in CI. - `.goreleaser.yaml:62` to `.goreleaser.yaml:70` publishes the Homebrew formula with the tap token. ## Reproduction Inspect the release workflow. Any unexpected upstream change in a referenced mutable action or the latest GoReleaser binary executes in a job that can publish GitHub release artifacts and update the Homebrew tap. ## Why It Matters The release job is the software supply-chain boundary for distributed binaries and the Homebrew formula. Mutable tooling in that token context increases the blast radius of compromised or unexpectedly changed upstream release tooling. ## Suggested Fix Pin actions and GoReleaser to immutable versions or SHAs, keep permissions minimal per job, and document a regular pin-refresh process.
KofTwentyTwo commented 2026-06-04 16:28:53 +00:00 (Migrated from github.com)

Status: confirmed — every file:line citation in the body checks out against the tree at v0.2.1 (HEAD f4a111f); the release job runs publish-capable tokens alongside mutable action tags and version: latest GoReleaser.

Severity: medium (label is bug/github_actions, no severity label). This is a real supply-chain hardening gap, but it is latent: it requires a compromised/mutated upstream action or GoReleaser build to bite. No defect in shipped behavior. Medium is appropriate — not high, since there's no evidence of an active vector and the tokens are scoped (contents: write + a dedicated tap PAT), not org-admin.

Verified root cause (this is CI/config, not Go source — the render/detect boundary code is unrelated):

  • .github/workflows/release.yml:9-10 — workflow-level permissions: contents: write; the goreleaser job has no job-level permissions: override, so the write scope is the job's effective scope.
  • .github/workflows/release.yml:18,25,31 — actions/checkout@v6, actions/setup-go@v6, goreleaser/goreleaser-action@v7 (mutable major tags).
  • .github/workflows/release.yml:33 — version: latest pulls the newest GoReleaser binary at run time.
  • .github/workflows/release.yml:37,39 — GITHUB_TOKEN and HOMEBREW_TAP_TOKEN both injected into that same step.
  • .github/workflows/ci.yml:25,28,46,61,82,94,105 — mutable tags (checkout@v6, setup-go@v6, golangci-lint-action@v9, upload-artifact@v7) plus version: latest for golangci-lint at ci.yml:51.
  • .goreleaser.yaml:62-70 — brews: block pushes the formula to KofTwentyTwo/homebrew-tap using {{ .Env.HOMEBREW_TAP_TOKEN }} (line 70).

Suggested fix:

  • Pin all uses: to a commit SHA with the version in a trailing comment (e.g. actions/checkout@<sha> # v6.x), and let Dependabot (github_actions ecosystem) bump the SHAs — this preserves the current "dependabot bumps the tag" flow (cf. ce4edf7) while making each ref immutable.
  • Pin GoReleaser: replace version: latest at release.yml:33 with a concrete tag (e.g. version: "~> v2" or an exact vX.Y.Z). Mirror the already-good precedent in this repo — ci.yml:72 pins go-test-coverage to @v2.18.8. Apply the same discipline to golangci-lint's version: latest at ci.yml:51.
  • Scope the token blast radius: add a job-level permissions: { contents: write } block on the goreleaser job so the grant is explicit and local rather than inherited workflow-wide; the tap PAT is already a separate secret (good).
  • Document a pin-refresh cadence in RELEASING.md.
  • No precedent transfer / no boundary work: the v0.2.1 universal-shebang fix and its two-boundary rule (internal/render/render.go INSERT path + internal/detect/detect.go PreserveBoundary) plus the filetype table are about header placement and are not relevant here. This change is YAML-only.
  • 100% gate: unaffected — no ./internal/... or ./cmd/... Go source changes, so .testcoverage.yml (total: 100) stays green with no new tests.

Corrections to the issue:

  • The body lumps ci.yml:72 into "mutable action version tags," but that line is the go-test-coverage gate invoked as go run github.com/vladopajic/go-test-coverage/v2@v2.18.8 — it is already pinned to an exact version, not mutable. The genuinely mutable CI refs are the uses: major tags and golangci-lint-action's version: latest (ci.yml:51), which the body's range (25-72) covers but does not call out.
  • Minor: release.yml:30-33 is the GoReleaser step block; the actual version: latest line is :33.

Open questions (maintainer must decide before implementing):

  • Pin style: exact SHAs (strongest, Dependabot-maintained) vs. exact semver tags (readable, weaker)? Repo currently relies on Dependabot tag bumps — moving to SHAs changes that workflow.
  • GoReleaser version policy: hard-pin an exact vX.Y.Z vs. a ~> v2 constraint (floating within a major)?
  • Should the job-level permissions tightening be in scope for this issue or tracked separately?

Overlaps: none identified among the issues reviewed. This is the only CI/supply-chain hardening item; it does not depend on the render/detect boundary issues.

Automated maintainer review.

**Status**: confirmed — every file:line citation in the body checks out against the tree at `v0.2.1` (HEAD `f4a111f`); the release job runs publish-capable tokens alongside mutable action tags and `version: latest` GoReleaser. **Severity**: medium (label is `bug`/`github_actions`, no severity label). This is a real supply-chain hardening gap, but it is latent: it requires a compromised/mutated upstream action or GoReleaser build to bite. No defect in shipped behavior. Medium is appropriate — not high, since there's no evidence of an active vector and the tokens are scoped (`contents: write` + a dedicated tap PAT), not org-admin. **Verified root cause** (this is CI/config, not Go source — the render/detect boundary code is unrelated): - `.github/workflows/release.yml:9-10` — workflow-level `permissions: contents: write`; the `goreleaser` job has **no** job-level `permissions:` override, so the write scope is the job's effective scope. - `.github/workflows/release.yml:18,25,31` — `actions/checkout@v6`, `actions/setup-go@v6`, `goreleaser/goreleaser-action@v7` (mutable major tags). - `.github/workflows/release.yml:33` — `version: latest` pulls the newest GoReleaser binary at run time. - `.github/workflows/release.yml:37,39` — `GITHUB_TOKEN` and `HOMEBREW_TAP_TOKEN` both injected into that same step. - `.github/workflows/ci.yml:25,28,46,61,82,94,105` — mutable tags (`checkout@v6`, `setup-go@v6`, `golangci-lint-action@v9`, `upload-artifact@v7`) plus `version: latest` for golangci-lint at `ci.yml:51`. - `.goreleaser.yaml:62-70` — `brews:` block pushes the formula to `KofTwentyTwo/homebrew-tap` using `{{ .Env.HOMEBREW_TAP_TOKEN }}` (line 70). **Suggested fix**: - Pin all `uses:` to a commit SHA with the version in a trailing comment (e.g. `actions/checkout@<sha> # v6.x`), and let Dependabot (`github_actions` ecosystem) bump the SHAs — this preserves the current "dependabot bumps the tag" flow (cf. `ce4edf7`) while making each ref immutable. - Pin GoReleaser: replace `version: latest` at `release.yml:33` with a concrete tag (e.g. `version: "~> v2"` or an exact `vX.Y.Z`). Mirror the already-good precedent in this repo — `ci.yml:72` pins go-test-coverage to `@v2.18.8`. Apply the same discipline to `golangci-lint`'s `version: latest` at `ci.yml:51`. - Scope the token blast radius: add a job-level `permissions: { contents: write }` block on the `goreleaser` job so the grant is explicit and local rather than inherited workflow-wide; the tap PAT is already a separate secret (good). - Document a pin-refresh cadence in `RELEASING.md`. - **No precedent transfer / no boundary work**: the v0.2.1 universal-shebang fix and its two-boundary rule (`internal/render/render.go` INSERT path + `internal/detect/detect.go PreserveBoundary`) plus the filetype table are about header placement and are **not** relevant here. This change is YAML-only. - **100% gate**: unaffected — no `./internal/...` or `./cmd/...` Go source changes, so `.testcoverage.yml` (total: 100) stays green with no new tests. **Corrections to the issue**: - The body lumps `ci.yml:72` into "mutable action version tags," but that line is the go-test-coverage gate invoked as `go run github.com/vladopajic/go-test-coverage/v2@v2.18.8` — it is **already pinned** to an exact version, not mutable. The genuinely mutable CI refs are the `uses:` major tags and `golangci-lint-action`'s `version: latest` (`ci.yml:51`), which the body's range (25-72) covers but does not call out. - Minor: `release.yml:30-33` is the GoReleaser step block; the actual `version: latest` line is `:33`. **Open questions** (maintainer must decide before implementing): - Pin style: exact SHAs (strongest, Dependabot-maintained) vs. exact semver tags (readable, weaker)? Repo currently relies on Dependabot tag bumps — moving to SHAs changes that workflow. - GoReleaser version policy: hard-pin an exact `vX.Y.Z` vs. a `~> v2` constraint (floating within a major)? - Should the job-level `permissions` tightening be in scope for this issue or tracked separately? **Overlaps**: none identified among the issues reviewed. This is the only CI/supply-chain hardening item; it does not depend on the render/detect boundary issues. _Automated maintainer review._
KofTwentyTwo commented 2026-06-04 20:28:16 +00:00 (Migrated from github.com)

Resolved on develop in commit 742c124e6ec2afa9312075db3112bc2def3c91ff.

Validation:

Closing as fixed.

Resolved on `develop` in commit `742c124e6ec2afa9312075db3112bc2def3c91ff`. Validation: - CI: success, https://github.com/KofTwentyTwo/license-tool/actions/runs/26977377097 - CodeQL: success, https://github.com/KofTwentyTwo/license-tool/actions/runs/26977376421 - Coverage gate: 100% (2087/2087) Closing as fixed.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/license-tool#21