Three significant CI/CD incidents in 2025: the tj-actions supply chain attack in March (23,000 repositories), GhostAction in September (3,325 secrets stolen), and the Shai Hulud worm in November (20,000+ repositories infected). The pipeline is now one of the most targeted parts of a software deployment. Here is what to check.
Most teams set up GitHub Actions when they start a project, wire in some secrets, and never revisit it. The workflow files accumulate. Third-party actions get added. Permissions creep. By the time someone thinks to look at security, the pipeline has more access than it should and the secrets it holds are more valuable than anyone remembered.
The 10-point checklist
1. Are any third-party actions pinned by version tag?
Version tags like @v3 or @v2.1.0 are mutable. The tj-actions incident worked precisely because version tags can be moved to point to any commit, including a malicious one. The only safe reference is a full commit SHA.
# Find all uses that are NOT pinned to a 40-character SHA
grep -rn "uses:" .github/workflows/ | grep -v "@[0-9a-f]{40}"
# Example of what to change:
# BEFORE (unsafe):
- uses: actions/checkout@v4
# AFTER (safe):
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.22. Does any job have excessive permissions?
GitHub Actions workflows have access to a GITHUB_TOKEN that can read and write repository contents, packages, deployments, and more. The default permissions are broader than most workflows need. Restrict them explicitly.
# At the top of your workflow file, restrict to minimum:
permissions:
contents: read # most read-only workflows need only this
# Only add what the specific job actually needs:
# deployments: write
# packages: write
# id-token: write (for OIDC only)3. Are long-lived cloud credentials stored as secrets?
AWS access keys and Azure service principal credentials stored as GitHub Secrets exist persistently. If they are printed to logs by a compromised action, they remain valid until you rotate them. Switch to OIDC (short-lived tokens federated from GitHub).
# AWS OIDC: request a temporary token at workflow run time
permissions:
id-token: write
contents: read
- name: Configure AWS via OIDC
uses: aws-actions/configure-aws-credentials@v4 # pin to SHA!
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/GitHubDeploy
aws-region: us-east-1The token expires after the workflow run. No long-lived credentials stored anywhere.
4. Do any workflows run on pull_request from forks?
Public repositories accept pull requests from forks. If your workflow triggers onpull_request and has access to secrets, a malicious contributor can submit a PR that modifies the workflow and exfiltrates your credentials.
# Use pull_request_target only when needed, not pull_request, for fork PRs
# And ensure secrets are only accessible in specific environments:
on:
pull_request:
# This workflow will NOT have access to secrets from forks
# which is correct for build/test jobs
pull_request_target:
# This DOES get secrets - only use for trusted scenarios5. Are secrets ever printed to logs?
GitHub masks known secret values in logs, but only secrets it knows about. If you pass a secret to a script that transforms it, or if an action reads environment variables and prints them, the transformed value may not be masked. The tj-actions attack worked exactly this way: the injected code iterated over environment variables and printed them to logs without using the secretscontext directly, so GitHub's masking did not catch it.
# Never do this:
- run: echo "Deploying with key ${{ secrets.API_KEY }}"
# Use the secret in a step without echoing it:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}6. Are there any secrets in your git history?
A developer commits a .env file by accident and removes it in the next commit. The credential is still in the git history. Anyone who clones the repository and runs git log -p can find it. Rotation is the only fix: the git history cannot be cleaned from all downstream clones.
# Scan your entire git history for secrets with gitleaks
docker run --rm -v "$(pwd):/path" zricethezav/gitleaks:latest detect --source /path --report-format json --report-path /path/gitleaks-report.json
# Or use trufflehog for a broader scan:
docker run trufflesecurity/trufflehog:latest git file://. --only-verifiedThe other four things worth checking
Audit your secrets list. Go to Settings → Secrets and Variables → Actions and look at every entry. Remove any that belong to services you no longer use. Rotate anything that has not been rotated in 90+ days. Most teams have secrets from integrations that were set up and abandoned.
Protect your deployment environments. GitHub Environments let you require a manual approval before a deployment proceeds. Even if a workflow is triggered unexpectedly, production stays protected. Set this up for anything that touches live infrastructure.
Think carefully about self-hosted runners for sensitive jobs. Self-hosted runners persist between jobs. If a previous run left something behind, the next run inherits it. GitHub-hosted runners are ephemeral: a clean VM for every run, destroyed immediately after. For workflows that touch production credentials, GitHub-hosted is generally safer than a shared persistent runner.
Add outbound network monitoring. The step-security/harden-runner action monitors outbound network traffic from your CI runner and flags unexpected connections. It would have caught the tj-actions exfiltration in real time. It is free for public repos.
# Add at the start of any sensitive job
steps:
- name: Harden runner
uses: step-security/harden-runner@v2 # pin to SHA
with:
egress-policy: audit # start with audit, move to block once you know your domainsPriority order
If you do nothing else: check points 1, 3, and 7. Mutable action references, long-lived credentials, and secrets in git history are the three highest-impact findings in the shortest time to check.
$ harden --your-pipeline
If you want your GitHub Actions files actually reviewed against these checks (pinned actions, OIDC setup, secret scanning, environment configuration), I can do that.
$ ./request-devops-review.sh →