Trusted Publishing, Untrusted Branch: Inside the Red Hat npm Compromise
threat-intel

Trusted Publishing, Untrusted Branch: Inside the Red Hat npm Compromise

TL;DR: On June 1, 2026, more than 30 @redhat-cloud-services npm packages were published carrying a credential-stealing worm (“Miasma”). How they were published is now well-documented across several independent reports: a compromised maintainer account pushed a counterfeit release workflow to short-lived throwaway branches, which minted npm OIDC tokens and published. We want to focus on the part that should worry defenders most. Every supply-chain trust control in the path passed: the packages carried valid SLSA provenance, npm trusted publishing accepted them, and branch protection on main was never touched. They passed because trusted publishing anchors trust to a workflow filename on any branch, not to a protected release identity. The provenance even records the throwaway branch, permanently, in Sigstore’s public transparency log. Nothing checked it.


Note: This article is published early in the interest of supporting ongoing community threat hunting efforts. Our investigation is still active, and we have not yet fully validated every detail. We will update this post as our analysis progresses. If you have additional evidence or corrections, please reach out to labs@boostsecurity.io.


LIVE UPDATEJune 1, 2026, ~21:55 UTC: still active hours in, and it adds a developer-endpoint vector that pulling packages alone will not resolve.[ click to expand ]

This update comes out of a live threat hunt with peers across the community. Cross-referencing the GitHub Activity API with our own event hunting, two things stand out: the campaign ran far longer than the public reports captured, and partway through it added a second technique that does not touch npm at all.

The malicious activity ran for hours, in repeated bursts. The same counterfeit release fired again and again, each time from a short-lived oidc-* throwaway branch (created and deleted within about a minute: 1 to 73 seconds across the nine we recovered), all server-attributed to the same maintainer account, and landing on the two repositories in tight pairs about a minute apart:

Time (UTC, June 1)javascript-clientsfrontend-components
10:53oidc-4d5900f3
13:44-13:45oidc-6523a11boidc-af10000d
14:22-14:23oidc-b67eedcaoidc-79388f24
15:53-15:54oidc-1a048388oidc-8247e6b4
20:21-20:22oidc-c9a7d93foidc-2e22f6c8

Each oidc-* branch carried a counterfeit .github/workflows/ci.yml (on: push: branches: ['*'], id-token: write) and the same publish mechanism as the first wave. The surviving malicious releases landed in three waves between roughly 10:55 and 14:24 UTC across the 15 @redhat-cloud-services/*-client packages. npm has since unpublished those malicious versions outright: they no longer resolve and return 404, surviving only as timestamps in the registry’s metadata and, permanently, in Sigstore’s transparency log (the build for @redhat-cloud-services/rbac-client@9.0.6, for instance, is logged from refs/heads/oidc-b67eedca, commit bc025908). Each latest tag now points back to the last known-good release. Note there is no “security holding” placeholder to look for: the packages themselves are legitimate, so only the bad versions were removed, which is why they simply 404 rather than showing a takedown notice.

We are careful about what this shows. What is observable is that the account kept taking authenticated, server-attributed actions across roughly 9.5 hours (10:53 to past 20:23 UTC), with the bursts continuing for hours after the most detailed public report (Wiz) stopped updating around 3PM UTC. We are not claiming a single stolen credential simply stayed valid the whole time. Red Hat is the victim here, and the fair reading is that their responders were very likely already revoking and rotating: evicting an attacker who holds a maintainer’s own credentials is one of the hardest things to do, and doing it carefully takes time.

What is visible from the outside, as of June 1, 2026 (about 21:55 UTC), is registry-side cleanup. The most recent malicious push we observe is at 20:24 UTC, roughly an hour and a half earlier, and the Activity API attributes it to the maintainer’s account by its authenticated identity, not the forgeable commit author. So some valid credential for that account was still being accepted at 20:24; we cannot tell from the outside whether it was the original secret or another one the attacker also held. Unpublishing the malicious versions clearly happened, and it matters, but on its own it does not reach the account’s credentials or the affected branches. We cannot see endpoint or credential remediation from outside, and it may well be underway. The current lull is not yet proof of containment: the gap between the 15:54 and 20:22 UTC waves was about 4.5 hours, so a quiet hour and a half still sits inside the attacker’s own idle gaps. We are treating this as a live, possibly ongoing intrusion and monitoring the Activity API for new oidc-* bursts and branch-poisoning pushes.

The campaign also kept expanding, which matters for anyone defending against this. Partway through, the account began pushing commits disguised as routine work (feat!: ... [skip ci], chore!: updating rbac-client ...) onto live feature and ticket branches across both repos (switch-rbac-new-builder, update, RHCLOUD-30109, api-info-spec-update, js-clients-bump, and more); the [skip ci] keeps them quiet. The earliest we can date is 15:54 UTC on frontend-components, dropping only a loader (.github/setup.js). By the final 20:23-20:24 UTC wave it had escalated to a full kit on both repositories: the loader plus two ways to auto-run it:

  • .claude/settings.json: a SessionStart hook that runs node .github/setup.js whenever an agent like Claude Code opens a session in the checkout.
  • .vscode/tasks.json: a task with "runOn": "folderOpen" that runs the same script the moment the folder is opened in VS Code.
  • .github/setup.js: an obfuscated loader that decrypts and runs an embedded payload entirely offline. It makes no network call, so egress filtering on a developer box does not stop it.

The significance is less the malware than the shape of the problem. This path never touches npm, so unpublishing the bad versions, the cleanup we can see, does not reach it: a developer who checks out a poisoned branch and opens it in an editor or coding agent runs the loader locally. And the activity was still expanding late in the day, poisoning branches and firing an oidc-* npm burst together in the 20:22-20:24 window. Endpoint and credential remediation are harder and slower than a package pull, and may well be in progress out of public view; the point for the rest of us is simply that the package pulls alone leave this branch-level vector open.

We want to be clear about the line between evidence and speculation here. Evidence: the account kept taking authenticated actions, and the techniques kept expanding, across roughly 9.5 hours, with only registry-side cleanup visible from outside. Speculation, and only that: even if rotation was underway, the activity could continue for fairly mundane reasons, such as the attacker holding several secrets of equivalent value (a personal access token, then an SSH key, then an OAuth session) and rotating through them as each is burned. The worse case, which we cannot rule out and have no evidence for, is a resident foothold on the maintainer’s endpoint still capturing whatever appears next: fresh tokens, a live GitHub session cookie, even 2FA prompts, which would let activity continue straight through a rotation. We raise it only as a hypothesis worth checking, and it is exactly why endpoint cleanup has to go hand in hand with credential rotation.

Indicators (for defenders). The malicious June 1 versions, now unpublished from npm: you will not find them by browsing the packages today (they return 404), so check lockfiles, caches, and any internal mirror for these exact versions:

compliance-client            4.0.3, 4.0.4, 4.0.6
config-manager-client        5.0.4, 5.0.5, 5.0.7
entitlements-client          4.0.11, 4.0.12, 4.0.14
host-inventory-client        5.0.3, 5.0.4, 5.0.6
insights-client              4.0.4, 4.0.5, 4.0.7
integrations-client          6.0.4, 6.0.5, 6.0.7
notifications-client         6.1.4, 6.1.5, 6.1.7
patch-client                 4.0.4, 4.0.5, 4.0.7
quickstarts-client           4.0.11, 4.0.12, 4.0.14
rbac-client                  9.0.3, 9.0.4, 9.0.6
remediations-client          4.0.4, 4.0.5, 4.0.7
javascript-clients-shared    2.0.8, 2.0.9, 2.0.11
sources-client               3.0.10, 3.0.11, 3.0.13
topological-inventory-client 3.0.10, 3.0.11, 3.0.13
vulnerabilities-client       2.1.8, 2.1.9, 2.1.11

For the branch-poisoning vector, treat any branch carrying .github/setup.js, or a .claude/settings.json SessionStart hook or a .vscode/tasks.json folderOpen task that shells out to it, as hostile. Do not open an untrusted checkout in an editor or coding agent before inspecting these paths. This is the developer endpoint as the softest target again, and it is why the section below treats the credential, not just the registry, as the thing to protect.

What happened

The payload, IOCs, and publish mechanism are well documented elsewhere, so we will keep this short. A legitimate RedHatInsights maintainer account pushed a small counterfeit workflow to throwaway branches across three repositories. It triggered on a push to any branch (on: push: branches: ['*']), requested id-token: write, and used npm OIDC trusted publishing to ship packages whose preinstall hook runs an obfuscated 4.2 MB index.js on every npm install.

What stands out is what the attacker did not touch. The legitimate release pipeline (ci.yml on main) never ran, and main is a protected branch: direct pushes are blocked and changes land through reviewed pull requests, so the stolen credential could not push a poisoned release there. That control held; the attacker did not need it.

So how does a throwaway branch publish to npm at all? The earlier reports answered how the attacker operated. The more durable question is what the trust model actually guarantees, because on paper this package had everything a careful consumer looks for. That is where the forensics stop and a design problem starts.

What trusted publishing asserts, and what it doesn’t

Two mechanisms get conflated here, and the difference is the whole story.

Trusted publishing decides whether to accept the publish. Per npm’s documentation, a GitHub trusted publisher is configured with an organization, a repository, a workflow filename (“enter only the filename, not the full path”), and an optional environment name. At publish, npm validates the token against those values, but not the git ref or branch. The only way to constrain the branch is a GitHub Environment with deployment-branch rules: npm checks the environment name, GitHub enforces the branch. javascript-clients evidently required no environment, so an OIDC token from a workflow with the matching filename, on any branch, was accepted.

This is not an npm quirk. PyPI’s trusted publishing uses the same model: organization, repository, workflow filename, and an optional environment, no ref field. The pattern binds trust to a workflow’s name and leaves branch restriction an opt-in most projects never enable.

Note what is pinned: the filename, not the workflow’s content or commit. A brand-new .github/workflows/ci.yml on a one-minute-old branch satisfies the check as completely as the real release workflow. The attacker proved the point by matching each repo’s exact filename, down to ci.yml, ci.yaml, and release.yml. The _index.js was byte-identical (4,215,480 bytes) every time; only the package list changed.

Provenance is a separate thing: a cryptographic receipt, not a policy. Trusted publishing automatically generates a SLSA provenance attestation, signed through Sigstore. It faithfully records where the build ran: the repository, the commit GITHUB_SHA, the workflow ref, and GITHUB_REF, the branch. That is an honest record of the build, and that is all it is. “Valid provenance” means “this genuinely came from this repository at this ref and commit.” It does not mean “this came from a trusted branch.” Provenance records the ref; it does not require the ref to be trusted.

Valid provenance, untrusted branch

Put the two together and the result is public and permanent. Take @redhat-cloud-services/types@3.6.1, one of the malicious versions. npm has since pulled the package, but its valid SLSA provenance is recorded permanently in Sigstore’s transparency log. The OIDC proved the repository; the signatures verify. And the attestation says, in plain text, that the build ran from RedHatInsights/frontend-components at ref refs/heads/oidc-61fff775, workflow .github/workflows/ci.yaml, commit 8bf05125: the same throwaway branch tip we recovered from the Activity API, on a branch that lived about a second before it was deleted. The evidence of the attack sits inside the cryptographic attestation that made the package look trustworthy, and nothing in the publish path, or the typical consumer check (“has provenance from the expected repo?”), looks at the ref.

Be precise about which boundary failed. Branch protection guarded main and did its job; the exploitable condition lived on the registry side, where trusted publishing extended publish rights to a workflow filename on any branch and provenance vouched for the result without anyone asserting the branch was a release branch. It would be easy to file this under “Red Hat should have required an environment,” and they should have, but the problem is structural: the safe configuration is opt-in and the dangerous one is the default. The ecosystem is told that trusted publishing plus provenance equals a trustworthy origin; for an attacker who can reach the repository, it equals a trustworthy filename. It is the browser-padlock fallacy again: the padlock only ever meant the connection was encrypted, never that the site was safe, and phishing pages showed one too. Provenance proves where a package was built, not that it is safe to install. A filename is not an identity.

As fellow researcher Adnan Khan put it in a back channel, it “just turns GitHub PATs into the new npm token”: the feature meant to retire the stealable npm token just moved it to the GitHub credential.

The likely entry point: a developer endpoint

Step back to how the attacker got write access. Unlike most pipeline compromises we cover (a pwn request, an injection), there was no flaw in the pipeline. The pushes were server-attributed by the Activity API (which records the authenticated actor, not the forgeable commit author) to a legitimate maintainer account: the signature of a stolen credential, most plausibly lifted from a developer endpoint this worm sweeps for ~/.npmrc, tokens, and SSH keys.

The event trail fits: in the same bursts as the publishing, the account pushed dozens of the maintainer’s unrelated working branches across other repos and planted the secret-stealer on a fourth package repo the public reports omit, ai-web-clients. Nobody pushes to seven branches across four repos in ten seconds by hand; that is automated bulk pushing of local clones from a compromised machine.

It fits a thesis we have argued before: the developer’s laptop is the softest target in the supply chain. We built bagel to inventory it; it is the open source core of the developer endpoint protection we offer.

Detection and remediation

  • Require a GitHub Environment with protected-branch rules, referenced only by the release job. It is the one control that pins publication to a trusted ref.
  • Verify the provenance ref, not just its presence. “Has provenance from repo X” is not enough; check GITHUB_REF is a release branch or tag.
  • Scope id-token: write to the publish job, never workflow-wide.
  • Hunt ephemeral branches via branch_creation/branch_deletion in the Activity API, and flag any id-token: write workflow that can run off your release ref.

And a note for the registries and the SLSA tooling, not just the projects: trusted publishing should let a maintainer pin a ref and treat publication from an unprotected ref as a warning, not a silent pass. That will not fully solve solo-maintainer projects, where there is no second reviewer and branch protection cannot stop the credential holder from reaching main. But forcing publication onto a protected ref, even through a pull request, costs the attacker the sixty-second throwaway branch and trades it for something slower and far more visible. And provenance verifiers should surface the source ref and whether it was protected, not treat any valid attestation as a pass.

Credit where due: npm’s newly GA staged publishing holds a release until a maintainer approves it with 2FA, which would have stopped this automated publish cold. But like the optional environment, it is opt-in: a sliver of maintainers will enable it, and the consumers who benefit will never know to thank them.

What we are still validating

  • Initial access. The credential-theft route is unconfirmed; a developer-endpoint infostealer (this worm’s documented behavior) is the leading theory.
  • The trusted-publisher configuration. Our filename-only, no-environment conclusion is inferred from behavior (publishes from arbitrary branches succeeded), not from the registry-side config. We are also still correlating which wave shipped which versions, and whether ai-web-clients packages were published at all. Corrections and additional evidence welcome at labs@boostsecurity.io.

Assume breach

Step back from this one incident. Once any identity can push to a repo (an insider, a stolen intern or developer credential, or a worm like Shai-Hulud acting as one), a workflow on a throwaway branch with on: push is RCE-As-A-Service that sidesteps branch protection and peer review: the run fires on the push, before anyone looks. That holds for a private repo where an intern can only push a feature branch as much as it did here. Our offensive tool SmokedMeat implements exactly this technique under an assume-breach model: hand it an intern’s, a developer’s, or a maintainer’s token and measure how far the blast radius reaches.

We treat each incident as a reason to update our threat model, not to shrug. Trusted publishing and SLSA provenance are real progress; this is the part that has not caught up, and the fix is not only on the registry side: stop treating pipelines as glue code and start treating them as the production systems they are. We would rather raise that with the people building these controls than file it under “just another Monday.”

Credits

StepSecurity for the disclosure, affected-package inventory, and payload behavior; Aikido for the deep reverse engineering of the implant; Wiz for the workflow reconstruction and provenance observation that sharpened this analysis.