Contents

Supply Chain Attacks: From npm Typosquatting to Poisoned Container Images

The threat that arrives through your own build pipeline

Contents

The most effective way to attack a company isn’t to break down its front door. It’s to stand inside the delivery van the company waves through the gate every morning. Supply chain attacks work because they exploit the one thing every developer does without thinking: pull in code and images they didn’t write, from people they’ve never met, and run them with full trust.

You did it an hour ago. npm install. docker pull. pip install. Each one is an act of faith that the thing at the other end is what it claims to be and hasn’t been tampered with since you last looked. Mostly that faith is rewarded. The supply chain attacker’s entire business is the times it isn’t — and those times are getting more frequent. The 2024 backdoor slipped into xz-utils (CVE-2024-3094) nearly made it into every mainstream Linux distribution, planted patiently over years by an attacker who social-engineered their way into becoming a co-maintainer. That is the shape of the modern threat: not a smash-and-grab, but a slow poisoning of the things you trust by default.

I run a homelab that pulls hundreds of container images and npm packages a week, and I have watched a Dependabot alert light up over a package I’d installed three days earlier. This is the guide I wish I’d had: what the attacks actually are, and the small, boring habits that neutralise most of them.

Typosquatting: a single fat finger

Advertisement

The simplest attack barely deserves the name. Package registries like npm and PyPI let anyone publish almost any name. So an attacker publishes a package called electorn (note the transposed letters) hoping that someone, somewhere, in a hurry, will fumble electron. Or they register python3-requests betting you’ll grab it instead of requests. The malicious package does whatever the real one does — so nothing seems broken — while also quietly running an install script that exfiltrates your environment variables. On a CI runner that means your cloud credentials, registry tokens and signing keys, posted to some endpoint the moment npm install fires.

The install-script angle is the sharp edge. An npm package runs arbitrary code in its preinstall/postinstall hooks, on your machine, with your permissions, before you’ve imported a single line. You don’t have to use the package to be compromised; you only have to install it.

This isn’t hypothetical. Registries pull down dozens of typosquatted packages every month, and npm added mandatory attention around install scripts precisely because they are the favourite vector. The defence is unglamorous and effective: pin your dependencies, read the names, and stop scripts from running when you don’t need them.

1
2
3
# don't just `npm install` in CI — install exactly what the lockfile says:
npm ci                    # fails if package.json and lock disagree; no surprise upgrades
npm ci --ignore-scripts   # and don't run install hooks you didn't ask for

npm ci (and pip install --require-hashes, and friends) turns “grab whatever’s newest” into “grab precisely the bytes I reviewed”. --ignore-scripts closes the drive-by-code door. That combination shuts a whole category of poisoning.

Dependency confusion: the clever cousin

A nastier variant exploits how tooling resolves package names. If your company uses an internal package called acme-utils that lives only in your private registry, an attacker can publish a public package with the same name and a higher version number. Misconfigured tooling, asked for acme-utils, sees the public one is “newer” and pulls the attacker’s code instead of yours. The security researcher Alex Birsan demonstrated exactly this in 2021, landing benign proof-of-concept code inside the build systems of Apple, Microsoft, PayPal and dozens of others simply by guessing internal package names and out-versioning them on the public registry.

The fix is to be explicit about where a name may come from. Scope internal packages and pin the resolver to the right registry, so a public impostor can never win the version race:

1
2
3
# .npmrc — force the internal scope to the private registry, only
@acme:registry=https://registry.mylab.local/
//registry.mylab.local/:_authToken=${NPM_TOKEN}

With a scoped name (@acme/utils) and a registry binding, npm never even looks at the public registry for that package. The confusion attack has nothing to confuse.

Poisoned container images: the same trick, bigger blast radius

Advertisement

Containers raise the stakes because an image isn’t one dependency — it’s a frozen filesystem full of them, plus a base image, plus whatever the maintainer baked in. The attacks rhyme with the npm ones:

  • Typosquatted images on public registries, banking on nginix instead of nginx.
  • Tag mutation. latest is not a contract. The image you pulled last week and the one you pull today can be completely different bytes under the same tag, and nothing warns you.
  • Compromised upstreams. A maintainer’s credentials get phished and a popular image quietly gains a backdoored layer.

The single most important habit here is to pin by digest, not by tag. A tag is a sticky note someone can move; a digest is a cryptographic hash of the exact image:

1
2
3
4
5
# fragile: whatever 'latest' points at today
FROM node:latest

# pinned: this and only this image, forever
FROM node:20-slim@sha256:9d0e0d8e8f4b3a1c2f5e6d7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8

That @sha256:... means your build gets bit-for-bit the image you vetted, or it fails loudly. No silent substitution. Slimming the base image helps too: the fewer things inside, the fewer things to poison, which is one reason I’m a fan of distroless images despite the debugging pain they cause.

Scanning: find the rot before you ship it

Pinning stops substitution. It does nothing about a dependency that was vulnerable all along. For that you scan. A scanner reads the packages inside an image — OS packages and language dependencies — and cross-references them against vulnerability databases:

1
2
# scan an image and fail the build on anything HIGH or worse
trivy image --severity HIGH,CRITICAL --exit-code 1 myorg/app:1.4.2

Wire that --exit-code 1 into CI and a build with a critical CVE simply doesn’t merge. I cover the practicalities — how to keep the noise down, which findings actually matter, and where scanners lie to you — in the dedicated piece on container scanning with Trivy.

Assembling the gate in one place

Individually these habits are easy; the value is in wiring them into a single choke point that every artefact must pass before it ships. In practice that is a handful of CI steps that each fail the build rather than merely warn:

1
2
3
4
5
6
7
8
# a build gate that refuses to ship a poisoned or vulnerable artefact
steps:
  - run: npm ci --ignore-scripts            # reproducible install, no drive-by hooks
  - run: npm audit --audit-level=high        # known-vulnerable deps fail the build
  - run: docker build -t myorg/app:${SHA} .  # base image is digest-pinned in the Dockerfile
  - run: trivy image --severity CRITICAL --exit-code 1 myorg/app:${SHA}
  - run: syft myorg/app:${SHA} -o spdx-json > sbom.json   # generate the SBOM
  - run: cosign sign --yes myorg/app:${SHA}  # keyless OIDC signature

Nothing here is clever in isolation. What makes it work is that each step is a hard gate: a build with a typosquatted dependency, an unpinned base, a critical CVE, or a missing signature simply does not produce a shippable artefact. The attacker has to beat every gate; you only have to keep one of them honest for the attack to stall.

Provenance: knowing where things came from

The frontier of supply chain defence is provenance — being able to prove an artefact was built from the source you think it was, by the pipeline you think built it. This is where SBOMs (Software Bills of Materials) and signing come in. An SBOM is just a manifest of everything inside an artefact; tooling generates one and later answers “am I affected by the new CVE in libfoo?” without you guessing.

Signing closes the loop. With cosign and keyless OIDC-based signing, your CI signs each image using a short-lived certificate tied to its workflow identity, and your deploy step verifies that signature — refusing anything that wasn’t built by the pipeline you trust:

1
2
3
4
5
6
7
8
# in CI: sign with the workflow's OIDC identity (no long-lived keys to leak)
cosign sign myorg/app@sha256:9d0e0d8e...

# at deploy time: verify, or refuse to run it
cosign verify \
  --certificate-identity-regexp '.*github.com/myorg/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  myorg/app@sha256:9d0e0d8e...

The point isn’t any single tool. It’s the shift in posture: from “I trust this because it has the right name” to “I trust this because I can verify where it came from”. The mechanics of keyless signing and the transparency log behind it get a full treatment in the Sigstore and cosign guide.

What goes wrong (and how to fix it)

The theory is tidy; production is not. The failures I see most often:

  • A digest pin that breaks rebuilds. You pin node:20-slim@sha256:..., then months later the tag is retired and the digest garbage-collected from the registry, and your build fails to pull. The fix is a pull-through cache or an internal registry mirror that retains the images you depend on. Pinning without retention just moves the fragility.
  • --ignore-scripts breaking a package that genuinely needs its build step. Native modules (node-gyp, sharp, bcrypt) compile on install. The answer is to allow scripts only for the vetted packages that need them, via an allowlist, rather than globally re-enabling them.
  • Scanner fatigue. Turn on scanning and the first run returns 400 findings, most of them in a base image you can’t patch. If you fail the build on all of them, developers will disable the gate within a week. Start by failing only on CRITICAL with a fix available, and tighten from there.
  • Signature verification that isn’t actually enforced. Signing images is theatre if nothing checks the signature at deploy time. The verify step must be a hard gate — an admission controller in Kubernetes, or a mandatory CI job — not a log line everyone ignores.
  • The lockfile that lies. A lockfile pins versions but not necessarily integrity hashes unless the ecosystem records them. Confirm your lockfile stores sha512 integrity fields (npm does; check yours), because a version number alone doesn’t prove the bytes are unchanged.

The verdict

You cannot audit every line of every dependency you pull — nobody can, and pretending otherwise is how people get paralysed. What you can do is make tampering loud instead of silent: lockfiles, npm ci and --ignore-scripts so you get exactly what you reviewed and nothing runs behind your back; digest-pinned, minimal base images so tags can’t betray you; scoped internal packages so confusion attacks fail; scanning wired into CI as a hard gate; and signing plus SBOMs so you can prove provenance and answer the “are we affected” question in minutes rather than days.

Who is this for? Anyone with a build pipeline — which is anyone who ships software. None of this is exotic, and you don’t need all of it on day one. Pin your dependencies this week, add npm ci --ignore-scripts to CI next week, and turn on scanning the week after. The supply chain attack doesn’t care how good your firewall is, because it walks in through the front gate you hold open every morning. The least you can do is check the van.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.