Every `npm install` is an act of trust. You are executing code written by strangers, versioned by strangers, published through infrastructure neither of you controls — and your CI pipeline does it automatically, hundreds of times a day, with access to your secrets, your cloud credentials, and your production deploy keys. Most of the time that trust holds. When it doesn’t, the blast radius is enormous.
The last two years have made this concrete. The Shai-Hulud campaign showed a self-replicating worm sweeping through npm packages, and its “mini” successors kept striking through 2026 — including a coordinated wave across npm and PyPI that hit hundreds of package versions in a single day. Separately, Microsoft’s threat intelligence team tracked 33 malicious npm packages that abused dependency confusion to profile developer machines, spoofing internal infrastructure URLs in their package manifests to look legitimate. These are not exotic nation-state operations. They are scriptable, repeatable attacks against the weakest link in modern software: the dependency tree you didn’t write.
This post walks through the three main ways packages get compromised — typosquatting, dependency confusion, and account takeover — and then lays out a defense-in-depth setup you can implement in an afternoon: lockfiles treated as security artifacts, registry scoping, minimized install lifecycle scripts, and build provenance you can actually verify.
Three ways a package name betrays you
Package manager resolution is a naming system, and naming systems can be gamed. Nearly every package manager attack falls into one of three buckets.
1. Typosquatting and name collision
The attacker publishes a package whose name is one transposition away from a popular one — reqeusts for requests, crossenv for cross-env. A mistyped npm install or a hallucinated import in AI-assisted code is all it takes. This class has grown more dangerous with LLM-generated code: models routinely invent plausible package names, and attackers now monitor for names that models tend to hallucinate, publishing malicious packages under those names before anyone notices. The technique even has a name in the research community — slopsquatting.
2. Dependency confusion
If your organization has an internal package named company-analytics-client that only exists in your private registry, what happens when someone publishes a public package with the same name? The answer depends entirely on how your tooling resolves names. If the public registry is consulted first — or if your internal version number is lower than the attacker’s — the malicious package wins. The original demonstration of this technique reportedly reached the internal build systems of multiple major tech companies within hours of publication, because their CI machines resolved the public name automatically.
The 2026 npm campaign Microsoft documented refined this playbook: the packages didn’t just execute a payload on install, they spoofed internal GitHub Enterprise and Jira URLs in their metadata to survive casual inspection, and used obfuscated postinstall scripts to fetch a second-stage payload that profiled the developer environment.
3. Maintainer account takeover
Here the package is real and popular, but the publish credentials are compromised. Phishing campaigns have repeatedly targeted npm and PyPI maintainers with fake “verify your account” pages; a successful phish lets the attacker publish a malicious patch release of a package already in millions of dependency trees. This is the hardest class to defend against, because the package name, maintainer, and history are all genuine — only the new version is poisoned. Version pinning plus provenance verification is the practical answer, which is why we’ll spend time on both.
The install hook is the attack surface
Why is npm install dangerous at all? Because it’s not just a download. npm executes lifecycle scripts — preinstall, postinstall, prepare — on the host machine during installation. A single line in a transitive dependency’s package.json gives arbitrary code execution with your user’s permissions, your environment variables, and often your cloud credentials from ~/.aws, ~/.npmrc tokens, or CI-injected secrets.
Almost every npm worm and infostealer campaign in recent memory has used the same chain: npm install → postinstall script → obfuscated JS → fetch second stage → exfiltrate tokens. The good news is that this entire chain is cut off if lifecycle scripts don’t run.
npm ships with npm config set ignore-scripts true as a one-line global hardening, and modern alternatives have made this the default. pnpm blocks lifecycle scripts of dependencies by default and requires explicit approval via pnpm approve-builds. Bun 1.2+ does the same with a trustedDependencies list. If you’re still on stock npm with scripts enabled everywhere, that’s the single highest-leverage change in this post.
When a legitimate package genuinely needs a postinstall script (native module compilation, notably), approve it explicitly and review what it does. The approval list becomes your audit trail.
Lockfiles are security artifacts
Most teams treat package-lock.json or pnpm-lock.yaml as a reproducibility convenience. They’re more than that: the lockfile is the complete, hash-pinned inventory of your dependency tree. Three practices follow from taking it seriously.
Commit it, always. A missing lockfile means every install re-resolves version ranges at build time — which is exactly the window dependency confusion and account-takeover attacks exploit. A freshly published malicious version satisfying ^1.2.3 gets picked up by your next CI build if you don’t have a lockfile pinning it.
Review lockfile diffs in PRs. The diff is small when it’s a deliberate upgrade and enormous when something unexpected swept in. A lockfile diff adding dozens of packages nobody on the PR touched is a red flag worth a human minute. Several teams automate this: fail CI if a dependency diff touches packages outside an allowlist.
Use minimum release age. The newest version of anything is the least scrutinized. pnpm supports a minimumReleaseAge setting in pnpm-workspace.yaml that refuses to install versions younger than your threshold, and npm has been moving in the same direction with resolution policy options. This single setting would have blunted most account-takeover campaigns, because the malicious release gets pulled or flagged by maintainers, registries, or automated scanners within days. Combine it with the lockfile: day-to-day installs resolve normally, but nothing younger than your threshold can enter the tree, and the lockfile freezes what did.
Scope your registries
Dependency confusion dies completely if public resolution is explicit rather than implicit. The mechanism is registry scoping in .npmrc:
; internal packages live ONLY in the private registry
@mycompany:registry=https://registry.mycompany.example/repository/npm-private/
; everything else comes from the public registry
registry=https://registry.npmjs.org/
; always send credentials to the scoped registry
always-auth=true
The critical detail: any package under the @mycompany scope resolves only from your private registry. An attacker publishing @mycompany/internal-utils to the public npm registry can’t intercept the resolution, because public npm is never consulted for that scope. Namespaces turn naming from a global lottery into a partitioned system.
The same pattern exists in every ecosystem. Python has no native scopes, so the defense is a pinned index-url plus an explicitly documented extra-index-url policy — or better, tooling like uv with a locked, pinned index configuration. Go modules sidestep the problem architecturally: the module path includes the hostname (company.example/tools/thing), so there is no public namespace to collide with. This is quietly one of the best supply chain designs in any mainstream language.
Audit your setup for one specific failure mode: a CI job that configures the private registry as an additional index rather than the scoped primary. Additional-index configurations let the resolver pick the higher version number from either source — which is exactly the dependency confusion win condition.
Provenance: proving where a build came from
Pinning and scoping protect the resolution step. Provenance protects the build step: it answers “was this artifact produced by the build process I think it was, from the source I think it came from?”
The standard here is SLSA, which defines a build-integrity track with three practical levels. Build L1 means provenance exists — a signed statement of how the artifact was built, which prevents mistakes but is trivially forgeable without infrastructure backing. Build L2 means the provenance is generated and signed by a hosted build platform, so forging it requires attacking the platform rather than a developer laptop. Build L3 requires the platform itself to be hardened so build jobs can’t tamper with each other or with the signing process.
The practical entry point for most teams is GitHub Actions plus Sigstore Cosign. GitHub’s hosted runners can generate SLSA L2-compliant provenance for releases, and Cosign’s keyless signing ties the signature to the workflow’s OIDC identity — no long-lived signing keys to leak. A minimal attestation step looks like this:
- name: Install cosign
uses: sigstore/cosign-installer@v4
- name: Sign and attest the release image
run: |
cosign sign --yes ghcr.io/myorg/myapp@sha256:EXAMPLE_DIGEST
cosign attest --yes \
--predicate provenance.json \
--type slsaprovenance \
ghcr.io/myorg/myapp@sha256:EXAMPLE_DIGEST
On the consuming side, a Kubernetes admission policy (Kyverno or the Sigstore policy-controller) verifies the attestation before the image is allowed to run. That closes the loop: even if an attacker slips a malicious artifact into your registry, it can’t deploy without valid provenance signed by your build platform identity.
Provenance is also becoming an ecosystem-native feature. PyPI now supports trusted publishing with attestation via OIDC, and npm supports provenance statements for packages published from GitHub Actions. When a package you depend on publishes provenance, its integrity story is verifiable rather than vibes-based. When choosing between two equivalent libraries, provenance support is a reasonable tiebreaker — it tells you the maintainer takes the release pipeline seriously.
Scanning: necessary but not sufficient
Dependency scanning (Dependabot, Snyk, npm audit, Trivy) is table stakes, but understand what it can and can’t catch. Scanners match known CVEs against known versions. They are blind to a package published fifteen minutes ago, and most attacks — the worm campaigns, the dependency confusion waves — burn their window of effectiveness inside exactly that blindness. Treat scanning as the floor, not the strategy. The layers above it (lockfile discipline, script blocking, release age, provenance) are what catch the novel attack.
One addition that pays off: enable Dependabot alerts plus automatic security updates so that patched versions flow in within days, not quarters. The median time from disclosure to in-the-wild exploitation keeps shrinking; a quarterly dependency review cadence doesn’t survive contact with that reality.
A priority-ordered checklist
- Block lifecycle scripts by default (pnpm and Bun do this natively;
ignore-scripts=truefor npm), with an explicit approval list for packages that genuinely need them. - Commit lockfiles and review their diffs. Fail CI on lockfile drift that nobody can explain.
- Set a minimum release age (a week is a reasonable start) so freshly published versions can’t enter your tree on day zero.
- Scope private namespaces to the private registry and confirm no CI job uses the private registry as an additional index.
- Generate and verify provenance for your own releases (GitHub Actions + Cosign gets you SLSA Build L2), and prefer provenance-publishing dependencies when choosing between equals.
- Keep the scanner running — it’s the floor. Enable automatic security updates so patches land in days.
None of these steps is complicated. The hard part is that supply chain security is a posture, not a patch: it only helps if it’s already in place when the campaign that targets your ecosystem starts. The teams hit hardest by the recent waves weren’t running exotic software — they just had postinstall scripts enabled, no lockfile discipline, and a CI configuration that resolved names from the public registry by default. Fix those three, and the economics of attacking your project change dramatically.