23,000+
repositories affected by the tj-actions/changed-files supply chain attack in March 2025 (Wiz Research)
4x
increase in GitHub Actions supply chain attacks between 2024 and 2025 (Datadog Security Labs)
62%
of GitHub Actions workflows use at least one unpinned third-party action (StepSecurity analysis 2025)

In March 2025, the tj-actions/changed-files GitHub Action was compromised. Attackers modified the action to print CI/CD secrets to workflow logs. Over 23,000 repositories that used the action with a mutable tag (like @v45) were affected. Repositories that pinned to a specific commit SHA were not.

This was not an isolated event. GitHub Actions supply chain attacks quadrupled between 2024 and 2025. The attack surface is real and growing: Actions are third-party code running inside your CI/CD pipeline with access to your cloud credentials, deployment keys, and production systems.

This checklist covers every control that prevents Actions-based supply chain attacks. Each control includes the exact YAML to add to your workflows.

Control 1: Pin All Third-Party Actions to Commit SHA

This is the single most important control. When you reference an Action with a tag (uses: actions/checkout@v4), that tag is a mutable pointer. The Action author can push new code to that tag at any time. When you reference a commit SHA (uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29), you are pinned to an immutable state.

Do not use:

- uses: actions/checkout@v4
- uses: tj-actions/changed-files@v45
- uses: actions/setup-python@main

Use instead (full commit SHA):

- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29  # v4.1.1
- uses: actions/setup-python@0a5c61591373683505ea898e09424558ab9bea9  # v5.0.0

The comment after the SHA is essential. SHAs are not human-readable; the comment tells reviewers and future maintainers what version the SHA corresponds to.

Finding the commit SHA for an Action:

# Method 1: GitHub UI
# Go to the Action's GitHub page > Tags > click the tag > copy the commit SHA from the URL

# Method 2: GitHub API
curl -s "https://api.github.com/repos/actions/checkout/git/refs/tags/v4" | \
  python3 -c "import json,sys; r=json.load(sys.stdin); print(r['object']['sha'])"

# Method 3: StepSecurity Harden-Runner generates pinned SHAs automatically
# Visit app.stepsecurity.io and paste your workflow

Automating SHA updates (so you do not miss security updates):

Use Dependabot to automatically open PRs when a pinned Action has a new release:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    # Dependabot will update the SHA and the comment automatically

With this configuration, you get the security of SHA pinning plus automated PRs when new versions are released.

Control 2: Restrict Workflow Permissions to Least Privilege

By default, GitHub Actions workflows receive a GITHUB_TOKEN with read access to the repository and write access to packages, deployments, and other GitHub resources. Most workflows do not need write access to most of these scopes.

Set the default to read-only at the org or repo level:

In your repository: Settings > Actions > General > Workflow permissions > select 'Read repository contents and packages permissions.'

Or enforce it via workflow YAML:

# At the top of your workflow file:
permissions:
  contents: read  # minimum for checkout

Grant additional permissions only to the jobs that need them:

jobs:
  build:
    permissions:
      contents: read  # checkout only
      packages: write  # only if pushing to GHCR

  deploy:
    permissions:
      contents: read
      id-token: write  # OIDC token for cloud auth (see Control 3)

  security-scan:
    permissions:
      contents: read
      security-events: write  # only if uploading SARIF to Code Scanning

Common permissions by use case:

Use casePermissions needed
Checkout code onlycontents: read
Push to GHCRcontents: read, packages: write
Create releasecontents: write
Upload SARIF resultscontents: read, security-events: write
OIDC for AWS/GCP/Azurecontents: read, id-token: write
Comment on PRcontents: read, pull-requests: write

Why this matters: If an Action is compromised and it runs in a workflow with broad token permissions, the attacker can use the GITHUB_TOKEN to read secrets, push code, modify releases, or trigger other workflows. Least-privilege permissions limit the blast radius.

Free daily briefing

Briefings like this, every morning before 9am.

Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.

Control 3: Use OIDC Instead of Long-Lived Cloud Credentials

The most common way CI/CD pipelines interact with cloud providers (AWS, GCP, Azure) is via long-lived credentials stored as repository secrets: an AWS access key in secrets.AWS_ACCESS_KEY_ID. If those secrets are exfiltrated (via a compromised Action or workflow misconfiguration), an attacker has persistent cloud access until the keys are manually rotated.

OIDC (OpenID Connect) eliminates long-lived credentials. The workflow exchanges a short-lived OIDC token (valid for the duration of the workflow run) for temporary cloud credentials. If an attacker exfiltrates the token, it expires within minutes.

AWS OIDC setup:

jobs:
  deploy:
    permissions:
      id-token: write  # required for OIDC
      contents: read
    steps:
      - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29  # v4.1.1

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
          # No access key or secret key needed

On the AWS side, create an IAM OIDC Identity Provider for GitHub and a role that trusts it with the appropriate condition:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:YOUR-ORG/YOUR-REPO:*"
        }
      }
    }
  ]
}

Scope the sub condition to the specific repository and optionally the specific branch (repo:org/repo:ref:refs/heads/main) to prevent other repos from assuming the role.

GCP OIDC uses Workload Identity Federation; Azure uses federated credentials on a service principal. The principle is identical across all three providers.

Control 4: Prevent Malicious PRs From Accessing Secrets

GitHub has two workflow trigger types that differ critically in their access to secrets:

  • pull_request: Triggered on PRs from forks. Has no access to secrets and runs with read-only token. Safe to run automated tests.
  • pull_request_target: Triggered on PRs but runs in the context of the base repository. Has access to secrets. Used for labeling, commenting, and other privileged operations.

The dangerous pattern:

# DANGEROUS: pull_request_target with checkout of fork code
on:
  pull_request_target:
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Checking out fork code
      - run: npm test  # Running fork code with access to secrets

This pattern lets an attacker submit a malicious PR that runs arbitrary code in your secrets context.

Safe pattern for privileged operations on PRs:

# Safe: Separate the privileged and unprivileged steps into two workflows
# Workflow 1: Runs on pull_request (no secrets, safe to run fork code)
on: pull_request
jobs:
  tests:
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
      - run: npm test
      - uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: ./test-results

# Workflow 2: Runs on workflow_run (can access secrets, does NOT run fork code)
on:
  workflow_run:
    workflows: ["Run Tests"]
    types: [completed]
jobs:
  post-results:
    permissions:
      pull-requests: write
      checks: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: test-results
      - name: Post comment to PR
        # This runs in your repo context, never executes fork code

Never use pull_request_target with a checkout of the PR's head commit. If you must use pull_request_target, only check out code from the base branch, not from the PR.

Control 5: Audit and Restrict Secrets, Dependencies, and Self-Hosted Runners

Secrets scoping:

Secrets defined at the organization level are available to all repositories. Secrets defined at the repository level are available to all workflows in that repository. Apply least-privilege:

  • Cloud credentials (AWS, GCP, Azure): per-repository secrets, scoped to the specific environment (production, staging) via GitHub Environments
  • Deployment keys: per-environment secrets using GitHub Environments with required reviewers for production
  • API keys for external services: per-repository, scoped to only the workflows that need them
# Use GitHub Environments to gate production secrets
jobs:
  deploy-production:
    environment: production  # Requires approval from configured reviewers
    steps:
      - name: Deploy
        env:
          PROD_KEY: ${{ secrets.PROD_DEPLOY_KEY }}  # Only available after approval

Prevent secrets from appearing in logs:

# Never echo secrets directly
- run: echo "${{ secrets.MY_SECRET }}"  # WRONG: may appear in logs

# Pass via environment variables
- run: ./deploy.sh
  env:
    API_KEY: ${{ secrets.API_KEY }}  # Masked in logs; passed as env var to script

Audit your current Actions usage:

# Find all external Actions used across your org (requires GitHub CLI)
gh repo list YOUR-ORG --limit 1000 --json nameWithOwner --jq '.[].nameWithOwner' | \
  while read repo; do
    gh api /repos/$repo/contents/.github/workflows --jq '.[].name' 2>/dev/null | \
      while read workflow; do
        gh api /repos/$repo/contents/.github/workflows/$workflow --jq '.content' | \
          base64 --decode | grep -oE 'uses: [^@]+@[^\n]+' | grep -v 'actions/' | echo "$repo: $(cat)"
      done
  done

This lists all non-GitHub-owned Actions in use across your organization, helping you identify which ones need SHA pinning and which are high-risk third-party dependencies.

Self-hosted runner security:

Self-hosted runners execute workflow jobs on your infrastructure. Key risks:

  • A compromised workflow job can persist artifacts on the runner between jobs (unlike ephemeral GitHub-hosted runners)
  • A fork PR can request a self-hosted runner job if the runner is not scoped to specific repositories

Mitigation:

# Restrict self-hosted runners to specific trusted workflows only
jobs:
  deploy:
    runs-on: self-hosted  # Only use for trusted workflows, not PR checks

And in your runner registration: register runners at the repository level (not organization level) to prevent them from being used by other repositories.

Using StepSecurity to Automate These Controls

StepSecurity (stepsecurity.io) is a free tool that automates several of these hardening controls:

Harden-Runner Action:

- uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7  # v2.10.1
  with:
    egress-policy: audit  # Start in audit mode; switch to 'block' once tested
    allowed-endpoints: >
      github.com:443
      api.github.com:443
      registry.npmjs.org:443

Harden-Runner monitors all network egress from workflow jobs. In audit mode, it logs all outbound connections. In block mode, it blocks any connection not on the allowlist -- including connections a compromised Action might make to exfiltrate secrets.

The tj-actions attack revisited: The compromised action printed secrets to logs and also attempted to POST them to an external endpoint. Harden-Runner with egress-policy: block and an allowlist that did not include the attacker's endpoint would have blocked the exfiltration even if the action ran.

Free automated PR generator:

StepSecurity also offers a free service at app.stepsecurity.io that analyzes your workflow files and opens a PR that:

  • Pins all Actions to their current commit SHA
  • Adds minimum permissions declarations
  • Adds Harden-Runner

For organizations with many repositories, this is the fastest path to baseline hardening.

The bottom line

GitHub Actions supply chain attacks are not theoretical. They compromised 23,000 repositories in 2025. Every control in this checklist addresses a real attack vector with a concrete YAML implementation. The priority order: (1) pin all third-party Actions to commit SHA today -- it prevents the entire class of tag-mutation attacks; (2) set workflow permissions to read-only by default; (3) migrate cloud credentials to OIDC; (4) audit for pull_request_target misuse. The SHA pinning alone would have made the tj-actions attack a non-event for your pipelines.

Frequently asked questions

What was the tj-actions supply chain attack?

In March 2025, attackers compromised the GitHub account that maintained tj-actions/changed-files, a widely-used Action that detects which files changed in a PR. They pushed malicious code to the existing version tags that printed CI/CD secrets and environment variables to workflow logs. Any repository using the action via a mutable tag (like @v45) automatically ran the malicious code on their next workflow trigger. Repositories that had pinned the action to a specific commit SHA were not affected because their workflows continued running the previously-verified code.

Is SHA pinning really sufficient protection?

SHA pinning prevents tag-mutation attacks where an attacker modifies an existing version. It does not prevent a scenario where the attacker also controls a new release that a Dependabot PR updates to -- which is why human review of Dependabot PRs for Actions updates matters. SHA pinning combined with required PR review for Dependabot automation updates provides strong protection. The combination of pinning plus Harden-Runner egress blocking provides defense-in-depth: even if a compromised action runs, it cannot exfiltrate secrets to an external endpoint.

What is the difference between pull_request and pull_request_target triggers?

pull_request runs in the context of the fork -- it has no access to repository secrets and uses a read-only token. This is safe for running tests on PR code from external contributors. pull_request_target runs in the context of the base repository, has access to secrets, and uses the full GITHUB_TOKEN. It was designed for scenarios like commenting on PRs or posting test results, where write access to the base repository is needed. The danger arises when pull_request_target workflows check out and execute code from the PR's head branch -- that combines external (fork) code execution with privileged base repository access.

Does OIDC work for all cloud providers?

Yes. AWS, GCP (via Workload Identity Federation), and Azure (via federated credentials on a service principal) all support OIDC-based authentication from GitHub Actions. The setup steps differ slightly per provider but the principle is the same: GitHub issues a short-lived OIDC token, the cloud provider verifies the token's claims (repository, branch, environment) against a trust policy, and issues temporary credentials. No long-lived cloud credentials are stored in GitHub Secrets.

How do I find which Actions in my organization are unpinned?

StepSecurity's free tool at app.stepsecurity.io can scan your workflows and identify unpinned Actions. For a self-hosted approach, use the GitHub CLI command in Control 5 to list all external Actions across your org. For automated remediation, StepSecurity can open PRs to pin all Actions in bulk. If your organization has more than 20 repositories with workflows, the automated PR approach is significantly faster than manual pinning.

Should I use GitHub-hosted or self-hosted runners?

GitHub-hosted runners are ephemeral -- they spin up fresh for each job and are destroyed after. This eliminates persistence risks from previous jobs. Self-hosted runners are appropriate when you need specific hardware, access to private network resources, or significant compute that exceeds GitHub-hosted runner limits. If you use self-hosted runners, do not expose them to fork PR workflows -- only use them for trusted workflows on your own branches. Register runners at the repository level, not the organization level, to prevent cross-repository access.

Sources & references

  1. GitHub Actions Security Hardening Guide
  2. OpenSSF Scorecard
  3. SLSA Supply Chain Security Framework
  4. MITRE ATT&CK Framework
  5. Sigma Rules Project (SigmaHQ)
  6. IBM Cost of a Data Breach Report

Free resources

25
Free download

Critical CVE Reference Card 2025–2026

25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.

No spam. Unsubscribe anytime.

Free download

Ransomware Incident Response Playbook

Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.

No spam. Unsubscribe anytime.

Free newsletter

Get threat intel before your inbox does.

50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, weekly, no spam.

Unsubscribe anytime. We never sell your data.

Eric Bang
Author

Founder & Cybersecurity Evangelist, Decryption Digest

Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals weekly.

Free Brief

The Mythos Brief is free.

AI that finds 27-year-old zero-days. What it means for your security program.

Joins Decryption Digest. Unsubscribe anytime.

Daily Briefing

Get briefings like this every morning

Actionable threat intelligence for working practitioners. Free. No spam. Trusted by 50,000+ SOC analysts, CISOs, and security engineers.

Unsubscribe anytime.

Mythos Brief

Anthropic's AI finds zero-days your scanners miss.