ChainDrop: what a self-propagating worm proves about credential-only publishing
ChainDrop began in 11 compromised npm packages and propagated into 433 more — 444 in roughly four hours, with valid build provenance on every release. The loop breaks at one step the worm cannot complete autonomously: a fresh human signature bound to the exact release.
On August 4, 2026, the ChainDrop worm began in 11 compromised npm packages and propagated into 433 additional ones — 444 affected packages in roughly four hours. The entry point was a stolen GitHub maintainer account and poisoned commits on release branches. The projects' own release workflows then built and shipped the malicious versions — with valid SLSA provenance attestations, because the legitimate pipelines really did build them. Once installed, the payload harvested npm tokens, GitHub credentials, and cloud keys from every environment it reached, and used them to republish itself onward. StepSecurity's incident analysis counts 2,212 malicious versions, with initial carriers that include packages seeing more than 100 million downloads a week.
The loop that makes a worm possible
- Steal a credential — a registry token, a maintainer session, a CI secret.
- Use it to ship a poisoned release of a package people trust.
- Run inside every environment that installs the package, and harvest the credentials lying there.
- Repeat, now holding hundreds of credentials instead of one.
Every step is machine-executable. That is the whole story: publishing is an irreversible, high-blast-radius action whose only precondition is possession of a secret, and secrets are exactly what a worm running inside your CI is best positioned to collect.
Why provenance did not help
SLSA attestations answer where and how an artifact was built. ChainDrop's releases passed that check because the answer was true: the legitimate release workflow built them, from commits an authenticated maintainer account had pushed. Provenance binds an artifact to its pipeline. It cannot bind a release to a human decision, because no step in the pipeline ever asked for one.
Origin and intent are different claims. Provenance proves how an artifact was built. INTYGA proves that a human approved the exact artifact for release. The supply-chain stack has spent years hardening the first while the second remained a token check.
The step a worm cannot complete autonomously
The propagation loop survives on one assumption: whoever holds the credential can publish. Remove the assumption and the loop dies. Publishes run in CI, so the gate runs there too — but not as one approval call bolted into repository-controlled code. Split the process across two independently administered trust boundaries. The package repository may build the artifact and request approval, but a separately managed release workflow, pinned to reviewed code, must retrieve the artifact, recompute its digest, verify the human approval locally, and only then obtain short-lived authority to publish.
# Package repository workflow
jobs:
build: # untrusted — no publishing authority
runs-on: ubuntu-latest
outputs:
approval-id: ${{ steps.approval.outputs.approval-id }}
steps:
- uses: actions/checkout@<FULL_COMMIT_SHA>
- run: npm ci
- run: npm pack --pack-destination dist
- uses: actions/upload-artifact@<FULL_COMMIT_SHA>
with:
name: release-artifact
path: dist/*.tgz
if-no-files-found: error
- id: approval
run: node request-publish-approval.mjs dist/*.tgz
env:
INTYGA_API_KEY: ${{ secrets.INTYGA_REQUESTER_KEY }}
release:
needs: build
# Maintained outside this package repository and pinned to reviewed code.
uses: acme/security-workflows/.github/workflows/npm-release.yml@<FULL_COMMIT_SHA>
with:
artifact-name: release-artifact
approval-id: ${{ needs.build.outputs.approval-id }}
secrets: inheritThe separately administered release workflow should be the only place that can obtain publishing authority. The package repository can request a release, but it cannot modify the code that decides whether the protected release environment will publish.
# Separately administered reusable release workflow
jobs:
release:
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@<FULL_COMMIT_SHA>
with:
name: ${{ inputs.artifact-name }}
path: dist
- run: node recompute-and-verify-release.mjs dist/*.tgz
env:
INTYGA_APPROVAL_ID: ${{ inputs.approval-id }}
INTYGA_WEBAUTHN_ORIGIN: ${{ vars.INTYGA_WEBAUTHN_ORIGIN }}
INTYGA_WEBAUTHN_RP_ID: ${{ vars.INTYGA_WEBAUTHN_RP_ID }}
- run: npm publish dist/*.tgz --provenance// recompute-and-verify-release.mjs — runs only in the protected workflow
const action = {
target: "npmjs:@acme/api-client",
actionType: "package_publish",
params: {
repository: "https://github.com/acme/api-client",
commit: "a18ef92d4f...",
registry: "https://registry.npmjs.org",
name: "@acme/api-client",
version: "4.2.0",
artifactDigest: { algorithm: "sha512", value: TARBALL_SHA512 },
},
};
// never trust the build job's digest — hash the bytes actually received
const actualDigest = await sha512File(artifactPath);
if (actualDigest !== action.params.artifactDigest.value) {
throw new Error("artifact digest does not match the approved release");
}
// nonce and approvers come from protected configuration —
// a receipt cannot vouch for its own signer.
const check = verifyApprovalReceipt(receipt, {
...action, nonce, approvers: TRUSTED_MAINTAINERS,
}, {
expectedOrigin: process.env.INTYGA_WEBAUTHN_ORIGIN!,
expectedRpId: process.env.INTYGA_WEBAUTHN_RP_ID!,
});
if (!check.ok) throw new Error("receipt mismatch: " + check.reason);
// npm publish runs as the next step — authority exists only here- repository identifies the source project.
- commit improves forensic traceability.
- registry prevents reuse against another destination.
- name prevents cross-package replay.
- version prevents cross-version replay.
- artifactDigest prevents post-approval substitution.
Verification must cover the bytes the protected release workflow actually received, not merely metadata supplied by the build job. And TRUSTED_MAINTAINERS must come from protected configuration that the package change cannot rewrite as part of the same release — organization-managed policy, separately administered INTYGA configuration, protected environment variables, a central security repository, or another source with independent write authority. A receipt must never be allowed to declare its own trusted signer.
The signature comes from an authenticator that requires user presence and user verification. A worm with complete access to every credential available inside the build environment can request approval. It cannot autonomously enter the separately protected release path without obtaining a valid approval from an enrolled maintainer authenticator. And the attempted request is itself recorded, including whether it was denied, expired, or approved.
The INTYGA key in your CI secrets is a requester credential. Harvesting it gives an attacker the ability to request approval, not the authority to grant approval or enter the protected publish path.
The gate must be outside the attacker's change boundary
A release gate is only independent if the same compromised change cannot both poison the package and weaken the control that protects publication. A verification call inside repository-controlled workflow code is weaker than it appears: the attacker may be able to edit the workflow, replace the verifier, change trusted approvers, or invoke publishing authority through another step. The stronger pattern puts the release decision inside a separately administered environment whose control code the package repository cannot modify.
- The repository-controlled build job produces the package without publishing authority.
- It computes the initial artifact digest and requests approval for the exact repository, commit, registry, package, version, and digest.
- A maintainer approves — or does not — using an enrolled passkey or hardware authenticator.
- The artifact and approval reference are transferred to a separately administered release workflow.
- The protected workflow recomputes the artifact digest from the bytes it actually receives.
- It reconstructs the expected action from protected configuration.
- It verifies the receipt locally against trusted maintainer identities.
- Only after successful verification does it obtain short-lived publishing authority.
- It publishes exactly the verified artifact once.
repository-controlled code
│
▼
untrusted build environment
- no publishing authority
- produces package
- computes initial digest
- requests approval
│
▼
immutable artifact transfer
│
▼
human approval bound to
- repository
- commit
- registry
- package
- version
- artifact digest
│
▼
separately administered release environment
- recomputes digest
- loads protected approver trust
- verifies receipt locally
- obtains short-lived publishing authority
- publishes exactly once
│
▼
registryThe security boundary is not the API call to INTYGA. The security boundary is the separately administered release environment that refuses to obtain publishing authority unless the exact human approval verifies.
What this does and does not prevent
- It stops autonomous propagation. Each unattended republish stops at an approval request instead of immediately becoming a release. The request remains incomplete, is denied, or expires unless an enrolled maintainer explicitly approves it.
- It removes credential possession as sufficient authority. A leaked requester key, CI secret, maintainer session, or publishing credential cannot by itself satisfy the protected release workflow.
- It does not review the diff. A maintainer who approves an artifact built from poisoned code signs the poison. Code review, branch protection, provenance, and release policy still matter.
- It does not protect consumers from other packages. Minimum-release-age policies, dependency controls, and registry response remain separate defenses.
- It depends on an independent release boundary. If the compromised repository can remove the gate or directly access publishing authority, the intended control has not been established.
Be precise about the counterfactual. A signature gate does not mean ChainDrop could not have happened at all. But note that the initial poisoned releases were also unattended: the account's real owner never cut them, and would instead have received an approval request for a release they never started — a loud signal that something is wrong. What is structurally gone is the multiplier. The difference between 11 compromised packages and 444 was four hours of publishing that no human touched.
This pattern extends beyond package publishing
The same architecture applies anywhere a machine performs an irreversible action on behalf of a human:
- npm publish
- cargo publish
- docker push
- helm release
- terraform apply
- production deployments
- privileged infrastructure changes
Package publishing is only one example. The security property is broader: possession of a credential is no longer sufficient authority. A human decision becomes part of the execution path.
The cost of gating a release
Nothing, on the Developer tier. Approvals are not metered and are never refused for billing reasons, so gating every release costs the same as gating none. What the paid tiers add is seats, a longer evidence-retention window, and quorum rules.
If you gate your releases, say it where your users look first. These badges are free to embed once your publish workflow requires an approval:
These badges are maintainer declarations, not INTYGA certifications. They indicate that the project claims to gate releases with INTYGA; users should review the project's published release policy and evidence where available.