@wojciech_lesicki/security-lifecycle-demo
v1.0.0
Published
Educational demo of npm preinstall/postinstall lifecycle scripts - each hook fetches a package's registry metadata and logs the response, and reports how npm >=12's allowScripts blocking affects it.
Maintainers
Readme
npm-lifecycle-demo
A tiny, harmless demo package showing how npm's preinstall and
postinstall lifecycle scripts work — and, just as importantly, when
npm actually runs them at all. Install it as a dependency in a real
project and it doubles as a quick probe for "does this environment
execute lifecycle scripts, and if so, is the output even visible?" —
useful when checking a CI setup, a teammate's machine, or your own npm
config for how permissive it is toward install scripts.
What it does
When this package is installed, npm runs two scripts automatically
(defined in package.json → scripts):
preinstall— runs before the package's files are placed innode_modules.postinstall— runs after they are.
Both scripts call the same helper, scripts/check-registry.js,
which does exactly one thing: it makes a single HTTPS GET request to
the public npm registry's package metadata endpoint, e.g.
https://registry.npmjs.org/npmand prints a few fields from the JSON response (latest version,
description, number of published versions) to the console. This is a
real, unauthenticated, read-only registry endpoint — no npm login or
token is needed, and the response is actually interesting to look at
(unlike /-/whoami, which just returns 401 Unauthorized unless
you're logged in).
By default it looks up the npm package itself — fitting, since this
whole demo is about npm's own lifecycle-script behavior — but you can
point it at anything:
LIFECYCLE_DEMO_PKG=react npm installThe script does not:
- read any files outside itself
- read environment variables, credentials, or
.npmrctokens - write anything to disk
- send any data anywhere other than that one GET request
- do anything conditional on install failing/succeeding
It exists purely so you can see lifecycle scripts fire and watch a
real (if trivial) network call happen during npm install.
Try it locally
npm installYou should see console output like:
[npm-lifecycle-demo] preinstall script running - GET https://registry.npmjs.org/npm
[npm-lifecycle-demo] preinstall: running under npm v10
[npm-lifecycle-demo] preinstall: HTTP 200
[npm-lifecycle-demo] preinstall: package -> npm
[npm-lifecycle-demo] preinstall: latest ver. -> 12.0.2
[npm-lifecycle-demo] preinstall: description -> a package manager for JavaScript
[npm-lifecycle-demo] preinstall: # versions -> 604
...
[npm-lifecycle-demo] postinstall script running - GET https://registry.npmjs.org/npm
[npm-lifecycle-demo] postinstall: running under npm v10
[npm-lifecycle-demo] postinstall: HTTP 200
[npm-lifecycle-demo] postinstall: package -> npm
[npm-lifecycle-demo] postinstall: latest ver. -> 12.0.2
...Exact numbers will differ over time since it's a live lookup against the real registry.
You can also simulate installing it as a dependency into another project without publishing it anywhere:
npm pack
# creates npm-lifecycle-demo-1.0.0.tgz
cd /path/to/some-other-project
npm install /path/to/npm-lifecycle-demo-1.0.0.tgzConsole output isn't guaranteed
Whether you actually see the console.log lines above depends on
how the package is installed:
As the root project you're standing in (
cd npm-lifecycle-demo && npm install, which is what "Try it locally" above does) — npm always prints lifecycle script output live. This is the case in every example in this README.As a dependency inside another project (
npm install npm-lifecycle-demofrom someone else'spackage.json) — since npm 7, output from a dependency'spreinstall/install/postinstallis captured in the background and not printed at all by default, unless the script exits with an error. You can confirm this yourself:mkdir /tmp/dep-test && cd /tmp/dep-test && npm init -y npm install /path/to/npm-lifecycle-demo # -> silent, no [npm-lifecycle-demo] lines, even though the scripts ran rm -rf node_modules package-lock.json npm install /path/to/npm-lifecycle-demo --foreground-scripts # -> now you see the full [npm-lifecycle-demo] output live
This matters because it's exactly the gap real malicious packages
exploit: as a dependency, their postinstall can phone home, drop a
payload, whatever — completely silently, by default, with nothing in
your terminal to tip you off. --foreground-scripts (or npm config
set foreground-scripts true) is how you'd audit that.
npm v12: dependency scripts are blocked by default
npm v12 (released July 8, 2026) goes a step further than just hiding output — it stops running dependency lifecycle scripts at all by default:
- The
allowScriptspolicy defaults to off.preinstall,install, andpostinstallscripts belonging to dependencies (including implicitnode-gyprebuilds) are skipped with a warning, not executed — the install still succeeds ("soft skip"). - This only applies to scripts coming from
node_modulesdependencies. The root project's own lifecycle scripts (i.e. whatnpm installruns when you're standing inside this repo) are unaffected and still run normally — same as before. - To allowlist a package's scripts:
npm install-scripts lslists what's currently blocked,npm install-scripts approve <pkg>writes the approval intopackage.json'sallowScripts(there's alsonpm install-scripts deny <pkg>to explicitly refuse). The approved scripts don't run immediately though — you then neednpm rebuild(or a freshnpm install) to actually execute them. And even then, their output stays hidden unless you also pass--foreground-scripts— see the section above. - Warnings about this change start appearing in npm 11.16.0+; it becomes the default in npm 12.0.0.
This whole flow is verified against a real npm 12.0.2 install, end to end:
mkdir /tmp/dep-test && cd /tmp/dep-test && npm init -y
npm install /path/to/npm-lifecycle-demo
# npm warn install-scripts 1 package had install scripts blocked because
# they are not covered by allowScripts: [email protected] (...)
# npm warn install-scripts Run `npm install-scripts ls` to review, or
# `npm install-scripts approve <pkg>` to allow.
npm install-scripts ls
# 1 package has install scripts blocked ... [email protected] (...)
npm install-scripts approve npm-lifecycle-demo
# Approved ... — written into package.json's "allowScripts"
npm install --foreground-scripts
# now the scripts actually run AND their [npm-lifecycle-demo] output
# is visible, in one stepscripts/check-registry.js in this package detects the running npm's
major version from the npm_config_user_agent env var (which npm
sets for every lifecycle script — no subprocess needed) and prints a
note when it's ≥ 12. If you install this package as a dependency under
npm 12+ and do see its [npm-lifecycle-demo] output, that by itself
tells you the script was explicitly allowlisted via
npm install-scripts approve.
Sources: this package's own verified test above; GitHub Changelog — Upcoming breaking changes for npm v12; npm CLI v12 changelog. Note: a couple of secondary blog posts found while researching this got the approval command's name wrong (npm approve-scripts) — the command npm itself actually prints, confirmed above, is npm install-scripts approve <pkg>.
Why this matters
preinstall/postinstall scripts run arbitrary code on the installing
machine with the same permissions as the user running npm install —
that's exactly the mechanism abused by real supply-chain attacks
(credential theft, crypto-miners, etc.). This package is a minimal,
transparent, read-only example meant to illustrate that the mechanism
exists and what it can reach (the network), without doing anything
harmful, so you can reason about why --ignore-scripts (or, on npm
12+, the default-off allowScripts policy) and dependency auditing
matter.
License
MIT
