According to SafeDep, an attacker used compromised GitHub credentials between 10 November 2026 (12:46 UTC) and 12 November (04:36 UTC) to push a secret stealer into repositories those accounts could write to. Aikido Security, which calls the campaign wfx-loot, reports 4,837 compromised accounts that opened probe pull requests in 61,764 repositories, with a pull request window from 10 November 12:45 UTC to 11 November 15:00 UTC. Aikido’s summary bullet says the payload ran and credentials were exfiltrated in at least 1,835 repositories; its body text says indications point to at least 1,806 and that this may understate the total. The Hacker News reported “1,806+”. These are vendor telemetry figures, and we found no independent count.
Both vendors describe the access as stolen or compromised credentials. The Hacker News headline says “hijacked” accounts, and none of the three reports explains how the credentials were obtained. Aikido assesses this as distinct from the earlier GhostAction campaign, while also reporting overlap (more than 100 repositories where wfx-loot was injected into GhostAction workflow files); The Hacker News mentions an overlap lead too. We have not examined either.
Our inference, not a sourced claim: the attacker used features working as designed with valid write access, so the useful question is what a write token can reach in your repository and how much of that you can remove in advance.
How the attack worked
Per Aikido’s analysis, for each repository a stolen token could write to, the attacker:
- Opened a pull request titled
docs: dependency audit notesfrom a branch namedwfx-docs, to confirm write access. - Pushed the payload in one of three ways (the reports list further commit messages too, such as “chore: add dependency audit workflow” and “chore: initial commit”): about 1,500 lines added to an existing workflow (
ci: refresh build cache), apostinstallhook inpackage.jsonthat runs a hidden script onnpm install(chore: add ci helper), or a new workflow on a throwawaychore/branch. - Reverted the change and deleted the workflow run and its logs. Aikido says within at most one minute; The Hacker News, in a correction note, says within minutes and sometimes left longer.
SafeDep, in a separate analysis, counted 8,093 repositories that received malicious commits (default branches of public repositories only). Its table lists three methods: 7,309 additions to an existing workflow, 771 npm postinstall hooks (which also run on a developer machine) and 16 new workflows in empty repositories. Those sum to 8,096 against the 8,093 headline; we did not reconcile the difference of 3.
The injected workflow step passes every secret to the payload as one JSON blob:
# Pattern reported by Aikido and SafeDep (defanged excerpt)
env:
WF_SECRETS: "${{ toJSON(secrets) }}"
WF_VARS: "${{ toJSON(vars) }}"
The workflow SafeDep captured also requested id-token: write and statuses: write. The script reportedly requests OIDC tokens for AWS, GCP and Azure and tries to exchange them for cloud sessions.
What a write token can reach
Our general knowledge of GitHub Actions, not a claim from the reports: a workflow file is code that runs on a runner with access to the secrets the repository exposes to it. Whether a pushed branch can read a given secret depends on whether Actions is enabled, whether the secret is repository-level or environment-level, and the workflow trigger. Aikido says the payload did not run everywhere, “due to configurations or the permissions of the compromised user”. Reverting the commit does not undo a run that already happened.
Aikido reports the payload also tried to persist beyond secret rotation: rewriting an AWS role trust policy to "Principal": {"AWS": "*"} and adding federated identity credentials named wfx-ci-... to Azure AD applications. On forged status checks the vendors disagree: Aikido says the payload forges success checks, while SafeDep says the code exists but no sample it analysed switched it on. The persistence claims rest on Aikido and SafeDep alone.
Controls that reduce the blast radius
These are our assessment of what could limit this kind of attack, from general GitHub behaviour and the docs cited. We have not tested them against the campaign. Note that the attacker authored the workflows and used new branches, so controls that only gate merges to the default branch would not by themselves have stopped a run.
1. Bind cloud roles to an environment, not the whole repository. GitHub’s AWS OIDC documentation recommends evaluating the sub claim in the trust policy, and says that when a workflow uses an environment, sub must reference it, for example repo:ORG/REPO:environment:prod. It also recommends protection rules on the environment, such as restricting which branches can deploy. A wildcard like repo:ORG/REPO:* lets any branch assume the role, which allows a throwaway branch to assume the role. Our inference: an attacker who controls the workflow can also name an environment, so the environment’s protection rules (branch and reviewer restrictions) are what actually bind them.
{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:ORG/REPO:environment:prod"
}
}
}
2. Keep production secrets out of repository-level secrets. Store them as environment secrets behind branch restrictions or required reviewers (our assessment, based on general GitHub environment behaviour; verify in your plan tier).
3. Set minimal workflow permissions. Declare permissions: {} at the top of each workflow and grant per job. Only grant id-token: write to the deploy job. This limits what your own workflows hold, but it does not stop an attacker who writes a new workflow; treat it as hygiene, not a fix for this attack.
4. Disable lifecycle scripts in CI. The postinstall variant is neutralised on a runner or laptop that does not run it:
npm ci --ignore-scripts
# or persistently, in .npmrc
# ignore-scripts=true
This breaks packages that legitimately need install scripts, so allow them explicitly.
5. Restrict who can create branches and require signed commits. SafeDep says the malicious commits were authored as real users of the repository, with their noreply email and no signature, which it says points to stolen tokens. Branch protection on the default branch gates merges, not pushes to new branches such as wfx-docs or chore/.... Repository rulesets that restrict branch creation, and a rule requiring signed commits on all branches, may help; this is our inference from GitHub behaviour, untested, so confirm how rulesets apply to the account types involved. A CODEOWNERS entry for .github/workflows/ protects the default branch only.
Audit your workflows for the pattern
The following script has limits: it checks only the root package.json, flags every postinstall hook (noisy), does not check preinstall or install hooks, does not walk monorepos or other branches, and cannot find reverted commits. It flags toJSON(secrets), toJSON(vars) and postinstall hooks. It is illustrative and should be tested before use; a hit is a lead, not proof of compromise.
#!/usr/bin/env python3
import json, re, sys
from pathlib import Path
PATTERNS = [
re.compile(r"toJSON\(\s*secrets\s*\)", re.I),
re.compile(r"toJSON\(\s*vars\s*\)", re.I),
]
def scan(root: Path) -> int:
hits = 0
for wf in list(root.glob(".github/workflows/*.yml")) + list(root.glob(".github/workflows/*.yaml")):
for n, line in enumerate(wf.read_text(errors="replace").splitlines(), 1):
if any(p.search(line) for p in PATTERNS):
print(f"{wf}:{n}: {line.strip()}")
hits += 1
pkg = root / "package.json"
if pkg.exists():
scripts = json.loads(pkg.read_text()).get("scripts", {})
if "postinstall" in scripts:
print(f"{pkg}: postinstall = {scripts['postinstall']!r}")
hits += 1
return hits
if __name__ == "__main__":
sys.exit(1 if scan(Path(sys.argv[1] if len(sys.argv) > 1 else ".")) else 0)
Search history separately, because the attacker reverted quickly. Look for wfx-docs or chore/ branches, the pull request title above, and the commit messages ci: refresh build cache, ci: restore and ci: cleanup. Aikido says a missing run number in the workflow history is often the only trace.
If you were hit
Aikido advises assuming that every secret and cloud role the affected workflows could reach is compromised. SafeDep (and The Hacker News, relaying it) advise revoking the commit author’s tokens, OAuth grants, SSH keys and sessions and enabling 2FA, and rotating the keys of any machine that ran npm install on an affected commit. SafeDep’s cloud-backdoor list also covers checking cloud accounts for new IAM access keys, altered trust policies and unexpected federated credentials. For indicators (IP addresses, hashes, branch and commit patterns), take them from the Aikido and SafeDep reports directly rather than from us.
References
- Aikido Security, wfx-loot campaign analysis: https://www.aikido.dev/blog/compromised-github-accounts-push-ci-secret-stealer-to-tens-of-thousands-of-repositories
- SafeDep, wfx credential stealer: https://safedep.io/blog/github-actions-wfx-credential-stealer
- The Hacker News, 12 November 2026: https://thehackernews.com/2026/11/4837-hijacked-github-accounts-ran-ci.html
- GitHub Docs, OIDC in AWS: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws