GitHub Actions Security: Your CI Pipeline Is a Dependency Graph
A GitHub Actions workflow file is a dependency manifest that nobody reviews like one. Every uses: line pulls in code you did not write, runs it with access to your repository, your secrets and your release credentials, and resolves a version at run time unless you told it otherwise.
GitHub Actions security is supply-chain security. The same questions you ask about an npm package apply to an action, and the stakes are often higher, because the code runs inside your build with a token that can push to your repository. This post covers how the CI supply chain gets attacked, what to lock down first, and how to keep it locked down as workflows change.
Why your CI pipeline is a dependency graph
Look at a typical workflow and count what it depends on:
- Third-party actions such as
actions/checkout, a cloud login action, a linter, a release helper. - Reusable workflows and composite actions, which themselves call further actions, several layers deep.
- Packages bundled inside JavaScript actions, shipped in the action's
distfolder and never listed in your repository. - Container images used by Docker actions and service containers.
- Tools downloaded at run time by
curlin arun:step.
That is a full dependency graph with transitive edges, and in most organizations it has no inventory, no vulnerability matching and no review policy. The application's dependencies get scanned on every pull request. The pipeline that builds and ships them does not.
How the CI supply chain gets attacked
The attacks on Actions fall into a few recurring patterns.
Compromised actions
In March 2025 the widely used tj-actions/changed-files action was compromised and its version tags were repointed to a malicious commit that dumped CI secrets into workflow logs (CVE-2025-30066). Every workflow that referenced it by tag ran the malicious code on its next run. Workflows pinned to a full commit SHA of an earlier, clean version did not.
Mutable references
A reference like @v4 is a Git tag, and tags can be moved. @main is a branch, which moves by design. Either way, the code your workflow runs can change without any change to your repository.
Impostor commits
Because of how GitHub stores forks, a commit that exists only in a fork can be reachable through the parent repository's name. A workflow that pins owner/action@<sha> can end up running code the real maintainer never wrote, if that SHA came from a fork. A SHA pin is only as trustworthy as the check that the commit belongs to the real repository.
Dangerous triggers and script injection
The pull_request_target trigger runs in the context of the base repository, with access to secrets and a write-capable token, even for pull requests from forks. Combine it with a checkout of the pull request's code and you have handed untrusted code your secrets. Separately, expressions like ${{ github.event.issue.title }} interpolated straight into a run: script let anyone who can open an issue inject shell commands.
Over-broad tokens
The GITHUB_TOKEN available to a job can be granted write access to contents, packages, pull requests and more. A compromised step inherits whatever the job was given. A job that only runs tests does not need to push to your repository.
GitHub Actions security basics: what to lock down first
GitHub's own security hardening guide for GitHub Actions covers these in depth. In order of payoff:
1. Pin actions to a full commit SHA
Replace tag and branch references with the full 40-character SHA, and keep the version in a comment so humans and update bots can read it:
uses: actions/checkout@<full-40-character-sha> # v4.2.2
Take the SHA from the action's own repository, not from a fork, and let an update tool propose SHA bumps as reviewed pull requests.
2. Set token permissions explicitly
Declare the minimum at the top of every workflow and widen per job only where needed:
permissions: { contents: read }
Do not rely on the organization default. It may be read-only today and changed by someone else tomorrow.
3. Treat pull_request_target as dangerous
Use pull_request for anything that builds or tests contributed code. Reserve pull_request_target for jobs that never check out or execute the pull request's code, such as labelling.
4. Never interpolate untrusted input into scripts
Pass event data through an environment variable and quote it, instead of putting ${{ }} expressions inside run: blocks.
5. Wait before adopting a new release
A malicious release is usually detected within days. Requiring a new action version to have been public for a minimum period before you adopt it removes most of that window.
Deciding which actions to trust
Pinning stops a trusted action from changing under you. It does not tell you whether to trust the action in the first place. Before adopting a third-party action, look at:
- The publisher. A verified publisher and an established maintainer history are better signals than a single-contributor repository created last month.
- Provenance. Whether releases carry a signed build attestation that shows where the artifact came from.
- Project health. Branch protection, signed releases, token permissions and maintenance activity, the kind of signal the OpenSSF Scorecard measures.
- What it bundles. A JavaScript action ships its own dependencies. Those can carry known vulnerabilities or a compromised package of their own.
- Whether you need it. A five-line shell step you own is often safer than an action that does the same thing.
Keeping it locked down
A one-time cleanup of workflow files does not last. New workflows are added every week, often copied from examples that use tags, and increasingly written by AI coding agents that reproduce the most common patterns in public repositories, which are tag references and broad permissions.
That means GitHub Actions needs the same controls as any other dependency ecosystem:
- An inventory of every action and reusable workflow, direct and transitive, with the exact version each workflow references.
- Vulnerability and compromise matching against that inventory, including the packages bundled inside actions.
- A pull request check that blocks new unpinned actions, new compromised references and dangerous workflow patterns before they merge.
- Detection of workflow files that change outside the normal review path.
A GitHub Actions security checklist
- Pin every action to a full commit SHA from the real repository, with the version in a comment.
- Declare
permissions:in every workflow, starting fromcontents: read. - Remove
pull_request_targetfrom any job that checks out or runs contributed code. - Move untrusted event data into environment variables before it reaches a script.
- Require a minimum age for new action releases.
- Inventory actions and their bundled packages, and match them against advisories.
- Gate new unpinned or compromised actions on the pull request.
- Review workflow changes with the same care as application code, via CODEOWNERS on
.github/workflows.
How Heeler secures the Actions supply chain
Heeler treats GitHub Actions as a first-class dependency ecosystem. It reads every workflow file in monitored repositories and resolves the actions and reusable workflows they reference, including the composite actions and reusable workflows those pull in, several layers deep. For JavaScript actions it tracks the packages bundled inside, so a vulnerability in an action's dependencies surfaces like one in your own. Each action is recorded at the exact version the workflow points at, whether a commit SHA, a tag or a branch, and scored on pin status, publisher trust, provenance and source health. The details are in the GitHub Actions supply chain documentation.
Findings cover impostor commits, unpinned actions, compromised actions, excessive GITHUB_TOKEN permissions and dangerous triggers. PR Guardrails can block pull requests that introduce unpinned actions, enforce a minimum age on actions and their bundled packages, and block or warn on compromised actions. Tag and branch references such as @v3, @main and @latest count as unpinned. The same minimum-age idea applies to packages across your application dependencies, so a freshly published malicious release cannot slip straight into the build.
Actions sit in the same inventory, SBOM and remediation flow as the rest of your dependencies. See how Heeler covers the pipeline end to end on the CI/CD Security page.
FAQ
Why should GitHub Actions be pinned to a commit SHA?
A commit SHA is the only immutable reference to an action's code. Tags and branches can be moved, so a workflow referencing @v4 can run different code tomorrow without any change to your repository.
What is the risk with pull_request_target?
It runs with the base repository's secrets and a write-capable token, even for pull requests from forks. Checking out and running the pull request's code under it gives untrusted code access to those secrets.
What GITHUB_TOKEN permissions should a workflow have?
Start from contents: read at the workflow level and grant additional scopes only to the jobs that need them. A test job rarely needs any write permission.
Can a pinned action still be malicious?
Yes. A SHA pin freezes whatever code you pinned, including a malicious version or an impostor commit from a fork. Pin to verified commits from the real repository and match your actions against compromise advisories.
Do the packages inside a GitHub Action matter?
JavaScript actions ship their own bundled dependencies, which run in your pipeline. A vulnerable or compromised package inside an action is as dangerous as one in your application.
Your CI pipeline is a dependency graph with access to your secrets. To see every action in it inventoried, scored and gated at the pull request alongside your application dependencies, Get a demo.


.jpg)
