|
| 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