Last week I spent a full Saturday auditing every GitHub Actions workflow across our repos. Not because I wanted to, but because the Trivy supply chain attack made me realize how thin the ice was under my feet.

If you missed it: someone managed to sneak a malicious commit into the actions/checkout action by exploiting GitHub’s fork commit reachability. They swapped a SHA pin in Trivy’s release workflow to point at an orphaned commit in a fork. The commit looked legit, the comment said # v6.0.2, the author was spoofed to look like a real maintainer. The actual payload downloaded Go files from a typosquatted domain and replaced Trivy’s source code during the build.

Two lines changed. Fourteen lines in the diff total, most of it cosmetic noise like swapping single quotes for double quotes. The kind of PR that gets approved because there’s “nothing to see.”

Why SHA Pinning Isn’t Enough

I’ve been telling people for years to pin actions by SHA instead of tags. And that’s still good advice. But the Trivy attack showed that SHA pinning has a blind spot that most of us didn’t think about.

GitHub’s architecture makes fork commits reachable by SHA from the parent repository. So if someone creates a fork of actions/checkout, pushes a malicious commit, and you reference that SHA from the parent repo, GitHub will happily resolve it. The commit doesn’t need to be on any branch. It doesn’t need to be merged. It just needs to exist somewhere in the fork network.

That changes the threat model completely. You’re not just trusting the maintainers of the action. You’re trusting that nobody in the entire fork graph has pushed something malicious that happens to match a SHA you reference.

What I Actually Changed

Here’s what my audit looked like and what I fixed.

1. Allowlist Actions at the Org Level

GitHub lets you restrict which actions can run in your org. I went from “allow all actions” to an explicit allowlist:

# .github/settings.yml (or org settings UI)
actions_permissions:
  allowed_actions: selected
  github_owned_allowed: true
  verified_creators_allowed: true
  patterns_allowed:
    - "aquasecurity/*"
    - "docker/*"
    - "hashicorp/*"

This won’t stop everything, but it limits the blast radius.

2. Pin AND Verify

SHA pinning stays, but now I verify what’s at the other end. I wrote a small script that resolves each pinned SHA and checks if it actually lives on a tagged release branch:

#!/bin/bash
# verify-action-pins.sh
set -euo pipefail

grep -rh "uses:" .github/workflows/*.yml | \
  grep "@" | \
  sed 's/.*uses: //' | \
  sort -u | \
while read -r action; do
  repo=$(echo "$action" | cut -d@ -f1)
  sha=$(echo "$action" | cut -d@ -f2)

  # Check if this SHA is on a tag
  tags=$(gh api "repos/${repo}/git/ref/tags" --paginate -q '.[].ref' 2>/dev/null || echo "")
  found=false
  for tag_ref in $tags; do
    tag_sha=$(gh api "repos/${repo}/git/${tag_ref}" -q '.object.sha' 2>/dev/null || echo "")
    if [[ "$tag_sha" == "$sha" ]]; then
      found=true
      break
    fi
  done

  if [[ "$found" == "false" ]]; then
    echo "WARNING: $action - SHA not found on any tag"
  fi
done

Not pretty, but it catches orphaned commits.

3. Read-Only Tokens by Default

Every workflow now uses explicit permissions:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write  # only if actually needed

If your workflow doesn’t declare permissions, GitHub gives it a default token that can write to your repo. That’s the difference between “attacker read my source” and “attacker pushed to my main branch.”

4. Network Egress Monitoring

The Trivy payload called out to scan.aquasecurtiy[.]org (note the typo in “security”). If you’re running self-hosted runners, you can block unexpected egress. For GitHub-hosted runners, you’re more limited, but you can at least add a step that logs DNS queries:

- name: Capture DNS baseline
  run: |
    cat /etc/resolv.conf
    resolvectl query github.com || true    

Not a real defense, but it gives you forensic data after an incident.

5. Review Bot for Workflow Changes

Any PR that touches .github/workflows/ now requires review from the security team. We enforce this with CODEOWNERS:

# .github/CODEOWNERS
.github/workflows/ @org/security-team

Simple, effective, and it would have caught the Trivy commit if it had gone through a PR (it didn’t, it was a direct push, which is a separate problem).

The Uncomfortable Truth

The real lesson here isn’t technical. It’s that CI/CD pipelines are running arbitrary code from the internet and we’ve normalized it. We wouldn’t curl | bash in production, but we do the equivalent in every GitHub Actions workflow.

I spent years hardening Kubernetes clusters, setting up network policies, running admission controllers, signing container images. Meanwhile my build pipeline was pulling unverified code from fork networks and running it with write access to my repositories.

The Trivy incident was caught relatively fast. The next one might not be. If you haven’t audited your workflows recently, block out a Saturday. You’ll probably find things you don’t like.

Quick Checklist

  • Org-level action allowlist enabled
  • All actions pinned by SHA with comment showing version
  • Workflow permissions are explicit and minimal
  • CODEOWNERS protects .github/workflows/
  • Self-hosted runners have network egress controls
  • A script or tool verifies pinned SHAs against tagged releases
  • --skip=validate or equivalent flags are banned in CI configs

None of this is perfect. Supply chain security is a spectrum, not a checkbox. But after last week, I sleep a little better knowing my pipelines aren’t just trusting the internet to be nice.