Defending Against Supply Chain Attacks: An npm and PyPI Defense Playbook

When a popular npm package turns malicious, the window between publication and detection is measured in hours, and the first wave of damage happens automatically — a developer runs an install, a lifecycle script fires, credentials are lifted, and the package’s own publishing token becomes the next infection vector. Post-incident write-ups tend to focus on the glamour part: how the malware worked. What actually limits your exposure is much more mundane. It is the set of constraints your build environment imposes on what an arbitrary package is allowed to do when it lands on a machine.

This post is a defense playbook organized by the steps of a realistic npm/PyPI supply chain attack: install-time code execution, dependency resolution hijacking, malicious publish, and compromised CI. For each step there is at least one concrete control you can apply this week, with the exact configuration. None of these controls require exotic tooling — they are mostly flags and registry configuration that have existed for years and remain underused.

Step 1: Cut the install-time code execution surface

The single highest-leverage change for npm projects is disabling install lifecycle scripts. A large share of npm supply chain malware executes through preinstall, postinstall, or prepare hooks, because those hooks run arbitrary shell commands during npm install with the full privileges of the user running it. The lifecycle scripts documentation covers the full set of hooks, and the OWASP NPM Security Cheat Sheet treats script suppression as a top-tier control.

Set it project-wide in .npmrc:

# .npmrc — committed to the repo
ignore-scripts=true

The known objection is that some legitimate packages need build scripts — anything shipping native binaries (image codecs, crypto, database drivers) typically compiles or downloads a prebuilt binary in a postinstall hook. The practical pattern is default-deny with a small allowlist. The npm config reference documents per-invocation overrides, so CI can run with the same policy as laptops:

# Default: no scripts anywhere
npm ci

# Explicit, reviewed exception for a package that truly needs it
npm rebuild esbuild --foreground-scripts

The key property is that the exception is a deliberate, reviewed decision that appears in your build logs, not an invisible side effect of a transitive dependency’s package.json. Most projects discover that fewer than five packages across their whole tree genuinely need scripts. Everything else was silent shell execution from strangers.

Python has a weaker version of the same problem: pip’s secure installs documentation covers the defenses it offers, but the ecosystem’s real risk concentrates elsewhere — in how versions get resolved, which is the next step.

Step 2: Make dependency resolution boring and reproducible

Resolution hijacking comes in two flavors. The first is version drift: your build says ^1.2.3, an attacker publishes 1.2.4, and every fresh install picks up the malicious version. The second is typosquatting and name confusion, where the attacker relies on a transitive dependency declaring a name that they registered first. Both flavors fail against the same two controls: lockfiles treated as authoritative, and registries that only ever serve what the lockfile pinned.

The npm-specific rule is that CI must use npm ci, never npm install. The difference is not cosmetic: npm ci refuses to run unless package-lock.json exactly matches package.json, and installs precisely the locked tree. That converts every dependency change into a visible, reviewable diff in your repository:

# .github/workflows/ci.yml (fragment)
- run: npm ci          # fails on lock/package.json drift — that is the point
- run: npm run build

For Python, the equivalent is pinning transitive dependencies in a lockfile — pip’s requirements file format supports exact pins, and tools like uv generate lockfiles with hashes. Hash-pinned installs go one step further: even if an attacker managed to publish a trojanized version under an existing version number, the hash check fails and the install aborts.

Go deserves a mention because its design made the attack class structurally harder: the module proxy and the go.sum database mean that a retracted or maliciously re-published version cannot silently replace what you already verified. The Go modules reference describes the verification chain. The transferable idea is the checksum DB itself — a third party attesting that “version X of module Y has hash Z” — rather than any Go-specific mechanism.

One more resolution control with a bad reputation it does not deserve: overrides in package.json. When a transitive dependency is known-vulnerable and upstream has not cut a release, an override forces the patched version everywhere in the tree. Teams avoid overrides because they feel like a hack, but a pinned, commented override that gets removed when upstream fixes the issue is exactly the kind of small, explicit deviation a lockfile diff is designed to surface.

Step 3: Raise the cost of publishing malware as “you”

The worm campaigns of 2025 and 2026 mostly propagated through stolen maintainer credentials: phished or malware-harvested npm tokens used to publish trojanized versions of packages the victims maintained. You cannot stop other maintainers from being compromised, but you can control the two halves of the publish path you own: authentication for your own packages, and provenance for what your CI publishes.

On PyPI, Trusted Publishers replaces long-lived API tokens with short-lived OIDC tokens minted by your CI. The token exists for the duration of one publish job and is bound to a specific repository, workflow, and environment — a phished static token has nothing to steal. npm’s equivalent is provenance statements, which cryptographically link a published package to the GitHub Actions run that built it. A release that claims to come from your repo but has no provenance, or provenance from the wrong workflow, is immediately suspicious — and that check can be automated.

For artifacts built outside package registries — container images, binaries — Sigstore’s Cosign provides the same shape of guarantee: keyless signing bound to a CI identity, verification at deploy time. The common thread across all three is replacing “who published this” (a credential that can be stolen) with “what identity produced this, verifiable at install time” (a claim that requires compromising your CI to forge).

Step 4: Assume CI is the target, and fence it in

The highest-value prize in a developer ecosystem is not a laptop — it is the CI runner. A CI machine typically holds a registry publish token, cloud credentials for deployments, and often a GitHub token with write access to releases. That is why modern npm malware specifically fingerprints CI environments and behaves differently there: quieter, more patient, focused on token exfiltration rather than cryptomining.

GitHub publishes a dedicated hardening guide for Actions (security-hardening-for-github-actions); three items in it do most of the work:

  • Minimal token permissions. Set permissions: {} at the workflow level and grant read or write per job, per need. The default GITHUB_TOKEN scope is wider than most workflows will ever use.
  • Pin third-party actions to full commit SHAs. A tag is a mutable pointer; if the action’s repo is compromised, uses: vendor/action@v3 happily runs the malicious update on your runner.
  • Publish from ephemeral, single-purpose workflows. The publish job should hold only the publish credential, and that credential should be the short-lived OIDC token from Step 3 — nothing else rides along.

Build isolation completes the fence. BuildKit containers keep builds from writing outside their context, and for higher-assurance setups a build run on ephemeral infrastructure that is destroyed after the job removes most of the persistence paths install scripts rely on. The goal is not to make compromise impossible — it is to make a compromised build produce one bad artifact instead of a stolen token that mints bad artifacts forever.

Step 5: Detect the rest — audit continuously, not annually

Every control above reduces the probability that a malicious package does damage on install. None of them eliminates it, which is why detection has to run continuously. Three tools cover the ground with almost no operational cost:

  • npm audit for known vulnerabilities in the resolved tree — run it in CI and fail the build on high-severity regressions.
  • Dependabot for automated update PRs. The security value is subtler than the CVE patches: update PRs keep your tree close to current, so the diff when something needs an emergency change is small.
  • OSV-Scanner for scans against the OSV database across lockfiles, SBOMs, and container images — useful when your project spans more than one ecosystem.

The often-missed detection rule is behavioral: alert on install scripts regardless of vulnerability status. A package that never needed a postinstall hook and suddenly grows one is a stronger compromise signal than most CVE feeds — and with ignore-scripts in place, surfacing suppressed scripts in CI logs is nearly free.

What this buys you, honestly

Stack the controls and the attack math changes. Install-time malware loses its primary execution path to ignore-scripts. Version drift dies at the lockfile. Stolen publish tokens become worthless when publishing requires your CI identity. A compromised CI yields one tainted artifact instead of persistent credentials. And whatever survives all of that still has to pass an audit diff that a human reviews. No single control is a wall; the compounding is the defense. The uncomfortable summary of every post-incident analysis in this space is the same: the victims were not lacking advanced tooling, they were running default configuration. Defaults are the attack surface. The configuration above takes an afternoon, ages well, and covers the steps where realistic attacks actually live.

Leave a Reply

Your email address will not be published. Required fields are marked *