Shai-Hulud 2.0 turned every machine it infected into a GitHub Actions runner. With a stolen GitHub token it created a public repo, registered the host as a self-hosted runner named SHA1HULUD on it, then added a discussion.yaml workflow that interpolated GitHub Discussion text straight into a run: step. Opening a discussion on that repo ran commands on the infected laptop or CI server, through GitHub’s own infrastructure.

Part 1 and part 2 hardened how workflows are written. This post covers the machines jobs run on and the audit log trail that shows when someone tampers with either.

Keep self-hosted runners off public repos

A self-hosted runner executes whatever job lands on it. On a public repo, that includes pull requests from forks. First-time contributors need approval before their workflows run, but anyone with one merged typo fix clears that bar. GitHub’s secure-use reference says self-hosted runners “should almost never be used for public repositories.”

Runner groups enforce this. By default only private repositories can use a runner group, and the setting to allow public repos is per group. Leave it off. Public repos get GitHub-hosted runners, which start a fresh VM for every job.

Make every runner single-use

A long-lived runner keeps whatever the last job left behind. A compromised job can plant a process that waits for the next job and reads its secrets. Some jobs pass secrets as command-line arguments, which any later job on the same machine can see in the process list.

A runner registered with --ephemeral is deregistered by GitHub after a single job. Just-in-time runners go further. Your automation asks the REST API for a one-job runner config and passes it straight to the runner, so no reusable registration token ever touches the host:

gh api -X POST /orgs/my-org/actions/runners/generate-jitconfig \
  -f name="build-$(date +%s)" \
  -F runner_group_id=3 \
  -f 'labels[]=linux-build' \
  --jq .encoded_jit_config > jitconfig

./run.sh --jitconfig "$(cat jitconfig)"

On Kubernetes, Actions Runner Controller handles this and scales runners with demand. Whatever runs them, a single-use runner on reused hardware still inherits what the last job wrote to disk. Rebuild the VM or container from a clean image for every job.

Group runners by trust

Split runners by what they can reach. A deploy runner with network access to production and an instance role in AWS belongs in its own runner group, restricted to the one or two repos that deploy. Build and test runners go in another group with no route to anything sensitive. Register runners at the org or enterprise level so one team manages them, and avoid repo-level runners nobody remembers creating.

Keep credentials off the runner host itself. Anything on disk or in the instance metadata is readable by every job that lands there. OIDC, covered in part 2, removes most reasons to bake cloud credentials into a runner image.

Know what GitHub-hosted runners give you

GitHub-hosted runners start a fresh VM for every job and destroy it afterwards, which removes the persistence problem. GitHub publishes an SBOM with every runner-images release, so you can scan the preinstalled software and fold it into your own bill of materials.

The runner’s /etc/hosts points known mining pools and malicious domains at localhost. GitHub’s own docs say not to treat that as a network control. A job can still reach most of the internet, so exfiltration from a hosted runner only needs a webhook URL.

Watch the audit log

Every step of the Shai-Hulud runner backdoor, and most of the tampering covered in this series, leaves an entry in the org audit log. These are the events I’d alert on, with their exact names from GitHub’s audit log reference:

EventWhat it shows
org.register_self_hosted_runner, repo.register_self_hosted_runnerA new machine can now pick up jobs
org.configure_self_hosted_jit_runner, repo.configure_self_hosted_jit_runnerSame, through the JIT API
org.runner_group_updated, org.runner_group_visiblity_updated, org.runner_group_runners_addedRunner group scope or membership changed
org.create_actions_secret, org.update_actions_secret, repo.create_actions_secret, repo.update_actions_secret, environment.update_actions_secretA secret was added or overwritten
org.set_default_workflow_permissions, repo.set_default_workflow_permissionsDefault token permissions changed
org.set_workflow_permission_can_approve_pr, repo.set_workflow_permission_can_approve_prWorkflows were allowed to approve pull requests
org.set_fork_pr_workflows_policy, org.set_actions_fork_pr_approvals_policyFork PR workflow rules changed
org.update_actions_settings, repo.update_actions_settingsAllowed actions or pinning policy changed

org.runner_group_visiblity_updated is misspelled in GitHub’s own reference, so match it exactly.

Runner registrations deserve the tightest rule. If JIT runners come from one automation account, any registration by another actor is an alert. Settings changes happen rarely enough that each one can go to a human. Edits to workflow files show up as ordinary pushes, so CODEOWNERS and branch protection on .github/workflows/ cover those better than the audit log does.

GitHub Enterprise Cloud can stream the audit log to a SIEM or a storage bucket.

Hunt for runners you didn’t create

To check an org for the Shai-Hulud indicators, list every runner and search for the backdoor workflow. Cycode also lists formatter_*.yml, a workflow the worm used to dump repository secrets into an artifact. The repos it created carry random 18-character names and the description “Sha1-Hulud: The Second Coming.”

gh api /orgs/my-org/actions/runners --paginate \
  --jq '.runners[] | [.name, .os, .status] | @tsv'

gh search code --owner my-org --filename discussion.yaml

Any runner named SHA1HULUD, or any runner your provisioning automation didn’t create, gets removed and its host treated as compromised. Rotate every credential that machine could reach, starting with the GitHub token that registered it.