# Supply-Chain Security

> Your dependencies are your attack surface: the npm install that owned you, lockfiles, typosquatting, and how to trust code you did not write.


---

# Supply-Chain Security

Open your `node_modules` folder and count the directories. A thousand? Five thousand? You wrote almost none of it, you read approximately none of it, and every line of it runs with the same privileges as your own code. That is the uncomfortable truth this guide makes peace with: most of your application is code from strangers, and an attacker who can change that code does not need to break into your server at all. They get in through your `install` step, on your own machine, with your blessing.

The relief is that this is a solvable problem. You cannot read five thousand packages, but you can pin what you depend on, see what changed, scan for known holes, and shrink the blast radius when something does go wrong. None of it is exotic.

## How to read this

Read the phases in order. Phase 1 rewires how you picture your project so the threat becomes obvious instead of invisible. Phase 2 is the everyday defense you turn on this week. Phase 3 is the bad day, real incidents and what would have stopped them. Skim the code blocks, but read the *What just happened:* line after each one, that is where the point lands.

## The phases

1. [Your code is mostly other people's code](01-the-real-attack-surface.md) - the mental model: why dependencies are an attack surface and who is trying to abuse it.
2. [Pinning, scanning, and seeing what you ship](02-everyday-defenses.md) - lockfiles, vulnerability scans, install scripts, and the SBOM.
3. [The terrible day, and what stops it](03-when-it-breaks.md) - real incidents, least-privilege CI, and a survivable response.


---

# Your code is mostly other people's code

You ran `npm install`. It printed a wall of green, maybe a deprecation warning, and a friendly "added 1,243 packages in 8s." You moved on. That moment, repeated daily across the industry, is the single most trusting thing most developers do, and almost nobody thinks of it as a trust decision at all.

Here is the reframe: the code *you* wrote is a thin shell. Underneath it sits a mountain of code written by people you have never met, pulled from a server you do not control, executed with the full privileges of your own process. Your authentication logic, your database driver, your date formatter, your tiny "is this string a valid email" helper, all of it runs in the same memory, reads the same environment variables, and reaches the same network as the code you actually reviewed.

## Count what you actually trust

Pull up a real project and look at the gap between what you wrote and what you ship.

```bash
# How many packages are actually installed?
$ find node_modules -name package.json -maxdepth 2 | wc -l
1243

# How many did YOU list as direct dependencies?
$ jq '.dependencies | length' package.json
14
```

*What just happened:* You declared 14 dependencies. You got 1,243. The other ~1,229 are transitive, dependencies of your dependencies, pulled in automatically and silently. You never chose them, you cannot name them, and they run with the same rights as everything else.

This is not a Node problem. Python has the same shape with `pip`, Ruby with `gem`, Rust with `cargo`, every modern ecosystem. The numbers differ, the structure does not: a small chosen surface sitting on a huge unchosen one.

> The mental model to carry: **a dependency is not a feature you borrowed. It is a person you let run code on your machine, plus everyone *they* trust, recursively.**

## Where the trust actually breaks

An attacker does not need to find a bug in your code if they can get *their* code into your dependency tree. There are a handful of well-worn ways they do it. Knowing the names matters, because the defenses in Phase 2 each target one of these.

```text
THE FOUR DOORS

1. Malicious / hijacked package
   A maintainer goes rogue, OR their account gets phished, OR a popular
   package is sold to someone who slips in a payload. Same package name,
   poisoned new version.

2. Typosquatting
   You meant `lodash`. You typed `lodahs`. A package sits at the typo,
   waiting, doing what lodash does PLUS exfiltrating your env vars.

3. Dependency confusion
   Your company has a private package `acme-utils`. An attacker publishes
   a PUBLIC `acme-utils` with a higher version. Your installer, seeing a
   "newer" version, grabs the attacker's.

4. Compromised maintainer infrastructure
   Not the code, the pipeline. Stolen npm token, hijacked CI, a
   build server that injects a payload AFTER the maintainer's clean commit.
```

*What just happened:* Every real-world incident in Phase 3 walks through one of these four doors. They share a theme: the attacker never touches your repository. They touch the supply *upstream* of you, and the poison flows downhill into your `install`.

## The part people forget: install runs code

The dangerous assumption is that downloading a package is passive, like saving a file. It is not. Many ecosystems let a package run scripts *at install time*, before you have imported anything, before your tests, before you have read a single line.

```json
// package.json inside a malicious dependency
{
  "name": "helpful-looking-utility",
  "version": "2.1.0",
  "scripts": {
    "postinstall": "node ./scripts/setup.js"
  }
}
```

*What just happened:* The instant `npm install` finishes fetching this package, it executes `setup.js` automatically. That script runs as you, with your shell, your `~/.aws/credentials`, your `.env`, your SSH keys. "I only installed it, I never used it" is no defense at all, the harm happens at install, not at import.

> [!WARNING]
> A package can do everything *you* can do from a terminal: read files, make network calls, spawn processes. There is no sandbox by default. Treat `npm install <new-thing>` with the same caution you would treat `curl ... | bash`.

## For builders

When you pick a dependency, you are not evaluating one library, you are adopting its entire tree and every maintainer in it. Before you add one, glance at the cost: How many transitive packages does it drag in? When was it last published, and by how many maintainers? A "tiny" utility that pulls 80 sub-dependencies is a bigger trust decision than a slightly larger library with zero. The cheapest supply-chain defense is the dependency you decided not to add.

This connects directly to [/guides/secrets-management](/guides/secrets-management), because the thing a malicious package wants most is the credentials sitting in your environment, and to [/guides/owasp-top-10](/guides/owasp-top-10), where vulnerable-and-outdated components are a named category for exactly this reason.

```quiz
[
  {
    "q": "In a typical project with 14 direct dependencies and 1,243 installed packages, who chose the other ~1,229?",
    "choices": [
      "You did, during npm install",
      "Nobody - they are transitive dependencies pulled in automatically by your dependencies",
      "The npm registry curators",
      "Your operating system"
    ],
    "answer": 1,
    "explain": "The ~1,229 extra packages are transitive: dependencies of your dependencies, pulled in silently. You never chose or reviewed them, yet they run with full privileges."
  },
  {
    "q": "Why is 'I installed the package but never imported it' a weak defense?",
    "choices": [
      "Because npm logs every install to a public registry",
      "Because install-time scripts (e.g. postinstall) run automatically, before you import anything",
      "Because unimported packages are deleted automatically",
      "Because importing is the only way code can run"
    ],
    "answer": 1,
    "explain": "Lifecycle scripts like postinstall execute the moment the package is fetched, with your full privileges - no import required."
  },
  {
    "q": "An attacker publishes a PUBLIC package with the same name as your company's PRIVATE package, at a higher version, and your installer grabs it. What is this called?",
    "choices": [
      "Typosquatting",
      "Dependency confusion",
      "A hijacked maintainer account",
      "Cross-site scripting"
    ],
    "answer": 1,
    "explain": "Dependency confusion exploits installers preferring the 'newer' version, pulling the attacker's public package over your intended private one."
  }
]
```


---

# Pinning, scanning, and seeing what you ship

You cannot read five thousand packages. Good news: you do not have to. The everyday defense is not heroic auditing, it is a handful of habits that make your dependency tree *boring and visible*. Boring means it does not change without you noticing. Visible means you can answer, at any moment, "exactly what is in here, and does any of it have a known hole?" That is the whole game for the normal Tuesday.

## Pin it, or it isn't pinned

Open a `package.json` and you will see version ranges, not versions.

```json
{
  "dependencies": {
    "express": "^4.18.2",
    "axios": "~1.6.0"
  }
}
```

*What just happened:* The carets and tildes are *ranges*, not pins. `^4.18.2` means "any 4.x at or above 4.18.2." So two developers, or your laptop and the CI server, can run `npm install` an hour apart and get *different* code. That gap is exactly where a freshly poisoned patch release sneaks in.

The fix is the lockfile. It records the exact resolved version *and a cryptographic hash* of every package, direct and transitive.

```text
# inside package-lock.json (npm) - one entry, simplified
"node_modules/axios": {
  "version": "1.6.2",
  "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
  "integrity": "sha512-7Pj1...exact-hash-of-this-tarball..."
}
```

*What just happened:* The lockfile nails `axios` to `1.6.2` and stores the `integrity` hash. If the registry ever serves a tarball that does not match that hash, the install fails loudly instead of silently accepting tampered bytes. The lockfile is your "nothing changed without me knowing" guarantee.

But the guarantee only holds if you *use* it correctly. The plain install command will happily update the lockfile. In automation, you want the strict mode that refuses to.

```bash
# Local dev: may update the lockfile (fine, you're choosing to)
$ npm install

# CI / production: FAIL if anything doesn't match the lockfile exactly
$ npm ci
```

*What just happened:* `npm ci` installs strictly from the lockfile and errors out if `package.json` and the lockfile disagree, or if a hash is wrong. The equivalents elsewhere are the same idea: `pip install -r requirements.txt` with hashes, `poetry install --no-update`, `cargo build --locked`, `bundle install --frozen`. Rule of thumb: **lockfile in version control, strict install in CI.**

> [!TIP]
> Commit your lockfile. A lockfile that is gitignored is a lockfile that protects no one. Reviewers should see lockfile changes in the diff, that diff is often the first place a sketchy version bump becomes visible.

## Scan for the holes you already know about

Pinning stops *silent* changes. It does nothing about a version you pinned that turns out to have a public vulnerability. For that, scan against the known-vulnerability databases.

```bash
$ npm audit

found 3 vulnerabilities (1 moderate, 2 high)
  high  Prototype Pollution in lodash  <4.17.21
        fix available via `npm audit fix`
```

*What just happened:* `npm audit` cross-referenced your locked versions against a database of disclosed flaws and found a `lodash` with a known prototype-pollution bug. The equivalents: `pip-audit` for Python, `cargo audit` for Rust, `bundle audit` for Ruby, or a cross-ecosystem tool. None of these find *unknown* attacks, they find *published* ones, which is most of what actually bites people.

The trap with scanners is alert fatigue. A "high" deep in a transitive dev-only tool that never touches production is not the same as a "high" in your request handler. Triage on two axes:

```text
DOES IT REACH PRODUCTION?   IS THERE A REACHABLE PATH TO THE BUG?
        |                              |
   yes  |  no                     yes  |  no
   -----+-----                   -----+-----
  FIX   | note &                 FIX   | lower priority
  NOW   | defer                  NOW   | (still patch when easy)
```

*What just happened:* A vulnerability only matters if attacker-controlled input can actually reach the flawed code in a deployed path. Fix the ones that do, first. Do not let a wall of dev-dependency criticals bury the one that is genuinely exploitable.

## Read the install scripts before you run them

Phase 1 showed that `postinstall` runs code automatically. You can put a gate in front of that.

```bash
# See which packages even HAVE install scripts (npm 9+ style)
$ npm install --ignore-scripts

# Then, deliberately, allow only what you trust to run its scripts
```

*What just happened:* `--ignore-scripts` installs everything but refuses to execute lifecycle scripts. Some legitimate packages (native modules that compile on install) genuinely need them, so you cannot leave this on blindly forever, but it turns "every package runs arbitrary code on my laptop" into "I decide which ones do." For a new or suspicious dependency, installing with scripts ignored and reading the script yourself first is cheap insurance.

> [!WARNING]
> The riskiest moment is adding a *new* dependency you have not used before. That is the install where a typosquat or a fresh hijack does its damage. Slow down for the first install of anything unfamiliar.

## Know exactly what you ship: the SBOM

When something does go wrong upstream, say a CVE drops in `log4j`-style at 2am, the only question that matters is "are we affected, and where?" If your answer is "let me grep around for a while," you have already lost hours. The fix is a **Software Bill of Materials**: a machine-readable manifest listing every component and version in your build.

```bash
# Generate an SBOM in a standard format (CycloneDX example)
$ npm sbom --sbom-format cyclonedx > sbom.json

# Now answering "do we ship the bad version?" is one query
$ jq '.components[] | select(.name=="log4j-core") | .version' sbom.json
"2.14.1"
```

*What just happened:* The SBOM turned a frantic codebase-wide hunt into a single lookup. You shipped `log4j-core 2.14.1`, you know it instantly, across every service that has an SBOM. SBOMs come in standard formats (CycloneDX, SPDX) so tools can ingest them, and increasingly customers and regulators ask for one. Generate it as part of your build and store it with the release artifact, an SBOM you produce only during the incident is too late.

## For builders

Wire these into the pipeline, not your memory. Make CI run the strict install (`npm ci` and friends), run the audit and fail on high-severity reachable issues, and emit an SBOM as a build artifact. The point is that the *machine* enforces "boring and visible," so a tired human at the end of a sprint cannot accidentally ship an unpinned, unscanned, unknown tree. Pair this with [/guides/secrets-management](/guides/secrets-management): even a perfect scan will not catch a brand-new malicious package, so the credentials it could steal should be scoped and short-lived in the first place.

```quiz
[
  {
    "q": "What is the practical difference between `npm install` and `npm ci` for a CI pipeline?",
    "choices": [
      "`npm ci` is faster but otherwise identical",
      "`npm ci` installs strictly from the lockfile and fails on any mismatch, while `npm install` may update the lockfile",
      "`npm ci` skips transitive dependencies",
      "`npm install` is for production and `npm ci` is for development"
    ],
    "answer": 1,
    "explain": "`npm ci` enforces the lockfile exactly and errors on mismatches or bad hashes - the strict, reproducible install you want in automation."
  },
  {
    "q": "A scanner reports a 'high' vulnerability in a transitive dev-only dependency that never runs in production. What is the right first move?",
    "choices": [
      "Treat it identically to a high in your production request handler",
      "Triage by reachability: confirm whether attacker input can reach it, and prioritize production-reachable issues first",
      "Ignore all scanner output as noise",
      "Immediately delete the dependency without checking"
    ],
    "answer": 1,
    "explain": "Severity is not the same as exploitability. Prioritize vulnerabilities on a path that reaches production with reachable attacker input."
  },
  {
    "q": "When a critical CVE drops in a widely-used component at 2am, what makes an SBOM valuable?",
    "choices": [
      "It automatically patches the vulnerability",
      "It encrypts your dependencies",
      "It lets you answer 'do we ship the affected version, and where?' with a single lookup instead of a frantic hunt",
      "It blocks the package from installing"
    ],
    "answer": 2,
    "explain": "An SBOM is a precomputed manifest of every component and version, turning incident triage into a fast query rather than a codebase-wide search."
  }
]
```


---

# The terrible day, and what stops it

Every defense in Phase 2 sounds like overhead until the morning it would have saved you. So let's spend this phase in the aftermath of real incidents, the kind that have actually happened to real teams, told as the bad days they were. For each one: how the attacker got in, what it cost, and the specific habit that would have shortened or stopped it. None of these are hypothetical genres. They are the doors from Phase 1, kicked in.

## The terrible day: the dependency you trusted turned

Picture the maintainer of a tiny, beloved utility, the kind of package that does one small thing and is depended on by thousands of bigger packages, which are depended on by millions of apps. One day a "helpful new contributor" offers to take over maintenance. The tired maintainer, who never wanted the burden, hands over the keys. A few weeks later, a new version ships with an obfuscated payload buried in it. This exact shape has played out more than once in the npm ecosystem (the `event-stream` incident is the textbook case).

```text
THE CHAIN

  attacker gains publish rights to  tiny-popular-lib
        |
  publishes  tiny-popular-lib@3.3.6  (looks normal, has payload)
        |
  big-framework  depends on ^3.3.0  → resolves to 3.3.6 automatically
        |
  YOUR app runs `npm install` next Tuesday → payload runs as you
```

*What just happened:* You never depended on `tiny-popular-lib` directly. You never even heard its name. A version range four levels up resolved to the poisoned release, and because your install was not pinned, your next routine `npm install` pulled it in. The payload typically reaches for what is nearby: environment variables, tokens, wallet keys, `.npmrc` credentials.

**What would have stopped or shrunk it:** a committed lockfile means your build keeps using the known-good resolved version until *you* deliberately update, and that update shows as a reviewable diff. The hash in the lockfile means a swapped tarball fails the install. And least-privilege secrets (next section) mean that even on the machine where it *did* run, there is far less worth stealing.

## The terrible day: you typed it wrong

A developer in a hurry runs an install. Their finger slips, or autocomplete betrays them, and they fetch a package one character off from the real one. The typosquat is a working copy of the real library, so nothing looks broken, the app runs fine. In the background, its install script has already shipped the contents of `.env` to a server in who-knows-where.

```bash
# What they meant:
$ npm install crossenv

# Wait - the real one is `cross-env`. `crossenv` was a known typosquat.
# It bundled the real behavior PLUS stole environment variables on install.
```

*What just happened:* The malicious package worked correctly *as a library*, which is exactly why it survived, the developer's code passed its tests. The damage happened silently at install time, before any test ran. Typosquats cluster around the most popular packages precisely because slips on those names are most common.

**What would have stopped or shrunk it:** copy-paste the exact name from the official docs instead of typing it; review the lockfile diff (`crossenv` appearing where you expected `cross-env` is glaring once you look); and `--ignore-scripts` on first install of anything unfamiliar would have neutered the exfiltration script entirely.

## The terrible day: the build server, not the code

This one is the worst because the source code is clean. A maintainer commits perfectly fine code. But the build pipeline that turns that code into a published artifact has been compromised, a stolen CI token, a poisoned build step, and the payload is injected *after* the clean commit, into the binary or bundle that actually ships. (The SolarWinds breach is the famous large-scale version of this shape.)

```text
clean source commit  ✓
        |
  [ compromised build server injects payload here ]
        |
  signed, published artifact  ✗  ← looks official, is poisoned
        |
  every customer who updates  ✗
```

*What just happened:* Auditing the source repository would have found *nothing*, because the source was never the problem. The trust boundary that failed was the build infrastructure and the credentials it held. This is why supply-chain security is not only about *which* packages you pick, but about protecting the pipeline that produces your *own* releases.

**What would have stopped or shrunk it:** treating CI like production, least-privilege tokens, signed builds with verifiable provenance, and reproducible builds so an injected payload shows up as a mismatch. Which brings us to the lever you most control.

## The one knob that limits every blast radius: least privilege in CI

You cannot guarantee a dependency will never turn malicious. What you *can* control is how much that malicious code can do once it runs. The biggest, most common mistake is handing CI a token that can do everything.

```yaml
# DANGEROUS: a CI job with god-mode credentials in the environment
env:
  NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}   # can publish ANY package
  AWS_ACCESS_KEY_ID: ${{ secrets.PROD_ADMIN }}  # full prod admin, always present
```

*What just happened:* Now *every* package in that build, all 1,243 of them, runs with the power to publish packages as you and administer your production cloud. A single poisoned `postinstall` script in any transitive dependency inherits all of it. You handed the keys to the entire tree.

```yaml
# BETTER: scoped, short-lived, present only where needed
permissions:
  contents: read            # the job can read the repo, nothing more
jobs:
  publish:
    # publish credentials exist ONLY in this one job, ONLY at release time
    environment: release
    steps:
      - run: npm publish --provenance   # short-lived OIDC token, not a long-lived secret
```

*What just happened:* The build-and-test job no longer has any publish or prod credentials at all, so a malicious install script there finds nothing worth stealing. The powerful credential exists only in the `publish` job, only during a release, and ideally as a short-lived OIDC token rather than a long-lived secret sitting in a variable. Same attack, drastically smaller blast radius.

> [!TIP]
> Audit your CI secrets by asking, for each one: "if a random transitive dependency read this during a build, how bad would it be?" Anything that answers "catastrophic" should not be present in the test job at all. Push it into a separate, gated, minimal job.

## The survivable response

When you suspect a compromise, the order of operations matters more than the speed. Panic-deleting things destroys the evidence you need.

```text
1. CONTAIN   Revoke the credentials that build/CI could reach. Assume
             anything in that environment is now the attacker's.
2. ASSESS    Use your SBOM + lockfile to find exactly which version
             you ran, where, and for how long.
3. ERADICATE Pin to a known-good version, purge caches, rebuild clean.
4. RECOVER   Rotate every secret the malicious code could have seen.
5. LEARN     Write down which door it came through and which gate was open.
```

*What just happened:* Notice that steps 1 and 4 are about *secrets*, and step 2 is impossible without the *SBOM and lockfile* from Phase 2. The work you did on a calm Tuesday is what makes the terrible day survivable instead of catastrophic. A team with pinned dependencies, an SBOM, and least-privilege tokens spends the incident doing lookups and rotations. A team without them spends it guessing.

## For builders

Run the drill once before you need it: pretend a package you ship turned malicious last night, and try to answer "which version, where, for how long, and what could it have stolen?" The gaps you hit are your real backlog. Tie this to [/guides/secrets-management](/guides/secrets-management) for the rotation and short-lived-credential muscles, and to [/guides/owasp-top-10](/guides/owasp-top-10) where vulnerable-and-outdated components and software-integrity failures are named risks. The whole discipline reduces to one sentence: you will run code you did not write, so make sure you can *see* it, *pin* it, and *limit* what it can do.

```quiz
[
  {
    "q": "In the hijacked-maintainer scenario, why did a poisoned version reach an app that never directly depended on the tiny library?",
    "choices": [
      "The app's firewall was misconfigured",
      "A version range several levels up resolved to the poisoned release, and the install was not pinned to a known-good version",
      "The library was preinstalled with the operating system",
      "The developer manually installed it"
    ],
    "answer": 1,
    "explain": "Unpinned transitive ranges resolve to whatever is 'newest in range' - including a freshly poisoned release - which is exactly what a committed lockfile prevents."
  },
  {
    "q": "Why is putting a long-lived prod-admin token in the build-and-test CI job dangerous?",
    "choices": [
      "It slows the build down",
      "Every dependency in that build - including malicious install scripts - runs with access to that token's full power",
      "Tokens expire too quickly to be useful there",
      "It is only dangerous if the repo is public"
    ],
    "answer": 1,
    "explain": "A token in the environment is available to all code that runs there, including a poisoned transitive postinstall. Least privilege keeps powerful credentials out of the test job."
  },
  {
    "q": "During incident response, why is revoking credentials the first step rather than deleting the suspicious package?",
    "choices": [
      "Deleting packages is impossible",
      "Containing access stops ongoing theft and preserves evidence, while deleting first can destroy what you need to assess the scope",
      "Credentials cannot be revoked once leaked",
      "The package deletes itself automatically"
    ],
    "answer": 1,
    "explain": "Contain first: revoke reachable credentials to stop the bleeding and assume the environment is compromised, then assess scope with your SBOM and lockfile before eradicating."
  }
]
```
