Dependency and Supply Chain Risk

Most of the code running in your API was written by strangers, and it runs with all of your permissions.

A typical Node API has hundreds of direct dependencies and thousands transitively. Every one runs in your process, with your environment variables, your network access and your file system. You are trusting all of them equally.

The two failure modes

  • Known vulnerabilities. A CVE in a package you use. Detectable, fixable, and the easier half.
  • Malicious packages. A compromised maintainer account, a typosquat, or a dependency that turns hostile in a patch release. Not detectable by a CVE scanner, because there is no advisory yet.

Reducing the surface

The most effective control is fewer dependencies. A one-line utility package is a permanent supply-chain relationship in exchange for code you could have written. Audit what a new dependency drags in before adding it.

Practical controls

  1. Lockfiles, committed, and npm ci rather than npm install in CI — install exactly what was reviewed.
  2. Scan continuously, not once. A package is safe until the day it is not.
  3. Pin versions for anything security-critical, and review the diff before upgrading.
  4. Delay adoption of brand-new versions. Most malicious releases are discovered within days; a cooling-off period catches them.
  5. Disable install scripts where you can — npm ci --ignore-scripts — since that is where most package malware executes.
  6. Generate an SBOM, so when the next widely-publicised vulnerability lands you can answer "are we affected?" in minutes.

Limit what a compromised package can reach

Secrets in environment variables are readable by every package in the process. Workload identity, short-lived credentials and network egress restrictions turn a malicious dependency from a full compromise into a failed exfiltration attempt.

Example

Example · bash
# How much do you actually depend on?
npm ls --all --parseable 2>/dev/null | wc -l
# 1247

# How many can execute code at install time?
npm query ":attr(scripts, [preinstall]), :attr(scripts, [install]), \
           :attr(scripts, [postinstall])" 2>/dev/null | jq -r '.[].name' | sort -u

# Install without running any of them
npm ci --ignore-scripts

# What changed in a version bump, before you trust it
npm diff [email protected] [email protected]

When to use it

  • A malicious postinstall script in a transitive dependency is prevented from running because CI installs with --ignore-scripts.
  • An SBOM lets a team answer 'are we affected?' within minutes of a widely-publicised vulnerability announcement.
  • A compromised package cannot exfiltrate credentials because the workload uses short-lived identity rather than environment secrets.

More examples

A CI pipeline that actually blocks

The cooling-off check covers the gap CVE scanners structurally cannot: a malicious release has no advisory on the day it ships, but it does have a publish date.

Example · bash
# .github/workflows/security.yml
name: dependency-security
on:
  push: { branches: [main] }
  pull_request:
  schedule: [{ cron: '0 6 * * *' }]      # daily — a package is safe until it is not

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # 1. Install EXACTLY the lockfile, and run no package scripts.
      - run: npm ci --ignore-scripts

      # 2. Known vulnerabilities — fail on high and above.
      - run: npm audit --audit-level=high

      # 3. Richer analysis, with reachability so you are not drowning in noise.
      - uses: snyk/actions/node@master
        env: { SNYK_TOKEN: '${{ secrets.SNYK_TOKEN }}' }
        with: { args: --severity-threshold=high --fail-on=upgradable }

      # 4. Generate and publish an SBOM. This is what lets you answer
      #    "are we affected?" in minutes rather than days.
      - run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json
      - uses: actions/upload-artifact@v4
        with: { name: sbom, path: sbom.json }

      # 5. Licence check — a legal risk that arrives through the same channel.
      - run: npx license-checker --failOn 'GPL-3.0;AGPL-3.0' --production

      # 6. Fail on a lockfile that does not match package.json.
      - run: git diff --exit-code package-lock.json

      # 7. Refuse brand-new versions — most malicious releases are caught
      #    within days, so a cooling-off period is cheap protection.
      - name: reject packages published in the last 3 days
        run: |
          node -e '
            const lock = require("./package-lock.json");
            const names = Object.keys(lock.packages)
              .filter(Boolean).map(p => p.replace("node_modules/", ""));
            (async () => {
              const tooNew = [];
              for (const name of names.slice(0, 500)) {
                const r = await fetch(`https://registry.npmjs.org/${name}`);
                if (!r.ok) continue;
                const meta = await r.json();
                const version = lock.packages[`node_modules/${name}`]?.version;
                const published = meta.time?.[version];
                if (published && Date.now() - Date.parse(published) < 3*86400e3) {
                  tooNew.push(`${name}@${version} published ${published}`);
                }
              }
              if (tooNew.length) { console.error(tooNew.join("\n")); process.exit(1); }
            })();
          '

# The cooling-off check is the one that catches what CVE scanning cannot:
# a package that turned malicious yesterday has no advisory yet.

Bounding what a compromised package can do

Deleting secrets from process.env after loading them is a cheap, real improvement — it defeats the most common exfiltration pattern without any infrastructure change.

Example · javascript
// Assume one dependency turns hostile. What does it reach?

// ── 1. Environment variables: readable by EVERY package in the process ──
// ❌ Secrets sitting in process.env for the lifetime of the app
//    process.env.DATABASE_URL, process.env.STRIPE_SECRET_KEY
//    A malicious package reads all of them in one line.

// ✅ Fetch secrets at startup, then remove them from the environment.
const secrets = await loadFromSecretManager();
for (const key of ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'JWT_PRIVATE_KEY']) {
  delete process.env[key];
}
// They live in a module-scoped closure that packages cannot enumerate.
// Not perfect — a determined package can still walk the module graph — but it
// defeats the common `JSON.stringify(process.env)` exfiltration.

// ✅ Better: short-lived credentials from workload identity, so what leaks
//    expires in minutes and cannot be used from elsewhere.

// ── 2. Network egress: exfiltration needs a route out ─────────────────
// Kubernetes NetworkPolicy — default deny, then allow only what is needed.
// apiVersion: networking.k8s.io/v1
// kind: NetworkPolicy
// spec:
//   podSelector: { matchLabels: { app: api } }
//   policyTypes: [Egress]
//   egress:
//     - to: [{ namespaceSelector: { matchLabels: { name: data } } }]   # db
//     - to: [{ ipBlock: { cidr: 0.0.0.0/0 } }]
//       ports: [{ port: 443 }]
//       # ...and an egress proxy with a destination allowlist in front of that
//
// A package that cannot reach evil.com cannot exfiltrate to it.

// ── 3. Filesystem: read-only root ────────────────────────────────────
// docker-compose.yml
//   read_only: true
//   tmpfs: [/tmp:size=64m]
//   cap_drop: [ALL]
//   security_opt: [no-new-privileges:true]

// ── 4. Permissions model, where the runtime offers one ───────────────
// Deno:  deno run --allow-net=api.stripe.com --allow-read=/app/config app.ts
// Node:  --experimental-permission --allow-fs-read=/app
// Not yet mature in Node, but the direction is right.

// ── 5. Isolate the highest-risk processing ───────────────────────────
// Media conversion, PDF rendering and archive extraction all use libraries
// with long CVE histories AND process hostile input. Run them in a separate
// container with no network, no secrets, and a memory limit.

// The principle: dependencies are not going away. Make a compromise reach as
// little as possible.

Reviewing a new dependency before adding it

The overrides field is the practical answer to a vulnerable transitive dependency whose parent has not updated — it patches without waiting for the maintainer.

Example · bash
# Five minutes before `npm install`. The cost of a dependency is permanent.

PKG="some-package"

# 1. How much are you actually adding?
npm view "$PKG" dependencies
npx howfat "$PKG"                    # transitive count and total size
# 47 transitive dependencies for a date formatter is a decision, not a detail.

# 2. Is it maintained?
npm view "$PKG" time.modified maintainers repository.url
# Last published 4 years ago, one maintainer → who patches the next CVE?

# 3. Does it run code at install time?
npm view "$PKG" scripts
# preinstall/install/postinstall → it executes on every developer machine and
# every CI run, before any code review.

# 4. Typosquatting — check the name character by character
#   lodash     vs  1odash / lodahs / lodash-es(legit) / node-lodash
#   cross-env  vs  crossenv (a real historical attack)
#   Download counts are a strong signal: 12 weekly downloads for a package
#   with a familiar name is a red flag.
npm view "$PKG" | head -20

# 5. Read the code, if it is small enough
npm pack "$PKG" && tar -xzf *.tgz && wc -l package/**/*.js
# For a utility of a few hundred lines, reading it is faster than the review.

# 6. Could you write it instead?
#   left-pad, is-odd, is-number — a permanent supply-chain relationship in
#   exchange for three lines you could own.

# ── Then keep it honest over time ────────────────────────────────────
# Find what is unused
npx depcheck

# Find what is outdated
npm outdated

# Find where a transitive dependency comes from
npm ls some-vulnerable-package

# Force a fixed version of a transitive dependency you do not control:
#   package.json
#   "overrides": { "vulnerable-package": "^2.0.1" }

# The most valuable question, asked before every addition:
#   "What does this cost us for the next five years?"

Discussion

  • Be the first to comment on this lesson.