Vetting a GitHub Action means reading its source at the exact commit you will run, pinning to that full SHA rather than a tag, and scoping the workflow so that even a compromised action cannot reach secrets it does not need. A third-party action executes inside your job with your GITHUB_TOKEN, your environment, and whatever secrets the job exposes. The tj-actions/changed-files compromise in March 2025 (CVE-2025-30066) dumped CI secrets to build logs across an estimated 23,000 repositories — through an action most of those teams had never read. Here is the routine that would have contained it.
Read the action at the ref you will actually run
Start with action.yml at the specific tag or commit, not the README on the default branch. It tells you the action's type, which determines what you are auditing:
- Composite actions are YAML calling shell and other actions — quick to read, and check what they pin.
- Docker actions pull or build an image; now you are vetting a Dockerfile and a registry reference too.
- JavaScript actions are the tricky ones, because what executes is the committed
dist/index.jsbundle — frequently 40,000 lines of webpacked output — not the readablesrc/directory.
For JavaScript actions, verify the bundle matches the source instead of trying to read it: check out the repo, npm ci && npm run build, and diff your dist/ against the committed one. A mismatch between src/ and dist/ is precisely how a compromised maintainer hides a payload in plain sight. If the project publishes build provenance for its releases, verify that instead — it is the same assurance without the local rebuild.
While you are in the tree, grep for the things that have no business in most actions:
git grep -nE 'curl|wget|process\.env|http\.request|child_process' -- dist/ src/
Network calls plus environment access is the exfiltration signature. Some actions legitimately need both. Know which ones, and why.
Pin to a full commit SHA, never a tag
Tags are mutable. Whoever controls the repo — or compromises it — can move v4 to arbitrary code after your review, which retroactively converts your audit into theater. The tj-actions attack did exactly this: the attacker retagged existing version tags to point at a malicious commit.
# Auditable: this exact tree, forever
- uses: tj-actions/changed-files@2f7c5bfce28377bc069a65ba478de0a74aa0ca32 # v46.0.1
# Not auditable: whatever the tag points at today
- uses: tj-actions/changed-files@v46
Keep the human-readable version in a trailing comment; Dependabot and Renovate both understand SHA-pinned actions and will bump the SHA and the comment together, so you keep updates without losing immutability. GitHub's push toward immutable releases helps, but SHA pinning works today for every action, maintained or not, and organization policy can enforce it via allowlists (Settings → Actions → allow specified actions only).
Scope what a compromised action could steal
Assume the vetting fails eventually — some action you trust ships a bad release. The blast radius is set by workflow configuration, not by hope:
permissions:
contents: read # default for everything below
jobs:
build:
permissions:
contents: read # this job needs nothing more
Set the default GITHUB_TOKEN permissions to read-only at the organization level and grant write scopes per job, per need. Keep secrets out of job-wide env: blocks — pass them to the single step that needs them, because every step in a job can read job-level environment variables. And treat pull_request_target with standing dread: it runs with secrets in the context of your base branch against attacker-controlled PRs, and it features in a disproportionate share of CI compromises. The Codecov bash uploader retrospective is the canonical case study in what one leaky CI step costs downstream.
Check the maintainer and release hygiene
Five minutes of due diligence on the project itself:
| Check | How |
|---|---|
| Who publishes it | Org with multiple maintainers beats a personal account with bus factor 1 |
| Verified creator badge | Marketplace listing; weak signal, but its absence on a "popular" action is odd |
| Release cadence | Regular releases with changelogs; a silent burst after dormancy is a flag |
| OpenSSF Scorecard | scorecard --repo=github.com/owner/action — look at Token-Permissions and Pinned-Dependencies |
| Open issues | Search for "security", "compromise", "malware" in issues before adopting |
None of these is decisive alone. Together they sort the maintained from the abandoned, and abandonment is the leading indicator — hijacks overwhelmingly happen to popular-but-unmaintained projects.
Contain the runner's egress
The last layer assumes everything above failed: the action is malicious and running. Exfiltration still needs a network path. step-security/harden-runner audits and then blocks outbound connections to anything outside an allowlist:
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
registry.npmjs.org:443
Run it in audit mode for a week to learn your baseline, then flip to block. It was harden-runner's anomaly detection that surfaced the tj-actions payload, which is as good an endorsement as the category gets. For inventorying which actions your organization already runs — usually a longer list than anyone expects — Safeguard's SCA treats workflow files as manifests and flags unpinned or known-compromised actions on the PR that introduces them.
Frequently asked questions
Is pinning to a major version tag like v4 safe enough?
No. Tags can be moved to arbitrary commits by anyone with repo write access, which is exactly what happened in the tj-actions compromise. Pin the full 40-character SHA and let Renovate or Dependabot manage updates.
Do I need to vet actions published by GitHub itself?
actions/* and github/* are held to a different standard and are reasonable to trust at major-version tags. Everything else — including very popular community actions — gets the full routine. Popularity is reach, not safety; it makes an action a better target.
What about forking actions into our own org?
Forking gives you immutability and review control at the cost of maintenance — you now own updates. It is the right call for a small set of critical actions in regulated environments; SHA pinning gets you most of the benefit for the long tail.
How do I find every third-party action we currently use?
Search your org for uses: lines: gh search code --owner your-org 'uses:' --filename '*.yml' --path .github/workflows, or let an SCA tool that parses workflow manifests build the inventory continuously. Expect surprises either way.