Skip to main content
CICD-SEC-3

Dependency Chain Abuse

The build fetches code from the internet and runs it, and an attacker only has to get their package into that set.

If this is new to you

When you write `npm install express`, you are asking a registry to send you some code, and then you are running that code with your own privileges. That is worth sitting with for a moment. A dependency is not a document you read — it is a program you execute.

Which means every mechanism that turns a *name* into *code* is a place where somebody who is not you can change the answer. Who owns that package name? What happens if a similar name exists? What runs during installation, before any of your tests have had a chance to look at it?

What to do about it

Commit your lockfile and install from it with a command that fails when the lockfile and the manifest disagree — `npm ci`, not `npm install`. And check there is no fallback: a command like `npm ci || npm install` looks defensive and quietly removes the guarantee, because the failure case it was added to catch is exactly the case where it gives up and resolves fresh.

Run installs with `--ignore-scripts`. Package lifecycle scripts execute arbitrary code during installation, before your test suite has run, so by the time anything checks the dependency it has already done whatever it was going to do. Add specific scripts back once you have read them.

If you publish internal packages, scope them and pin the scope to your own registry. An unscoped internal name that also exists publicly is the classic dependency-confusion setup, and the resolver will often prefer the public one.

Going deeper

Treat dependency updates as code changes, because they are. An automated version bump merged without review is a supply-chain path with extra steps and a green tick.

Beyond that, the direction of travel is provenance: being able to say not just "we installed version 4.18.2" but "and here is cryptographic evidence it was built from the source it claims". Tooling here is improving quickly and is worth watching even if you are not ready to adopt it.

Words used on this page
Lockfile
A file recording the exact resolved version of every dependency, direct and transitive, so a later install reproduces the same tree.
Transitive dependency
A dependency of a dependency. You did not choose it, you may not know its name, and it runs with the same privileges as everything else.
Dependency confusion
An attack where an internal package name also exists on a public registry, and the resolver picks the public one.
Typosquatting
Publishing a package whose name is a plausible misspelling of a popular one, and waiting.

Why this matters

A dependency is code you execute with your own privileges, chosen by a name. Every mechanism that resolves a name to code — registry lookup order, version ranges, install scripts — is a place where the answer can be changed by someone who is not you.

What it looks like in practice

If any of these sound familiar, that is the point — most of them are things somebody did for a sensible reason on a busy afternoon.

  • Dependency confusion: an internal package name that also exists on the public registry, where the resolver prefers the public one.
  • Typosquatting: a transposed character in a package name, installed once and then locked in.
  • Install scripts (`preinstall`, `postinstall`) that run arbitrary code during `npm install`, before any test has executed.
  • Floating version ranges, so a build that passed yesterday installs different code today.
  • A lockfile that exists but is bypassed, so its guarantees never apply.

Check your own pipeline

Questions you can actually answer, rather than a maturity score. If you cannot answer one of them, that is itself the finding — and a more useful one than a number.

  1. Does your build install exactly what the lockfile says?

    `npm ci` fails when the lockfile and manifest disagree; `npm install` quietly resolves the difference. Confirm which one your build actually runs, including any fallback.

    npm ci --ignore-scripts
  2. Do install scripts run during your build?

    They execute before your tests, so a malicious one has already run by the time anything checks. `--ignore-scripts` is the default worth adopting.

  3. Could an internal package name resolve to the public registry?

    Check your registry configuration for scope pinning. An unscoped internal name is the classic dependency-confusion setup.

How to fix it

  • Commit the lockfile and install from it with a command that fails on mismatch, with no fallback that reverts to resolving.
  • Run installs with `--ignore-scripts` and add back only the scripts you have read.
  • Scope internal packages and pin the scope to your own registry, so a public name cannot win the lookup.
  • Review dependency updates as code. An automated bump merged without review is a supply-chain path with extra steps.

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere

Risk taxonomy from OWASP Top 10 CI/CD Security Risks (Daniel Krivelevich and Omer Gil, September 2022, CC BY-SA 4.0). Explanations, checks, fixes and definitions written for this site.