TRENDING
Five alphabetical thumb-index tabs cut into the edge of a dictionary, each labeled with a letter range
September 27, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
Five sample state-issued EBT benefit cards fanned out on a white background
September 27, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
A real wooden outdoor sandbox filled with sand and toys, empty of people
September 27, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
Subway turnstiles showing a green ENTER sign and a red DO NOT ENTER sign side by side
September 27, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
Macro photo of a brass keyhole with a key partially inserted in a wooden door
September 27, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
27 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A green highway sign splitting into an EXPRESS lane and a LOCAL lane, the same express-lane idea a skip list uses to skip ahead through sorted data
How to Build a Skip List in Python to Get Balanced-Tree Speed Without the Rotations
September 27, 2026
Two well-worn paper archery targets riddled with arrow holes, mounted on cardboard backing at an outdoor range
Red Hat’s RHEL 10 STIG Update Turns Compliance Into a Moving Target
September 27, 2026
A manila file folder with a paperclip clipped to its tab, against a white background
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
September 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 210 Posts
News 211 Posts
Learning Hub 181 Posts
Home/Learning Hub/How to Pin and Verify GitHub Actions to Stop Supply Chain Poisoning
Learning Hub

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.

September 18, 2026 19 Min Read
21

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.
  • git installed and on your PATH. 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: requests and pyyaml. Install them with pip 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 separate uses: 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/tags endpoint 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 named Check out repository (fixed: pin now matches the comment) broke YAML parsing outright with mapping values are not allowed here, because the colon after “fixed” reads as a new mapping key in an unquoted scalar. This is exactly why audit_workflow calls yaml.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 in name: "...", 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.

Tags:

CI/CD SecurityDevSecOpsGitHub ActionsPythonSupply Chain Security

Share

The Virginia State Capitol in Richmond, where Governor Spanberger signed Executive Order 22 on data center accountability
Previous Post

Virginia’s Governor Signs an Executive Order to Rein In Data Centers and Launch an AI Task Force

Macro photo of a vintage film camera's shutter speed dial and hot shoe, used as a general photography visual for a story about a flaw in an image-decoding library
Next Post

Wordfence’s AI Testing Framework Found a Critical Flaw in the Library Behind Every iPhone Photo

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
27 Sep
How to Build a Skip List in Python to Get Balanced-Tree Speed Without the Rotations
27 Sep
Red Hat’s RHEL 10 STIG Update Turns Compliance Into a Moving Target
Trending
September 27, 2026
How to Build a Skip List in Python to Get Balanced-Tree Speed Without the Rotations
September 27, 2026
Red Hat’s RHEL 10 STIG Update Turns Compliance Into a Moving Target
September 27, 2026
CISA Orders Federal Agencies to Patch a SharePoint RCE Flaw Microsoft First Called Spoofing
September 26, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026