Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

README.md

npm Supply Chain Defense — Claude Code 3-Layer Architecture

A working example of npm supply chain protection using Claude Code's Hook + MCP mechanisms. Automatically intercepts suspicious packages before AI-driven installs.

Threat Model

AI coding assistants autonomously run npm install / yarn add / pnpm add / bun add on behalf of users. This introduces threats that don't exist in manual workflows:

Threat Description Frequency
Package hallucination AI invents a package name that doesn't exist on npm — or worse, matches a typosquat someone registered to intercept this exact scenario ~1 in 15 AI-assisted additions*
Version hallucination Package exists, but AI specifies a version that doesn't — or one with known CVEs More common than package hallucination*
Malicious install scripts postinstall / preinstall hooks exfiltrate credentials, install backdoors, or download remote payloads Ongoing supply chain risk
Typosquatting lodash vs l0dash — a single character difference leads to a malicious package Registry-level threat
Vulnerable pinning AI pins to an old version with known prototype pollution, ReDoS, or other CVEs Common with outdated training data

* Frequency estimates from community feedback on anthropics/claude-code#39421.

Architecture

Claude runs: npm install foo
        │
        ▼
┌─ Layer 1: .npmrc ─────────────────┐
│  ignore-scripts=true              │
│  Blocks all preinstall/postinstall│
└───────────────────────────────────┘
        │
        ▼
┌─ Layer 2: PreToolUse Hook ────────┐
│  npm-pkg-check.sh                 │
│  ✓ Safe → one-line output, pass   │
│  🚫 Suspicious → exit 2, block    │
└───────────────────────────────────┘
        │
        ▼
┌─ Layer 3: Semgrep Supply Chain ───┐
│  Scans lockfile for known CVEs    │
│  Detects code-level malice        │
└───────────────────────────────────┘

What Each Layer Defends Against

Layer Threat Mechanism
1 Malicious postinstall/preinstall scripts .npmrc globally blocks install scripts
2 Typosquatting, vulnerable versions, nonexistent packages, unknown CLI flags, version hallucination Hook queries npm registry + OSV.dev, resolves versions, validates CLI syntax
3 Known CVEs, malicious code executed at import time Semgrep semgrep_scan_supply_chain MCP tool

Quick Start

# 1. Copy .npmrc to your project
cp .npmrc /path/to/your/project/

# 2. Install hook script
mkdir -p ~/.claude/scripts
cp scripts/npm-pkg-check.sh ~/.claude/scripts/
cp scripts/npm-safe-packages.txt ~/.claude/scripts/
chmod +x ~/.claude/scripts/npm-pkg-check.sh

# 3. Add hooks to ~/.claude/settings.json (see settings-snippet.jsonc)

# 4. Enable Semgrep plugin in ~/.claude/settings.json

Files

npm-supply-chain-defense/
├── .npmrc                          # Layer 1: ignore-scripts=true
├── scripts/
│   ├── npm-pkg-check.sh           # Layer 2: pre-install check (Python inside bash)
│   └── npm-safe-packages.txt      # Whitelist for packages needing install scripts
├── tests/
│   └── run-tests.py               # 58 regression tests (46 unit + 12 live)
├── settings-snippet.jsonc          # Hook configuration for settings.json
├── README.md                       # This file (English)
└── README.zh-TW.md                # Traditional Chinese version

Hook Behavior

Allow (exit 0)

Scenario Output
git status (non-npm command) none
npm install (bare, from lockfile) none
npm install esbuild (whitelisted; best-effort latest/CVE check) ✓ esbuild (whitelisted)
npm install lodash (popular, clean) ✓ lodash — 125M/week, 3 maintainers
npm install express@latest (dist-tag resolved) ✓ express@5.2.1 — 91M/week, ...

Block (exit 2)

Scenario Output
npm install -g esbuild (global install) 🚫 BLOCKED (global installs are not allowed)
NPM_PKG_CHECK_MODE=allowlist-only npm install lodash 🚫 BLOCKED (not in allowlist)
npm install git+https://... (non-registry specifier) 🚫 BLOCKED (only registry packages are allowed)
npm install ./pkg.tgz (tarball / file: / GitHub shorthand) 🚫 BLOCKED (only registry packages are allowed)
npm install zzz-fake-pkg (not on registry) ⚠ not found on npm registry
npm install lodash@4.17.20 (known CVE) 🚫 BLOCKED (3 known vuln(s): GHSA-...)
npm install <whitelisted-pkg>@<vuln-version> 🚫 BLOCKED (...known vuln(s)...) [whitelisted]
npm install lodash@beta (version unresolvable) 🚫 BLOCKED (version not resolved)
npm install --mystery-flag lodash (unknown CLI flag) 🚫 BLOCKED (unrecognized option)
Low downloads + install scripts 🚫 BLOCKED (low downloads, has install scripts)
Invalid TOOL_INPUT JSON ⚠ blocking as precaution
Registry unreachable (core signal) 🚫 BLOCKED (registry unreachable)

Design Principles

Fail-closed

The hook blocks by default when it cannot determine safety. Core signals must succeed for a package to be allowed; supplementary signals degrade gracefully.

Signal Type On failure Rationale
Registry metadata Core Block Can't verify package exists
Version resolution Core Block Can't check the actual version being installed
Install scripts check Core Block Can't determine if scripts are safe
OSV vulnerability check Supplementary Warning only CVE database may be temporarily down
Download count Supplementary Warning only Stats API is non-critical

This means: if npm's registry is unreachable, the hook blocks. If OSV.dev is down, the hook warns but allows (since the registry check already passed).

Optional allowlist-only mode

Set NPM_PKG_CHECK_MODE=allowlist-only to switch to a stricter workflow:

  • Packages in NPM_PKG_CHECK_SAFE_LIST bypass reputation/download/install-script heuristics. Explicit version requests still get fail-closed version/CVE checks; bare installs get best-effort checks when metadata is reachable
  • Any package not in NPM_PKG_CHECK_SAFE_LIST is blocked
  • Non-whitelisted packages skip registry, OSV, download-count, and version-resolution network checks entirely

This mode is useful when you want AI agents limited to a pre-approved package set, while still rejecting obviously vulnerable pinned versions of those approved packages.

Per-PM option parsing

The hook validates CLI flags against per-package-manager arity tables (npm, yarn, pnpm, bun). Unknown flags are blocked to prevent parser bypass.

  • Known value-taking flags (e.g. --registry, --tag, --cwd) correctly consume their next token
  • Boolean flags (e.g. --fund, --save-dev) do not
  • --flag=value syntax validates the base flag against the same table
  • --no-xxx variants of boolean flags are auto-recognized
  • npm install verb aliases (in, ins, isnt, isntall, etc.) are supported
  • Shell operators (&&, ||, ;, |) stop parsing — only the first command is checked

Version resolution

Specifier Resolution method
4.17.21 (exact semver) Used directly
latest, beta (dist-tag) Looked up from registry dist-tags
^4.17.0, ~2.0 (range) Resolved via npm view with 3s timeout
Unresolvable Blocked (fail-closed)

Dependency scope detection

The hook detects dependency scope from CLI flags and records it in the structured log:

Flag Scope
--save-dev, -D dev
--save-optional, -O optional
--save-peer peer
--save-prod, -P, --save, -S (or no flag) prod

Scope is logged but does not currently change block policy. This data enables future analysis of dev vs prod failure rates.

Global install policy

--global / -g installs are blocked before any package checks run. In an AI-agent workflow, global installs are high-impact and usually unnecessary compared with local dependencies or npx.

Environment Variables

Variable Default Purpose
NPM_PKG_CHECK_MODE dynamic dynamic = registry/OSV/download checks, allowlist-only = only allow packages in safe list
NPM_PKG_CHECK_SAFE_LIST ~/.claude/scripts/npm-safe-packages.txt Override whitelist file path
NPM_PKG_CHECK_LOG ~/.claude/logs/npm-pkg-check.jsonl Override structured log file path

Structured Logging

Every check decision is logged as a JSONL entry to NPM_PKG_CHECK_LOG:

{
  "ts": "2026-03-26T16:43:55Z",
  "pm": "npm",
  "package": "lodash",
  "spec": "4.17.20",
  "resolved_version": "4.17.20",
  "scope": "prod",
  "decision": "blocked",
  "reason_code": "known_vulnerability",
  "reason_detail": "GHSA-29mw-wpgm-hmr9, GHSA-35jh-r3h4-6jhm, GHSA-xxjr-mmjv-4gpg"
}

Taxonomy

decision (fixed enum):

Value Meaning
allow Package passed all checks
allow_with_warning Allowed but has non-blocking risk signals
blocked Install prevented

reason_code (fixed enum):

Value Meaning
clean No risks detected
whitelisted Package in safe list, allowed directly or after best-effort/version-aware checks
has_risks Non-blocking risks present
package_not_found Package does not exist on registry
version_not_resolved Version spec could not be resolved to exact semver
known_vulnerability OSV.dev returned CVE/GHSA matches
low_downloads_with_scripts Low weekly downloads AND has install scripts
core_signal_unavailable Registry or version manifest unreachable
global_install Global install attempt via --global / -g
not_in_allowlist Package blocked by allowlist-only mode
non_registry_specifier Git URL, tarball, file:, GitHub shorthand, or npm: alias
unknown_option CLI flag not in arity table
parse_error TOOL_INPUT JSON could not be parsed

reason_detail (optional free text): vulnerability IDs, error messages, risk descriptions.

This separation allows GROUP BY reason_code for analytics without string parsing.

Running Tests

# Unit tests only (no network, fully reproducible)
python3 tests/run-tests.py

# Unit + live integration tests (requires network)
python3 tests/run-tests.py --live

Test design

  • Unit tests (46): Use whitelisted packages (esbuild) to isolate parsing logic from network. Verify exit codes, scope detection, global-install blocking, allowlist-only mode, non-registry specifier blocking, log structure, reason_code taxonomy, and decision taxonomy.
  • Live tests (12): Hit real npm registry, OSV.dev, and npm view. Verify version resolution, vulnerability detection, whitelisted vulnerable-version blocking, and real-world package checks.
  • Log validation: Tests verify every JSONL entry has required fields, reason_code is from the fixed taxonomy, decision is from the fixed taxonomy, and specific reason_code values appear for known test cases. Log validation failure causes the test run to fail (not just warn).
  • Environment isolation: The test harness overrides NPM_PKG_CHECK_SAFE_LIST and NPM_PKG_CHECK_LOG via environment variables, so results are independent of the user's home directory.
Result: 46 passed, 0 failed, 12 skipped / 58 total
All 46 executed tests passed. Run with --live for full suite.

Limitations

These three layers cannot defend against:

  • RDD (Remote Dynamic Dependency) attacks — payload fetched at runtime, invisible to static analysis
  • Zero-day hijacks — legitimate package compromised before CVE is published
  • Highly obfuscated malicious code — may evade Semgrep rules
  • Unlisted PM flags — if a package manager adds new flags, they will be blocked until added to the arity table
  • Post-install import validation — the hook checks the package itself, not whether the AI's import statements match the package's actual exports (complementary CI-level tools like Open Code Review address this)

These require runtime defenses (Docker network restrictions, Node.js --experimental-permission) at the DevOps/infra level.