From 9fe79d680bbdfab838b85114dc96c7e5e885214b Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 8 May 2026 21:51:23 +0100 Subject: [PATCH] Strengthen lockfile registry guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `registry=` pin from .npmrc — it's npm's default and pinning it shadows contributors' own proxy registry config. The committed package-lock.json must reference only registry.npmjs.org, but `npm install` records whichever registry it actually resolved against (URL *and* integrity digest, which can differ for older packages). Replace the inline grep checks in .husky/pre-commit and CI with scripts/check-lockfile-registry.mjs, which also has a `--fix` mode that re-fetches resolved/integrity from registry.npmjs.org for the leaked entries. Also: - `update-lock:docker` no longer hard-codes `--registry=https://registry.npmjs.org/`; it uses the system registry and runs `--fix` at the end. - AGENTS.md updated to describe the new flow and the `npm run lint:lockfile` helper. --- .github/workflows/ci.yml | 2 +- .husky/pre-commit | 10 +-- .npmrc | 9 +- AGENTS.md | 19 +++-- package.json | 3 +- scripts/check-lockfile-registry.mjs | 125 ++++++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 scripts/check-lockfile-registry.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb419382..5d6f08d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: Verify no private URLs in package-lock.json shell: bash - run: '! grep -E "\"resolved\": \"https?://" package-lock.json | grep -v registry.npmjs.org' + run: node scripts/check-lockfile-registry.mjs - name: Verify example dependency versions shell: bash diff --git a/.husky/pre-commit b/.husky/pre-commit index ae5b3c73..54737338 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -7,12 +7,10 @@ export NVM_DIR="$HOME/.nvm" [ -d "/opt/homebrew/bin" ] && export PATH="/opt/homebrew/bin:$PATH" # homebrew (macOS ARM) [ -d "/usr/local/bin" ] && export PATH="/usr/local/bin:$PATH" # homebrew (macOS Intel) -# Verify no private registry URLs in package-lock.json -if grep -E '"resolved": "https?://' package-lock.json | grep -v registry.npmjs.org > /dev/null; then - echo "ERROR: package-lock.json contains non-npmjs.org URLs" - echo "Run: docker run --rm -i -v \$PWD:/src -w /src node:latest npm i --registry=https://registry.npmjs.org/" - exit 1 -fi +# Verify no private registry URLs in package-lock.json. `npm install` records +# whichever registry it resolved against (e.g. a corporate proxy from +# ~/.npmrc); the committed lockfile must reference only the public registry. +node scripts/check-lockfile-registry.mjs # Capture staged files so we only re-stage what the user intended to commit # (avoids sweeping unrelated WIP into the commit). --diff-filter=d skips diff --git a/.npmrc b/.npmrc index 214c29d1..bc26fdc4 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,8 @@ -registry=https://registry.npmjs.org/ +# Intentionally no `registry=` pin here. The npm default is the public +# registry (https://registry.npmjs.org/), and contributors who fetch through a +# corporate proxy registry should keep using it (it's their supply-chain gate). +# +# What *must* hold is that the committed package-lock.json references only the +# public registry — that's enforced by `npm run lint:lockfile` +# (scripts/check-lockfile-registry.mjs), which runs in CI and the pre-commit +# hook, and auto-fixed via `npm run lint:lockfile -- --fix`. diff --git a/AGENTS.md b/AGENTS.md index 6bfc8cdd..d1f881f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,14 +39,17 @@ npm test # Check JSDoc comment syntax and `{@link}` references npm exec typedoc -- --treatValidationWarningsAsErrors --emit none -# Regenerate package-lock.json -# Note: repo .npmrc pins registry to npmjs.org, so a plain `npm i` is safe even -# if your global npm config points elsewhere. The Docker step below is optional -# — it locks linux-amd64 optionalDependencies (sharp, rollup, bun) for CI. -rm -fR package-lock.json node_modules && \ - docker run --rm -it --platform linux/amd64 -v $PWD:/src:rw -w /src node:latest npm i && \ - rm -fR node_modules && \ - npm i --cache=~/.npm-mcp-apps --registry=https://registry.npmjs.org/ +# Regenerate package-lock.json from scratch (the Docker step locks +# linux-amd64 optionalDependencies — sharp, rollup, bun — for CI). +# Use whatever registry your machine is configured for; the final --fix step +# rewrites the committed lockfile to public registry.npmjs.org URLs. +npm run update-lock:docker + +# Verify the lockfile only references the public npm registry (also runs in +# CI and the pre-commit hook). Use --fix after `npm install ` against a +# proxy registry to rewrite the resolved URLs. +npm run lint:lockfile +npm run lint:lockfile -- --fix ``` ## Architecture diff --git a/package.json b/package.json index 2f31c246..19d44131 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,9 @@ "prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check", "prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write", "check:versions": "node scripts/check-versions.mjs", + "lint:lockfile": "node scripts/check-lockfile-registry.mjs", "bump": "node scripts/bump-version.mjs", - "update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/" + "update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i && node scripts/check-lockfile-registry.mjs --fix" }, "author": "Olivier Chafik", "devDependencies": { diff --git a/scripts/check-lockfile-registry.mjs b/scripts/check-lockfile-registry.mjs new file mode 100644 index 00000000..29a4554b --- /dev/null +++ b/scripts/check-lockfile-registry.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Checks that package-lock.json only references the public npm registry. + * + * Why: contributors and CI may have a corporate proxy registry configured + * (e.g. via `~/.npmrc` or `npm_config_registry`). npm records the registry it + * resolved against in each entry's `resolved` field. Committing those URLs + * leaks an internal hostname, breaks installs for everyone else, and can + * record a different `integrity` digest than the public registry would. + * + * Usage: + * node scripts/check-lockfile-registry.mjs # check (exit 1 on leak) + * node scripts/check-lockfile-registry.mjs --fix # rewrite leaked entries + * # from registry.npmjs.org + * + * `--fix` looks up each offending `@` on registry.npmjs.org and + * rewrites the entry's `resolved` / `integrity` to the public registry's + * canonical values, then re-stringifies the lockfile with npm's two-space + * indentation. It is safe to run after `npm install ` against a proxy. + * + * Run automatically by `.husky/pre-commit` and CI. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; +const LOCKFILE = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "package-lock.json", +); + +const fix = process.argv.includes("--fix"); + +let raw; +try { + raw = readFileSync(LOCKFILE, "utf8"); +} catch { + // No lockfile (fresh clone before install) — nothing to check. + process.exit(0); +} +const lock = JSON.parse(raw); + +/** @type {{ path: string, name: string, version: string, pkg: any }[]} */ +const offenders = []; +for (const [path, pkg] of Object.entries(lock.packages ?? {})) { + if (!path || !pkg.resolved) continue; // root / workspace member + if (!/^https?:\/\//.test(pkg.resolved)) continue; // git, file, link… + if (pkg.resolved.startsWith(PUBLIC_REGISTRY)) continue; + const name = path.replace(/^.*node_modules\//, ""); + offenders.push({ path, name, version: pkg.version, pkg }); +} + +if (offenders.length === 0) process.exit(0); + +if (!fix) { + console.error( + `package-lock.json references ${offenders.length} non-public registry URL(s):\n`, + ); + for (const { name, version, pkg } of offenders.slice(0, 20)) { + console.error(` ${name}@${version}\n ${pkg.resolved}`); + } + if (offenders.length > 20) + console.error(` … and ${offenders.length - 20} more`); + console.error(` +This usually means a dependency was added while a corporate proxy registry was +configured (~/.npmrc, npm_config_registry, etc.). Public lockfiles must only +reference ${PUBLIC_REGISTRY} so they're reproducible and don't leak internal +hostnames. + +To fix: + node scripts/check-lockfile-registry.mjs --fix + git add package-lock.json +`); + process.exit(1); +} + +// --fix: rewrite each offending entry from registry.npmjs.org metadata. +const fetchMeta = async (name, version) => { + const url = `${PUBLIC_REGISTRY}${name.replaceAll("/", "%2f")}/${version}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `${name}@${version}: ${res.status} ${res.statusText} from ${PUBLIC_REGISTRY} — ` + + `not a public package? It can't be referenced from a public lockfile.`, + ); + } + return /** @type {{ dist: { tarball: string, integrity?: string, shasum?: string } }} */ ( + await res.json() + ); +}; + +let failed = false; +for (const { name, version, pkg } of offenders) { + let dist; + try { + ({ dist } = await fetchMeta(name, version)); + } catch (err) { + console.error(String(err)); + failed = true; + continue; + } + const integrity = + dist.integrity ?? + (dist.shasum + ? `sha1-${Buffer.from(dist.shasum, "hex").toString("base64")}` + : undefined); + if (!integrity) { + console.error( + `${name}@${version}: no integrity from registry; fix manually.`, + ); + failed = true; + continue; + } + pkg.resolved = dist.tarball; + pkg.integrity = integrity; + console.log(`fixed ${name}@${version}`); +} + +if (failed) process.exit(1); +writeFileSync(LOCKFILE, JSON.stringify(lock, null, 2) + "\n"); +console.log("package-lock.json sanitized.");