Skip to content

ci: pin actions to commit SHAs and scope workflow permissions - #935

Open
Totara-thib wants to merge 3 commits into
air-verse:masterfrom
Totara-thib:ci-hardening
Open

Totara-thib wants to merge 3 commits into
air-verse:masterfrom
Totara-thib:ci-hardening

Conversation

@Totara-thib

@Totara-thib Totara-thib commented Aug 4, 2026

Copy link
Copy Markdown

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 @v3 is 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.yml pushes cosmtrek/air:latest and the ghcr image on every push to master while holding the Docker Hub token, and release.yml runs 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-action in 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@v2 to v2.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 the github-actions ecosystem (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 in build.yml which keeps packages: write (it logs in to GHCR with the token) and the release job which gets contents: write (GoReleaser creates the GitHub release). Docker Hub and Codecov use their own secrets, not the token. stale.yml already declares scoped permissions (nicely done) and only got the pin.

Commit 3 turns off the Go module cache restore in the release job (cache: false on 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 be owner/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.

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

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant