ATALAIA
  1. Home
  2. Resources
  3. Blog
  4. Anatomy of a malicious package
Supply chain · Deep dive

Anatomy of a malicious package

Four ways a bad package reaches your build, what each one looks like in a manifest or a log, and how to check yourself.

3 min readAtalaia team

npmPyPIOpen source

A malicious package does not need a clever exploit. It needs to be installed once, on a laptop or a build runner, by someone who trusts the registry. From there it can read environment variables, tokens and SSH keys, and send them somewhere else.

There are only a handful of ways in. Knowing them makes the defences obvious.

The chain

Almost every case follows the same shape, whatever the ecosystem:

  1. PublishA package is published, or an existing one gets a new version, with a payload inside.
  2. Get chosenA developer, a lockfile update or a resolver picks it up.
  3. Run at installA lifecycle script or setup file executes during install, with the user's permissions.
  4. HarvestThe payload collects tokens, cloud credentials and environment variables.
  5. SpreadStolen publish tokens are used to push infected versions of other packages.

The last step is not hypothetical. The Shai-Hulud npm worm in September 2025 did exactly this, using stolen tokens to republish other packages its victims maintained.

Door one: typosquats

The attacker publishes a name close to a popular one: a swapped letter, a missing hyphen, a plausible suffix such as -utils or -cli. The package often works, wrapping or re-exporting the real one, so nothing breaks and nobody looks twice.

What it looks like: a dependency you do not recognise in a diff, with few downloads, a recent first publish and a description copied from the original. Check new direct dependencies when they are added, not months later.

Typosquats increasingly arrive through suggestions rather than typing. A copied snippet from a forum, an outdated tutorial or a coding assistant can all name a package that sounds right but does not exist, and attackers watch for those names and register them. The habit that helps is the same either way: before adding a dependency, open its registry page and its source repository, and check that the two match and that the project has a history.

Door two: install scripts

npm runs preinstall, install and postinstall scripts by default. Python source distributions can run arbitrary code in setup.py. That means the payload executes before any test, linter or code review sees it.

{
  "name": "left-padder",
  "version": "1.0.3",
  "scripts": {
    "postinstall": "node ./lib/telemetry.js"
  }
}

A script named telemetry.js or setup.js that is minified, encoded or fetches a second stage from the network is the classic tell. You can list which installed packages declare a postinstall script:

npm query ":attr(scripts, [postinstall])" | jq -r '.[].name'

Most projects have a short list of packages that genuinely need to build something natively. Everything else on that list deserves a look.

Door three: maintainer takeover

This is the hardest one to spot, because the package name is right. An attacker phishes a maintainer, reuses a leaked token or is handed ownership of an abandoned project, then publishes a new patch version. Your semver range accepts it automatically.

What it looks like: a patch release from a long-quiet package, a new publisher, an install script that was not there before, or a version with no matching tag in the source repository. Provenance attestations, where the ecosystem supports them, help here: a version built outside the project's normal pipeline will not carry one.

Door four: dependency confusion

Your organisation has an internal package, say acme-billing-utils, on a private registry. An attacker publishes the same name on the public registry with a higher version number. If your tooling looks at both registries and picks the highest version, it installs theirs.

The classic misconfiguration in Python is --extra-index-url, which merges indexes rather than preferring one:

# risky: pip may resolve from either index
pip install --extra-index-url https://pypi.internal.example/simple acme-billing-utils

# safer: one index, which proxies the public one
pip install --index-url https://pypi.internal.example/simple acme-billing-utils

In npm, put internal packages under a scope and bind that scope to your registry, so the public registry is never asked:

# .npmrc
@acme:registry=https://npm.internal.example/

Check yours in 10 minutes

  • Run the npm query above in your largest repository and read the list.
  • Search CI config and developer docs for extra-index-url.
  • List internal package names and confirm each one is scoped or reserved on the public registry.
  • Check which publish tokens exist for your own packages, who holds them and whether they are scoped and short-lived.
  • Look at your lockfile diffs for the last month: were new direct dependencies reviewed by a person?

None of these needs a product. They need someone to look, once, and then a gate so you do not have to look by hand again.

Where this sits on the line

Talk it through

A 30-minute call. No slides, no price list, and a next step either way.