ATALAIA
  1. Home
  2. Resources
  3. Blog
  4. Pin your actions: a one-afternoon fix
Pipeline · Guide

Pin your actions: a one-afternoon fix

Tags move, SHAs do not. Pin every action, let a bot keep them current and cut the token down to what each job needs.

3 min readAtalaia team

GitHub ActionsCI/CDHardening

Most workflow files reference actions like actions/checkout@v4. That looks like a version, but it is a git tag, and whoever controls the repository can point a tag at any commit they like, including one that did not exist yesterday. Your pipeline then runs the new code with your secrets, on the next push, without a single line changing in your repository.

The fix is not glamorous and it does not need a new tool. It is an afternoon of pinning, a small config file and a couple of permission lines. Here is the order we would do it in.

Why tags are the weak point

A tag reference is resolved at run time. If an attacker gets write access to an action's repository, or to a maintainer's account, they can force-push a tag to a malicious commit and every workflow using that tag picks it up. This is exactly what happened in the tj-actions/changed-files compromise in March 2025, where retagged versions dumped CI secrets into build logs.

A full 40-character commit SHA is different. It names one exact tree of files. Nobody can make it point somewhere else. Short SHAs and branch names do not count: only the full hash is immutable.

Pin every third-party action

Find what you are using first. This lists every uses: line that is not already a full SHA, ignoring local actions:

grep -rn "uses:" .github/workflows \
  | grep -v "./" \
  | grep -Ev "@[0-9a-f]{40}"

You can resolve a tag by hand with git ls-remote, but a tool is faster and less error-prone. The open-source pinact rewrites tags to SHAs and keeps the version as a trailing comment, so humans can still read it:

pinact run

# before
- uses: actions/setup-node@v4
# after
- uses: actions/setup-node@<full-40-char-sha> # v4.1.0

Two edge cases. Docker-based steps using docker:// should be pinned by image digest, not tag. And reusable workflows called from other repositories need the same treatment as actions.

Let a bot keep the pins current

The usual objection to pinning is that you stop getting fixes. You do not, if Dependabot watches the github-actions ecosystem. It understands SHA pins with version comments and raises a pull request that updates both.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      actions:
        patterns: ["*"]

Grouping keeps it to one pull request a week instead of a dozen. Review it like any other dependency change: read the diff of the action when the jump is large or the publisher is small.

Cut the GITHUB_TOKEN down

Pinning limits what code runs. Permissions limit what that code can do if something still goes wrong. Set the organisation or repository default to read-only, then declare permissions at the top of each workflow and widen them only on the job that needs it:

permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write

An empty permissions: {} is valid too, for jobs that only lint or test. While you are here, look for long-lived cloud keys in secrets and replace them with OIDC federation where your provider supports it.

The pull_request_target trap

pull_request_target runs in the context of the base repository, with access to secrets and a token that can write. It exists so that workflows can label or comment on pull requests from forks. The trap is checking out the fork's code and running it:

on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test   # runs attacker code with your secrets

The same goes for interpolating untrusted fields such as a pull request title straight into a run: script. Pass them through an environment variable instead, so the shell treats them as data.

Check yours in 10 minutes

  1. Run the grep above and count the unpinned references.
  2. Run zizmor .github/workflows. It flags unpinned actions, excessive permissions, risky triggers and template injection.
  3. Run actionlint to catch the syntax mistakes pinning sometimes introduces.
  4. Check the repository setting for default workflow permissions and set it to read.
  5. Add the Dependabot file and merge the first grouped update.

That is the afternoon. Once it is done, every change to what runs in your pipeline arrives as a reviewed pull request rather than a silent retag.

Where this sits on the line

In the tower (in development):

Talk it through

A 30-minute call. No slides, no price list, and a next step either way.