# Supply Chain (Dependencies + CDN) - Security Checklist

Supply-chain safety: pinning dependencies, vetting packages, CDN integrity, and reducing what a compromised dependency can reach.

Part of the TIGZIG security checklist (112 items across 12 categories, distilled from hardening 20+ live microservices). Full checklist: https://www.tigzig.com/security

### 11.1. Quarantine Fresh Releases (Cooldown Window)

**THE RISK:** When a package maintainer's account gets compromised, the attacker pushes a malicious version of the (legitimate, trusted) package to the registry. Within hours it lands in everyone's build via npm/pip install. The community usually catches it within hours to days. Someone notices weird behavior, files an issue, the registry yanks the version. But every build that ran during that window pulled the malicious payload. This is exactly how Axios, TanStack, LiteLLM, DurableTask and several others got hit in 2024-2026. The package itself was legitimate. Just the latest release was poisoned. Pinning to old versions does not protect you if you ever re-resolve.

**THE SOLUTION:** Set a cooldown window: refuse to install any package version published less than N days ago. N is a tradeoff. A few days catches most attacks. A few weeks catches almost all. The window does not block you from USING a package, it just delays adopting NEW releases of it. By the time your build would install version X, either X is past your cutoff (proven good) or the registry has yanked it (auto-protected). Single most powerful defense against the compromised-maintainer attack class. pnpm 10+ supports this natively via .npmrc. npm does not have a built-in equivalent yet.

**THE FIX:**
```
# pnpm 10+: refuse installs of packages younger than N days
# .npmrc at the project root
minimum-release-age=Nd               # pick your N
minimum-release-age-exclude=         # empty = no exceptions

# Pair with the package-manager pin (11.4) so this enforcement is consistent
# package.json
"packageManager": "pnpm@X.Y.Z"

# On npm: no native cooldown today. Compensating controls:
# 1. Pin exact versions in package.json (no caret / no tilde)
# 2. Use npm ci (NOT npm install) so the lockfile is authoritative
# 3. Slow your update cadence; review releases before adopting
```

*Cooldown does NOT protect against typosquatted packages you install for the FIRST time (see 11.6). It does NOT protect existing pins from being silently widened by \`pnpm add somepkg\` (default writes a caret range) (see 11.3 save-prefix). It does NOT protect the package manager itself (see 11.4). And it does NOT catch already-public CVEs in old aged versions (see 11.7 audit). Layered defense; cooldown is one layer.*

### 11.2. Block Postinstall Scripts by Default (Narrow Allowlist)

**THE RISK:** When you install a malicious package, the actual exfiltration usually happens via the package's postinstall script. npm/pip run it automatically, with full filesystem and network access. This is how the real damage is done in supply-chain attacks: the postinstall reads your env vars, AWS credentials, .npmrc tokens, SSH keys, and uploads them to an attacker server. The package's runtime code is often clean. The postinstall IS the entire attack. Default npm/yarn behavior runs every postinstall script. So even with cooldown and exact pins, one poisoned package added before your other defenses landed gets full code execution at install time.

**THE SOLUTION:** Block postinstall scripts by default. Maintain a NARROW allowlist of packages whose postinstall is FUNCTIONALLY REQUIRED. Typically just the few that compile a native module or download a prebuilt binary. Everything else (marketing banners, telemetry pings, "thank you for installing" messages) gets blocked. Decision rule for the allowlist: read the postinstall script first. If it touches the filesystem to install a real artifact, allow. If it just prints or pings home, block. pnpm 10+ blocks ALL postinstalls by default and requires explicit allowlist - this is the safest default available today.

**THE FIX:**
```
# pnpm 10+ blocks postinstalls by default; allowlist what is needed
# package.json
{
  "pnpm": {
    "onlyBuiltDependencies": [
      "esbuild"              // downloads native compiler binary
      // add others only if their postinstall is FUNCTIONAL
      // everything else: BLOCKED by default
    ]
  }
}

# Before adding a package to the allowlist, read its postinstall
cat node_modules/<pkg>/package.json | grep -A 3 '"scripts"'
# Look for scripts.postinstall, scripts.install
# Decide: functional (downloads binary, compiles native)? or decorative (banner, ping)?

# npm equivalent (heavier hammer, no per-package allowlist):
npm install --ignore-scripts
# Then manually \
```

*Common ALLOW (functional): native compilers and binary fetchers (esbuild, sharp, native CSS engines). Common BLOCK (decorative): donation banners, vendor "thank you" messages, telemetry. Vendor reputation is NOT a free pass to allow a postinstall - a known vendor's postinstall can still be a banner that the package functions perfectly well without. The bar is "is the postinstall functionally required" not "is the vendor trusted."*

### 11.3. Lockfile Discipline (Frozen Install + Exact Pins + save-prefix)

**THE RISK:** Without a lockfile, every install resolves dependency ranges to whatever the registry returns NOW. Two installs of the same package.json can produce two different builds. Even WITH a lockfile, the default \`npm install\` / \`pnpm install\` commands SILENTLY UPDATE the lockfile if the package.json was edited to allow a newer version. So a tiny \`pnpm add somepackage\` on a developer machine can quietly widen a transitive dep to a version you never reviewed. By the time it ships, the lockfile has drifted. Caret (^1.2.3) and tilde (~1.2.3) ranges in package.json are the leak path. So is \`pnpm add\` without a version - it writes a caret range by default.

**THE SOLUTION:** Three layers, all needed. (1) Commit the lockfile to git and treat it as authoritative. (2) On CI / production builds use the FROZEN install command (\`pnpm install --frozen-lockfile\` or \`npm ci\`) which REFUSES to update the lockfile, fails loud on drift. (3) Force exact pins in package.json (no caret / no tilde) and configure the package manager to write exact pins by default for future adds (save-prefix= in .npmrc for pnpm). Together these mean: the version on prod IS the version in the lockfile IS the exact version in package.json. No drift possible at any stage.

**THE FIX:**
```
# .npmrc - force exact pins on future \
```

*Pin DIRECT deps in package.json (no caret/tilde). Pin TRANSITIVE deps via the committed lockfile (one content hash per resolved version). The two layers work together. Direct pins keep package.json from drifting. The lockfile keeps transitive deps from drifting. Neither alone is enough. The save-prefix= setting is what KEEPS the discipline durable over time - without it, every future \`pnpm add\` reintroduces a caret range and your direct pinning silently degrades.*

### 11.4. Pin the Package Manager Itself

**THE RISK:** All your other supply-chain defenses (cooldown, postinstall block, frozen lockfile) are ENFORCED by your package manager. If pnpm itself is compromised, the malicious pnpm just ignores the .npmrc cooldown setting and runs whatever postinstalls it wants. Same risk for npm. So pinning every other dependency exactly but leaving \`pnpm install\` or \`pnpm@latest\` in your install command is a hole big enough to drive a truck through. Same threat model as pinning every Python package via pip freeze but installing pip via \`curl | sh\` from an unverified URL.

**THE SOLUTION:** Pin the package manager to an exact version in TWO places. (1) In package.json via the packageManager field, which modern AI agents and CI environments respect. (2) In your install command (Vercel, GitHub Actions, etc.) by invoking the package manager with an EXACT version via \`npx --yes\`. Use an AGED version (months old, proven), not the latest. Upgrade the package manager deliberately, same way you upgrade any other dependency. Bonus: this also means a fresh developer machine builds bit-identically to CI. No "works on my machine" from a stale local pnpm.

**THE FIX:**
```
# package.json - declares which package manager + version owns this repo
{
  "packageManager": "pnpm@X.Y.Z"   // EXACT version, not "pnpm@>=X"
}

# Vercel vercel.json install command:
# WRONG - uses whatever pnpm Vercel happens to have cached
"installCommand": "pnpm install --frozen-lockfile"

# WRONG - uses latest, gets a NEW pnpm on every build
"installCommand": "npx --yes pnpm@latest install --frozen-lockfile"

# RIGHT - exact version, fetched fresh per build, no cache drift
"installCommand": "npx --yes pnpm@X.Y.Z install --frozen-lockfile"

# Same principle for npm:
"installCommand": "npx --yes npm@X.Y.Z ci"
```

*Pick a package-manager version that has been GA for at least a few weeks. Latest pnpm/npm on the day you redeploy is the same risk as latest react on the day of a CVE - you have not given it time to be tested. When you do upgrade the package manager, treat it like any other dep bump: pick an aged version, verify the build works, commit both .npmrc and vercel.json (or equivalent) in one PR.*

### 11.5. Pin to Currently-Running, Not Latest

**THE RISK:** When you set up exact pins for the first time, the natural instinct is to bump everything to today's latest version and pin THAT. This is wrong twice. (1) The latest version is unaged, so none of your cooldown safety applies. (2) You are silently UPGRADING dozens of transitive deps in the same commit, mixing "pin discipline" with "upgrade everything", so if anything breaks you cannot tell whether the pinning broke it or the upgrade did. The opposite mistake is also possible: pinning to versions you THINK are right (from training data, a tutorial, or vague memory) instead of what is actually running on prod right now.

**THE SOLUTION:** Pin to the EXACT versions currently serving production. They are proven-good (live traffic right now) and already aged (resolved at the last build, often weeks or months ago). The aging IS the same safety the cooldown gives you. Capture them mechanically, not by hand. On backend: \`pip freeze\` inside the live container, paste into requirements.txt. On frontend: read the existing package-lock.json (or run \`pnpm import\` against it). Now your pinning commit is a ZERO-BEHAVIOR-CHANGE lock, not a hidden mass upgrade. Future upgrades become a separate, deliberate decision (one package at a time, named in the commit).

**THE FIX:**
```
# Backend (Python on containers): capture the running freeze
ssh <host> docker exec <container> /opt/venv/bin/pip freeze > requirements.txt
# Now requirements.txt has e.g.
#   fastapi==0.115.0
#   numpy==2.1.3
# Exact ==, no >=, no ~=. Commit + redeploy + verify same versions land
# on the new container.

# Frontend (Node on Vercel): preserve the resolved tree from package-lock.json
pnpm import          # reads package-lock.json, writes pnpm-lock.yaml at same versions
# OR if staying on npm, just commit the existing package-lock.json + use npm ci.
# Either way, rewrite package.json caret/tilde ranges to EXACT versions
# matching what was actually installed.

# Future upgrades: deliberate, ONE package per commit
# Bump that package's version in package.json, run install, verify, commit.
# Each commit is a single named upgrade, not a mass bump.
```

*This works because the version on prod NOW has been there for weeks. Any malicious version published since the original install never got into this codebase, and a future redeploy with the same pin re-installs the same proven version. Your pinning commit is essentially a snapshot of "what was running on YYYY-MM-DD" for the rest of time. Same principle as why your fridge brand never makes you sick - you already tested it.*

### 11.6. Verify Before Install (Typosquatting + AI Hallucinations)

**THE RISK:** AI coding tools suggest packages constantly, and typosquatted packages (names that look like popular ones but are malicious) specifically target developers who install without verifying. A single malicious dependency can exfiltrate environment variables, inject backdoors, or compromise your entire build pipeline. This risk multiplies when AI assistants generate install commands. They can hallucinate package names that happen to be typosquats. Cooldown windows (11.1) do NOT protect against this because typosquats are NEW packages, not poisoned versions of trusted ones. Postinstall blocking (11.2) helps but only if you have it on; the safe move is not installing the bad package in the first place.

**THE SOLUTION:** Before installing any package, verify it is the official one. Check the npm/PyPI page, look at download counts, check the publisher, look at the repo link. When an AI suggests a package you have not used before, verify it EXISTS and is legitimate before running the install command. Treat AI-suggested install commands the same way you treat AI-suggested SQL: never run blind, always read first. Combined with cooldown (11.1) for known packages and audits (11.7) for known CVEs, this closes the install-time path.

**THE FIX:**
```
# Before installing ANY new package:
# 1. Visit the npm / PyPI page directly (npmjs.com/package/<name>)
# 2. Check: real publisher? respectable download count? linked repo with history?
# 3. Compare the name character-by-character against the well-known one
#    (e.g. reqeusts vs requests, lodahs vs lodash)
# 4. If an AI assistant suggested the name, ask: is this what the AI MEANT?
# 5. Read the package README + source link briefly

# Audit-style commands
npm audit                  # known CVEs (see 11.7 for the full audit item)
pip install pip-audit
pip-audit                  # known CVEs for Python

# Pin exact AFTER you have verified (see 11.3, 11.5)
"dependencies": { "express": "4.18.2" }     # exact, no caret/tilde
```

*AI coding assistants confidently invent package names that do not exist or that point to typosquats. This is becoming a primary attack vector: attackers register typosquats for common AI-hallucination strings. Verification is a 30-second check that closes this class of attack entirely. Do not skip it because the AI sounds confident.*

### 11.7. Run Regular Dependency Audits (Known CVEs)

**THE RISK:** Even with exact pins (11.3), cooldowns (11.1), and postinstall blocking (11.2), you can still be on an OLD version of a package that has a publicly-disclosed CVE. Outdated packages contain known vulnerabilities that attackers actively exploit. For example, older versions of react-router-dom had an XSS via open redirects. The exact-pin discipline that protects you from supply-chain attacks ALSO means you do not auto-pick-up the security patch. So you need a separate routine to surface "we are on version X, version Y patches a CVE, you should upgrade."

**THE SOLUTION:** Run a single command on a regular cadence (before each release, weekly cron, or both) that checks all your installed packages against the public vulnerability database. If anything HIGH/CRITICAL is flagged, evaluate: does the CVE affect YOUR code path? If yes, bump that one package (per the deliberate-upgrade pattern in 11.5), redeploy, re-pin. Audits are complementary to cooldown: cooldown defends against fresh malicious releases (zero-day-shaped); audit defends against old known-bad releases (public-CVE-shaped).

**THE FIX:**
```
npm audit                            # known CVEs in installed JS packages
npm install <pkg>@<safe-version>     # bump THAT package; do NOT npm audit fix --force

# Python:
pip install pip-audit
pip-audit                            # known CVEs in installed Python packages

# Don\'t blindly accept all upgrades
# - Assess whether the specific vuln affects YOUR code path
# - Many vulns are mitigated by infrastructure (e.g. a transitive vuln behind
#   a request layer your code never invokes)
# - Force-upgrading transitive deps via \
```

*Watch for: react-router-dom (historic XSS), @babel/helpers (ReDoS), ajv (ReDoS). @vercel/node typically has transitive vulnerabilities (undici, path-to-regexp) that require breaking version bumps to fix. Assess whether the vuln affects your code path before force-upgrading. Most are mitigated by Vercel's infrastructure layer. Don't blindly run \`npm audit fix --force\`. Pair with the deliberate-upgrade pattern from 11.5: ONE named bump per commit.*

### 11.8. Self-Host Critical CDN Assets

**THE RISK:** Loading JavaScript, WASM binaries, or CSS from external CDNs (sql.js.org, cdnjs, unpkg) means a compromised CDN serves malicious code to all your users. The code executes in your domain's security context with full access to cookies, localStorage, and the DOM. This is a supply chain attack that bypasses all your other security: CSP allows the CDN domain, so the malicious script runs unchallenged. It is the same threat model as a poisoned npm package, just at RUNTIME instead of build time. Cooldown and lockfile defenses do not help because there is no install step.

**THE SOLUTION:** Copy critical assets (WASM binaries, JS libraries, fonts) to your own public/ folder and reference them locally. This eliminates the external dependency entirely. A CDN compromise cannot affect you. If self-hosting is not practical (large files, frequent updates), use Subresource Integrity (SRI) hashes on the script/link tag. SRI tells the browser: only execute this file if its hash matches what I expect. Any tampering causes the browser to reject it. Same principle as package lockfile content hashes (11.3), just at the browser/CDN layer.

**THE FIX:**
```
// WRONG - external CDN dependency, no integrity check
<script src="https://cdn.example.com/sql-wasm.js"></script>

// RIGHT - self-hosted copy
// 1. Download: curl -o public/sql-wasm.js https://cdn.example.com/sql-wasm.js
// 2. Reference locally:
<script src="/sql-wasm.js"></script>

// ALTERNATIVE - SRI hash (if self-hosting is not practical)
<script src="https://cdn.example.com/lib.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossorigin="anonymous"></script>

// Generate SRI hash:
// cat file.js | openssl dgst -sha384 -binary | openssl base64 -A
```

*After self-hosting a CDN asset, remember to remove the old CDN domain from your CSP connect-src / script-src directives. Stale CSP entries are harmless but indicate incomplete cleanup. Why this lives in Supply Chain (not Frontend): the threat model is identical to a poisoned npm package (untrusted third party serves code that runs as you), and the defenses (pin via content hash, prefer self-hosted) mirror the package-manager controls.*
