Loading...

Zero-CVE Worms in Your CDE Pipeline: Detecting and Containing Self-Propagating npm/PyPI Compromises When Vulnerability Management Sees Nothing

July 2026
Zero-CVE Worms in Your CDE Pipeline

Your vulnerability-management program is a CVE search engine. It ingests advisories, ranks by CVSS, cross-references CISA KEV, and files a ticket when a scanner recognizes a known-vulnerable version. It is very good at that job. And it is structurally blind to the thing that ran on your build host last month.

In June 2026 a self-propagating worm class moved through npm and PyPI at a scale that is hard to overstate. Socket tracked 448 malicious artifacts across the two registries in a single campaign: 411 npm artifacts spanning 106 packages, plus 37 poisoned PyPI wheels across 19 projects. Red Hat had 32 of its own npm packages republished with malware across 96 versions, together carrying more than 116,000 downloads. A worm called IronWorm hit 36 packages. The Mastra AI framework saw 140-plus packages republished in roughly an hour and a half. Those packages carried a combined 1.1 million weekly downloads. On July 2, 2026, the FBI issued a FLASH alert naming the group behind much of it, TeamPCP, and cataloging four malware families and a list of compromised developer tools.

Here is the number that should reorganize how you think about Requirement 6: zero. Zero CVEs were assigned to any artifact in the Miasma/Hades campaign. None. There was nothing for a scanner to match, no advisory to ingest, no KEV entry to alert on. By the time a version identifier existed that you could have blocked, the credentials were already gone.

This is the zero-CVE worm, and if your PCI program treats "vulnerability management" and "CVE management" as synonyms, it cannot see this attack. Let's fix that, in engineering terms.

In our previous post we made the case for deleting standing cloud credentials from your build hosts and replacing them with short-lived, federated identity. This is the attack that makes that argument non-optional: the worm is built to harvest exactly the credentials that piece told you to remove.

Why this class is invisible to a KEV-driven Req 6

A traditional software vulnerability is a latent flaw. It sits in a version, waits to be discovered, gets a CVE, and eventually gets patched. The entire Requirement 6 machine assumes that shape: identify (6.3.1), inventory (6.3.2), rank, remediate.

A supply-chain worm has none of that shape. There is no flaw in the code you wrote or the version you pinned. Instead, an attacker takes over a maintainer account, or forges a CI identity, and publishes a new, malicious version of a package you already trust. The Red Hat packages were pushed through GitHub Actions OIDC rather than a stolen npm token, meaning the compromise rode the CI/CD pipeline's own trusted identity and even carried valid SLSA provenance; the mechanics of that OIDC abuse, and why provenance saved no one, are in our previous post. The Mastra attack declared a typosquatted dependency, easy-day-js, as a floating ^1.11.21 range, and a caret range is the opposite of pinning: it explicitly permits minor and patch updates, so npm silently resolved to the weaponized 1.11.22 the moment anyone ran npm install.

None of that produces a CVE. The malicious version is not a "known vulnerable component" in your SBOM; it is a brand-new artifact that did not exist when you built your inventory. Requirement 6.3.2 tells you to maintain an inventory of third-party components "to facilitate vulnerability and patch management." Good hygiene, and you should do it. But an inventory built to catch known-vulnerable versions does nothing against a freshly poisoned version of a package that is already on the list. The control was designed for a different threat.

So the first honest step is to stop expecting the CVE pipeline to catch this. It won't. Detection and containment have to live somewhere else.

The reachability problem: your build host is the target, not your app

Here is the part that turns a developer-ecosystem story into a PCI scope story.

These payloads do not wait for you to import the poisoned package. They run at install time, through lifecycle hooks that execute during dependency resolution:

  • Mastra shipped a 4,572-byte setup.cjs dropper invoked by "postinstall": "node setup.cjs". It ran on npm install, before a single line of your application executed.
  • IronWorm used a preinstall hook to drop a Rust ELF binary, hidden behind an eBPF rootkit, beaconing over Tor.
  • Hades, the PyPI variant, dropped a *-setup.pth file into site-packages. Python executes import-prefixed lines in .pth files automatically at interpreter startup, so the payload fired on every Python invocation on the host, downloading the Bun runtime and running an obfuscated JavaScript stage.

Install-time execution means the attacker's code runs in the identity of your build runner. And what is sitting in the environment of a typical CI job? Exactly what these worms are built to harvest. The Miasma payload enumerated GitHub PATs and Actions runner secrets, AWS credentials plus STS, SSM, and Secrets Manager, GCP identity and Secret Manager, Azure identity and Key Vault, Kubernetes service-account tokens and cluster secrets, and Vault tokens. IronWorm scraped 86 environment variables and 20 credential files (OpenAI, AWS, Anthropic, and npm keys), Vault configs, and SSH keys. Mastra went straight for AWS_ACCESS_KEY_ID, GITHUB_TOKEN, NPM_TOKEN, and database connection strings.

Now draw the reachability graph. If your build runner holds standing credentials that can reach into the cardholder data environment, then a poisoned npm install is remote code execution with those credentials. The distance from "a dependency got compromised" to "an attacker has a role that can read your CDE secrets" is one lifecycle hook. That is not a developer-productivity problem. That is a CDE boundary problem, and a QSA who understands cloud architecture will treat the build pipeline as in-scope connected-to infrastructure, because the credentials living on it say it is.

And then it spreads. When the payload finds a credential that can publish, it republishes itself through every package that token can reach. IronWorm self-propagated specifically by abusing secrets tied to npm's Trusted Publishing workflow. That standing NPM_TOKEN in your CI is not just an exfiltration target; it is the worm's transport.

The control-mapping trap (and where 6.4.3 actually lives)

When a build-pipeline compromise lands on a compliance team's desk, the reflex is to reach for Requirement 6.4.3, because it has the word "scripts" in it. Resist that. It is the wrong control, and mapping to it will make your ROC weaker, not stronger.

Requirement 6.4.3 governs "all payment page scripts that are loaded and executed in the consumer's browser." It is a client-side, anti-e-skimming control: authorize each script, assure its integrity (CSP, Subresource Integrity), and inventory it with written justification. It is about the Magecart threat to your checkout page. It has nothing to say about a postinstall hook running on a GitHub Actions runner. Citing it here is a category error that an experienced assessor will catch immediately.

The zero-CVE worm maps to a different set of controls, and naming them correctly is what makes the story credible:

ControlWhat it actually requiresWhy the worm implicates it
Req 1 / scopingSegment the CDE; justify connected-to systemsA build host with standing CDE-reachable credentials is in scope. Prove it is segmented, or bring it in.
6.3.2Inventory bespoke, custom, and third-party componentsNecessary baseline, but insufficient alone; it catches known-vulnerable versions, not freshly poisoned ones.
Req 8 / identityManage authentication for all accessStanding, long-lived publishing and cloud tokens on build hosts are the harvest target and the propagation vector.
12.8Manage third-party service provider riskYour registry, your maintainers, your CI provider are all third parties in your software supply chain.
6.5.x / change controlControl and review changesA dependency silently resolving to a new major-minor is an unreviewed change entering your CDE.

The point is not to memorize the grid. It is that this attack is a scoping and identity problem wearing a "software vulnerability" costume, and the remediation is engineering, not documentation.

Containment, written as infrastructure

None of the following is a policy statement. Each maps to a control above and each is enforceable in IaC, which is how Arbure prefers to leave a client: with the guardrail merged, not with a finding in a spreadsheet.

1. Kill standing publish credentials. Move to OIDC trusted publishing. The single highest-leverage change. Trusted publishing establishes an OIDC trust between the registry and your CI provider: at publish time the workflow mints a short-lived, cryptographically signed token bound to a specific repository and workflow, valid for minutes, unusable outside that execution. There is no NPM_TOKEN sitting in the environment for a worm to harvest and republish with. You remove both the target and the transport at once.

# .github/workflows/publish.yml - no long-lived NPM_TOKEN in the environment
permissions:
  id-token: write   # mint the short-lived OIDC token
  contents: read
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block            # default-deny egress; see #2
          allowed-endpoints: >
            registry.npmjs.org:443
            github.com:443
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with: { node-version: 22, registry-url: 'https://registry.npmjs.org' }
      - run: npm ci --ignore-scripts        # do not run lifecycle hooks in CI; see #3
      - run: npm audit signatures           # verify registry signatures/attestations; see #5
      - run: npm publish --provenance       # OIDC auth + signed provenance, no token needed

2. Default-deny egress on build runners. The worm has to phone home to exfiltrate. Mastra's dropper was stopped cold precisely here: StepSecurity's Harden-Runner blocked the fetch to 23.254.164.92:8000 at the network layer, and no payload was ever written to disk. A build runner does not need to reach an arbitrary IP on port 8000. Allowlist the registries and your artifact store; block the rest. This is a Requirement 1 egress control applied to CI, and it degrades gracefully: even a novel, un-cataloged payload cannot exfiltrate to an endpoint it is not allowed to reach.

3. Do not execute lifecycle scripts in CI. Install with --ignore-scripts (npm) or the equivalent, and run any genuinely required build steps in a sandboxed stage with no CDE credentials in scope. Microsoft's own guidance for the Mastra compromise led with this. It directly defeats the postinstall/preinstall execution vector for the machine that matters most.

4. Strip standing cloud credentials off the build host. The build runner should assume a short-lived, tightly scoped role via OIDC, and that role must not have a path into the CDE. If your CI role can read CDE secrets today, that is the finding. We walk this control through end to end in our previous post; the Terraform below is the short version:

# Build-runner role: federated via OIDC, no CDE reach, session-scoped
data "aws_iam_policy_document" "ci_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:arbure/app:ref:refs/heads/main"]  # bind to repo + ref
    }
  }
}

resource "aws_iam_role" "ci_build" {
  name                 = "ci-build-runner"
  assume_role_policy   = data.aws_iam_policy_document.ci_trust.json
  max_session_duration = 3600            # short-lived, not a standing key
  # attach only build-artifact permissions; NO kms:Decrypt on CDE keys,
  # NO secretsmanager:GetSecretValue on CDE secrets.
}

5. Make provenance a failing gate, not an unenforced attestation. SLSA provenance and npm's provenance attestations only matter if something fails the build when they are missing or unverifiable. An attestation nobody checks is a comment. The mechanism matters, because plain npm install will not fail closed on its own: run npm audit signatures in CI to verify registry signatures and provenance attestations and exit non-zero when a package cannot prove where it was built, and for a hard organizational gate, enforce it with an admission or policy engine (Kyverno, Sigstore policy-controller) rather than trusting every developer to remember a flag. Publish your own packages with npm publish --provenance so downstream consumers can enforce the same check against you.

6. Add a cooldown and pin hard. The FBI's FLASH guidance is concrete and worth adopting verbatim: enforce a minimum package-age threshold (they suggest 7 days) so a maliciously republished version cannot be pulled the instant it lands, pin GitHub Actions to full commit SHAs rather than floating tags, and audit npm accounts for expired recovery-email domains, which is exactly how TeamPCP took maintainer accounts over in the first place.

7. Make the guardrails fail closed: policy as code. Everything above is only as strong as its enforcement, and a compliance team should not have to read Terraform to know whether a pipeline complies. Open Policy Agent turns each control into a rule that fails the pull request. Because OPA treats configuration as data, one tool gates both sides of the pipeline: run conftest against terraform show -json to reject a plan whose CI role can reach CDE secrets, and against the workflow YAML itself to reject a publish job that still carries a static token or runs install scripts. This is the part teams usually assume they cannot enforce on the GitHub Actions side, and it is the same Rego either way:

package github.workflows

# Deny a publish workflow that authenticates with a long-lived npm
# token instead of OIDC trusted publishing.
deny contains msg if {
  step := input.jobs[_].steps[_]
  contains(step.run, "npm publish")
  regex.match(`NPM_TOKEN|NODE_AUTH_TOKEN|secrets\.NPM`, step.run)
  msg := "publish step uses a static npm token; require OIDC trusted publishing (id-token: write, no token)"
}

# Deny any CI install that executes lifecycle scripts.
deny contains msg if {
  step := input.jobs[_].steps[_]
  contains(step.run, "npm ci")
  not contains(step.run, "--ignore-scripts")
  msg := "npm ci must run with --ignore-scripts in CI"
}

Wire that as a required check and a developer cannot merge a workflow that reintroduces the risk. The QSA gets a build log that is evidence the control is enforced, not a policy document asserting it should be. That is compliance as code, which is the only kind that survives contact with a real pipeline.

Detection, because containment is never complete

Assume one gets through. These campaigns leave a consistent forensic trail, and none of it lives in a CVE feed:

  • Egress anomalies from build runners. Outbound connections to raw GitHub repositories used as exfil dead-drops, downloads of the Bun runtime from github.com/oven-sh/bun/releases, or beacons to hardcoded IPs on odd ports (Mastra's 23.254.164.92). Your default-deny policy from step 2 is also your detection surface: every block is an alert.
  • Filesystem artifacts. .pth files appearing in site-packages, /tmp/.bun_ran sentinels, $TMPDIR/.pkg_history and .pkg_logs markers, and random hex-named .js files in home and temp directories.
  • Behavioral tells. A postinstall step spawning a detached process, or any install-time process setting NODE_TLS_REJECT_UNAUTHORIZED=0 to talk to a self-signed C2. Audit CI logs for lifecycle-hook execution; if you adopted --ignore-scripts, any hook firing is by definition an anomaly.

If any build host is implicated, treat every credential reachable from it as burned and rotate on the assumption the worm already enumerated them: GitHub and npm tokens, AWS keys, Azure service principals, GCP service accounts, SSH keys, Kubernetes secrets. That is the Miasma response playbook, and it is the right default.

The assessment question

Strip away the package names and the malware families and one question remains, and it is the question we ask on every engagement: if an arbitrary dependency executed code on your build host right now, what could its credentials touch?

If the honest answer includes anything inside the CDE, then your build pipeline is part of your cardholder data environment whether your scope diagram says so or not, and a KEV-driven Requirement 6 program will never tell you, because there is no CVE to find. The defense is not a better scanner. It is short-lived federated identity, default-deny egress, install-time sandboxing, and provenance enforced as a gate, all of it expressible as Terraform you can merge this week.

That is the difference between auditing compliance and engineering it. If you want a reachability-graph look at your own pipeline, mapped to the controls a QSA will actually cite, that is the kind of assessment we do.

Sources

Relevant tags:

#PCI#SupplyChain#CICD
Christopher Callas

Christopher Callas

Christopher is the Principal at Arbure Inc., leading strategic and technical initiatives that shape the firm's cybersecurity consulting services. With over a decade of experience, he has built a reputation for delivering tailored security solutions that align with business objectives while addressing modern threats. His expertise spans cloud security, compliance, and risk management, guiding organizations through complex regulatory landscapes and securing multi-cloud environments.

Stay up to date with our research & events: