Trusting what you ship
You chose a handful of packages and installed hundreds. Five real ways the code you install stops being the code you meant - and why every defence is blind to at least one of them.
This site asks for 8 packages by name in its package.json. Installing them puts 346 packages on disk. You chose eight; strangers chose the other 338, and their dependencies chose some of those. Every one runs with your permissions when you build.
That’s what “supply chain” means here: everything between a stranger’s keyboard and your running code, and every place along that path where the code can quietly become something else.
Three times it really happened
| When | What | How it got in |
|---|---|---|
| 2017 | crossenv, a lookalike of the popular cross-env, sent your environment variables to the attacker’s server. npm removed about 40 packages from the same publisher. |
You typed the wrong name |
| 2018 | event-stream: a new maintainer took over the package and added a dependency, flatmap-stream, whose payload targeted a Bitcoin wallet app. |
An update you didn’t read |
| 2024 | xz utils 5.6.0 and 5.6.1: the line that switched the backdoor on existed only in the released tarballs, not in the git repository. It was found by an engineer chasing odd ssh behaviour, who measured logins taking about 0.8s instead of 0.3s. | The artifact differed from the source |
The xz one deserves a second read. The repository held the payload only as two innocent-looking binary test files. The line that unpacked them into the build lived in the tarball - the thing people actually installed - and nowhere in the repository. Reviewing the source would not have shown you the switch.
- A package travels from source, through a build, to a registry, to you. Every hop is a place it can change.
- xz changed here: the release tarball was not what the tagged source would build. Reviewing the source cannot see this hop.
- Provenance is a signed record of which source and which build produced the artifact. No record from the expected build, no install.
- A compromised mirror or a stolen publish token changes what the registry hands out - same name, different bytes.
- Your lockfile pins the exact version and hash you first installed. Different bytes, and npm ci stops.
Install day
Five incidents, modelled on the shapes above, against a toy project. Every package name starts with toy- - real names in a lesson about backdoors would accuse real maintainers. Switch controls on and off and read why each one does or doesn’t help.
What each control actually buys
A lockfile with npm ci. package-lock.json records the exact tree that was installed, and for each package an integrity field: a sha512 hash of the artifact. npm ci installs exactly that, and if the lockfile and package.json disagree it exits with an error instead of “fixing” anything. It never writes to either file. So bytes can’t change under you - but the lockfile hashes whatever you had when you locked. Lock a backdoored release and you’ve pinned the backdoor.
Pinning also has a cost you saw in the widget: a lockfile turns “the attacker’s version arrives today” into “arrives at your next routine update”. That’s a delay, not a defence. Security fixes arrive the same slow way, which is the trade-off the tags and digests lesson describes for images.
Dependency review. A person, or a tool such as a dependency-review check on pull requests, looks at every package name that’s new in the diff. It’s the only control here that notices you asked for the wrong thing. It’s blind to bytes changing under a name you already approved.
Provenance and signatures. SLSA defines provenance as a record of “what entity built the artifact, what process they used, and what the inputs were”. Its levels climb from “package has provenance” (easy to forge) to builds where forging it means exploiting a vulnerability. Sigstore makes signing practical without key management: a short-lived certificate is issued for the signer’s OIDC identity, the private key is discarded after one signature, and the record goes into a public append-only log, Rekor. Verifying then means checking that the identity in the certificate is the one you expected - for example, the project’s own release workflow.
Provenance only covers packages that publish it, and most of a real tree doesn’t yet. And it proves where something came from, not that it’s good: a typosquat can have flawless provenance from the attacker’s own repository.
An SBOM - a software bill of materials - blocks nothing. It’s the list of what’s inside each thing you ship. Its value shows on the morning a new vulnerability is announced: with SBOMs you can answer “are we affected?” by searching, instead of by guessing. That’s the same job the Trivy scanner does continuously in the policy and scanning lesson.
A release tarball contains code that isn't in the project's git repository, and the reviewed source is clean. Which control can catch it?
The lockfile would hash the bad tarball faithfully, and the source is clean so review finds nothing. Only a link between artifact and source - provenance - exposes the gap. That's the xz shape.
Your team uses lockfiles and npm ci everywhere. A maintainer's account is taken over and a malicious patch release is published. Are you safe?
A lockfile delays new versions; it doesn't judge them. Updates are when they arrive - which is why review and provenance checks belong on the update, not just the install.
Someone adds 'toy-cross-envv' to package.json by mistake. The package is properly signed and has valid provenance. What catches it?
Signatures and provenance prove where a package came from - here, faithfully, the attacker. The lockfile records exactly what you asked for. Only reviewing the request itself notices the typo.
Everything above ran in your browser. Now do it on a real cluster withthe tilt platform. You need Docker, kind and Tilt, about 15 GB of free disk and 8 GB of spare RAM. Setup guide · Can my machine run it?
Two checks you can run today. First, any Node project with a lockfile, including this site’s repository:
npm ci # refuses to "fix" a lockfile that disagrees with package.json
npm audit signatures # verifies registry signatures and provenance attestationsThen, on the tilt platform, see how many images your cluster runs by a movable tag versus a fixed digest:
kubectl get pods -A -o jsonpath='{range .items[*].spec.containers[*]}{.image}{"\n"}{end}' | sort -u > images.txt
wc -l < images.txt # distinct images
grep -c '@sha256:' images.txt # pinned by digestThe count by digest is usually small. That’s the tags-and-digests trade-off at cluster scale: every tag is something that can be repointed under you.
Sources
- npm blog - Details about the event-stream incident (2018)
- npm blog - crossenv malware on the npm registry (2017)
- Andres Freund - backdoor in upstream xz/liblzma (oss-security, 2024-03-29)
- npm docs - package-lock.json (integrity, resolved)
- npm docs - npm ci
- npm docs - npm audit (audit signatures)
- SLSA v1.0 - Security levels
- Sigstore - Overview
- TemplateMechanics/tilt - apps/harbor.yaml
Missions and quizzes above earn their own XP. This marks the lesson done.