GitHub Actions: Pin the Commit, Not the Tag
Two compromised actions came back online in September with every release tag still on a May payload. Pin third-party actions to a full commit SHA, let a bot move the pins under review, and make release jobs wait for a person.
On 24 September Socket reported that two GitHub Actions compromised in May, actions-cool/issues-helper and actions-cool/maintain-one-comment, had been back online since 16 September with every release tag still pointing at the malicious code. The Hacker News and BleepingComputer followed. GitHub's dependency graph lists about 15,000 repositories that depend on issues-helper alone, though Socket has not established how many reference it by tag.
The same week, the Rust Security Response Team warned of secrets leaking through the Actions cache, and MemTensor's release workflows handed its publish tokens to an attacker. All three turn on what a workflow references and what it can reach.
A tag is a pointer, and someone moved it
On 18 May an attacker moved every tag in both repositories, 53 in one and 15 in the other, each to a new commit that the default branch never contained, StepSecurity found. The payload installs Bun, reads the job's decrypted secrets out of the Runner.Worker process and sends them to t.m-kosche[.]com. GitHub disabled both repositories the next day.
On 16 September both became reachable again with the tags unchanged. No workflow changed and the attacker had nothing more to do. The workflows that call them usually run on a schedule or when someone opens an issue or pull request, so Socket reckons most affected repositories “probably ran the payload within a day”. GitHub's API shows both blocked again on 24 September at 23:51 UTC.
Socket also says workflows pinned to the full SHA of a release from before 18 May were not affected. A tag is resolved each time a job starts, by whoever controls the repository that day. A SHA names the content. If a workflow referenced either action by tag on or after 16 September:
- Find every reference. Search
.github/in every repository foractions-cool/, and check the composite actions and reusable workflows you call for it too. Then remove the step, or pin a SHA you have checked predates 18 May. - Rotate every secret those workflows could reach. Check what each job's
GITHUB_TOKENwas allowed to do as well. The payload reads runner memory and runsgh auth token, so it takes more than the action's inputs. - Read the run history and the commit log. Look for runs that start passing after a stretch of Set up job failures, runs that jump from seconds to minutes,
setup-bunorbun runin the logs, and commits nobody can account for since 16 September.
Watch one tag resolve to two commits
Press Run the timeline below. Three workflows call issues-helper: by @v3, by @v2.2.1 (the example tag in Socket's advisory) and by a full SHA pinned before 18 May. As the calendar runs from May to late September, each line shows what the runner fetches and whether the payload runs.
Pin by SHA, and let a bot move the pins
GitHub's guidance calls a full-length commit SHA “currently the only way to use an action as an immutable release”. Since August 2025 a repository, organisation or enterprise policy can require one and fail any workflow that does not comply. Keep the version in a comment after the SHA. Dependabot's github-actions ecosystem updates both together, and Renovate's helpers:pinGitHubActionDigests preset turns tags into digests.
Pinning has two costs, and one problem it shares with tags.
- Fixes stop arriving on their own. In June
actions/checkoutv7 began refusing fork code inpull_request_targetworkflows, and on 20 July GitHub shipped the same change in new v2 to v6 releases. Floating tags such as@v4got it automatically; GitHub's changelog says SHA, minor and patch pins did not. Dependabot also raises no security alerts for SHA-pinned actions. - A bump is code review. Dependabot now holds a new release back for three days, but it does not tell you where a commit came from. GitHub's docs say to check the SHA belongs to the action's own repository, not a fork. Check it is on one of the action's branches too: GitHub's page for an actions-cool imposter commit warned that it belonged to no branch in the repository.
- Old versions run on a newer Node, pinned or not. On github.com, runners have used Node 24 by default since 16 June. On 23 September Node 20 and the
ACTIONS_ALLOW_USE_opt-out went. The runner's source runsUNSECURE_NODE_VERSION node20actions such ascheckout@v4on Node 24 with an annotation rather than refusing them. A tag and a SHA both need moving to anode24release to clear the annotation.
A pin freezes one file, not what it calls
Pinning a composite action or reusable workflow by SHA fixes its YAML, but that YAML resolves its own uses: lines at run time, and tags there still float. pypa/gh-action-pypi-publish v1.14.2, used below, pins actions/setup-python by SHA inside, yet pulls its Docker image from ghcr.io by a registry tag, not a digest. Check what yours fetch at run time.
Release jobs should hold nothing worth stealing
On 21 September the Rust team said Miri had been saving every environment variable, any secrets among them, into target/. Rust projects sometimes cache that directory. In the usual setup, CI on main writes the cache and pull request runs read it, and anyone who can open a PR can trigger one of those. The team found one repository with the issue and seven that “do not appear to be vulnerable but should be cautious anyway”. The advice holds for any tool: “ensure jobs that can write to public caches do not have access to secrets.”
MemTensor's leak needed no pull request. SafeDep says an attacker pushing as the Memtensor-AI GitHub account changed code that ran early in the release jobs: a validation script for npm, the build backend for PyPI. That code wrote BASH_ENV into $GITHUB_ENV, so every later Bash step in the same job ran the attacker's script first. It read the npm token inside the npm publish step and the PyPI API token inside the pypa/gh-action-pypi-publish container, then ended each run before anything was uploaded. Backdoored releases followed: memos-cloud-openclaw-plugin 0.1.21, 0.1.23 and 0.1.25, and MemoryOS 2.0.34.
SafeDep does not know how the attacker got push access as Memtensor-AI; its best guess is a stolen token, which it could not confirm. Its verdict: “Protected tags and a required reviewer on the release environment would have blocked those runs.”
So split the work: build in a job with no secrets, and publish from one that runs none of the repository's code, waits for a reviewer and holds only a short-lived OIDC token. The workflow below uses the same publish action MemTensor did. The difference is that $GITHUB_ENV only reaches later steps of the same job, and here the build runs in another one.
on:
push:
tags: ["v*"] # TAGS: a ruleset limits who may create v* tags
permissions: {} # every job starts with nothing
jobs:
build: # BUILD: read-only token, no secrets to leak
runs-on: ubuntu-24.04
permissions:
contents: read
steps: # PIN: full SHA, version in the comment
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: { persist-credentials: false }
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: { python-version: "3.13" }
- run: python -m pip install build==1.6.1 && python -m build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: { name: dist, path: dist/ }
publish: # PUBLISH: runs none of the repository's code
needs: build
runs-on: ubuntu-24.04
environment: pypi # REVIEW: required reviewers, v* tags only
permissions:
id-token: write # OIDC: trusted publishing, no PYPI_API_TOKEN
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: { name: dist, path: dist/ }
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
Name the pypi environment in the project's PyPI trusted publisher too, so a workflow edited to drop it gets no token PyPI accepts. Required reviewers on a private repository's environment need GitHub Enterprise; on Free, Pro and Team plans they work only in public repositories. Leave out any workflow_dispatch that builds a commit of the caller's choosing.
One deadline is close. From 2 November GitHub will disable pull_request_target by default on public repositories without their own Actions event policy. The trigger runs with the base repository's secrets, so search your workflows for it, check Insights for the runs it would block if your plan shows that view, and allow only the workflow files that need it.
Takeaways
- The actions-cool tags still pointed at May's malicious commits when the repositories returned, so tag references ran the payload again with no workflow change.
- GitHub still calls a full commit SHA the only way to use an action as an immutable release, and a GitHub policy can fail any workflow that uses an unpinned one.
- SHA pins miss backported fixes and Dependabot alerts, so let a bot move them and review each bump as code.
- A pin freezes one action's YAML, not the references or images it fetches at run time.
- Keep secrets out of jobs that write caches, and publish from a job that runs no repository code, waits for a reviewer and holds only an OIDC token.
Still referencing actions by tag?
We inventory every uses: line across your organisation, pin third-party actions to full commit SHAs with a bot to move them, and put release jobs behind reviewed environments and trusted publishing.
Audit your workflows