Skip to content

Back to the catalog

Hardening GitHub Actions Against Outside Threats
socket.devarticle

Hardening GitHub Actions Against Outside Threats

Part one of a Socket series on hardening GitHub Actions against attackers who hold no organization credential. It covers nine controls: SHA pinning and workflow lockfiles, read-only GITHUB_TOKEN, avoiding pull_request_target, expression injection, cache isolation, self-hosted runners, dependency intake, verifying downloaded scripts, and egress limits. Each is tied to incidents such as tj-actions, Nx s1ngularity, Ultralytics and TanStack Router, with YAML examples. The page was truncated before the end of chapter 9.

TL;DR Walks through nine GitHub Actions hardening controls against external attackers, each tied to a real 2024-2026 supply chain incident and shown with config examples.
Document

What it is

Part one of Karlo Zanki's Socket series on GitHub Actions hardening, written for attackers with no credential for your organization. The page is complete, and it adds specifics the catalog summary lacks: tool behavior, version boundaries, a dated deadline and the gaps in each control. It states that the hostile input arrives through pull requests, dependencies, third-party actions, downloaded scripts or reachable hosts.

What to do

Pinning (chapter 1). Prefer GitHub's workflow lockfile over inline SHAs:

gh extension install github/gh-actions-lock
gh actions-lock
gh actions-lock --migrate-local-actions
gh actions-lock --verify      # CI check
gh actions-lock --relock      # deliberately advances floating refs; review the diff

Then set an org Actions policy that allows only specific actions and turn on Require lockfile. Keep the SHA-pinning policy on as well. Onboard shared reusable-workflow repositories first, because Require lockfile blocks any caller whose reusable workflow's repository is not onboarded, and a SHA pin does not substitute. For repositories not yet onboarded, run zizmor (--persona=pedantic, with a GitHub token so the online impostor-commit audit runs).

Tokens (chapter 2). Set the org default to read-only, put permissions: {} at workflow level and grant per job. Organizations created before February 2023 default to read/write.

Privileged triggers (chapter 3). Use plain pull_request. For privileged follow-up, build in an unprivileged workflow and consume a single-file artifact in a workflow_run job that never checks out fork code. Pin download-artifact to github.event.workflow_run.id and treat the contents as untrusted data. Set persist-credentials: false on checkout. Give build jobs no publishing credential, and publish from a separate gated job.

Injection (chapter 4). Pass ${{ }} values through env: and quote the shell variable. In actions/github-script, read context.payload or process.env instead of interpolating. Do not write untrusted content to $GITHUB_ENV or $GITHUB_PATH.

Cache (chapter 5). Set cache-mode: none on release jobs and cache-mode: read on untrusted jobs. Treat write or write-only on a low-trust trigger as a review finding.

Dependencies (chapter 7). Use npm ci or pnpm install --frozen-lockfile, and --ignore-scripts. Commit this .npmrc:

min-release-age=3
ignore-scripts=true

Re-enable scripts only for reviewed packages: npm rebuild --ignore-scripts=false better-sqlite3 esbuild.

Downloads (chapter 8). Download, verify the checksum, then execute, as separate steps. For codecov/codecov-action, set version explicitly, because the default is latest. Leave the binary input unset, since supplying one bypasses the GPG-signed checksum check.

Self-hosted runners (chapter 6). Avoid them on public repositories. Otherwise use ephemeral or just-in-time runners (generate-jitconfig) on a fresh host per job, and keep public repositories off shared runner groups.

Egress (chapter 9). Run an egress agent in audit mode first, then deny by default. It needs alerts on blocked traffic and a policy the job cannot widen.

Preconditions and dates

  • Workflow lockfiles are not supported on GitHub Enterprise Server, so SHA pinning remains the control there.
  • GitHub enforces its default pull_request_target block on public repositories from 2 December 2026. It is in evaluate mode now, and private and internal repositories are unaffected. Renovate and labeler workflows often depend on this trigger.
  • min-release-age needs npm 11.10.0 (February 2026) or later. Older clients ignore it silently. It does not apply to npm ci, so configure Dependabot cooldown (3 days by default on github.com) and Renovate minimumReleaseAge separately. Both cover version updates only, not security updates.
  • Wiz estimates a 7 to 14 day delay catches 80 to 90% of supply chain attacks.

Caveats

  • Lockfiles do not cover reusable workflow calls, local ./ actions (until migrated), container images, inputs or secrets. A lockfile also preserves a malicious commit if you onboard one, so it pins rather than vets.
  • A SHA proves immutability, not provenance. Impostor commits from a fork can resolve under the upstream URL. A SHA also does not pin nested actions called by composite actions or reusable workflows. A token-less zizmor run skips the online audits and still shows green.
  • Inline SHA pins stop Dependabot security alerts for that action.
  • The author could not confirm that LD_PRELOAD and BASH_ENV are block-listed in the environment files.
  • Egress allowlists work on hosts, not accounts, so allowing github.com permits exfiltration to an attacker's repository there. tj-actions leaked secrets through public build logs, which egress control does not see. GitHub's out-of-VM Layer 7 firewall for hosted runners is on the 2026 roadmap and not generally available.
  • Insider and stolen-maintainer-credential cases (GhostAction, LiteLLM in October 2026) fall outside these controls and are deferred to part two.

Organisations named here

  • Axios
  • John Stawinski
  • Runner on Runner
  • TanStack Router
  • Wiz

CVEs named here

  • CVE-2025-30066
  • CVE-2024-54132

Technologies named here

  • GitHub Actions
  • tj-actions/changed-files
  • zizmor
  • actionlint
  • reviewdog/action-setup
  • Dependabot
  • aquasecurity/trivy-action
  • Ultralytics
  • Renovate
  • Nx
  • GitHub Advisory Database
  • Dependency Review
  • @tanstack/* packages
  • axios
  • LiteLLM 1.107.0
  • npm
  • GitHub CLI
  • Actions Runner Controller
  • actions/checkout
  • GitHub Enterprise Server
  • Trivy
  • GitHub
  • PyTorch
  • actions/cache
  • actions/download-artifact
  • actions/github-script
  • codecov-action
  • Codecov Bash Uploader
  • gh actions-lock
  • GitHub Actions cache
  • PyTorch self-hosted runners
  • runner egress agent

Techniques named here

  • Pwn Request
  • Cache poisoning
  • Checksum verification of downloaded scripts
  • Credential harvesting from runner process memory
  • Egress filtering
  • Expression injection / command injection
  • Impostor commit
  • Least-privilege GITHUB_TOKEN
  • Malicious install lifecycle script (postinstall)
  • OIDC token extraction from runner memory
  • Runner on Runner
  • SHA pinning of actions
  • Tag repointing (mutable tag hijack)
  • Two-stage untrusted/privileged workflow split

Concepts named here

  • Blast radius
  • Ephemeral runners
  • Immutability versus provenance
  • Outside versus insider threat
  • Release-age cooldown
  • Release pipeline compromise
  • Transitive action dependencies
  • Trust boundary

Threat actors named here

  • TeamPCP
  • Shai-Hulud
  • Nx s1ngularity
  • GhostAction
  • tj-actions/changed-files compromise
  • Axios compromise
  • s1ngularity
  • CodeCov
  • Codecov Bash Uploader compromise (31 January to 1 April 2021, more than 29,000 customers)
  • LiteLLM 1.107.0 incident (October 2026)
  • reviewdog/action-setup compromise (March 2025)
  • Runner on Runner technique (Adnan Khan and John Stawinski; PyTorch 2023, DEF CON 32)
  • TanStack Router compromise (May 2026; 84 malicious versions across 42 @tanstack/* packages)
  • Ultralytics compromise (December 2024; crypto miner shipped to PyPI as versions 8.3.41 and 8.3.42)

Also named here

  • Adnan Khan