Safeguard
Guides

How to Vet a GitHub Action Before You Trust It with Secrets

Third-party Actions run with your repo's token and secrets. A vetting routine: read the source at the pinned SHA, audit the bundled dist, scope permissions, and contain egress.

Marcus Webb
DevSecOps Lead
6 min read

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.js bundle — frequently 40,000 lines of webpacked output — not the readable src/ 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:

CheckHow
Who publishes itOrg with multiple maintainers beats a personal account with bus factor 1
Verified creator badgeMarketplace listing; weak signal, but its absence on a "popular" action is odd
Release cadenceRegular releases with changelogs; a silent burst after dormancy is a flag
OpenSSF Scorecardscorecard --repo=github.com/owner/action — look at Token-Permissions and Pinned-Dependencies
Open issuesSearch 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.