
Auditing GitHub Actions for the wfx-loot Pattern: Workflow Triggers, OIDC Scope, and npm Execution
On this page
Introduction: a CI pipeline that never needed your password
The part of the wfx-loot reporting I keep coming back to is not the malware. Almost nothing in the described chain required breaking authentication. Nobody was cracking CI. They were logging in. What follows is the audit I built from that observation: how to enumerate the GitHub Actions workflow triggers, token permissions, OIDC trust conditions, and npm install paths that let a stolen GitHub login turn into cloud credentials — and which of those to fix first.
According to the OX Security write-up (published November 12, 2026) and the coverage that followed, the campaign used thousands of hijacked GitHub accounts to push malicious workflows into repositories, then let GitHub Actions do the rest: check out the repo, run npm, reach into the runner environment, and pull out CI and cloud secrets. The reporting puts the probe count above 60,000 repositories, with somewhere between 1,806 and 1,835 actually hit by malicious workflow execution.
That gap between probing and executing is the whole story. Probes are cheap. Execution needs write access to .github/workflows/, which means the account takeover already worked.
I've mapped the chain as five trust boundaries, each with a configuration setting that decides whether it can be crossed. My position up front: pull_request_target combined with id-token: write is the most dangerous two-line combination in a workflow file, and most orgs don't know how many of theirs have it.
In this post, confirmed means a primary report states it or GitHub/Microsoft/AWS documentation specifies it. Inferred means I'm reasoning from how the platform behaves. Anything about wfx-loot internals — payloads, workflow names, IOCs — is marked unknown, because the public reporting doesn't contain it.
What the public reporting actually establishes
The numbers: 60,000+ repositories probed, 1,806 vs 1,835 reported as affected
The confirmed set from the reporting is small:
| Claim | Status |
|---|---|
A campaign dubbed wfx-loot used hijacked GitHub accounts | Stated by OX Security and secondary outlets |
| Malicious workflows were committed to repositories and executed via Actions | Stated |
| npm execution was part of the chain | Stated |
| More than 60,000 repositories were probed | Stated |
| Affected repo counts reported as 1,806 (Cyber Press) and 1,835 (Techaeris) | Stated, conflicting |
| Publication date of the OX Security write-up | November 12, 2026 |
The 1,806 versus 1,835 gap isn't worth chasing. My read — inference, not reporting — is that they're different snapshots or different units. Repo counts and repo-plus-workflow-pair counts diverge the moment a single compromised repo gets more than one malicious workflow file, and so do "unique repositories" and "repositories including forks." Without the raw dataset I'm not picking a winner, and I wouldn't quote either number as precise in an internal report.
The 60,000+ figure matters more than the affected count. Probing 60k repos to land ~1.8k is a shotgun, not a targeted intrusion. That pattern fits credential stuffing and infostealer logs, not spear-phishing a specific org.
What the reporting does not disclose: workflow names, payloads, IOCs, and CVE ids
I want to be blunt about the gaps, because a lot of detection guidance written about campaigns like this quietly invents detail.
- No workflow filenames are published. I don't know whether the payload was dropped as a new file, patched into an existing workflow, or injected through a reusable workflow reference.
- No payload or hash is published. There's nothing to grep runner logs for.
- No IOC list — no domains, no IPs, no commit SHAs.
- No CVE. This is not a vulnerability in GitHub Actions. Every capability used is documented, intentional platform behavior. No patch is coming.
- No confirmed list of targeted cloud providers. OIDC theft is the obvious inference from "CI and cloud secrets," but I haven't seen a provider breakdown.
- No attribution. Not going to speculate.
If you're writing detection rules this week, you're writing them against the pattern, not the campaign. That's fine — the pattern is what recurs.
The GitHub Actions attack chain as five trust boundaries
Mapping the chain as boundaries makes it auditable, because each one has a config setting that decides whether it can be crossed.
Boundary 1 — account takeover to workflow write access
To modify .github/workflows/*, an attacker needs push access to a branch that reaches the default branch, or a token with the workflow scope. Classic PATs with repo + workflow are the high-value credential. Fine-grained PATs need Workflows: write and Contents: write.
Your account-takeover telemetry matters as much as your Actions telemetry: audit-log pushes, new PATs, new SSH keys, new deploy keys. The signature is a 03:00 push that adds a workflow file and nothing else. The workflow is often the only thing pushed.
Boundary 2 — trigger selection: which events fire with secrets in scope
Not all triggers are equal. The one-line heuristic: if someone without write access can fire the event, and the job runs in the base repository's context, then secrets are in scope and untrusted input is inside the job.
pull_request from a fork: read-only GITHUB_TOKEN, no repository secrets. pull_request_target: full secrets, and frequently a write-scoped token, because that's why people chose it. workflow_run: fires in the base repo with secrets and often a write token.
Boundary 3 — code execution inside the runner (npm as the loader)
Once a job runs, the runner is the attacker's machine for the duration. npm ci executes lifecycle scripts from every package in the dependency tree — including one the attacker just added and pinned in package-lock.json with a valid integrity hash, because they generated the lockfile themselves.
Boundary 4 — OIDC token exchange for cloud credentials
With id-token: write, the runner can mint a signed JWT. That JWT is a bearer credential for whatever cloud role trusts the workflow's sub claim. No long-lived key has to exist anywhere on the runner.
Boundary 5 — exfiltration and the low-noise persistence options
Egress is usually unrestricted on GitHub-hosted runners, so an HTTPS POST to attacker infrastructure works. For persistence, the cheapest options are a schedule-triggered workflow (survives cleanup of the initial push), a new repository deploy key, a self-hosted runner registration, or an edit to an environment secret. All of those are quieter than a repo takeover.
Why pull_request_target and workflow_run keep showing up in these chains
The fork-secret asymmetry that makes pull_request_target attractive
The design problem is real. Maintainers legitimately need privileged steps on forks — commenting, labelling, deploying previews. pull_request_target exists for exactly that: the workflow definition comes from the base branch, so a fork can't edit the YAML, and secrets are available.
The trap is that the workflow now has secrets and a checkout path to attacker-controlled code. As soon as you checkout the PR head ref and run anything from it, you've handed over your secrets behind a lock you installed yourself.
workflow_run has the same structural problem: it fires when another workflow completes, runs in the base repo with secrets, and typically exists to download and process artifacts from the triggering run — which the attacker controlled.
The bad pull_request_target pattern versus the safe pull_request pattern
## BAD: secrets + attacker-controlled code + OIDC in the same job
on: pull_request_target
permissions:
contents: write
id-token: write
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci # postinstall from the PR now runs with secrets
- run: npm run build
## SAFE: unprivileged build, no secrets, no OIDC, read-only token
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci --ignore-scripts
- run: npm run build --if-present
The safe version isn't clever. It has no secrets to steal. If you genuinely need a privileged follow-up (label the PR, post a preview URL), split it into a workflow_run job that consumes only a build artifact — and treat that artifact as hostile input, not trusted output.
Auditing GitHub Actions workflow triggers: a concrete first pass
Enumerating every workflow and its trigger surface with gh + jq
Start with inventory. You can't audit triggers you haven't listed.
#!/usr/bin/env bash
set -euo pipefail
ORG="${1:?usage: enumerate-workflows.sh <org>}"
gh api --paginate "/orgs/$ORG/repos?per_page=100&type=all" --jq '.[] | select(.archived == false) | .full_name' | while read -r repo; do
gh api --paginate "/repos/$repo/actions/workflows" --jq '.workflows[] | [.path, .state, .name] | @tsv' 2>/dev/null | sed "s|^|$repo |" || true
done > workflows.tsv
wc -l workflows.tsvThe output is tab-separated columns per workflow. I haven't run this against a large org, so treat the rows below as illustrative, not real data:
acme/payments-api .github/workflows/ci.yml active CI
acme/payments-api .github/workflows/preview.yml active PR Preview
acme/web-frontend .github/workflows/release.yml active Release
Two practical notes: gh silently skips repos you can't read (2>/dev/null || true), so cross-check workflows.tsv against your repo count or you'll under-report. And archived repos still matter if they hold live secrets — I list them separately rather than filter them out.
Next, extract the trigger surface. Fetching every workflow's YAML over the API is slow; a cheaper first pass is a shallow clone and a local grep. On a fixture repo where I planted one dangerous workflow, the result was deterministic:
grep -rn --include='*.yml' --include='*.yaml' \
-E 'pull_request_target|workflow_run|id-token: *write|permissions: *write-all' \
.github/workflows
.github/workflows/preview.yml:1:on: pull_request_target
.github/workflows/preview.yml:5: id-token: write
You can reproduce that against your own tree. The grep is triage, not proof — you still have to read every hit.
Flagging dangerous trigger and permission combinations
| Trigger | Attacker-controlled input | Secrets in scope | Typical mistake |
|---|---|---|---|
pull_request | Head code, fork | No | Adding secrets: inherit to "make it work" |
pull_request_target | Head code (if checked out) | Yes | checkout with ref: ...head.sha, then build/test |
workflow_run | Artifacts from prior run | Yes | Downloading and executing artifacts from untrusted runs |
issue_comment | Comment body | Yes | Interpolating ${{ github.event.comment.body }} into run: |
release | Tag name, release body | Yes | Passing tag names into shell unquoted |
repository_dispatch | client_payload | Yes | Trusting payload fields as safe values |
schedule | None | Yes | Persistence target; rarely reviewed after creation |
workflow_dispatch | Inputs | Yes | Inputs used in run: without validation |
Reading GITHUB_TOKEN permissions the way an attacker does
Check the default first, because it silently applies to every job that omits permissions:.
gh api /repos/acme/payments-api/actions/permissions/workflow
Response shape per the GitHub REST API:
{
"default_workflow_permissions": "read",
"can_approve_pull_request_reviews": false
}
If that says write, every workflow without an explicit permissions: block is running with a write token right now. Check the org default too:
gh api /orgs/acme/actions/permissions/workflow
Three scopes matter most. contents: write lets a compromised job push back to the repo — self-propagation. id-token: write is the cloud-breach primitive. write-all is a promise nobody read the docs. I put permissions: contents: read at the top of every workflow and grant per-job exceptions; it costs nothing and deletes an entire class of escalation.
OIDC scope: where a CI bug becomes a cloud breach
How the id-token: write to STS exchange actually works, step by step
- The job declares
permissions: id-token: write. Nothing else about the workflow needs to be unusual. - The runner exports
ACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENinto the job environment. - Any process in the job — including a dependency's
postinstall— can exchange them for a JWT:
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com"
{"count":1,"value":"eyJhbGciOiJSUzI1NiIsImtpZCI6...REDACTED"}
- The payload of that JWT contains the claims the cloud provider authorizes against. Decoded shape, values redacted:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:acme/payments-api:ref:refs/heads/main",
"aud": "sts.amazonaws.com",
"ref": "refs/heads/main",
"repository": "acme/payments-api",
"repository_owner": "acme",
"workflow": "Deploy",
"job_workflow_ref": "acme/payments-api/.github/workflows/deploy.yml@refs/heads/main",
"event_name": "push"
}
- The job calls
AssumeRoleWithWebIdentity(AWS),GenerateAccessToken/STS exchange (GCP WIF), or the Entra federated credential flow (Azure) with that JWT. - The provider validates
iss,aud, and the trust policy condition onsub, then issues short-lived cloud credentials.
Look at step 3 again. Exfiltration doesn't require the workflow to reach step 5 at all. If the attacker's postinstall mints the token and POSTs it out, they hold a JWT they can redeem from their own laptop, from anywhere, until it expires. They never need network access to your cloud account.
The sub claim is the authorization boundary, not the audience
Almost every misconfiguration I find is a locked-down aud next to a wide-open sub. aud answers "which relying party is this token for." sub answers "which repository, ref, and environment may become this role." Only the second is an access decision.
A condition of repo:acme/payments-api:* means any branch, tag, or pull-request context in that repo can assume the role. Since pull_request runs in that repo's namespace with sub: repo:acme/payments-api:pull_request, a fork PR that reaches a job with id-token: write mints a token matching the wildcard. That is the seam where a CI config mistake becomes a cloud breach.
The strongest available constraint is the environment-scoped subject: repo:acme/payments-api:environment:production. It only matches when the job declares environment: production, which can require reviewers and branch restrictions.
Hunting wildcard subject claims in AWS, GCP, and Azure trust policies
| Provider | Risky condition | Constrained condition |
|---|---|---|
| AWS | StringLike on sub = repo:acme/* | StringEquals on sub = repo:acme/payments-api:environment:production |
| AWS | Also matching aud only, with no sub condition | sub exact + aud = sts.amazonaws.com |
| GCP | assertion.repository_owner == "acme" | assertion.sub == "repo:acme/payments-api:environment:production" |
| GCP | Provider with no attribute condition | Attribute condition on sub plus assertion.ref |
| Azure | Federated credential subject repo:acme/payments-api:* where a wildcard is accepted | Exact subject string; Entra documents exact matching, so use one credential per environment |
For AWS, the enumeration is straightforward:
aws iam list-roles --query \
"Roles[?AssumeRolePolicyDocument.Statement[?Condition.StringLike.\`token.actions.githubusercontent.com:sub\`]].RoleName"
I haven't run this against a large account, and I expect it to miss roles that use ForAnyValue:StringLike inside a List condition. Use it as a starting point, then confirm by reading get-role output. For Azure, list federated identity credentials per app registration and look for a subject ending in * or omitting the environment. For GCP, list workload identity pool providers and check attributeCondition; an empty condition is a finding no matter what the pool is called.
A safe, read-only test to see what your workflow can mint — and what to avoid testing in production
The safe version proves which claims your job produces without touching the cloud:
on: workflow_dispatch
permissions:
id-token: write
contents: read
jobs:
claims:
runs-on: ubuntu-latest
steps:
- name: Decode OIDC claims (no cloud call)
run: |
TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" | jq -r .value)
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
What to avoid: don't run this in a repo whose trust policy you haven't read yet, and don't run it from a PR-triggered workflow. Minting the token isn't the dangerous part — leaving it somewhere durable is. Never echo the full JWT into a retained log; print the decoded claims only, and print sub in full, because that's the claim you're auditing.
npm execution paths inside a compromised GitHub Actions job
Install-time hooks (preinstall, install, postinstall) versus npm run
npm install and npm ci execute lifecycle scripts from dependencies by default: preinstall, install, postinstall. That's the loader in the wfx-loot pattern, and it needs no privilege beyond "the workflow runs an install" — which is roughly every JavaScript pipeline in existence.
npm run <script> is a different mechanism (your own package scripts) and it isn't safer here. A compromised dependency can define a bin your build calls, ship a bundler plugin that executes at config load, or run code via prepare when installed from git.
npm ci is reproducibility, not a trust boundary
Here's the misconception worth killing. Teams treat npm ci plus a committed lockfile as a security control. It's a determinism control.
npm ci deletes node_modules, installs exactly from package-lock.json, and refuses to run when the lockfile and package.json disagree. You get identical trees across runs. It still executes lifecycle scripts. And if the attacker can write to the repo — boundary 1 — they commit a package.json and package-lock.json that agree with each other, their package in the tree, and a matching integrity hash.
Lockfile integrity, registry scoping, and what --ignore-scripts does and does not buy you
Lockfile integrity values are SHA-512 hashes of the tarball. They stop a compromised registry or CDN from serving different bytes than expected. They do not stop a repo writer who pinned the malicious tarball's real hash. Integrity is a transport control.
Registry scoping is worth the effort: put internal scopes in .npmrc with @acme:registry=https://npm.acme.internal/, and make sure the public-registry config can't satisfy @acme/*. That buys you dependency-confusion protection, not workflow-compromise protection.
--ignore-scripts disables lifecycle scripts for the install. What it does not do:
- It does not stop your own build from executing the dependency later.
- It does not stop
npm rebuild <pkg>from running that package's scripts. - It does not stop
npx, plugins, or loaders that pull code in at runtime. - It does not fix a compromised repo writer who edits your
buildscript directly.
A hardened install step with the tradeoff named
- name: Install without dependency scripts
run: npm ci --ignore-scripts --no-audit --no-fund
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
- name: Re-run scripts only for reviewed native packages
run: npm rebuild esbuild sharp --foreground-scripts=falseThe tradeoff is explicit: packages that genuinely need a postinstall step (native bindings, downloaded binaries) break under --ignore-scripts and have to be allowlisted by name. That allowlist becomes a reviewed artifact — exactly where you want human attention. Every package on it gets a version pin and an owner. In my experience this is the highest-value npm hardening you can do, because it turns "every dependency can run code at install" into "five named packages can."
Detection: signals a wfx-loot-class run leaves behind
Org audit log queries with gh api for workflow creation, edits, and token minting
gh api --paginate "/orgs/acme/audit-log?phrase=action:workflows&per_page=100" \
--jq '.[] | [.created_at, .action, .actor, .repo, .workflow_name] | @tsv'
Sample output shape, values redacted — illustrative:
2026-11-12T03:14:07Z workflows.created_workflow_run user:some-dev acme/payments-api Nightly Sync
2026-11-12T03:14:41Z workflows.completed_workflow_run user:some-dev acme/payments-api Nightly Sync
GitHub's audit log event names shift with plan and product surface, so don't build a brittle allowlist of action strings from a blog post — this one included. Use action:workflows as the family filter and confirm the exact names in your own tenant's output before you alert on them. Query account-level events too (git.push, personal_access_token.*, membership.*) for the takeover half of the chain; the workflow event only covers the second half.
Runner-side tells: unexpected egress, environment dumps, curl to paste or webhook endpoints
Payloads vary, but the shape is stable: read environment, encode, POST.
- A job that runs
env,printenv, orsetand then a network call in the same step. base64appearing in the same step ascurlorwget.- Egress to paste-style hosts, transfer services, generic webhook endpoints, or a domain registered in the last 30 days.
- Reads of
$ACTIONS_ID_TOKEN_REQUEST_TOKEN,$AWS_SECRET_ACCESS_KEY,$NPM_TOKEN,$GITHUB_TOKENoutside the steps that legitimately need them. - A workflow that runs successfully in under 30 seconds and does no build work.
The honest weakness: a competent attacker exfiltrates over HTTPS to a domain that looks like telemetry. Grep-based detection is a backstop, not a control.
Signal table
| Signal | Noise level | Recommended action |
|---|---|---|
| Workflow file created by a user with no prior commits to that repo | Low | Alert |
pull_request_target job with id-token: write added | Low | Auto-block in review |
OIDC token minted from a pull_request-context run | Low | Alert |
| Job duration collapse (no build output, fast exit) | Medium | Log + weekly review |
| Egress to unlisted domain from a runner | Low if allowlisted | Auto-block |
base64 + curl in one step | Medium | Alert |
| Repo secret updated by a new contributor | Low | Alert |
| Self-hosted runner registered by an unverified account | Low | Auto-block |
Any schedule workflow added | High (legitimate) | Log only |
Containment, ranked by payoff
Fix first — set org/repo default GITHUB_TOKEN permissions to read-only
One API call, and it neutralizes the whole self-propagation class. A compromised job with a read-only token can read code; it can't push a new workflow, cut a release, or edit settings. Do it at the org level so new repos inherit it.
gh api -X PUT /orgs/acme/actions/permissions/workflow \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=false
Second — constrain OIDC sub claims and gate deploys behind environments
Replace every repo:org/* and repo:org/repo:* condition with repo:org/repo:environment:<name>. Add environment: to the deploy job with required reviewers. That's a change to your cloud trust policies, not your code, so you can fix it without touching 400 repositories.
Third — egress control and runner isolation for anything holding cloud credentials
GitHub-hosted runners have open egress by default. If a job can assume a production role, it should run where egress is allowlisted. Ephemeral self-hosted runners are the practical answer; the property that matters is that the runner is destroyed after the job, so filesystem persistence isn't on the table.
Fourth — rotation and blast-radius planning once a workflow is suspect
Assume every secret the job could read is burned: repo, environment, and org secrets, PATs, deploy keys, npm tokens, SSH keys baked into the runner image, and any cloud credentials minted. For cloud, revoke active role sessions rather than just rotating keys — an OIDC-minted session bypasses key rotation entirely. For npm, revoke the token and audit the package's publish history; a hijacked publish token is a supply-chain attack on your consumers.
What buys you little: advisory-only scanners and long log retention presented as a control
Two things people will buy this quarter and regret. First, workflow linters that dump findings into a dashboard nobody gates on — a warning on a pull_request_target job isn't a control, a required check is. Second, log retention marketed as detection. Keeping 400 days of runner logs buys you nothing if you never query them; the audit-log query above is worth more than the retention upgrade.
What I confirmed versus what I did not test
Confirmed (stated by the supplied reporting or by vendor documentation): the wfx-loot campaign name, the hijacked-account vector, malicious workflow execution via Actions, npm involvement, the 60,000+ probe figure, the 1,806 vs 1,835 affected-repo counts, and the November 12, 2026 publication date. The pull_request_target secret behavior, workflow_run secret behavior, OIDC token endpoint environment variables, the claim names in the JWT, and the --ignore-scripts semantics are all documented platform and vendor behavior, not my findings.
Not tested, or unknown: I never had access to the wfx-loot payload, so no workflow filenames, hashes, or IOCs appear here — they aren't public. I didn't run the org-wide enumeration or the AWS role query against a production org or account, so those commands are validated by their flags and documented response shapes, not by output I'm showing you. The Azure federated-credential exact-match behavior comes from Microsoft's documentation and should be verified in your own tenant. I haven't confirmed which cloud providers the campaign targeted. The trigger and signal tables are my assessment of how the platform behaves; reasonable engineers would rank the rows differently.
The position I'll defend: most orgs don't have a detection problem, they have a permission problem. Read-only GITHUB_TOKEN plus environment-scoped OIDC sub claims removes more exploitable surface than every runner-log query in this post put together.
Further Reading
- OX Security — "Thousands Of GitHub Accounts Hijacked With wfx-loot CI Secret Stealing Malware Via GitHub Actions & npm" — the primary research write-up this post is built on (the link is a news-aggregator pointer; use the publisher's own site if you can reach it).
- GitHub Docs — Security hardening your deployments with OpenID Connect — the authoritative source for
subclaim formats and the token request endpoint. - GitHub Docs — Automatic token authentication —
GITHUB_TOKENscopes and the default-permissions setting. - GitHub Docs — Secure use reference for GitHub Actions — covers
pull_request_targetand script injection. - AWS — Creating a role for web identity federation — trust policy condition keys for
token.actions.githubusercontent.com:suband:aud. - npm Docs — config:
ignore-scripts— exactly which lifecycle scripts the flag suppresses.
More posts

Auditing GitHub Actions for Leaked Secrets: OIDC, Least-Privilege PATs, and Audit-Log Tripwires
A practical audit playbook for GitHub Actions secret exposure, covering OIDC trust policies, least-privilege PATs, secret scanning, and audit-log tripwires in the wake of the GhostAction campaign that compromised 500+ accounts to steal cloud and AI API credentials.

Detecting Outlook NTLM Credential Theft via Child Processes and Outbound SMB Auth
A technical walkthrough of the actively exploited Outlook NTLM credential-theft zero-day, covering the exploit path, suspicious Outlook child processes, outbound SMB authentication detection, and mitigation priorities beyond patching.

A JavaScript Cost Calculator for Choosing Between $0.10/M APIs and Self-Hosted Models
Build a JavaScript cost calculator that models per-token API pricing against self-hosted open-weight inference, including GPU, utilization, and ops overhead, to decide when $0.10/M APIs beat owning the stack.
Loading comments…