This website uses cookies

Read our Privacy policy and Terms of use for more information.

In SafeDep's write-up there is a detail that is easy to skim past. A scheduled workflow that depended on one of these actions failed in 5 to 8 seconds on September 14 and 15. On September 16 it ran for 9 minutes and 34 seconds and succeeded. Nobody had touched the workflow file or pushed a commit. The job simply started working again after two days of failing, and that was the whole signal.

What had happened is that GitHub re-enabled two repositories it had disabled four months earlier: actions-cool/issues-helper and actions-cool/maintain-one-comment. Both had been turned into credential stealers on May 18. Nobody cleaned the release tags. When the repositories came back, every workflow that referenced them by tag fetched the malicious commit again and ran it, with no attacker action required.

The argument of this essay is narrow. A takedown stops an attack only for as long as the takedown lasts, because the thing you depend on, a tag, lives in somebody else's repository and answers to whatever state that repository is in today. Pinning to a SHA is the fix most people know. The more useful question is which of your dependencies you have actually pinned, and which of them you only believe you have.

What actually happened

The sequence is documented by Socket, SafeDep and The Hacker News, and the facts are consistent across them.

On May 18, an attacker moved every release tag of both actions to new commits: 53 tags on issues-helper and 15 on maintain-one-comment. Each commit was titled "Build action for [tag]". The modified action.yml ran a payload first and then called the real action logic, so the step succeeded and the issue bot kept answering issues. A workflow that only checks whether a step went green sees nothing wrong.

GitHub disabled both repositories on May 19, one day later. Socket's summary of why that worked is short: disabling the repositories stopped the attack. A disabled repository cannot serve a tag, so every workflow referencing it failed at job setup, which is the 5 to 8 second failure above.

On September 16, between roughly 09:09 and 16:16 UTC, both repositories became accessible again. The reason has not been explained publicly. The tags still pointed at the malicious commits. In Socket's words, their release tags were not cleaned up first. SafeDep confirmed that v2.2.1 still resolved to commit a0c53dd4... four months later. GitHub's dependency graph listed roughly 15,000 dependent repositories for issues-helper alone.

Then the part that makes this worse than a replay. Five of the six workflows that SafeDep found infected were issue bots, which run daily or on issue events. They did not need the attacker to do anything. They ran, pulled the tag, executed the payload, and the payload read a ghs_ token out of the runner worker's memory and used it to commit files into the repository. Those files were a SessionStart hook in .claude/settings.json and a task in .vscode/tasks.json, both running a setup script that downloads Bun and executes a roughly 500 KB payload. Anyone who pulled that commit and opened the project in Claude Code or VS Code ran the malware on their own machine. Six repositories were hit between September 20 and 24, including projects with several thousand stars. On September 25 GitHub disabled both actions again.

A tag is a pointer, and you rent it

The mental model that gets people in trouble is that uses: owner/[email protected] means "version 2.2.1". It means "whatever commit the name v2.2.1 points at in that repository when this job starts". Git lets anyone with push access move a tag with git tag -f and git push -f --tags. GitHub's own documentation for action authors describes moving major and minor tags such as v1 as the normal way to ship updates. The ecosystem is built on a mutable pointer, and we have spent years pretending it is a version number.

That makes the dependency's security equal to the security of three things you do not control: the maintainer's credentials, the repository's state, and the platform's moderation decisions. The September incident is a case of the third one. Nobody's credentials were stolen in September. Nobody pushed anything. The repository's state changed from "disabled" to "enabled" and that was enough.

This is also why the March 2025 tj-actions/changed-files compromise (CVE-2025-30066) and this one rhyme. In both, the workflows that were safe were the ones that referenced a full commit SHA that predated the compromise. The mechanism of the attack varies. The thing that protects you does not.

What pinning buys, and where it stops

Pinning to a full 40-character commit SHA means the job fetches exactly that commit. The tag can move, the repository can be disabled, re-enabled, deleted or hijacked, and your workflow runs the same bytes. For the actions-cool incident, a workflow pinned to a SHA from before May 18 was never affected, and that is the only mitigation all three write-ups agree on.

Three limits are worth knowing before you declare victory.

First, a SHA is only as good as the commit it names. Pinning to a SHA that was already malicious pins you to the malware. The audit has to ask when you pinned, not just whether.

Second, impostor commits. A commit that exists only in a fork can be reachable by its SHA through the parent repository's object store. A pin that looks legitimate may point at code the maintainer never merged. Tools such as zizmor are built to detect this.

Third, pins rot. A SHA with no comment tells nobody which version it is, and an unmaintained pin is a vulnerability you have decided to keep. The practical answer is to pin with a version comment, such as @b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1, and let Dependabot or Renovate propose updates as reviewed pull requests.

The immutable-release option, and the catch

GitHub made immutable releases generally available on October 28, 2025. If the action's author turns on "Enable release immutability" in the repository settings, a published release and its tag cannot be changed or reused. A problem is fixed by shipping a new version. That attacks the root cause directly, because the pointer stops being movable.

The catch is in who holds the switch. The author turns it on, not you. GitHub's documentation notes that immutability applies only to tags tied to a GitHub release, and that tags without a release stay movable. Some authors that adopted it also stopped publishing floating tags: astral-sh/setup-uv dropped @v8 and @v8.0, so you must reference the full @v8.1.0. That is good for you, and it also means a reference you wrote last year may stop resolving.

The two actions in this story had been compromised through movable tags, which is exactly the case immutability is meant to close. It does nothing for a dependency whose maintainer never opted in, and you cannot tell from the uses: line whether they did.

There is a third piece on the way. A secure-pinning write-up from Classmethod describes workflow-level dependency locking, modeled on go.sum, that GitHub's late-March security roadmap scheduled for September 2026. I could not confirm that it shipped, so treat it as planned rather than available.

Auditing your own workflows in five minutes

Here is the smallest useful audit. It walks .github/workflows, finds every uses: that is not pinned to a 40-character hex SHA, and exits non-zero so you can put it in CI. I ran it against a sample workflow that contained one pinned action, one local action, and two tag-pinned ones, and it flagged the two tag-pinned lines and ignored the rest.

import re, sys, pathlib
SHA = re.compile(r"^[0-9a-f]{40}$")
USES = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)")
def audit(root):
    findings = []
    for f in pathlib.Path(root).glob(".github/workflows/*.y*ml"):
        for n, line in enumerate(f.read_text().splitlines(), 1):
            m = USES.match(line)
            if not m:
                continue
            ref = m.group(1)
            if ref.startswith("./") or ref.startswith("docker://"):
                continue
            name, _, rev = ref.partition("@")
            if not SHA.match(rev):
                findings.append((str(f), n, name, rev or "(none)"))
    return findings
if __name__ == "__main__":
    bad = audit(sys.argv[1] if len(sys.argv) > 1 else ".")
    for path, n, name, rev in bad:
        print(f"{path}:{n}  {name}@{rev}  <- mutable")
    sys.exit(1 if bad else 0)

The output for the sample looked like this:

wf/.github/workflows/ci.yml:5  actions-cool/[email protected]  <- mutable
wf/.github/workflows/ci.yml:7  astral-sh/setup-uv@v8.1.0  <- mutable

Notice the second line. [email protected] is flagged even though, by the immutable-release reasoning above, it is probably safe. The script cannot know that, and neither can you without checking the action's releases page. That is the honest state of the tooling: a blunt rule that over-reports is better than a clever one that under-reports, and the decision to accept a tag from an author who uses immutable releases is a human exception you should write down.

The strongest objection

The fair objection is that SHA pinning is a treadmill. A repository with 40 workflow steps becomes a repository where every dependency update is a pull request, and most teams stop reading them after the second week. An unreviewed bot merge of a new SHA is the same trust decision you started with, made faster. If the maintainer's account is compromised, the new SHA you auto-merge is the malicious one.

That is correct, and pinning does not claim otherwise. It changes the timing and the cost of the decision. A tag moves under you silently, at any moment, with no diff. A pin moves only when a pull request says so, with a diff you can read and a cooldown you can configure. Both Dependabot and Renovate can wait several days before proposing a brand-new release, and that single delay would have covered most of the window in which these compromises are detected and reverted.

The second objection is that none of this matters if the payload already has your token. True for the secrets. It does not apply to the hooks, though, and that is the underappreciated detail: the payload wrote files that execute on a developer laptop when the project is opened. A CI compromise is no longer contained in CI.

What to do on Monday

Search every repository you own for actions-cool/issues-helper and actions-cool/maintain-one-comment. If either appears with a tag, treat every secret the workflow could read as exposed and rotate it, then check the repository history for commits from September 16 onward that you cannot account for, and for .claude/settings.json or .vscode/tasks.json hooks you did not write.

Then run the audit above on the rest. Pin what you keep, with a version comment, and delete what you do not need: an issue-labeling bot is a convenience, and a convenience that holds a write token is a liability. Finally, add a rule that new third-party actions are SHA-pinned in review. The re-enablement in September was a decision made by someone else on a day you could not have predicted. The only part of that you control is what your workflow does when it happens.

Sources