ci: pin actions to commit SHAs and scope workflow permissions - #935
Open
Totara-thib wants to merge 3 commits into
Open
Totara-thib wants to merge 3 commits into
Totara-thib wants to merge 3 commits into
Conversation
Version tags like v4 or v8 are movable pointers: whoever controls an action's repository can re-point them at new code at any time, and every workflow here picks that code up on the next run. The build workflow logs in to Docker Hub and GHCR and pushes the public air images, and the release workflow runs GoReleaser with a contents-scoped token, so a re-pointed tag would run inside jobs that hold publish credentials. The tj-actions/changed-files incident (CVE-2025-30066) worked exactly this way: a trusted action's tags were re-pointed at credential-stealing code. Pinning by full commit SHA makes the reviewed code the code that runs. Each pin keeps a version comment so humans and update bots still see the release; Dependabot and Renovate both understand SHA pins and keep bumping them with the comment in sync.
Without an explicit permissions block, every job gets the repository's default token scope, which can include write access to contents, and runs that build pull request code hold that token while executing code from the PR. Scoping each workflow to what its steps actually use keeps a compromised dependency or build script from turning into repo access. Derived from actual token use per workflow: - build.yml drops to contents: read; only the push_to_docker_latest job keeps packages: write, because it logs in to GHCR with GITHUB_TOKEN to push the latest image. Docker Hub and Codecov use their own secrets, not the token. - release.yml drops to contents: read; the release job gets contents: write, which GoReleaser needs to create the GitHub release and upload archives. The Docker Hub push uses its own credentials. - smoke_test.yml and smoke_test_reuse_job.yml run no step that writes through the token, so they drop to contents: read. - stale.yml already declares its own scoped permissions, unchanged.
The release job restored the shared Go module cache through setup-go before GoReleaser builds the binaries that ship to users. GitHub Actions caches can be written by other workflow runs, so a poisoned cache entry would flow straight into a published release. Downloading modules from source on the (rare) release builds costs a little time and removes that path; go.sum still verifies every module either way.
Codecov Report✅ All modified and coverable lines are covered by tests. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi, drive-by CI hardening in three commits, one logical change each.
Commit 1 pins every action to its commit sha, versions kept as comments. A tag like
@v3is a movable pointer: whoever controls the action, or anyone who compromises it, can re-point it and your next run executes their code with the job's credentials. The spots that matter here:build.ymlpushescosmtrek/air:latestand the ghcr image on every push to master while holding the Docker Hub token, andrelease.ymlruns GoReleaser with a contents-scoped token plus the Docker Hub push for tagged releases. A re-pointed tag in either job means a poisoned image or release binary shipped under your name. Same pattern as the tj-actions/changed-files incident (CVE-2025-30066).One line deserves a specific mention:
nick-invision/assert-actionin the smoke test only resolves through a redirect these days, the author renamed to nick-fields. Redirects like that are how repo-jacking happens: if the old name ever gets re-registered, a tag ref silently resolves to whatever the new owner published, while a sha pin fails closed instead of running substituted code.You already went this direction once when you pinned
gr2m/merge-schedule-action@v2tov2.7.0(#815); shas finish the job. All shas were resolved from the upstream repos and cross checked against their release tags. The pins stay maintainable: a dependabot config with thegithub-actionsecosystem (the repo has none today, happy to add it here if you want) or renovate both understand sha pins and keep bumping them with the version comment in sync.Commit 2 scopes the GITHUB_TOKEN per workflow, derived from what each job actually uses: everything drops to
contents: read, except the docker job inbuild.ymlwhich keepspackages: write(it logs in to GHCR with the token) and the release job which getscontents: write(GoReleaser creates the GitHub release). Docker Hub and Codecov use their own secrets, not the token.stale.ymlalready declares scoped permissions (nicely done) and only got the pin.Commit 3 turns off the Go module cache restore in the release job (
cache: falseon setup-go). Actions caches are writable from other workflow runs, so the job that builds the binaries users download should fetch modules from source rather than trust a shared cache; go.sum still verifies everything either way. Release builds are rare enough that the extra download time does not matter.One heads up: if the org uses an Actions allowlist in settings, patterns written against tags (like
owner/action@v3) stop matching once refs are shas and workflows refuse to start. Entries need to beowner/action@*in that case.Found with the Plumber CLI (https://github.com/getplumber/plumber), verified on master. I also opened #936 which adds it to CI so this does not quietly drift back, that one is a bonus, this PR stands on its own.