Auditing GitHub Actions for the wfx-loot Pattern: Workflow Triggers, OIDC Scope, and npm Execution

Auditing GitHub Actions for the wfx-loot Pattern: Workflow Triggers, OIDC Scope, and npm Execution

pr0h0••20 min read•hacking
AI Usage (79%)
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:

ClaimStatus
A campaign dubbed wfx-loot used hijacked GitHub accountsStated by OX Security and secondary outlets
Malicious workflows were committed to repositories and executed via ActionsStated
npm execution was part of the chainStated
More than 60,000 repositories were probedStated
Affected repo counts reported as 1,806 (Cyber Press) and 1,835 (Techaeris)Stated, conflicting
Publication date of the OX Security write-upNovember 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.

enumerate-workflows.sh
#!/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.tsv

The 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

TriggerAttacker-controlled inputSecrets in scopeTypical mistake
pull_requestHead code, forkNoAdding secrets: inherit to "make it work"
pull_request_targetHead code (if checked out)Yescheckout with ref: ...head.sha, then build/test
workflow_runArtifacts from prior runYesDownloading and executing artifacts from untrusted runs
issue_commentComment bodyYesInterpolating ${{ github.event.comment.body }} into run:
releaseTag name, release bodyYesPassing tag names into shell unquoted
repository_dispatchclient_payloadYesTrusting payload fields as safe values
scheduleNoneYesPersistence target; rarely reviewed after creation
workflow_dispatchInputsYesInputs 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

  1. The job declares permissions: id-token: write. Nothing else about the workflow needs to be unusual.
  2. The runner exports ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN into the job environment.
  3. 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"}
  1. 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"
}
  1. The job calls AssumeRoleWithWebIdentity (AWS), GenerateAccessToken/STS exchange (GCP WIF), or the Entra federated credential flow (Azure) with that JWT.
  2. The provider validates iss, aud, and the trust policy condition on sub, 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

ProviderRisky conditionConstrained condition
AWSStringLike on sub = repo:acme/*StringEquals on sub = repo:acme/payments-api:environment:production
AWSAlso matching aud only, with no sub conditionsub exact + aud = sts.amazonaws.com
GCPassertion.repository_owner == "acme"assertion.sub == "repo:acme/payments-api:environment:production"
GCPProvider with no attribute conditionAttribute condition on sub plus assertion.ref
AzureFederated credential subject repo:acme/payments-api:* where a wildcard is acceptedExact 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 build script directly.

A hardened install step with the tradeoff named

hardened-install.yml
- 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=false

The 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, or set and then a network call in the same step.
  • base64 appearing in the same step as curl or wget.
  • 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_TOKEN outside 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

SignalNoise levelRecommended action
Workflow file created by a user with no prior commits to that repoLowAlert
pull_request_target job with id-token: write addedLowAuto-block in review
OIDC token minted from a pull_request-context runLowAlert
Job duration collapse (no build output, fast exit)MediumLog + weekly review
Egress to unlisted domain from a runnerLow if allowlistedAuto-block
base64 + curl in one stepMediumAlert
Repo secret updated by a new contributorLowAlert
Self-hosted runner registered by an unverified accountLowAuto-block
Any schedule workflow addedHigh (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

Share this post

More posts

Loading comments…