Hardening the pipeline
Pin actions by SHA, OIDC, ephemeral runners.
On 14 March 2025 an attacker who had stolen a GitHub personal access token pushed one malicious commit to the popular tj-actions/changed-files action and then re-pointed almost every version tag, from v1 through the then-current v45, at it. Any workflow that referenced the action by tag suddenly executed code that reached out to a GitHub gist for a short Python routine, dumped the runner's Runner.Worker process memory, and printed the harvested CI secrets, double-base64-encoded, straight into the build log. The action was referenced by more than 23,000 repositories, and every one running a public workflow risked exposing those secrets to anyone who could read the log. The teams that walked away unscathed had done two unglamorous things: they pinned the action to an immutable commit SHA, and they ran a runtime egress monitor that noticed the unexpected outbound connection. This lesson hardens the pipeline itself, the highest-value target in the entire supply chain, because whoever controls CI inherits its tokens, its signing identity, and its reach into every downstream consumer.
Pin every action to a commit SHA
A Git tag is a mutable pointer. A lightweight tag is a ref that names a commit directly; an annotated tag names a small tag object that in turn names the commit. Either way, the mapping from a tag to a specific tree of code is resolved at checkout time and can be rewritten at any moment by anyone with push access, which is exactly what the tj-actions attacker did. A commit SHA is different: it is the hash of the commit object, which itself commits to the root tree hash, the parent commits, the author, and the message. Change one byte of the action's source and the tree hash changes, so the commit hash changes; the reference is content-addressed and collision-resistant, and Git is migrating from SHA-1 to SHA-256 to keep it that way. Referencing the action by its 40-character SHA therefore nails the exact bytes that will run: the maintainer can publish v5, get compromised, or delete the repo and your pipeline keeps running the reviewed code. Always leave the human-readable tag in a trailing comment so upgrades stay legible, and review each bump as if it were application code, because it is. Automate this rather than doing it by hand: tools such as pinact or ratchet rewrite every tag reference to its current SHA and keep them current, and Dependabot understands SHA-pinned actions, opening pull requests that bump the SHA and its trailing comment together so you still review the diff. GitHub's newer immutable-actions and immutable-releases features raise the floor, but the commit SHA remains the guarantee that predates and outlives any single registry feature.
# Dereference a tag to the exact commit it points to right nowgh api repos/step-security/harden-runner/commits/v2.13.1 --jq .sha# Then pin (and keep pinned) every action reference across all workflowspinact run
f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a[pinact] .github/workflows/release.ymlstep-security/[email protected] -> @f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1actions/[email protected] -> @11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2actions/[email protected] -> @3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0sigstore/[email protected] -> @dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
Least-privilege GITHUB_TOKEN and OIDC
Every job receives an automatically-minted GITHUB_TOKEN. On many repositories its default scope is still read and write across contents, packages, and more, so a single compromised step can push commits, publish packages, or open a backdoor pull request. Declare permissions explicitly: set the top-level default to contents read, or an empty map for nothing, and elevate only the specific jobs that need more, to the narrowest scope. The signing job needs id-token write, and that permission lets the job request a short-lived OIDC JWT from GitHub's token endpoint, audience-scoped and carrying verifiable claims about the repository, ref, workflow, and runner environment. cosign exchanges that JWT with Fulcio, which mints a keyless signing certificate whose subject is bound to exactly those workflow-identity claims, and your cloud provider trusts the same JWT for federated login. There are no long-lived signing keys and no static cloud secrets to steal in the first place. Scope matters at the trigger level too: avoid pull_request_target combined with a checkout of untrusted PR code, the classic path by which a fork's build script runs with your write token and secrets. Least privilege plus OIDC shrinks both the blast radius of a compromised step and the standing credential inventory an attacker can harvest.
permissions:contents: read # least-privilege default for GITHUB_TOKENjobs:build-and-sign:runs-on: ubuntu-24.04 # GitHub-hosted: a fresh, ephemeral VM per jobpermissions:contents: readid-token: write # OIDC: keyless cosign + federated cloud authsteps:# harden-runner MUST be the first step so it monitors everything after it- uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1with:egress-policy: block # deny-by-default: only the endpoints belowdisable-sudo: trueallowed-endpoints: >github.com:443api.github.com:443objects.githubusercontent.com:443proxy.golang.org:443sum.golang.org:443fulcio.sigstore.dev:443rekor.sigstore.dev:443tuf-repo-cdn.sigstore.dev:443- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2- uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0- uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0- name: Buildrun: go build -trimpath -o app ./...- name: Sign the artifact keylessly (uses the OIDC token, no stored keys)run: cosign sign-blob --yes --bundle app.cosign.bundle app
harden-runner: runtime egress control
Pinning fixes what source runs; it cannot stop that source from doing something malicious at runtime, so add a network tripwire. StepSecurity's harden-runner installs a security agent as its first action: it points the runner's DNS resolver at a local proxy and loads eBPF programs into the kernel that observe every process, file, and outbound-network syscall for the life of the job. Because it hooks the kernel's real connect and DNS syscalls rather than trusting the application, it sees egress from any language, any subprocess, and any statically linked binary a compromised step spawns. In audit mode it records and reports each connection, and you start here for a week to learn your build's genuine endpoints; in block mode it permits DNS and TCP only to hosts named in allowed-endpoints and drops everything else, attributing each attempt to the exact process and step that made it. The insights dashboard diffs each run against a learned baseline and flags new destinations, which is how StepSecurity spotted the tj-actions anomaly in the wild within hours. When the compromised action reached out to fetch its second-stage memory-dumping script from an unexpected host, block mode simply refused to resolve and connect: the download failed, the memory dump never ran, and the run was annotated with the offending destination.
Run step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a[harden-runner] eBPF monitoring active | egress-policy: block# ...the compromised changed-files step tries to fetch its second-stage payload...Error: The runner attempted an outbound call that was blockedstep : Get changed filesprocess : node /home/runner/work/_actions/tj-actions/changed-files/dist/index.jsdns : gist.githubusercontent.com -> refused (not in allowed-endpoints)dest : 185.199.108.133:443policy : blockBlocked 1 endpoint. Full network log for this run:https://app.stepsecurity.io/github/acme/webapp/actions/runs/12783440915
Ephemeral runners and closing the loop
Two final layers. Run on ephemeral runners: GitHub-hosted runners are already a fresh, single-use VM destroyed after each job, and self-hosted fleets should use actions-runner-controller with ephemeral set true so that no malware, cached credential, or poisoned build tool can survive into the next job, the isolation that SLSA Build L3 requires of a hardened build platform. Ephemerality also removes a subtler risk: a long-lived runner accumulates caches, checked-out source, and tool downloads that a later job can read or tamper with, quietly breaking the isolation SLSA assumes between independent builds. Then close the loop. Everything earlier in this course, the provenance, in-toto attestations, cosign signatures, and SBOMs, is only assurance if something actually verifies it. Protect the release branch with required review and status checks so no single account can push straight to a release, and put a verification gate in the path: slsa-verifier or cosign verify at release time, or a policy controller at admission, so unsigned or unattested artifacts cannot ship. A hardened, least-privilege, egress-controlled pipeline that both produces and enforces its own evidence is the end-to-end guarantee this whole course has been building toward.
Try this
Work through “Ephemeral runners and closing the loop” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: a pinned SHA fixes the source, not what it fetches at runtime. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.