Skip to main content

Four npm Compromises in 77 Days: A Build-Sandbox Blueprint for Self-Hosted PaaS

12 min readDora NodaDora Noda
Share
On this page

Four npm supply-chain compromises landed between March 31 and June 16, 2026. They did not all exploit the same identity, the same release workflow, or even the same moment of code execution. They did share one downstream assumption: a build worker would download trusted-looking JavaScript and run it close to credentials and an open network.

That makes the response bigger than blocking four package versions. A git-push PaaS has to treat dependency installation, compilation, tests, and framework builds as hostile execution. The practical design is a disposable build sandbox with no tenant runtime secrets, controlled dependency intake, no arbitrary outbound network, and an SBOM tied to the resulting image.

Here is the promised answer before the incident history:

IncidentWhat the attacker crossedWhat ran downstreamControl that changes the outcome
Axios, March 31A maintainer's compromised machine and npm accountA malicious dependency installed a cross-platform RATFrozen clean lockfile, blocked install scripts, and denied egress
node-ipc, May 14A trusted package namespace after 21 months without a releaseAn obfuscated bundle executed when the module loadedVersion admission, no build secrets, and denied egress; script blocking alone was insufficient
Red Hat, May 29-June 1A compromised GitHub account and legitimate publication pipelineTrojanized packages used install-time execution to steal credentials and persistReviewed-source policy, script controls, and a sandbox that distrusts valid provenance
Mastra, June 15-16A phished maintainer and an npm token allowed to bypass MFA116 malicious versions were published in 25 minutesA release-age hold, frozen dependency graph, and registry denylist

No single column is the fix. The design works because a failure in one layer meets a different layer before it becomes a fleet-wide credential incident.

Four Incidents Broke Four Versions of “Trusted”

The Axios maintainer's postmortem records two malicious releases, 1.14.1 and 0.30.4, published on March 31. They added plain-crypto-js@4.2.1, which installed a remote access trojan on Linux, macOS, and Windows. The releases were available for about three hours. Anyone with an earlier clean version already frozen in a lockfile avoided a surprise update; anyone who resolved the dependency during that window could freeze the malicious artifact just as faithfully.

On May 14, three malicious node-ipc versions appeared at once: 9.1.6, 9.2.3, and 12.0.1. The public forensic report found the same obfuscated bundle in each and noted that it ran when the module loaded rather than through an npm lifecycle hook. That distinction matters. npm ci --ignore-scripts can stop a malicious postinstall; it cannot make imported JavaScript inert while a framework compiler, test runner, or server-side rendering build evaluates it.

The Red Hat incident crossed a different boundary. Red Hat's security bulletin says a GitHub account compromised through a malicious VS Code extension was used to push unauthorized commits into Red Hat repositories. The legitimate automation then published 32 compromised packages under @redhat-cloud-services; Microsoft's analysis documents credential theft and persistence through install-time code. A signature or provenance statement could correctly say which workflow produced an artifact while still describing a workflow that had been fed malicious source.

Mastra showed how quickly a stolen identity can fan out across a large namespace. Its incident report says a phished maintainer's token published 116 malicious npm packages between 6:12 and 6:37 p.m. Pacific time. Mastra required MFA for maintainers, but the affected packages allowed token bypass. The team eventually unpublished 110 versions, deprecated the remaining six, and removed token bypass.

These are not four copies of one incident. They cover compromised endpoints, publisher access, source repositories, release automation, install hooks, and module-load execution. A platform policy built around only one of those paths is an incident-specific patch, not a supply-chain boundary.

The Build Runner Is the Blast Radius

A dependency has the same operating-system privileges as the package manager, compiler, or test runner that invokes it. On a traditional shared CI worker, that can expose a surprisingly valuable inventory:

  • repository and package-registry tokens;
  • cloud credentials or a reachable instance-metadata endpoint;
  • image-registry push credentials;
  • deploy keys and signing material;
  • cached files left by earlier jobs;
  • network access to internal services, the control plane, and the public internet.

The unsafe sequence is simple: download an artifact, execute attacker-controlled code, expose durable credentials, allow arbitrary egress, then reuse the same worker. Removing any one condition helps. Keeping that code away from durable credentials, arbitrary egress, and reusable workers turns it into a short-lived intrusion with little to steal, nowhere useful to send it, and no next tenant to infect.

This is why a vulnerability scanner is not the primary boundary. Scanners work from signatures, advisories, heuristics, or behavioral analysis, all of which may arrive after the first poisoned release. The Axios versions were live for about three hours; Mastra's malicious publishing burst took 25 minutes. A scanner can shorten exposure and provide a denylist, but isolation has to be effective at minute zero.

The same reasoning applies to a self-hosted platform. Owning the machines removes a managed PaaS vendor from the trust chain; it does not make code fetched from npm trustworthy. In fact, the operator now owns the responsibility to keep a tenant build from reaching host sockets, cluster credentials, metadata services, other builds, and runtime workloads.

A Six-Gate Build Pipeline You Can Reproduce

The following pipeline is deliberately package-manager-aware but not npm-only. The control points also apply to pnpm, Yarn, Python wheels, Ruby gems, and build tools that download plugins at compile time.

Gate 1: Admit a declared dependency graph

Require a committed lockfile and fail when it disagrees with the manifest. npm documents that npm ci exits on a package.json/lockfile mismatch and never rewrites either file. Also reject unapproved Git dependencies, remote tarball URLs, and local path dependencies before executing package code.

A lockfile prevents an ordinary deploy from drifting from a reviewed clean version to today's poisoned latest. It does not prove that the committed version is safe. Treat a lockfile change like source code: review the added names, exact versions, integrity hashes, release ages, and unexpected script or native-build behavior.

Gate 2: Hold new releases before automatic adoption

Apply a default release-age policy when a dependency update is proposed. Current npm configuration supports min-release-age, and GitHub now waits three days before Dependabot opens ordinary version-update pull requests. The idea is not that day four is magically safe. It is that fast-moving campaigns depend on downstream automation resolving a malicious version before maintainers and registry operators can react.

Security updates need an auditable exception path. An actively exploited vulnerability may justify installing a release immediately; a routine patch usually does not. Record who overrode the hold, for which package and version, and why.

Gate 3: Fetch into quarantine, then build offline

Separate networked dependency intake from code execution. The intake job may reach only an approved registry proxy and DNS. It receives no deploy credential, cloud identity, runtime secret, signing key, or access to the cluster API. Verify the lockfile integrity values while filling an isolated cache.

The execution job consumes that cache with offline mode and starts with no external network. npm's offline setting guarantees the client makes no network requests; Docker build instructions also support RUN --network=none. On Kubernetes, enforce the boundary with a default-deny egress NetworkPolicy and explicit allowances only where the intake stage needs them.

Some builds genuinely download browsers, native binaries, or language toolchains. Do not answer that requirement with unrestricted internet access. Mirror the artifact, pin its digest, or grant a narrow hostname-and-port exception in a separate acquisition step that still has no secrets.

Gate 4: Deny install-time code unless it is version-approved

Start from no dependency lifecycle scripts. npm supports ignore-scripts as a blanket control and an allowScripts policy for reviewed exceptions. Native modules such as sharp and tools such as esbuild may legitimately need installation code, so the useful policy is a version-pinned allowlist, not a permanent global escape hatch.

This gate would have blocked the install-time path used by Axios. It would not have stopped the reported node-ipc bundle once a build imported it. That is why script policy sits inside a sandbox with no secrets and no egress instead of pretending to be the sandbox.

Gate 5: Compile as an unprivileged, disposable principal

Run installation, application build scripts, tests, and framework prerendering as non-root in an ephemeral worker. Do not mount the container runtime socket. Block cloud metadata endpoints. Give the job a read-only source snapshot, a writable scratch volume, and a narrowly scoped output location. Destroy the worker and its cache namespace after the job, whether the build succeeds or fails.

If the output must be pushed, mint a short-lived credential for exactly one image repository after the untrusted execution phase. Better still, hand the completed filesystem artifact to a separate trusted packaging job. Runtime secrets belong in the deployment environment after an image digest has passed policy, never in the environment that runs npm ci.

Gate 6: Attach evidence to the image digest

Generate an SBOM from the resolved tree and bind it to the immutable image digest. The npm sbom command emits SPDX or CycloneDX, including package versions, dependency relationships, hashes, and package URLs. Preserve the lockfile, base-image digest, package-manager version, script approvals, policy exceptions, and egress-denial logs alongside it.

An SBOM does not detect malware. It answers the first incident-response question quickly: “Which builds and running images contain this exact package version?” Without that inventory, a three-hour registry compromise becomes a multi-day search across repos, old build logs, and production containers.

An illustrative platform policy looks like this:

yaml
dependencies:
  requireLockfile: true
  minimumReleaseAgeDays: 3
  gitAndRemoteTarballs: deny
  installScripts:
    default: deny
    allow:
      - package: sharp
        version: 0.34.3
network:
  intake: registry-proxy-only
  execute: none
identity:
  runtimeSecrets: none
  cloudMetadata: deny
evidence:
  sbom: cyclonedx
  retainLockfile: true
  bindToImageDigest: true

This is policy pseudocode, not a claim that every package manager accepts the same file. Its value is the separation of concerns: dependency choice, code-execution approval, network reachability, identity, and evidence are independent gates.

Know Exactly Where the Blueprint Stops

Layered controls are useful only if their limits are explicit.

First, a lockfile can pin malware. It protects an existing application from unreviewed drift, but a developer who updates during the compromise window can commit the poisoned version and its valid integrity hash. Release-age admission and a registry denylist cover a different part of that failure.

Second, disabling lifecycle scripts does not stop malicious code that runs during import, tests, bundling, or application startup. Build isolation contains the compile-time attempt. Admission scanning and runtime egress policy still matter if the poisoned library is packaged into the final image.

Third, provenance proves lineage, not benevolence. The Red Hat case demonstrates the distinction: legitimate project infrastructure can publish malicious source after an upstream account or repository is compromised. Verify provenance because it eliminates some substitution attacks, but do not treat “published by the expected workflow” as “safe to execute.”

Finally, a network-denied build can still produce a backdoored artifact that behaves maliciously after deployment. Runtime workloads need their own least-privilege service accounts, namespace isolation, workload-specific egress, and secret scopes. The build boundary reduces the platform-wide blast radius; it does not certify application behavior.

Turn the Next Advisory Into a Query, Not a Hunt

When a package incident lands, stop new builds that could resolve the affected versions and block those versions at the registry proxy. Query stored SBOMs and lockfiles for the package-version set, then map each result to the build time, worker identity, injected credentials, network policy, image digest, and current deployments.

If malicious code executed in a worker that held a secret or had open egress, assume exposure. Destroy the worker, revoke the build identity, rotate every reachable credential, inspect control-plane and registry audit logs, and rebuild from a known-clean dependency graph with new credentials. Deleting node_modules is cleanup, not incident response.

Teams can adopt the blueprint incrementally:

  1. Require lockfiles and store an SBOM for every image.
  2. Remove runtime and cloud credentials from build jobs.
  3. Split registry intake from offline execution and deny egress by default.
  4. Introduce version-pinned script approvals and a release-age gate.
  5. Move image publication to a separate identity and retain policy evidence by digest.

For a platform such as Bex.co—where a git push becomes a running HTTPS service on machines the operator owns—these controls belong in the build architecture, not in a security tips page tenants may never read. The platform cannot decide whether every dependency is honest. It can decide that dishonesty in one dependency does not inherit the keys to the fleet.

The four incidents in those 77 days are useful precisely because they disagree about where trust failed. The durable answer is not a longer denylist. It is a build system designed so registry trust is never the only boundary standing between an npm tarball and production authority.

Bex.co is the open-source, AI-native Render alternative—push a git repo, get a running HTTPS service on machines you own. Star the repository on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex