Skip to content

Commit ad9c81b

Browse files
committed
Add option to require install script approval
This is an attempt to revive npm#488 and control which packages can run install lifecycle scripts.
1 parent 6f61e5a commit ad9c81b

File tree

1 file changed

+274
-0
lines changed

1 file changed

+274
-0
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Install Script Allowlist
2+
3+
## Summary
4+
5+
Add an opt-in mode where npm requires explicit approval before running install
6+
lifecycle scripts (`preinstall`, `install`, `postinstall`, and auto-detected gyp
7+
files). The default behavior is unchanged — scripts run as they do today. Users
8+
who enable the feature choose between two enforcement modes: `ignore` (silently
9+
skip unapproved scripts) and `error` (fail the install). Approved packages are
10+
tracked in a JSON allowlist that can live in `package.json`, in one or more
11+
standalone JSON files referenced by `.npmrc`, or both — all sources are merged
12+
together.
13+
14+
## Motivation
15+
16+
This proposal builds on [npm/rfcs#488](https://github.com/npm/rfcs/pull/488) by
17+
@tolmasky and the extensive community discussion there. The core security
18+
argument from that RFC still holds and has only grown stronger:
19+
20+
- Install lifecycle scripts execute arbitrary code at install time, before the
21+
consuming project ever `require`s or `import`s the package. Only ~0.6% of
22+
packages on npm use install lifecycle scripts (12,035 out of ~2 million
23+
packages as of November 2021, per
24+
[tolmasky's analysis in RFC #488](https://github.com/npm/rfcs/pull/488)), yet
25+
they represent the easiest vector for supply-chain attacks.
26+
- Real-world exploits continue to demonstrate the risk. The 2021 `coa`/`rc`
27+
compromises, the 2025–2026 "Shai Hulud" worm, and numerous typosquatting
28+
attacks all leveraged postinstall scripts to propagate.
29+
- Alternatives to install lifecycle scripts have matured. N-API,
30+
prebuild-install, and platform-specific optional dependencies
31+
(`os`/`cpu`/`libc` fields) cover the many legitimate use cases that
32+
historically required compilation at install time.
33+
34+
The original RFC proposed changing the default so that scripts are off unless
35+
explicitly allowed. That was rejected as too disruptive. This proposal takes a
36+
different approach: **the default does not change**. Instead, npm gains a new
37+
opt-in enforcement mode with an allowlist, giving security-conscious users and
38+
organizations a first-class way to control which packages may run scripts —
39+
without breaking anyone who doesn't opt in.
40+
41+
For organizations managing large codebases, shared management of the allowlist
42+
is essential. Organizations need to populate a shared allowlist with every
43+
package whose scripts are known to be in use, then enable `error` mode globally
44+
so that any _new_ unapproved script is caught immediately. The allowlist must be
45+
shareable across repositories without requiring changes to each project's
46+
`package.json`. See [Allowlist sources](#allowlist-sources) and
47+
[bikeshedding](#unresolved-questions-and-bikeshedding) for how this could work.
48+
49+
## Detailed Explanation
50+
51+
### Enforcement mode
52+
53+
A new `.npmrc` config value controls the feature:
54+
55+
```ini
56+
install-script-policy=off|ignore|error
57+
```
58+
59+
- `off` (default): Current behavior. All install lifecycle scripts run
60+
unconditionally.
61+
- `ignore`: Unapproved scripts are silently skipped. At the end of
62+
`node_modules` construction, npm prints a summary of every package whose
63+
scripts were skipped.
64+
- `error`: Unapproved scripts are skipped. At the end of `node_modules`
65+
construction, npm exits non-zero and prints the list of unapproved packages
66+
that had scripts.
67+
68+
In both `ignore` and `error` modes, npm collects the full list of skipped
69+
packages and reports them together at the end of the install, rather than
70+
failing on the first one. This makes it practical to build an allowlist in one
71+
shot.
72+
73+
### Allowlist format
74+
75+
The allowlist is a JSON object with a single top-level field:
76+
77+
```json
78+
{
79+
"approvedInstallScripts": {
80+
"node-gyp-build": "allowed",
81+
"esbuild": "allowed",
82+
"esbuild@>=0.25.0": "allowed",
83+
"sqlite3@4.x": "allowed",
84+
"husky": "ignored",
85+
"sponsorware-nag": "ignored"
86+
}
87+
}
88+
```
89+
90+
Keys are `<package-name>` or `<package-name>@<semver-range>`. A bare package
91+
name (no `@range`) is equivalent to `@*` and matches all versions. Semver ranges
92+
in this list match pre-release versions.
93+
94+
Values:
95+
96+
- `"allowed"` — the package's install lifecycle scripts will run.
97+
- `"ignored"` — the package's install lifecycle scripts will not run, and the
98+
package will not be listed as an error in `error` mode. This is useful for
99+
packages whose scripts are non-essential (telemetry, sponsorship messages,
100+
optional native compilation with a JS fallback, etc.).
101+
102+
### Allowlist sources
103+
104+
We need allowlist entries to come from at least two places because npmrc's
105+
config model flattens values — a project-level `.npmrc` replaces a global-level
106+
value rather than merging with it. If the allowlist lived only in `.npmrc`, a
107+
project that sets `install-script-allowlist=project-scripts.json` would silently
108+
discard the global `install-script-allowlist=company-scripts.json`. That defeats
109+
the central-management use case entirely.
110+
111+
The proposed solution uses two complementary sources:
112+
113+
1. **External JSON files referenced by `.npmrc`** — for shared and global
114+
config. An organization sets this once in a global or user `.npmrc` and every
115+
project inherits it.
116+
117+
```ini
118+
install-script-allowlist[]=/etc/npm/approved-scripts.json
119+
```
120+
121+
2. **`package.json`** — for per-project entries that are tracked in source
122+
control alongside the code.
123+
124+
```json
125+
{
126+
"name": "my-app",
127+
"approvedInstallScripts": {
128+
"esbuild": "allowed"
129+
}
130+
}
131+
```
132+
133+
All sources are merged (unioned). If the same key appears in multiple sources,
134+
the most permissive value wins (`"allowed"` > `"ignored"`). The goal is to
135+
reduce toil — an organization can maintain one shared list and have every
136+
project inherit it automatically, while individual projects can still add their
137+
own entries. Whether a project should also be able to _override_ a shared entry
138+
(e.g. downgrade `"allowed"` to `"ignored"`) is an open question, but not a hard
139+
requirement either way.
140+
141+
This two-source approach is a concrete proposal, but we're open to better ideas
142+
for achieving the same goal: a project-level list that coexists with a
143+
centrally-managed list. If there's a cleaner way to get mergeable lists out of
144+
npmrc, or a different file format that works better, we'd welcome that.
145+
146+
### Interaction with `--ignore-scripts`
147+
148+
`--ignore-scripts` continues to work as today — it disables all scripts
149+
unconditionally, regardless of the allowlist. The allowlist only applies when
150+
`install-script-policy` is `ignore` or `error` and `--ignore-scripts` is not
151+
set.
152+
153+
### Scope
154+
155+
This feature applies to the install lifecycle scripts that run during
156+
`npm install` for dependencies:
157+
158+
- `preinstall`
159+
- `install`
160+
- `postinstall`
161+
- Implicit `node-gyp rebuild` (when a `binding.gyp` is detected and no `install`
162+
script is defined)
163+
- `prepare` scripts of git dependencies (which run during install to build the
164+
package from source)
165+
166+
It does not affect:
167+
168+
- Scripts in the root project and workspaces' `package.json` (the package being
169+
developed). It's specifically for installed dependencies.
170+
- `npm run` / `npm test` / `npm start` etc.
171+
172+
## Rationale and Alternatives
173+
174+
### Alternative 1: Change the default (tolmasky's original RFC #488)
175+
176+
Making scripts opt-in by default is the ideal end state, but the npm team and
177+
community rejected it as too disruptive in 2021–2022. This proposal provides the
178+
same security benefit for users who opt in, without breaking anyone. It could
179+
serve as a stepping stone toward eventually changing the default in a future
180+
major version, once the ecosystem has had time to adapt.
181+
182+
### Alternative 2: Use `@lavamoat/allow-scripts` or similar userland tools
183+
184+
Tools like `@lavamoat/allow-scripts` and `can-i-ignore-scripts` exist and work
185+
today. However, they require adding a dependency and a workflow change to every
186+
project. A first-party npm feature would be more discoverable and require no
187+
extra dependencies.
188+
189+
### Alternative 3: Migrate to `pnpm`
190+
191+
Package owners that would opt-in to install script allowlists could migrate to
192+
pnpm instead. I know this would be a difficult migration for the codebase I
193+
manage.
194+
195+
## Implementation
196+
197+
The implementation touches a few areas of the npm CLI:
198+
199+
1. **Config**: Add `install-script-policy` (enum: `off`, `ignore`, `error`) and
200+
`install-script-allowlist` (array of file paths) to the config schema.
201+
202+
2. **Allowlist loading**: At the start of `npm install` / `npm ci`, read and
203+
merge the allowlist from all configured sources (package.json + external
204+
files). Build a lookup structure mapping `(package-name, version)`
205+
`allowed | ignored | unapproved`.
206+
207+
3. **Script gating**: In the lifecycle script runner (likely in
208+
`@npmcli/run-script` or the reification step in `@npmcli/arborist`), before
209+
executing an install lifecycle script for a dependency, check the merged
210+
allowlist. If the package+version is not approved, skip the script and record
211+
it.
212+
213+
4. **Reporting**: After reification completes, if any scripts were skipped,
214+
print a summary. In `error` mode, exit non-zero if any skipped packages were
215+
not marked `"ignored"`.
216+
217+
## Prior Art
218+
219+
- [npm/rfcs#488](https://github.com/npm/rfcs/pull/488)@tolmasky's "Make npm
220+
install scripts opt-in" RFC (2021). Extensive discussion, 369 👍, not merged
221+
due to breaking-change concerns.
222+
- [pnpm `onlyBuiltDependencies` / `allowBuilds`](https://pnpm.io/settings#allowbuilds)
223+
— pnpm 10 blocks lifecycle scripts by default with an allowlist in
224+
`package.json`.
225+
- [`@lavamoat/allow-scripts`](https://www.npmjs.com/package/@lavamoat/allow-scripts)
226+
— Userland tool that disables scripts via `--ignore-scripts` and selectively
227+
re-runs them from an allowlist in `package.json`.
228+
- [`can-i-ignore-scripts`](https://www.npmjs.com/package/can-i-ignore-scripts)
229+
Helps audit which dependencies actually need install lifecycle scripts.
230+
231+
## Unresolved Questions and Bikeshedding
232+
233+
### Config key names
234+
235+
`install-script-policy`, `install-script-allowlist`, and the JSON field
236+
`approvedInstallScripts` are suggestions. The names should be bikeshedded. What
237+
matters is the semantics: a mode toggle, a list of external config files, and a
238+
mergeable allowlist structure.
239+
240+
I proposed using both package.json fields and npmrc because that allows package
241+
and global config. Another option could be to use npmrc, but merge the arrays
242+
across rc files instead of replace them.
243+
244+
```
245+
# ~/.npmrc
246+
install-script-allowlist[]=/etc/npm/shared-approved-scripts.json
247+
248+
# /project/.npmrc
249+
install-script-allowlist[]=./approved-scripts.json
250+
251+
# runtime sees ['/etc/npm/shared-approved-scripts.json', './approved-scripts.json']
252+
```
253+
254+
I didn't propose this because it doesn't behave like other npm config arrays.
255+
256+
### Should `npm install <pkg>` in interactive mode prompt the user?
257+
258+
When adding a new dependency that has install lifecycle scripts, npm could
259+
interactively ask whether to approve it, similar to how `apt` confirms disk
260+
usage or how Composer prompts for plugin permissions.
261+
262+
### Should there be an `npm approve-scripts` command?
263+
264+
A helper command that scans `node_modules` (or the package tree) and
265+
generates/updates the allowlist would make adoption much easier. Something like
266+
`npm approve-scripts --init` to populate the allowlist with all
267+
currently-installed packages that have scripts.
268+
269+
### Interaction with workspaces
270+
271+
In a monorepo, should each workspace have its own `approvedInstallScripts` in
272+
its `package.json`, or should only the root's list apply? The external-file
273+
approach naturally handles this (point all workspaces at the same file), but the
274+
`package.json` story needs clarification.

0 commit comments

Comments
 (0)