How GHAPPIER Turned npm Trusted Publishing Into a Package Poisoning Pipeline

How GHAPPIER Turned npm Trusted Publishing Into a Package Poisoning Pipeline

pr0h0
supply-chainnpmgithub-actionsci-cdmalware
AI Usage (92%)

Sixty-five GitHub repositories compromised and one npm package poisoned to deliver a loader named GHAPPIER: that was the story on 2026-09-22, and most coverage led with the repo count. This post looks at the part that actually matters — how the malicious release rode npm's trusted publishing path, and how an attacker can turn CI/CD trust into a package poisoning pipeline.

The count is the boring part. What matters is the delivery mechanism: the publish went out through npm trusted publishing, the OIDC flow that exists so maintainers never have to keep a long-lived NPM_TOKEN sitting in GitHub secrets.

My position: trusted publishing closed off one class of credential theft and opened a narrower, sharper trust boundary in its place — whatever code runs inside the publishing job. An attacker who can steer that job doesn't need your token. The workflow mints one for them, on demand, correctly signed, provenance attached.

What the GHAPPIER reports actually say happened

Three items landed in the same news window. I'm keeping them separate, because the sources do, and nothing in the seed material connects them.

ReportPublishedClaim
CyberSecurityNews2026-09-22GHAPPIER supply chain attack compromised 65 GitHub repositories and poisoned an npm package, abusing npm trusted publishing
MSSP Alert2026-09-22A new malware loader named GHAPPIER abused npm trusted publishing
Dark Reading2026-09-22The Shai-Hulud attack reached CrowdSec's GitHub data

CyberSecurityNews carries both the repo count and the npm-publish claim. The MSSP Alert headline is where the loader name comes from. Dark Reading describes a different campaign against a different target, and I won't fold it into GHAPPIER without evidence that public reporting doesn't currently contain.

Confirmed facts versus what the public details do not establish

Established by the reports: 65 GitHub repositories compromised, one npm package poisoned to distribute a loader named GHAPPIER, and abuse of npm trusted publishing as the publish mechanism.

Not shown by the public details: the initial access vector, the loader's full capabilities, which package and which versions were affected, how those 65 repositories were selected, whether Shai-Hulud and GHAPPIER share tooling or operators. There is no CVE in the source set, no advisory ID, no version range. Anything more specific than the above would be me inventing detail, so I won't. What follows is architecture analysis: how the trusted-publishing path works, where it can be steered, and what a maintainer can actually check today.

How npm trusted publishing with GitHub OIDC is supposed to work

On npmjs.com, a package owner registers a trusted publisher: the GitHub organization, repository, workflow filename, optionally a GitHub Environment name. From then on, the registry accepts a publish that arrives with an OIDC token whose claims match that configuration. No static token is involved.

GitHub needs one permission on the job that publishes: id-token: write. The npm CLI requests an OIDC token from GitHub's token service, exchanges it with the registry, and receives a short-lived credential scoped to that single run. With --provenance, the CLI also builds a SLSA provenance statement describing the build and has it signed through Sigstore, then attaches it to the published version.

name: publish
on:
  push:
    tags: ["v*"]
permissions:
  contents: read
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: npm-publish
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@<40-char-commit-sha>
      - uses: actions/setup-node@<40-char-commit-sha>
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm publish --provenance --access public

Notice what is not in that file: NPM_TOKEN, secrets.NPM_TOKEN, or any repository secret at all. That absence is the whole point of the design.

The registry's view of a published version exposes the relevant fields through the packument:

$ npm view [email protected] --json | jq '{version, integrity: .dist.integrity, attestations: .dist.attestations}'

The output shape looks like this (illustrative — these are the field names to look for, not a capture from a specific package):

{
  "version": "1.4.2",
  "integrity": "sha512-<base64 sha512 of the tarball>",
  "attestations": {
    "url": "https://registry.npmjs.org/-/npm/v1/attestations/[email protected]",
    "provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
  }
}

No attestations key means no provenance, regardless of how the README reads.

Where the npm trusted-publishing trust chain actually breaks

The chain runs: tag push → workflow triggered → runner starts → job executes steps → OIDC token requested → registry validates claims → publish. Every arrow after "runner starts" is influenced by workflow content.

That hands an attacker a list of ordinary, boring CI patterns to work with:

PatternWhat it hands over
pull_request_target plus checkout of github.event.pull_request.head.shaAttacker-authored code executing in a job with base-repo permissions
Third-party action pinned to @v3 instead of a commit SHAWhoever controls that tag controls your job's code
npm ci on a lockfile the pull request can editArbitrary postinstall execution inside the workflow
actions/cache restore keys shared with PR jobsPoisoned files consumed later by the publish job
Artifact upload in a PR job, download as the publish inputAttacker controls the tarball while provenance points at your repo
Secrets or id-token: write granted at workflow levelEvery step in every job inherits the capability

To be explicit: this is design analysis of the OIDC publish path, not a claim about what GHAPPIER did. The reports never describe the intrusion chain.

The two things the attacker needs

Stripped to objectives, an attacker needs (1) code execution inside the publishing job's context and (2) reach at a moment when the OIDC token can be requested — either the publish step itself or any step that can write to the workspace the publish step will use.

Step one is often cheap, as the table shows. Step two tends to be free, since the token is requested by the same job that runs the build. Framing this as a build-graph problem rather than a malware problem is the useful reframe.

Detecting a poisoned npm publish and a hijacked workflow

These are checks a maintainer can run without any vendor tooling.

## 1. What the registry claims it published
npm view <pkg>@<version> --json | jq '.dist'

## 2. Fetch the exact tarball the registry serves and hash it
npm pack <pkg>@<version>

## dist.integrity is base64-encoded sha512; hash the tarball the same way
openssl dgst -sha512 -binary <pkg>-<version>.tgz | openssl base64 -A

If the two base64 strings differ, the bytes on the registry are not the bytes the integrity field describes — stop and investigate.

## 3. Verify registry signatures and provenance for your install tree
npm audit signatures

## 4. Read the attestation the registry stored for that version
curl -s https://registry.npmjs.org/-/npm/v1/attestations/<pkg>@<version> \
  | jq '.attestations[].predicateType'

Expect https://slsa.dev/provenance/v1. The provenance payload names the source repository, the workflow file, and the commit, and includes a subject digest you can compare against the tarball you downloaded.

## 5. Find action references that are not SHA-pinned
grep -rn "uses:" .github/workflows/ | grep -vE "@[0-9a-f]{40}"

## 6. Review history on the files that can publish
git log --oneline -- .github/workflows/

Command 6 is the one I'd run first after any supply-chain incident touching a repo I maintain. Publish-step edits that did not come from the release process are the highest-signal artifact available.

What weak signals look like in logs

These are heuristics with false positives, not detections:

  • A publish whose provenance workflow ref does not match the default branch head of the repository.
  • A version bump with no corresponding reviewed commit on the release branch.
  • A new postinstall, prepare, or prepublishOnly script appearing in a patch release.
  • OIDC or id-token claims in job logs for a job that has no business publishing.
  • A publish job that ran on a pull_request event rather than a tag or release.

Legitimate infrastructure changes trip these constantly. Treat them as reasons to look, not reasons to conclude.

Hardening GitHub Actions CI/CD against trusted-publishing abuse

In the order I'd actually do it:

  1. Pin every action to a full 40-character commit SHA. Use Dependabot to keep the pins moving.
  2. Set permissions: {} at workflow level and grant id-token: write only on the publish job.
  3. Move publishing into its own workflow triggered by tags or releases, gated behind a protected GitHub Environment with required reviewers.
  4. Never let pull-request code execute in a job that can request an OIDC token or read publish secrets.
  5. Once trusted publishing is verified working, revoke the automation tokens. Run both mechanisms in parallel and you have kept the weaker one.
ControlWhat it blocks
SHA-pinned actionsMutable-tag takeover of your job's code
Job-scoped id-token: writeToken minting from unrelated steps and jobs
Separate publish workflowPR-triggered paths reaching the publish step
Protected environmentSilent publishes from a compromised branch state
Token revocationReuse of any credential that leaks anyway

What this hardening does not fix

A compromised maintainer account still reaches the publish path, because the account can edit the workflow. Environment reviewers are a human control and can be social-engineered with a plausible release request. Provenance proves origin, not intent — a poisoned release published correctly by your own workflow will carry a valid attestation. And none of it protects consumers who install with --ignore-scripts off and never verify a signature.

My position: OIDC removed the long-lived secret, not the trust problem

Trusted publishing is a genuine improvement. Static npm tokens were exfiltratable, long-lived, and usually over-scoped, and provenance gives consumers something checkable that did not exist before. I wouldn't go back.

But framing this story as an npm vulnerability is wrong, and the framing matters because it points the fix in the wrong direction. npm's mechanism did exactly what it was designed to do: it verified that a request came from a configured repository and workflow, and it published. The registry cannot tell whether the workflow that published was the one you intended to run. That is not a registry bug; it is the boundary the design draws.

The design concentrates risk into two places: the CI configuration and the maintainer's GitHub identity. If I had to fix one thing first, it would be SHA-pinning and pulling publish out of any workflow reachable by pull requests. Everything else — environments, reviewers, provenance plumbing — matters after that.

Further Reading

Share this post

More posts

Comments