Articles › Security

Malicious npm and pip packages: defences for a small team, in order of payoff

Typosquats, hijacked maintainer accounts, install scripts, dependency confusion: how bad packages get in, and the defences ranked by payoff, with a checklist and CI.

Intermediate 9 min read October 21, 2026

What you will learn
  • Most malicious packages arrive as a brand-new version of a library you already use, not as a stranger
  • The two highest-payoff moves fit in one day: a committed lockfile installed with npm ci / pip --require-hashes, and install scripts off by default
  • Waiting three to seven days before adopting a new version (a cooldown) cuts out most attacks, because poisoned versions are usually pulled within days
  • npm audit and pip-audit only find already-published vulnerabilities; they cannot tell you a package is fake or hijacked

A small web project pulls in several hundred packages from npm; a Python project pulls dozens from PyPI. Every one is someone else’s code running on your laptop, your CI runner and your production server. In 2025-2026 several widely used packages were hijacked and quietly fitted with token-stealing code. This article covers how these attacks get in, in general terms, and what a small team should do first, ranked by payoff for the effort.

Commands were tested on Ubuntu 24.04 with Node 22 / npm 11 and Python 3.12 / pip 26. Option names change; check the current docs before relying on them.

How they get in

  • Typosquats. A package named almost like the real one, waiting for a typo or a name copied wrongly from a tutorial.
  • Hijacked maintainer accounts. A maintainer is phished or leaks a token; the attacker publishes a “new version” of the genuine library. This is the most dangerous case: the name is right, the source is right, only the latest version is poisoned.
  • Install scripts. npm lets a package run commands at install time (preinstall, postinstall), and a Python setup.py can run code too. A plain npm install is enough; your program never has to import it.
  • Dependency confusion. Your project uses an internal package billing-core from a company registry; someone registers the same name on the public registry with a higher version, and a badly configured installer picks the public one.

event-stream (2018). The maintainer handed the library to a volunteer, who added a dependency, flatmap-stream, later updated with code targeting one cryptocurrency wallet. It took about two months to notice. npm removed the package and took over event-stream on 26 November 2018 (npm’s write-up is linked below). Lesson: a library that “still works” but has no active owner is a weak point.

Shai-Hulud (September 2025). A worm that spread through npm itself. On a developer’s machine it searched for npm, GitHub and cloud tokens, then used the stolen npm token to publish new versions of other packages that developer owned, spreading in a chain. CISA’s alert of 23 September 2025 counted more than 500 affected packages and told teams to check lockfiles, rotate credentials and require phishing-resistant MFA. npm then revoked all classic tokens in December 2025 and pushed publishers towards trusted publishing.

Most malicious packages do not come from strange libraries. They come from the “latest version” of libraries you already use. So the defence starts with: when, and how, do we accept a new version?

Lockfile + npm ci / --require-hashesthe same versions every timeDisable install scriptsno surprise code at installWait 3-7 days before adopting new versionshijacked releases are usually pulled within daysCheck provenance + private registryreal builds from real maintainers, no name confusion
In order of payoff per effort: the lockfile comes first

Defences, in order of payoff

1. Commit the lockfile and install only from it

package-lock.json and a pinned requirements file record which versions you have accepted. If the lockfile is not committed, or CI runs a plain npm install, CI may get a version nobody has seen.

# developers' machines and CI
npm ci            # installs exactly what package-lock.json says; fails if the lock disagrees with package.json instead of quietly fixing it

For Python, use pip’s hash-checking mode. Every package, including transitive dependencies, needs both a pinned version and a hash; if even one is missing, pip refuses to install.

# produce a file with hashes (pip-tools)
pip install pip-tools
pip-compile --generate-hashes -o requirements.txt pyproject.toml

# install in CI and production
pip install --require-hashes -r requirements.txt

One line of the result looks like this:

requests==2.32.5 \
    --hash=sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

The gain: a new version of any library reaches you only when a person changes the lockfile, a diff you can review in a pull request.

2. Turn install scripts off by default

Set this on every developer machine and in CI:

npm config set ignore-scripts true

From now on npm install runs no package’s preinstall/postinstall. Your own npm run build and npm test still run as normal (though their pre/post hooks will not).

The trade-off. Packages that compile or download a binary at install time, such as sharp, esbuild or @swc/core, will not be ready. Build only the ones you have reviewed:

npm ci
npm rebuild sharp esbuild     # a fixed list, never "everything"

Many popular packages now ship prebuilt binaries as per-platform packages and need no install script. Turn scripts off, build, and add to the rebuild list only where something breaks.

3. Wait before adopting new versions (cooldown)

Hijacked packages are usually reported and pulled within hours to a few days. If you are not first in line, your risk drops sharply.

Dependabot has a cooldown option in .github/dependabot.yml. Per GitHub’s current docs, version updates get a default cooldown of 3 days even when nothing is configured, and the cooldown does not apply to security updates.

version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7
      semver-major-days: 14
  - package-ecosystem: pip
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7

Renovate calls it minimumReleaseAge (formerly stabilityDays), for example "minimumReleaseAge": "7 days"; the config:best-practices preset already sets 3 days for npm.

Caveat: a cooldown protects against a poisoned new version, not a typosquat you typed yourself, and someone must actually read the bot’s PRs instead of merging them all.

4. Check provenance and signatures

npm. A package published from CI with trusted publishing carries a provenance attestation naming the repository and workflow that built it. Check everything installed:

npm audit signatures

This verifies registry signatures and provenance of downloaded packages and errors if one is missing or invalid. A package with no provenance at all is not counted as a failure, so it is not yet a complete guard. It needs a recent npm; on an older Node LTS you may have to update npm separately.

PyPI supports attestations under PEP 740. Projects publishing with Trusted Publishing through the standard action (pypa/gh-action-pypi-publish) get them automatically. pip does not yet verify attestations at install time; the pypi-attestations tool and the Integrity API let you check by hand. Check the current docs.

If your team publishes packages, move to trusted publishing on both registries; no long-lived token sits in a CI secret any more.

5. Stop dependency confusion with scoped registries

Internal packages need a scope (npm) and an explicit registry:

# .npmrc in the project
@mycorp:registry=https://npm.internal.example/

With @mycorp/billing-core and the scope mapped only to the internal registry, a public package of the same name cannot be substituted. For pip, avoid --extra-index-url mixing public and private indexes; use an internal index that proxies PyPI, and reserve your internal names on the public registry if you can.

6. Low-privilege CI and SHA-pinned Actions

Several 2025-2026 incidents began with a token stolen from CI.

  • Set permissions: contents: read as the workflow default and raise it only for jobs that need more.
  • Reference third-party actions by full commit SHA, not a tag, because a tag can be moved. GitHub recommends this and suggests letting Dependabot update the SHAs.

A workflow combining steps 1, 2, 6 and the audits:

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
permissions:
  contents: read
jobs:
  node:
    runs-on: ubuntu-24.04
    steps:
      # replace <sha> with the full 40-character commit SHA from the action's own repository; keep the version as a comment
      - uses: actions/checkout@<sha>        # v5
      - uses: actions/setup-node@<sha>      # v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm rebuild sharp              # reviewed packages only
      - run: npm audit signatures
      - run: npm audit --audit-level=high
      - run: npm run build
  python:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@<sha>        # v5
      - uses: actions/setup-python@<sha>    # v6
        with:
          python-version: "3.12"
      - run: pip install --require-hashes -r requirements.txt
      - run: pip install pip-audit && pip-audit -r requirements.txt
      - run: pytest

7. What npm audit / pip-audit do, and do not do

Both compare your dependency list against a database of published vulnerabilities (npm advisories; the Python Packaging Advisory Database / OSV). Useful for ordinary security bugs in ordinary libraries.

What they do not do: tell you a package is a typosquat, or that the latest version was hijacked, until someone has reported it and it has reached the database, often days later. The pip-audit docs put it plainly: if you would not pip install it, do not expect pip audit to save you. Audits are a supporting layer, not the first one.

8. Clean developer machines

Worms like Shai-Hulud spread through developer machines, not servers.

  • No npm install -g or pip install of random tools into the global environment. Use npx/pipx, or a container.
  • Separate credentials by job: publishing tokens for npm/PyPI/cloud do not live on the machine used for experiments. Use short-lived tokens and 2FA on every account (npm now enforces 2FA for interactive publishing).
  • Before adding a library, check three things: the name matches the upstream repository, downloads and version history look reasonable, and when the latest version shipped. If today, wait.

Checklist

#ActionEffortProtects against
1Commit the lockfile; install with npm ci / pip install --require-hasheslowversions drifting in on their own, files altered in transit
2npm config set ignore-scripts true + rebuild a fixed listlowcode running at install time
33-7 day cooldown (Dependabot cooldown / Renovate minimumReleaseAge)lowfreshly published poisoned versions
4npm audit signatures; trusted publishing + attestationsmediumpackages published outside the normal pipeline
5Scopes + scoped registry for internal packagesmediumdependency confusion
6permissions: contents: read; pin Actions by SHAmediumleaked CI tokens, hijacked actions
7npm audit / pip-audit in CIlowpublished vulnerabilities (only)
8No random globals; separate credentials; 2FAlowworms spreading via developer machines

Common mistakes

  • Having a lockfile but running npm install in CI, so the lock is quietly rewritten
  • Setting ignore-scripts only on your own machine and forgetting CI and the Dockerfile
  • Enabling Dependabot with auto-merge on every PR, which cancels the benefit of the cooldown
  • Believing that a clean npm audit means a safe dependency tree

Sources

FAQ

If I disable install scripts, will packages that build native code, such as sharp or esbuild, break?

Some will. Re-enable only the ones you need with npm rebuild package-name after install, or install in CI with --ignore-scripts and rebuild a fixed, reviewed list. That is safer than letting every package run code at install time.

We are two people with nobody on security full time. Where do we start?

Do the first three rows of the checklist (lockfile + npm ci, ignore-scripts, cooldown). It takes under half a day. Add audits in CI and SHA-pinned Actions when you have time.

Commands and settings were tested on sample systems. Try them on a test machine before production.