GitHub Actions Reusable Workflow Pinning and Drift Audit: Closing the Post-tj-actions Gap
Problem
In March 2025 tj-actions/changed-files was compromised: a maintainer-account takeover let the attacker rewrite the action to dump secrets into job logs, and because most consumers referenced it as @v45 or @main the malicious version propagated to thousands of pipelines within hours. A near-identical pattern hit reviewdog/action-setup weeks later. Both incidents made one operational fact uncomfortable for security teams: SHA pinning, repeatedly recommended for years, is not what most organisations actually do, and even teams who pin their direct uses: tend to ignore the transitive pulls that reusable workflows perform under the hood.
A reusable workflow is a workflow file at .github/workflows/<name>.yml that another workflow calls via uses: org/repo/.github/workflows/<name>.yml@<ref>. It is similar to a JavaScript-action uses: but with two consequential differences. First, the reusable workflow runs with its own steps and its own uses: references, all of which the caller can be totally unaware of. Second, the reusable workflow inherits the caller’s secrets: (selectively) and runs in the caller’s repository security context, which means a compromised reusable workflow can extract secrets from the caller’s environment even though no source change has been made to the caller’s repo.
The combination — transitive uses: + secret inheritance + ref resolution at run time — means a single mutable tag in a reusable workflow’s dependency graph is sufficient to compromise every job in the org that ever calls it. GitHub’s own data shows the median Actions consumer has 12 distinct uses: references after expansion, of which roughly 60% point to mutable tags and only 11% to commit SHAs.
This article gives you a way out: a hard CI-side enforcement that rejects PRs introducing un-pinned references, a periodic drift audit that catches refs that were SHA-pinned but where the SHA now points to something different on the upstream, and a runtime detection that flags resolved-SHA mismatches before secrets are exposed.
Target systems: GitHub Enterprise Cloud or Server 3.13+, repositories using GitHub Actions, organisations with ≥10 repos pulling external actions, and any org consuming third-party reusable workflows (most CI/CD platform teams).
Threat Model
- Maintainer account takeover of a popular action (
actions/checkout,tj-actions/changed-files,reviewdog/action-setup). Goal: rewrite the action’s code or push a tag that points at malicious code, then wait for downstream CI to pull it. - Force-push to the action’s release branch, where consumers reference
@mainor@release/v1. Goal: replace what the existing tag/branch resolves to without rotating the version number. - Transitive compromise: the attacker compromises a reusable workflow that your trusted workflow calls. Your direct
uses:is fine; the transitive one is not. - Insider creating a deliberately-mutable in-house workflow: an employee adds
uses: ourorg/internal-workflows/.github/workflows/build.yml@mainto deploy pipelines, then later force-pushesmainto add a step that exfils production credentials. - Supply-chain attacker against the action’s dependencies: e.g., the action pulls
npm installof a transitively-compromised package at run time.
Without enforcement, all five succeed silently. With the controls in this article, 1 and 2 require a SHA-collision (effectively impossible) or are detected by drift audit; 3 is bounded because reusable-workflow refs are also pinned; 4 is rejected at PR time; 5 is mitigated by network egress controls (covered separately) and reproducible-build attestations.
Configuration / Implementation
Step 1 — Repository setting: require SHA-pinned actions org-wide
GitHub Enterprise (Cloud and Server 3.14+) ships an org-level policy “Require actions to be pinned to a full-length commit SHA” under Organization → Settings → Actions → General → Policies. Enable it. The policy applies to direct uses: in workflow files but does not verify reusable-workflow uses: recursively, so it is necessary but not sufficient.
# Confirm via REST.
gh api orgs/${ORG}/actions/permissions \
--jq '{enabled,allowed_actions,actions_pinning_required}'
Expect actions_pinning_required: true. If your enterprise plan does not expose the toggle, use the actionlint + pin-github-action pre-commit gate in Step 2 as the substitute.
Step 2 — Pre-commit and PR-time enforcement
actionlint 1.7+ catches the obvious cases. Add it as a required PR check:
# .github/workflows/pin-check.yml
name: pin-check
on:
pull_request:
paths: ['.github/workflows/**', '.github/actions/**']
permissions: { contents: read }
jobs:
actionlint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Run actionlint with SHA-pin rule
run: |
bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.4/scripts/download-actionlint.bash) 1.7.4
./actionlint -shellcheck= -ignore 'expected ".*"' \
-config-file .github/actionlint.yaml
- name: Reject non-SHA refs
run: |
set -euo pipefail
# Find every `uses:` that is not followed by 40-hex SHA.
mapfile -t bad < <(grep -RnE \
'uses:[[:space:]]+[^@]+@(?!([0-9a-f]{40})\b)[^[:space:]]+' \
.github/workflows .github/actions 2>/dev/null || true)
if [[ ${#bad[@]} -gt 0 ]]; then
printf 'Non-SHA-pinned uses: refs:\n'
printf ' %s\n' "${bad[@]}"
exit 1
fi
Add to .github/actionlint.yaml:
self-hosted-runner:
labels: []
config-variables:
- DEPLOY_ENV
The grep is deliberately strict: it accepts only 40-hex-character refs after @. A version tag, a branch name, or a partial SHA all fail. If you also support GitHub Apps action references (@<sha>), this still works because the SHA portion is what’s matched.
Step 3 — Pin reusable-workflow uses: too
The same rule applies but most teams forget: reusable workflows referenced from inside other reusable workflows are out of sight. Add a second check that walks the call graph:
expand-and-check-transitive:
runs-on: ubuntu-24.04
needs: actionlint
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Resolve transitive uses
env:
GH_TOKEN: ${{ secrets.GH_READONLY }}
run: |
# ./scripts/expand-uses.py walks every workflow in this repo,
# downloads any `uses: org/repo/.github/workflows/...@SHA`,
# and recurses into it, asserting all nested uses: are also SHA.
python3 scripts/expand-uses.py --max-depth 5 \
--fail-on-mutable
scripts/expand-uses.py is a ~150-line tool: parse YAML, for each uses: <org>/<repo>/.github/workflows/<file>.yml@<sha> reference, fetch that file at that SHA via the contents API, parse it, recurse. Fail if any step inside any reusable workflow uses a non-SHA ref. (See Tools and Scripts in the article-batch workflow doc for placement convention.)
Step 4 — Periodic drift audit across the org
A SHA pin is durable on your end but the meaning of that SHA can change for the upstream maintainer if they force-push the tag. They cannot change what your workflow runs (the SHA still points to immutable commit content), but they can break your update workflow by repurposing the version tag while you keep using the old SHA. More importantly, a drift audit is how you spot upstream tags that have moved suspiciously — a strong signal of a tj-actions-style takeover.
# scripts/drift_audit.py
#!/usr/bin/env python3
import os, re, sys, json, subprocess
from collections import defaultdict
import requests
GH = os.environ["GH_TOKEN"]
ORG = sys.argv[1]
PATTERN = re.compile(
r'uses:\s+([^/\s]+)/([^/\s@]+)(?:/[^@\s]+)?@([0-9a-f]{40})\b')
def gh(url):
r = requests.get(url, headers={"Authorization": f"Bearer {GH}",
"Accept": "application/vnd.github+json"})
r.raise_for_status()
return r.json()
# Find every workflow file in every repo.
findings = defaultdict(list)
for repo in gh(f"https://api.github.com/orgs/{ORG}/repos?per_page=100"):
name = repo["full_name"]
try:
tree = gh(f"https://api.github.com/repos/{name}/git/trees/HEAD?recursive=1")
except Exception:
continue
for item in tree.get("tree", []):
if not item["path"].startswith(".github/workflows/"):
continue
if not item["path"].endswith((".yml", ".yaml")):
continue
blob = gh(f"https://api.github.com/repos/{name}/contents/{item['path']}")
import base64
content = base64.b64decode(blob["content"]).decode("utf-8", "replace")
for m in PATTERN.finditer(content):
owner, repo_name, sha = m.group(1), m.group(2), m.group(3)
findings[(owner, repo_name, sha)].append(f"{name}:{item['path']}")
# For each unique (owner, repo, SHA), check what tags currently point at it
# and compare to the latest tag on the upstream repo.
for (owner, repo, sha), consumers in findings.items():
upstream = f"https://api.github.com/repos/{owner}/{repo}"
try:
tags = gh(f"{upstream}/tags?per_page=100")
except Exception:
continue
matching = [t["name"] for t in tags if t["commit"]["sha"] == sha]
latest = tags[0]["name"] if tags else "?"
print(json.dumps({
"owner": owner, "repo": repo, "pinned_sha": sha,
"matches_tags": matching, "latest_tag": latest,
"consumers": consumers[:3], "consumer_count": len(consumers),
}))
Run weekly, ship the output to your SIEM, and alert on:
- A
pinned_shathat no longer matches any tag on the upstream (the maintainer rotated tags away from it; benign or compromise — investigate). - A
pinned_shawhosematches_tagslist shrank between two audits (the tag was force-pushed off your SHA). - The same
pinned_shaconsumed by >50 workflows (a high-blast-radius dependency to keep on the watchlist).
Step 5 — Allowlist of permitted action sources
Pinning by SHA is one half. The other half is bounding which upstream repos your actions can come from at all. Configure the org allowlist:
# .github/actions-allowlist.yaml (consumed by your PR check)
allowed_owners:
- actions
- github
- docker
- aquasecurity
- sigstore
- slsa-framework
- $YOUR_ORG
allowed_repos:
- tj-actions/changed-files@d6e91a2266cdb9d62a2c1aa8d4c4e1e1b8e8c8c8
PR check reads this and rejects any uses: whose owner is not in allowed_owners and whose owner/repo@sha is not in allowed_repos.
Step 6 — Runtime: verify resolved SHA before secrets are exposed
Add a first job to every secret-using workflow that fails fast if the resolver brought in something different from what was pinned:
jobs:
verify-action-resolution:
runs-on: ubuntu-24.04
permissions: { contents: read }
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Verify all uses: pin to SHA
run: |
./scripts/expand-uses.py --workflow ${{ github.workflow }} \
--fail-on-mutable
build:
needs: verify-action-resolution
secrets:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
runs-on: ubuntu-24.04
steps: [...]
Putting this before the job that uses secrets means a malicious resolution never gets to see the DEPLOY_KEY.
Step 7 — Detect token use anomalies
Even with all the above, an action that resolved fine yesterday could behave differently today (e.g., a npm postinstall in a transitive dep). Use the runner’s audit log + egress restrictions:
- name: Restrict runner egress
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
harden-runner (in block mode) has caught both the tj-actions and reviewdog incidents at the egress layer for users who had it deployed; the actions tried to reach gist.githubusercontent.com and pastebin.com to dump secrets and were blocked.
Expected Behaviour
| Signal | Before | After |
|---|---|---|
uses: with mutable tag |
Accepted, runs whatever the tag points to | PR rejected at lint stage |
| Reusable-workflow transitive dep on a tag | Accepted, invisible to consumer | Rejected by expand-uses.py |
| Upstream tag force-push to a different SHA | Silent, runs new code on next pin update | Caught by weekly drift audit |
| Action attempts unexpected egress | Allowed | Blocked by harden-runner |
| Org-level pinning policy | “Recommended” docs | Enforced by GitHub setting |
| Action allowlist | Implicit (any allowed) | Explicit allowed_owners + per-repo SHA |
| Runtime token exposure to compromised action | Possible | Blocked by verify-action-resolution precondition |
Verification snippet:
# Local equivalent of the PR check.
grep -RnE 'uses:[[:space:]]+[^@]+@(?!([0-9a-f]{40})\b)[^[:space:]]+' \
.github/workflows .github/actions
# Expect: no output. Any output is a violation.
# Drift audit dry-run for one repo.
GH_TOKEN=$(gh auth token) python3 scripts/drift_audit.py "$YOUR_ORG" \
| jq 'select(.matches_tags | length == 0)'
# Expect: empty (every pinned SHA still maps to at least one upstream tag).
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Mandatory SHA pinning | Closes the mutable-tag class entirely | More friction updating actions | Renovate or Dependabot configured for SHA pins |
| Org-level allowlist | Bounds the upstream universe | New legitimate actions take a security review | Self-service portal with 24h SLO |
| Drift audit | Catches takeovers within a week | Noisy when upstreams rotate tags routinely | Tune to alert on removed, not added, tags |
| Egress block-list | Defeats secret-exfil even if action is compromised | False positives for actions that legitimately need outbound | Audit-mode rollout for two weeks before block |
| Transitive expansion | Catches reusable-workflow risk | Requires API token with read on all referenced repos | Use a fine-scoped GitHub App, not a PAT |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Lint check has a regex bug accepting partial SHA | Mutable refs slip through | Periodic re-audit of merged workflows | Tighten regex; add unit test |
| Allowlist drift (stale SHAs not maintained) | Failing builds, devs add bypass | Build failure metrics | Renovate or Dependabot with security review |
| GitHub App token has too-broad scope | Compromise of audit infra reads source | Audit log of token use | Fine-scope to actions:read, contents:read only |
| Reusable workflow stored privately | expand-uses.py cannot read it |
Tool emits “private, skipped” warning | Trust internal repos under same org; require attestation |
| harden-runner audit-mode forgotten | False sense of security | Annotation says “audit” not “block” | Enforce policy by lint on the workflow’s own contents |
| Org policy disabled per-repo | One repo opts out | Org config drift report | Make org policy non-overridable; review repo settings monthly |
| Force-push by your own maintainer to internal reusable workflow | Surprise behaviour change | Branch protection report | Branch protection: linear history, signed commits, no force-push |
When to Consider a Managed Alternative
- GitHub-hosted “Verified Creator” actions carry signed provenance attestations (Sigstore-bundle) and are sufficient for many compliance regimes. Combine with SHA pinning for defence in depth.
- GitLab CI does not have the reusable-workflow shape but introduces its own
include:directive with similar concerns; the same audit pattern applies. - Buildkite + agent-side allowlist sidesteps the GitHub-cloud question entirely if your runners are on-prem.