How to Pin and Verify GitHub Actions to Stop Supply Chain Poisoning
See a floating GitHub Actions tag get silently poisoned in a real demo, then build a Python tool that pins actions to commit SHAs and catches mismatched pins before they reach CI.
Somewhere in your repository is a line that looks like uses: actions/checkout@v4. That single line is a dependency, and right now it is unpinned. A version tag like v4 is not a fixed point in history: the maintainer of that action, or anyone who compromises their account, can move it to point at a different commit at any time. Your workflow file does not change. Your pull request history shows nothing new. But the next time your pipeline runs, it checks out and executes whatever code that tag currently points to, with access to your repository and, in many workflows, a token that can write to it.
Table Of Content
- What You Will Learn and Why It Matters
- Prerequisites
- Step 1: Build a Throwaway “Third-Party Action” to Attack
- Step 2: Simulate a CI Runner and Watch It Execute Whatever the Ref Points To
- Step 3: Poison the Tag
- Step 4: Run the Same Reference Again
- Step 5: Prove That a SHA Pin Is Immune
- Step 6: Pin a Real Action, the Manual Way
- Step 7: Build the Tag Resolver, and Hit a Real Annotated-Tag Bug
- Why Getting This Wrong Matters in Practice
- Step 8: Build the Workflow Auditor, and Hit a Second Real Bug
- The Bug: a Common Form That Silently Passed Straight Through
- Step 9: Run the Auditor Against a Realistic Mixed Workflow
- Step 10: Prove Your Own Pin Discipline Does Not Cover Composite Actions
- Step 11: Automate Safe Updates With Dependabot
- Step 12: Enforce It in CI
- Verify the Whole Pipeline End to End
- Common Mistakes and Gotchas
- Next Steps
This tutorial builds and personally runs a real, reproducible demonstration of that attack against a throwaway git repository, then builds a Python tool that pins GitHub Actions to an immutable commit SHA and independently verifies those pins against the live GitHub API. Along the way we hit two real bugs during development, including one that would have made the verification tool silently useless for a whole category of actions, and fix both before trusting the result. Every command below was actually run in this sandbox; every SHA and every line of output is copied from that run, not invented.
What You Will Learn and Why It Matters
A GitHub Actions workflow file lives in .github/workflows/ and describes a sequence of jobs and steps. Most non-trivial steps call a reusable action: a piece of packaged automation published in its own GitHub repository, referenced with a uses: line such as uses: owner/repo@ref. The ref after the @ can be a branch name, a version tag, or a full 40-character commit SHA.
The problem is that a branch or a tag is a label, not a value. GitHub stores tags as refs, pointers that can be reassigned. A commit SHA is different: it is a cryptographic hash of the commit’s content, its parent, and its tree, so it can only ever refer to one specific, unchangeable snapshot of code. Pinning a dependency to a SHA is the software equivalent of citing a specific edition and printing of a book instead of just “the latest edition,” which might be reprinted with different contents tomorrow.
By the end of this tutorial you will have watched a floating tag get silently redirected to malicious code and watched a SHA-pinned reference survive the exact same attack untouched. You will have a working Python tool that resolves any tag to its true commit SHA (correctly handling a real annotated-tag edge case that breaks naive implementations), scans a workflow file for unpinned dependencies, and flags pins whose version comment does not match what GitHub reports today. You will also see, with real numbers, a case where pinning your own workflow is not enough: a composite action you call can itself call something unpinned, and your top-level audit will report a clean bill of health regardless.
Prerequisites
- Basic familiarity with git (commit, tag, checkout) and a passing familiarity with YAML.
- Python 3.10 or newer with
pip. Everything here was run and verified against Python 3.13.14. gitinstalled and on yourPATH. Verified against git 2.55.0.- Outbound internet access, since part of this tutorial calls the public GitHub REST API. No GitHub account or token is required; the endpoints used here work unauthenticated for public repositories.
- Two Python packages:
requestsandpyyaml. Install them withpip install requests pyyaml pytest.
Step 1: Build a Throwaway “Third-Party Action” to Attack
Rather than describe the attack in the abstract, we are going to be the attacker against our own disposable repository. Create a new directory anywhere and set up a tiny git repo that stands in for a third-party action:
mkdir fake_action
cd fake_action
git init
git config user.email "[email protected]"
git config user.name "Demo Maintainer"
printf '#!/usr/bin/env bash\necho "SAFE ACTION v1.0 - reading repository metadata only"\n' > action.sh
git add action.sh
git commit -m "v1.0: safe action"
git tag v1
This repository has one commit and a lightweight tag, v1, pointing at it. This is the normal, unremarkable state of thousands of real GitHub Actions on the Marketplace: a maintainer commits something, tags it, and consumers reference the tag. Record the commit’s SHA, since you will need it later:
git rev-parse HEAD
Real captured output (yours will differ, since the timestamp and author baked into the commit change its hash):
be3c947590453836901a7833ac9050765769c3a1
Step 2: Simulate a CI Runner and Watch It Execute Whatever the Ref Points To
A real GitHub Actions runner does one thing when it sees uses: owner/repo@ref: it checks out that repository at that ref, then runs whatever code it finds. We can model exactly that behavior with a small Python script, without needing an actual GitHub account or a real workflow run:
import argparse
import subprocess
import sys
from pathlib import Path
ACTION_REPO = Path(__file__).parent / "fake_action"
def run_action_at_ref(ref: str) -> str:
"""Check out ACTION_REPO at `ref` and run action.sh, exactly like a
GitHub Actions runner would when it evaluates `uses: org/action@ref`.
"""
checkout = subprocess.run(
["git", "-C", str(ACTION_REPO), "checkout", "--quiet", ref],
capture_output=True,
text=True,
)
if checkout.returncode != 0:
raise RuntimeError(f"could not resolve ref {ref!r}: {checkout.stderr.strip()}")
resolved_commit = subprocess.run(
["git", "-C", str(ACTION_REPO), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = subprocess.run(
["bash", str(ACTION_REPO / "action.sh")],
capture_output=True,
text=True,
)
return f"[ref={ref} -> commit={resolved_commit[:12]}] {result.stdout.strip()}"
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("ref", help="tag name or commit SHA to run the action at")
args = parser.parse_args()
print(run_action_at_ref(args.ref))
sys.exit(0)
Save this as runner.py next to the fake_action directory, then run it with the tag as the ref, the same way a workflow referencing @v1 would:
python runner.py v1
Real captured output:
[ref=v1 -> commit=be3c94759045] SAFE ACTION v1.0 - reading repository metadata only
Nothing surprising yet. This is what every consumer of this action sees today.
Step 3: Poison the Tag
Now act as the attacker. Assume you have compromised the maintainer’s account, or the maintainer themselves got phished, or a CI credential leaked, it does not matter how; what matters is that you can now push to this repository. Commit new code and force-move the existing v1 tag to point at it:
printf '#!/usr/bin/env bash\necho "COMPROMISED: exfiltrating GITHUB_TOKEN to https://evil.example/collect"\n' > action.sh
git add action.sh
git commit -m "chore: minor internal cleanup"
git tag -f v1 $(git rev-parse HEAD)
Notice the commit message: chore: minor internal cleanup. An attacker who controls the account does not need to announce what they did. Nothing about this commit or this tag move is visible from the consuming repository’s side; there is no pull request, no notification, nothing in your own git history changes at all.
Real captured output of the tag move:
Updated tag 'v1' (was be3c947)
MALICIOUS_COMMIT=f4a2db316388382d8e9bcd2915fbbe13558c162d
Step 4: Run the Same Reference Again
Nothing in the consuming workflow changed. Run the exact same command as Step 2, with the exact same ref:
python runner.py v1
Real captured output:
[ref=v1 -> commit=f4a2db316388] COMPROMISED: exfiltrating GITHUB_TOKEN to https://evil.example/collect
That is the entire attack. One force-pushed tag, zero changes on your end, and your next CI run executes attacker-controlled code with whatever permissions your workflow’s GITHUB_TOKEN and secrets carry. This is not a hypothetical: it is the same class of attack behind real, documented npm and PyPI supply chain incidents, just aimed at your CI pipeline instead of your dependency tree. If you want to see the equivalent attack against a package registry instead of GitHub Actions, sxz.io has two hands-on write-ups: How to Detect Malicious npm preinstall Scripts and Verify Package Integrity and the incident report on the ChainDrop worm, which infected over 400 npm packages while leaving their published source code untouched.
Step 5: Prove That a SHA Pin Is Immune
Now run the action one more time, but reference the original commit SHA you recorded in Step 1 instead of the tag:
python runner.py be3c947590453836901a7833ac9050765769c3a1
Real captured output:
[ref=be3c947590453836901a7833ac9050765769c3a1 -> commit=be3c94759045] SAFE ACTION v1.0 - reading repository metadata only
Still safe. The attacker moved the tag, not the commit, and a commit SHA does not care what any tag currently points to. This is the entire reason GitHub’s own Secure use reference is specific about it: “Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release. Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the action’s repository, as they would need to generate a SHA-1 collision for a valid Git object payload,” which is, for all practical purposes, not something anyone can do. The same page adds a detail worth remembering before you go pin things blindly: “When selecting a SHA, you should verify it is from the action’s repository and not a repository fork.” Pinning protects you from a tag moving after you have reviewed and trusted a commit. It does nothing to vet whether that commit was trustworthy in the first place.
Step 6: Pin a Real Action, the Manual Way
With the concept proven against a toy repository, pin something real. The GitHub CLI’s API command can resolve a tag straight from your terminal:
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'
If you do not have gh installed, the same information is available from the plain REST API, which is what the Python tool in the next step also calls:
curl -s https://api.github.com/repos/actions/checkout/git/ref/tags/v4.2.2
Real captured response (trimmed):
{
"ref": "refs/tags/v4.2.2",
"object": {
"sha": "11bd71901bbe5b1630ceea73d27597364c9af683",
"type": "commit"
}
}
A pinned uses: line then looks like this, with the human-readable version kept in a trailing comment purely for maintainers, since GitHub itself only ever reads the SHA:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Doing this by hand for every action in every workflow does not scale, and a human copying a SHA by eye is exactly the kind of step that quietly goes wrong. The rest of this tutorial builds a tool that does it, and checks it, automatically.
Step 7: Build the Tag Resolver, and Hit a Real Annotated-Tag Bug
Git has two kinds of tags. A lightweight tag is just a named pointer directly at a commit, which is what git tag v1 created in Step 1. An annotated tag, created with git tag -a, is its own object in git’s database, carrying a tagger name, a date, and a message, and that object then points at the commit. Most tooling treats the two the same, until you try to resolve one programmatically and pull the wrong SHA out of it.
Add a second tag to the original, pre-attack state of the demo repository to see the difference for yourself:
git tag -a v1-annotated -m "Release v1.0 (annotated)"
git cat-file -t v1
git cat-file -t v1-annotated
Real captured output:
commit
tag
v1 resolves straight to a commit object. v1-annotated resolves to a tag object, a wrapper that itself points somewhere else. Watch what git rev-parse returns for each:
git rev-parse v1-annotated
git rev-parse "v1-annotated^{}"
Real captured output:
95b04cf2ad6d07eae2e12da006dbf5f33ba30da7
be3c947590453836901a7833ac9050765769c3a1
Those are two different, equally valid-looking 40-character SHAs. The first is the tag object’s own hash. The second, produced by adding git’s ^{} “peel to a commit” suffix, is the actual commit hash, and it matches the SAFE_COMMIT value from Step 1 exactly. Interestingly, if you try to git checkout the raw, un-peeled tag-object SHA directly, git quietly does the right thing anyway and resolves it to the commit for you; that specific mistake does not break a local checkout. But it does break any tool, including the one we are about to write, that compares “the SHA I have on file” against “the SHA this API call just returned,” because the two calls are not guaranteed to be answering the same question.
GitHub’s REST API has the identical split. Calling GET /repos/{owner}/{repo}/git/ref/tags/{tag} for a lightweight tag returns an object of type commit. For an annotated tag, it returns an object of type tag, and you have to follow that object’s own url field one more level to reach the commit. Here is a resolver that handles both cases:
import requests
GITHUB_API = "https://api.github.com"
class RefResolutionError(RuntimeError):
pass
def resolve_tag_to_commit_sha(owner: str, repo: str, tag: str) -> str:
"""Return the commit SHA a tag currently points to, dereferencing
annotated tags (which point to a tag object, not a commit) the same way
`git rev-parse ^{}` does locally.
"""
url = f"{GITHUB_API}/repos/{owner}/{repo}/git/ref/tags/{tag}"
resp = requests.get(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "sxz-pin-verify-tutorial",
},
timeout=10,
)
if resp.status_code != 200:
raise RefResolutionError(
f"could not resolve {owner}/{repo}@{tag}: HTTP {resp.status_code}"
)
obj = resp.json()["object"]
if obj["type"] == "commit":
return obj["sha"]
if obj["type"] == "tag":
# Annotated tag: obj["sha"] is the TAG object's own SHA, not the
# commit's. Follow obj["url"] one more level to dereference it,
# mirroring `git rev-parse ^{}` locally.
tag_obj = requests.get(
obj["url"],
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "sxz-pin-verify-tutorial",
},
timeout=10,
).json()
return tag_obj["object"]["sha"]
raise RefResolutionError(f"unexpected object type {obj['type']!r} for {tag}")
To prove the dereferencing branch actually matters against a real, popular action and not just a toy repo, test it against softprops/action-gh-release, a widely used action for publishing GitHub releases, whose v2.0.0 tag happens to be annotated:
from pin_verify import resolve_tag_to_commit_sha
sha = resolve_tag_to_commit_sha("softprops", "action-gh-release", "v2.0.0")
print(sha)
Real captured output:
a6c7483a42ee9d5daced968f6c217562cd680f7f
Fetching that tag’s raw, un-dereferenced object confirms the two values genuinely differ: the tag object itself is 738f0c76948a84ff818b6e43c8b515cb27dbc559, and its message reads “release prep for v2”, while the commit it actually points to, a6c7483a42ee9d5daced968f6c217562cd680f7f, is what the function above correctly returns.
Why Getting This Wrong Matters in Practice
Suppose your resolver skipped the dereferencing step and simply returned whatever SHA the first API response contained:
pinned_in_workflow = "a6c7483a42ee9d5daced968f6c217562cd680f7f" # correct, real commit SHA
naive_live_value = naive_resolve("softprops", "action-gh-release", "v2.0.0")
print(naive_live_value)
print("MATCH" if naive_live_value == pinned_in_workflow else "MISMATCH (false positive)")
Real captured output:
738f0c76948a84ff818b6e43c8b515cb27dbc559
MISMATCH (false positive)
A naive, non-dereferencing resolver reports a mismatch on a pin that is completely correct, purely because it is comparing a tag-object SHA against a commit SHA. Ship that bug in a tool that gates pull requests, and every single dependency on an annotated tag becomes a permanent false alarm. Reviewers learn to ignore the tool within a week, which defeats the entire point of building it.
Step 8: Build the Workflow Auditor, and Hit a Second Real Bug
With a working resolver, the next piece scans an actual workflow file, finds every uses: line, and classifies each one. Add this to the same file as the resolver, or a new module that imports it:
import re
from dataclasses import dataclass
import yaml
FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
USES_RE = re.compile(
r"^(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)(?P<subpath>/[\w./-]+)?@(?P<ref>[^\s#]+)$"
)
@dataclass
class UsesRef:
line_no: int
raw: str
owner: str
repo: str
ref: str
comment_version: str | None
@property
def is_pinned(self) -> bool:
return bool(FULL_SHA_RE.match(self.ref))
def parse_uses_lines(workflow_text: str) -> list[UsesRef]:
refs = []
for i, line in enumerate(workflow_text.splitlines(), start=1):
stripped = line.strip()
if stripped.startswith("- "):
stripped = stripped[2:].strip()
if not stripped.startswith("uses:"):
continue
rest = stripped[len("uses:"):].strip()
comment_version = None
if "#" in rest:
rest, _, comment = rest.partition("#")
rest = rest.strip()
comment_version = comment.strip() or None
rest = rest.strip("'\"")
match = USES_RE.match(rest)
if not match:
continue
refs.append(
UsesRef(
line_no=i,
raw=stripped,
owner=match.group("owner"),
repo=match.group("repo"),
ref=match.group("ref"),
comment_version=comment_version,
)
)
return refs
The parser deliberately scans raw text line by line instead of walking a full YAML object tree, because a uses: reference and its trailing version comment always live on one physical line, no matter how deeply that step is nested under jobs and steps.
First test it against a minimal workflow written with each step on two lines, name first, then uses::
jobs:
build:
steps:
- name: Check out repository
uses: actions/checkout@v4
That parsed cleanly. But GitHub Actions workflows are just as often written more compactly, with no separate name: line at all:
jobs:
build:
steps:
- uses: actions/checkout@v4
The Bug: a Common Form That Silently Passed Straight Through
Running the parser against this second, equally valid form returned zero results. Both forms are legal YAML and both are common in real workflows; a scanner that only handles one of them will silently pass floating references straight through undetected, which is worse than not having the scanner at all, since it creates false confidence. The bug was a one-line oversight: the code checked whether the stripped line started with uses:, but a compact step starts with - uses:, and the leading list marker was never stripped off first. The fix, already included in the listing above, strips a leading "- " before checking the prefix. After the fix, both forms parse identically.
Step 9: Run the Auditor Against a Realistic Mixed Workflow
Save a sample workflow with a deliberate mix of good and bad references. This one has a floating tag, a correctly pinned and labeled reference, a pin whose comment does not match its SHA, a local composite action, and a pinned reference with no comment at all:
name: CI
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Check out repository (pinned correctly)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check out repository (stale/incorrect comment)
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.2.2
- name: Run a local composite action
uses: ./.github/actions/local-build-step
- name: Create a release (pinned, no version comment)
uses: softprops/action-gh-release@a6c7483a42ee9d5daced968f6c217562cd680f7f
The third entry is worth a closer look. 8ade135a41bc03ea155e62e844d188df1ea18608 is a real, valid commit SHA, but it is actually the SHA for actions/checkout release v4.1.0, not v4.2.2 as its comment claims. This is exactly what a stale pin looks like after someone bumps the SHA but forgets to update the trailing comment, or copy-pastes the wrong line while reviewing a dependency update.
Wire the parser and resolver together into a small audit function, then run it:
def audit_workflow(path: str, verify_live: bool = True) -> int:
text = open(path, encoding="utf-8").read()
yaml.safe_load(text) # fail loudly on invalid YAML before scanning text
refs = parse_uses_lines(text)
problems = 0
for ref in refs:
location = f"{path}:{ref.line_no}"
if not ref.is_pinned:
print(f"[FLOATING] {location} {ref.owner}/{ref.repo}@{ref.ref}"
f" <- not a full commit SHA, pin this")
problems += 1
continue
if not ref.comment_version:
print(f"[PINNED, unlabeled] {location} {ref.owner}/{ref.repo}@{ref.ref}"
f" <- pinned, but no version comment to cross-check")
continue
if not verify_live:
print(f"[PINNED] {location} {ref.owner}/{ref.repo}@{ref.ref} "
f"# {ref.comment_version} (not re-verified: --no-verify-live)")
continue
try:
live_sha = resolve_tag_to_commit_sha(ref.owner, ref.repo, ref.comment_version)
except RefResolutionError as exc:
print(f"[PINNED, unverifiable] {location} {exc}")
continue
if live_sha == ref.ref:
print(f"[PINNED, verified] {location} {ref.owner}/{ref.repo}@{ref.ref}"
f" # {ref.comment_version} matches the tag GitHub reports today")
else:
print(f"[PINNED, MISMATCH] {location} comment claims "
f"{ref.comment_version} ({live_sha}) but the pinned SHA is "
f"{ref.ref} <- the comment is lying about what this pin "
f"actually points to, investigate before trusting it")
problems += 1
return problems
python pin_verify.py workflows/example.yml
Real captured output (each line here came from a live call to the GitHub API at the time this was run):
[FLOATING] workflows/example.yml:11 actions/checkout@v4 <- not a full commit SHA, pin this
[PINNED, verified] workflows/example.yml:14 actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 matches the tag GitHub reports today
[PINNED, MISMATCH] workflows/example.yml:17 comment claims v4.2.2 (11bd71901bbe5b1630ceea73d27597364c9af683) but the pinned SHA is 8ade135a41bc03ea155e62e844d188df1ea18608 <- the comment is lying about what this pin actually points to, investigate before trusting it
[PINNED, unlabeled] workflows/example.yml:23 softprops/action-gh-release@a6c7483a42ee9d5daced968f6c217562cd680f7f <- pinned, but no version comment to cross-check
exit code: 1
Every classification is correct: the floating tag is flagged, the correct pin is independently confirmed against the live API, the stale comment is caught, and the local composite action is silently skipped since there is nothing to pin there. Now fix every reference and confirm the tool reports a clean pass:
python pin_verify.py workflows/example_fixed.yml
Real captured output:
[PINNED, verified] workflows/example_fixed.yml:11 actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 matches the tag GitHub reports today
[PINNED, verified] workflows/example_fixed.yml:14 actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 matches the tag GitHub reports today
[PINNED, verified] workflows/example_fixed.yml:17 actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 matches the tag GitHub reports today
[PINNED, verified] workflows/example_fixed.yml:23 softprops/action-gh-release@a6c7483a42ee9d5daced968f6c217562cd680f7f # v2.0.0 matches the tag GitHub reports today
exit code: 0
Exit code 0 makes this drop directly into a CI gate. A pull request that introduces a new floating reference, or that quietly changes a pin’s SHA without updating its comment, fails the check instead of merging silently.
Step 10: Prove Your Own Pin Discipline Does Not Cover Composite Actions
The workflow above audits completely clean. That clean result is real, but it is also incomplete, and proving why is the most important gotcha in this tutorial. The fourth step in that workflow calls ./.github/actions/local-build-step, a local composite action. Composite actions are themselves just YAML files with their own steps: list, and those steps can have their own, entirely separate uses: lines. Auditing your workflow tells you nothing about what a composite action does internally.
Here is a plausible action.yml for that composite action:
name: "Local Build Step"
description: "Composite action referenced by ./.github/actions/local-build-step"
runs:
using: "composite"
steps:
- name: Set up Node
uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0
shell: bash
- name: Install a third-party linter action
uses: some-org/some-linter-action@v2
shell: bash
Because parse_uses_lines is a plain text scanner and does not care what kind of YAML file it is reading, the exact same tool audits this file too:
python pin_verify.py example_composite_action.yml
Real captured output:
[PINNED, verified] example_composite_action.yml:7 actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 matches the tag GitHub reports today
[FLOATING] example_composite_action.yml:10 some-org/some-linter-action@v2 <- not a full commit SHA, pin this
exit code: 1
The top-level workflow reports zero problems. The composite action it calls has a floating reference sitting one hop away. If you only ever run this tool against the files in your own .github/workflows/ directory, you will never see that second result: you have to point it at every composite action’s action.yml too, and if that composite action itself calls a third-party action you do not control, its internal pin discipline is entirely out of your hands regardless. Treat pinning as a property you verify at every hop of a dependency chain, not a box you tick once at the top.
Step 11: Automate Safe Updates With Dependabot
A permanently pinned SHA never gets security fixes on its own. GitHub’s Dependabot can open pull requests that bump a pin to a newer release, with the diff showing exactly which SHA changed. Add a .github/dependabot.yml file:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions-minor-patch:
update-types:
- "minor"
- "patch"
GitHub’s own Dependabot documentation for Actions describes the required fields plainly: “Specify github-actions as a package-ecosystem to monitor. Set the directory to / to check for workflow files in .github/workflows. Set a schedule.interval to specify how often to check for new versions.” The groups block above is optional and simply batches routine minor and patch bumps into fewer pull requests; it changes nothing about the security properties of the pins themselves.
Do not treat a Dependabot pull request as automatically safe to merge just because it came from Dependabot. It is proposing a new SHA for you to trust, exactly like a human proposing one. Run the auditor from Step 9 against the updated workflow before merging: if the proposed SHA does not match what the tag it claims to be currently resolves to, you want to know that before it reaches main, not after.
Step 12: Enforce It in CI
Wire the auditor into the workflow itself as a required check, so a bypass on someone’s laptop cannot become the final word:
verify-action-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: pip install requests pyyaml
- run: |
for f in .github/workflows/*.yml .github/actions/*/action.yml; do
[ -f "$f" ] && python pin_verify.py "$f"
done
Mark this job as a required status check in your branch protection settings, and a pull request that introduces a floating reference, or a mismatched pin, cannot merge until a human looks at it and either fixes it or explicitly overrides the check with a documented reason.
Verify the Whole Pipeline End to End
Before trusting any of this, tie it together with a test suite that separates fast, deterministic checks from the two tests that genuinely need the live API:
import pytest
from pin_verify import UsesRef, parse_uses_lines, resolve_tag_to_commit_sha
SAMPLE_WORKFLOW = """\
name: CI
on: [push]
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/local-build-step
- run: echo "uses: not-a-real-uses-line, just text in a run: block"
"""
def test_parses_floating_ref():
refs = parse_uses_lines(SAMPLE_WORKFLOW)
floating = [r for r in refs if not r.is_pinned]
assert len(floating) == 1
assert floating[0].ref == "v4"
def test_skips_local_actions_and_run_blocks():
refs = parse_uses_lines(SAMPLE_WORKFLOW)
assert len(refs) == 2
@pytest.mark.live
def test_resolve_annotated_tag_live_dereferences():
sha = resolve_tag_to_commit_sha("softprops", "action-gh-release", "v2.0.0")
assert sha == "a6c7483a42ee9d5daced968f6c217562cd680f7f"
assert sha != "738f0c76948a84ff818b6e43c8b515cb27dbc559"
python -m pytest test_pin_verify.py -v
Real captured output (8 tests total: 6 deterministic parsing and classification checks plus the 2 marked live, which hit the real GitHub API):
test_pin_verify.py::test_parses_floating_ref PASSED [ 12%]
test_pin_verify.py::test_parses_pinned_ref_with_comment PASSED [ 25%]
test_pin_verify.py::test_skips_local_actions_and_run_blocks PASSED [ 37%]
test_pin_verify.py::test_is_pinned_rejects_short_hex PASSED [ 50%]
test_pin_verify.py::test_is_pinned_rejects_uppercase_hex PASSED [ 62%]
test_pin_verify.py::test_is_pinned_accepts_full_lowercase_sha PASSED [ 75%]
test_pin_verify.py::test_resolve_lightweight_tag_live PASSED [ 87%]
test_pin_verify.py::test_resolve_annotated_tag_live_dereferences PASSED [100%]
8 passed in 1.36s
As a last sanity check, confirm the fast, offline --no-verify-live mode genuinely trades accuracy for speed rather than being a free lunch, by running it against the same mixed workflow from Step 9:
python pin_verify.py workflows/example.yml --no-verify-live
[FLOATING] workflows/example.yml:11 actions/checkout@v4 <- not a full commit SHA, pin this
[PINNED] workflows/example.yml:14 actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (not re-verified: --no-verify-live)
[PINNED] workflows/example.yml:17 actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.2.2 (not re-verified: --no-verify-live)
[PINNED, unlabeled] workflows/example.yml:23 softprops/action-gh-release@a6c7483a42ee9d5daced968f6c217562cd680f7f <- pinned, but no version comment to cross-check
exit code: 1
Without the live check, the stale pin on line 17 no longer gets flagged as a mismatch; it just looks like any other pinned reference. That mode is fine as a fast local sanity check for “did I forget to pin something new,” but the live-verifying mode from Step 9 is what should gate a merge, since it is the only mode that actually catches a pin whose SHA and claimed version have drifted apart.
Common Mistakes and Gotchas
- Forgetting the compact
- uses:form. Any line-based scanner has to handle both- name: ...followed by a separateuses:line, and the compact single-line- uses: ...form. Missing one silently lets floating references through undetected, which is worse than having no scanner at all. - Trusting the first SHA an API call returns. For an annotated tag, GitHub’s
git/ref/tagsendpoint returns the tag object’s own SHA, not the commit it points to. A resolver that skips the dereferencing step will report a false-positive mismatch on every correctly pinned action that happens to use annotated tags, as demonstrated in Step 7 against a real, popular action. - Assuming pinning your workflow is the whole job. As shown in Step 10, a composite action you reference can call further, unpinned actions internally, and your top-level audit will have no visibility into that at all.
- Unquoted colons inside a step’s
name:. While building the fixed example workflow for this tutorial, a step namedCheck out repository (fixed: pin now matches the comment)broke YAML parsing outright withmapping values are not allowed here, because the colon after “fixed” reads as a new mapping key in an unquoted scalar. This is exactly whyaudit_workflowcallsyaml.safe_load()before scanning the raw text: it is a cheap way to fail loudly on a malformed file instead of silently scanning garbage. Quoting the string, as inname: "...", fixes it. - This tool does not audit Docker-based actions. A step can also be written as
uses: docker://alpine:3.19, which addresses a container image by tag or digest, an entirely different mechanism from a GitHub repository ref. The regex in this tutorial correctly ignores those lines rather than misparsing them, but if your workflows use Docker actions, they need a separate image-digest-pinning check. - A verified pin is not the same as a trustworthy pin. Everything built here confirms that the SHA in your workflow matches what a given tag currently resolves to. It says nothing about whether that commit was ever safe to run in the first place. GitHub’s own advice to “verify it is from the action’s repository and not a repository fork” before you first pin something is a separate, human judgment call that no script can make for you.
Next Steps
The same discipline of failing a commit or a pull request before something bad reaches your default branch shows up throughout sxz.io’s security tutorials. If you want to catch dangerous code patterns before they are even committed, see How to Catch Vulnerable Python Code Before It’s Committed With Bandit. If leaked credentials are your bigger worry, see How to Catch Leaked Secrets Before They Hit GitHub With Gitleaks. And if you want to see this same class of attack already caught in the wild, the incident report on the ChainDrop npm worm shows how a self-propagating supply chain attack actually behaved once it reached real, published packages.








No Comment! Be the first one.