· 6 min read ·

SHA Pinning Solves One Problem in GitHub Actions Supply Chain Security

Source: lobsters

SHA pinning in GitHub Actions has become one of those practices that signals security-consciousness without necessarily delivering it. The recommendation appears in security guides, blog posts, and onboarding docs everywhere: replace uses: actions/checkout@v4 with uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683. Automated tools like Dependabot and Renovate will generate the PRs to do it. OpenSSF Scorecard will penalize you for skipping it. And yet, as a recent post on vaines.org argues, the protection provided is much narrower than the security posture it implies.

Understanding the model precisely makes it easier to see where it breaks down.

What the Guarantee Actually Is

Git tags are mutable. A lightweight tag is a named pointer to a commit object, and repo owners can force-push that pointer to a different commit: git tag -f v4 <new-sha> && git push --force origin v4. GitHub permits this. Your workflow file still says @v4, so on the next run, you execute whatever commit the tag now points to, with no indication that anything changed.

A git commit SHA is different. It is computed from the commit’s content: the tree it references, its parent commits, the author, the message, and the timestamp. Changing any of that produces a different SHA. You cannot create two commits with the same SHA without a hash collision; while SHA-1 collisions are theoretically achievable (see the SHAttered attack), git has deployed collision detection mitigations, and modern GitHub repositories use SHA-256.

When you write:

uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

you have a genuine cryptographic guarantee that the code running is exactly the code in that commit. No one can change what that SHA refers to. If the tag moves, you are unaffected.

The tj-actions/changed-files incident in March 2025 demonstrated this clearly. Attackers compromised the tj-actions repository, pushed malicious code, and moved version tags to point at it. The malicious code exfiltrated CI secrets by printing them to workflow logs. Repositories pinned to specific commit SHAs before the attack ran unaffected; repositories using tags ran the attacker’s code on the next CI trigger. This is the attack SHA pinning was designed to prevent, and in that specific case, it worked.

The guarantee holds within that one commit reference and extends no further.

The Transitive Dependency Problem

The tj-actions compromise was itself downstream of an earlier compromise: the reviewdog/action-setup action had its tags moved first. This matters because tj-actions/changed-files internally called reviewdog/action-setup inside its own action.yml. Repositories that had SHA-pinned tj-actions/changed-files to a known-good commit were still potentially exposed if that known-good commit of tj-actions referenced reviewdog by a mutable tag rather than a SHA.

Your SHA pin governs the commit that defines an action’s steps. When that action is a composite action calling other actions by tag, those inner references are resolved at runtime by GitHub’s runner. The pin does not reach them.

Consider what a composite action’s action.yml might contain:

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4       # resolved at runtime; your pin doesn't cover this
    - run: curl https://releases.example.com/tool-latest.tar.gz | tar -xz
    - run: ./tool/install.sh

Pinning to the SHA of this action guarantees the above file content is unchanged. It says nothing about where actions/setup-node@v4 points today, what that curl retrieves today, or what install.sh does.

JavaScript actions fare somewhat better because the bundled code lives in the repository, so the SHA pin covers the action’s actual executable logic. Docker actions that reference images by tag, composite actions calling other actions by tag, and any action that downloads external resources at runtime fall outside the SHA pin’s scope entirely.

This structural gap separates SHA pinning from npm’s package-lock.json. A lockfile records the exact version and integrity hash for every package in the full transitive dependency tree. When you run npm ci, npm verifies each tarball against its recorded SHA-512 hash before executing anything. The lock is deep. GitHub Actions SHA pinning is shallow: you lock the top-level reference, and everything that reference invokes is free to change. Using a SHA pin without understanding what the action does at runtime is roughly like locking your front door while leaving the back one open.

How Scorecard Creates Compliance Drift

The OpenSSF Scorecard includes a Pinned-Dependencies check that deducts points when workflow files reference actions by tag rather than SHA. This check integrates with GitHub’s dependency insights tooling and various enterprise security dashboards. A project with SHA-pinned actions scores higher, and that score influences procurement decisions, audits, and onboarding checklists.

The score is not wrong exactly. SHA pinning is better than not doing it for the specific attack it addresses. The problem is that the check creates an incentive to fix what Scorecard can measure while leaving unmeasured risk in place. A team that sees a green Pinned-Dependencies check may reasonably conclude their CI supply chain is in reasonable shape. Scorecard cannot tell you whether pinned actions download unverified binaries at runtime, whether their transitive action references are themselves pinned, or whether GITHUB_TOKEN permissions follow least privilege.

OpenSSF’s own documentation describes SHA pinning as a “defense-in-depth” measure, not a comprehensive control. That framing is accurate. The marketing around it has drifted from it.

There is also a practical tension with keeping pins current. A SHA pinned several months ago may correspond to a version with known vulnerabilities. Dependabot and Renovate both automate SHA update PRs, but if teams find the review volume burdensome and disable automation, they end up with frozen dependencies, which is its own class of security problem.

Controls That Address the Remaining Surface

The controls that cover what SHA pinning does not require different approaches.

Minimum GITHUB_TOKEN permissions reduce the damage a compromised action can do. Declaring the permissions block explicitly and granting only what a workflow needs means that an action exfiltrating your token gets a credential with minimal scope:

permissions:
  contents: read
  pull-requests: write

GitHub OIDC for cloud credentials removes the need for long-lived secrets stored as repository variables. Workflows request short-lived tokens from AWS, GCP, or Azure at runtime; a compromised action receives a credential that expires in minutes and is scoped to the specific workflow context. This significantly limits what stolen credentials can do even if an action is fully attacker-controlled.

Careful handling of pull_request_target matters because this trigger runs workflows with write permissions for PRs from forks, making it a common source of confused deputy attacks. GitHub’s security hardening guide recommends keeping external code checkouts isolated from the privileged workflow environment when using this trigger.

Expression injection is persistent and easy to miss. Passing ${{ github.event.pull_request.title }} directly into a run: step allows an attacker to craft a PR title that executes arbitrary shell commands in your CI runner. Using an intermediate environment variable breaks the injection path:

- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "$PR_TITLE"

None of these controls are as simple to check off as “are the SHAs pinned,” which is part of why the pinning recommendation propagated so widely. A scoring tool can parse a YAML file and count tag references; it cannot easily audit what an action fetches at runtime or whether OIDC is configured correctly for every cloud integration.

The Actual Threat Model

Supply chain attacks on CI pipelines are attractive because the CI environment is often the most privileged context in a software delivery system. It holds credentials to sign releases, push packages, deploy to production, and write back to the repository. Attackers who compromise an action that runs in your CI do not need to escalate privileges; they inherit the privileges already granted to the runner.

SHA pinning addresses one specific path into that environment: an attacker who can move a git tag on an action you reference. It is a real path, and the tj-actions incident confirms it. The honest framing of SHA pinning is that it closes this one path while other paths, transitive action dependencies, runtime binary downloads, Docker image tag mutability, token over-permissioning, and injection vulnerabilities, remain open until addressed independently.

The vaines.org post frames this as a comforting lie, and the framing holds. SHA pinning provides real but narrow protection and has accumulated security connotations well beyond what it delivers. Doing it is correct. Treating it as a supply chain security strategy is not.

Was this interesting?