GitHub Actions is a remote code execution engine that you invite into your repository and hand your secrets to. That's not a criticism — it's the accurate mental model, and it's the one the March 2025 tj-actions/changed-files compromise (CVE-2025-30066) forced on a lot of teams. Attackers pushed malicious code to a popular action and retagged its existing version tags to point at it, so every workflow referencing @v35 or similar silently began dumping CI runner memory and leaking secrets into build logs. Roughly 23,000 repositories were exposed. The lesson isn't "don't use third-party actions" — it's that a workflow's blast radius is defined by three things you fully control: what it's allowed to do, what code it trusts, and what it can reach. This checklist hardens all three.
Pin every action to a full commit SHA
A version tag like @v4 is mutable — the maintainer (or an attacker who compromises them) can repoint it at any time, and your workflow will run the new code on its next execution without any change on your side. A full commit SHA is immutable. Pin every third-party action to a 40-character SHA, and keep a comment noting the human-readable version so upgrades stay legible:
steps:
# BAD: mutable tag — silently repointable
- uses: actions/checkout@v4
# GOOD: pinned to an immutable commit SHA
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v4.2.2
Use a tool like Dependabot or pin-github-action to keep SHAs updated through reviewed pull requests, so pinning doesn't mean going stale.
Restrict GITHUB_TOKEN to read-only by default
Since 2023, GITHUB_TOKEN defaults to read-only for new repositories, but many existing repos and org policies still grant it broad write scope, and a single malicious action inherits whatever the token can do. Set the floor to read-only at the workflow level and grant write scope only to the specific jobs that need it:
permissions:
contents: read # workflow-wide floor
jobs:
release:
permissions:
contents: write # only the release job can push tags/releases
steps:
- ...
Never run untrusted code with secrets
The most damaging GitHub Actions vulnerabilities come from pull_request_target, which runs with the base repository's secrets. If a workflow using that trigger checks out and executes code from the incoming fork, an attacker's pull request can exfiltrate every secret the workflow can see. Two rules prevent it:
- Use the plain
pull_requesttrigger for anything that builds or tests fork code — it runs without secrets. - If you genuinely need
pull_request_target(for example, to label PRs), never check out the head ref, and keep the logic to trusted, first-party scripts only.
Also watch for script injection: interpolating ${{ github.event.pull_request.title }} or a branch name directly into a run: block lets an attacker embed shell commands in a PR title. Pass untrusted values through environment variables instead of inlining them.
Replace long-lived cloud secrets with OIDC
Storing a static AWS or GCP key as a repository secret means one leaked log line is a permanent compromise until someone notices and rotates it. GitHub's OIDC provider lets a workflow exchange its signed identity token for a short-lived cloud credential scoped to a specific role, with no standing secret to steal. Combined with a cloud-side trust policy that pins the repository and branch, this is one of the highest-leverage changes you can make.
Protect the runner and egress
On self-hosted runners especially, a compromised action can reach your internal network and phone home. A runtime agent (such as StepSecurity's harden-runner) can enforce an egress allowlist and detect anomalous outbound connections and file writes during a job — exactly the behavior the tj-actions payload exhibited. Prefer ephemeral runners that are destroyed after each job so nothing persists between runs, and never use self-hosted runners on public repositories where forks can target them.
Add a security-scanning gate to the workflow
Hardening the workflow mechanics still leaves the code and dependencies it ships. Add a scanning stage that generates an SBOM and runs software composition analysis on every pull request, gating on reachable high-severity findings. This catches a poisoned or vulnerable dependency before the artifact is built, and pairs naturally with pinning — pinning stops silent repointing, scanning catches a dependency that was malicious or vulnerable to begin with.
Hardening checklist
- Every third-party action pinned to a full commit SHA
-
permissions:set tocontents: readat the workflow level - Write scopes granted per-job, never workflow-wide
- No
pull_request_targetchecking out untrusted head code - Untrusted inputs passed via env vars, never inlined into
run: - OIDC federation replacing static cloud keys
- Ephemeral runners; no self-hosted runners on public repos
- Egress monitoring on runners
- SCA + SBOM scanning gate on every PR
- Secret scanning push protection enabled
How Safeguard Helps
Safeguard plugs into GitHub Actions as a single workflow step via our CLI, generating a software bill of materials and running reachability-aware SCA in the same pass. Findings are gated by exploitability, not raw count, so your workflow blocks the issues that matter without drowning developers. When a vulnerable action or package needs updating, auto-fix raises the SHA-pinned pull request for you. Teams already using GitHub Advanced Security often ask where it ends and where reachability-based gating starts — our Safeguard vs GitHub comparison lays that out directly.
Add Safeguard to your Actions workflows free at app.safeguard.sh/register, or read the GitHub integration docs at docs.safeguard.sh.