Skip to content

Commit 7068d42

Browse files
authored
feat: Phase 1 of allowScripts opt-in install-script policy (#9360)
Implements Phase 1 of [npm/rfcs#868](npm/rfcs#868), which makes dependency install scripts opt-in. **Install behaviour is unchanged.** Scripts still run as they always have. The only Phase 1 user-visible change is one advisory block at the end of `npm install` listing packages whose install scripts haven't been reviewed via the new `allowScripts` field in `package.json`. A future release will turn that advisory into an actual block. ### What landed - `allowScripts` field in `package.json`, read at install time - Three new configs: `allow-scripts`, `strict-script-builds`, `dangerously-allow-all-scripts`. The latter two are no-ops in this release. They're registered so projects can pin them in tooling ahead of the release that flips the default. - `npm approve-scripts` and `npm deny-scripts` commands, with the RFC's asymmetric pin rule (approves can pin, denies are always name-only) - Advisory warning during `npm install`, `ci`, `update`, and `rebuild`. `npm exec` / `npx` consult only the user/global `.npmrc` layer per the RFC, with the policy threaded through libnpmexec for Phase 2 enforcement. - Identity matcher in `@npmcli/arborist` covering registry, git, file, and remote tarballs. Registry identity is derived from the lockfile's resolved URL (via `versionFromTgz`), never from `node.packageName` or `node.version`. Those getters read the installed tarball's `package.json` and can be forged. - Aliases match against the underlying registered package, not the alias name. `trusted@npm:[email protected]` is approved by writing `naughty`, not `trusted`. Holds even under `omitLockfileRegistryResolved`, where the install location alone (`node_modules/trusted`) would be misleading. The underlying name is derived from the incoming edge's alias `subSpec`. - Bundled deps with install scripts are flagged as unreviewed and filtered out of `npm approve-scripts --all` and positional matches. Per RFC they cannot be allowlisted in Phase 1. - Warning when a non-root workspace declares its own `allowScripts` ### What's deliberately deferred - Actual blocking. The matcher exists and the policy is threaded through to arborist, but `arb.rebuild()`'s build set still runs everything. Phase 2 will gate `#addToBuildSet` on the matcher. - A safe allowlist syntax for bundled deps. The RFC notes a candidate `[email protected] > bundled-name` form for a follow-up. Refs: npm/rfcs#868
1 parent 33aebaa commit 7068d42

46 files changed

Lines changed: 5653 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
title: npm-approve-scripts
3+
section: 1
4+
description: Approve install scripts for specific dependencies
5+
---
6+
7+
### Synopsis
8+
9+
<!-- AUTOGENERATED USAGE DESCRIPTIONS -->
10+
11+
### Description
12+
13+
Manages the `allowScripts` field in your project's `package.json`, which
14+
records which of your dependencies are permitted to run install scripts
15+
(`preinstall`, `install`, `postinstall`, and `prepare` for non-registry
16+
sources). This command is the recommended way to maintain that field.
17+
18+
In the current release, this field is advisory: install scripts still run
19+
by default, but installs print a list of packages whose scripts have not
20+
been reviewed. A future release will block unreviewed install scripts.
21+
22+
There are three modes:
23+
24+
```bash
25+
npm approve-scripts <pkg> [<pkg> ...]
26+
npm approve-scripts --all
27+
npm approve-scripts --allow-scripts-pending
28+
```
29+
30+
`<pkg>` matches every installed version of that package. By default the
31+
command writes pinned entries (`[email protected]`), which keep their approval
32+
narrowed to the specific version you reviewed. Pass `--no-allow-scripts-pin` to write
33+
name-only entries that allow any future version.
34+
35+
`--all` approves every package with unreviewed install scripts in one go.
36+
37+
`--allow-scripts-pending` is read-only: it lists every package whose install scripts
38+
are not yet covered by `allowScripts`, without modifying `package.json`.
39+
40+
`approve-scripts` honours the asymmetric pin rule: if you re-approve a
41+
package whose installed version has changed, the existing pin is rewritten
42+
to track the new installed version. Multi-version statements
43+
(`pkg@1 || 2`) are left alone, since they likely capture intent that
44+
the command cannot infer. Existing `false` entries always win;
45+
`approve-scripts` will not silently re-allow a package you previously
46+
denied.
47+
48+
### Examples
49+
50+
```bash
51+
# Approve all currently-installed install scripts after reviewing them
52+
npm approve-scripts --all
53+
54+
# Approve specific packages, pinned to their installed version
55+
npm approve-scripts canvas sharp
56+
57+
# Approve name-only (any version of this package is allowed)
58+
npm approve-scripts --no-allow-scripts-pin canvas
59+
60+
# Preview which packages still need review
61+
npm approve-scripts --allow-scripts-pending
62+
```
63+
64+
### Configuration
65+
66+
<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->
67+
68+
### See Also
69+
70+
* [npm deny-scripts](/commands/npm-deny-scripts)
71+
* [npm install](/commands/npm-install)
72+
* [npm rebuild](/commands/npm-rebuild)
73+
* [package.json](/configuring-npm/package-json)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: npm-deny-scripts
3+
section: 1
4+
description: Deny install scripts for specific dependencies
5+
---
6+
7+
### Synopsis
8+
9+
<!-- AUTOGENERATED USAGE DESCRIPTIONS -->
10+
11+
### Description
12+
13+
The companion command to [`npm approve-scripts`](/commands/npm-approve-scripts).
14+
Writes `false` entries into the `allowScripts` field of your project's
15+
`package.json`, recording that a dependency must not run install scripts
16+
even if a future version would otherwise be eligible.
17+
18+
In the current release, install scripts still run by default, so `deny-scripts`
19+
only affects how installs of denied packages are reported. A future release
20+
will block unreviewed install scripts and respect deny entries at install
21+
time.
22+
23+
```bash
24+
npm deny-scripts <pkg> [<pkg> ...]
25+
npm deny-scripts --all
26+
```
27+
28+
`<pkg>` matches every installed version of that package. Denies are always
29+
written name-only (`"pkg": false`), regardless of `--allow-scripts-pin`. Pinning a deny
30+
to a specific version would silently re-allow scripts for any other version
31+
of the same package, which defeats the purpose; the command picks the
32+
safer default for you.
33+
34+
`--all` denies every package with unreviewed install scripts.
35+
36+
If a `true` (pinned or name-only) entry exists for a package and you then
37+
deny it, the existing allow entries are removed so the name-only deny is
38+
unambiguous.
39+
40+
### Examples
41+
42+
```bash
43+
# Deny a specific package outright
44+
npm deny-scripts telemetry-pkg
45+
46+
# Deny everything that has install scripts and isn't already approved
47+
npm deny-scripts --all
48+
```
49+
50+
### Configuration
51+
52+
<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->
53+
54+
### See Also
55+
56+
* [npm approve-scripts](/commands/npm-approve-scripts)
57+
* [npm install](/commands/npm-install)
58+
* [package.json](/configuring-npm/package-json)

docs/lib/content/nav.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
- title: npm access
1313
url: /commands/npm-access
1414
description: Set access level on published packages
15+
- title: npm approve-scripts
16+
url: /commands/npm-approve-scripts
17+
description: Approve install scripts for specific dependencies
1518
- title: npm audit
1619
url: /commands/npm-audit
1720
description: Run a security audit
@@ -33,6 +36,9 @@
3336
- title: npm dedupe
3437
url: /commands/npm-dedupe
3538
description: Reduce duplication in the package tree
39+
- title: npm deny-scripts
40+
url: /commands/npm-deny-scripts
41+
description: Deny install scripts for specific dependencies
3642
- title: npm deprecate
3743
url: /commands/npm-deprecate
3844
description: Deprecate a version of a package

lib/commands/approve-scripts.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const AllowScriptsCmd = require('../utils/allow-scripts-cmd.js')
2+
3+
class ApproveScripts extends AllowScriptsCmd {
4+
static description = 'Approve install scripts for specific dependencies'
5+
static name = 'approve-scripts'
6+
static usage = ['<pkg> [<pkg> ...]', '--all', '--allow-scripts-pending']
7+
static verb = 'approve'
8+
}
9+
10+
module.exports = ApproveScripts

lib/commands/ci.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const reifyFinish = require('../utils/reify-finish.js')
2+
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
3+
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
24
const runScript = require('@npmcli/run-script')
35
const fs = require('node:fs/promises')
46
const path = require('node:path')
@@ -25,6 +27,9 @@ class CI extends ArboristWorkspaceCmd {
2527
'allow-file',
2628
'allow-git',
2729
'allow-remote',
30+
'allow-scripts',
31+
'strict-allow-scripts',
32+
'dangerously-allow-all-scripts',
2833
'audit',
2934
'bin-links',
3035
'fund',
@@ -43,12 +48,14 @@ class CI extends ArboristWorkspaceCmd {
4348
const ignoreScripts = this.npm.config.get('ignore-scripts')
4449
const where = this.npm.prefix
4550
const Arborist = require('@npmcli/arborist')
51+
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
4652
const opts = {
4753
...this.npm.flatOptions,
4854
packageLock: true, // npm ci should never skip lock files
4955
path: where,
5056
save: false, // npm ci should never modify the lockfile or package.json
5157
workspaces: this.workspaceNames,
58+
allowScripts: allowScriptsPolicy,
5259
}
5360

5461
// generate an inventory from the virtual tree in the lockfile
@@ -69,6 +76,7 @@ class CI extends ArboristWorkspaceCmd {
6976
// We need a new one because the virtual tree from the lockfile can have extraneous dependencies in it that won't install on this platform
7077
const arb = new Arborist(opts)
7178
await arb.buildIdealTree()
79+
await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts })
7280

7381
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
7482
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)

lib/commands/deny-scripts.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const AllowScriptsCmd = require('../utils/allow-scripts-cmd.js')
2+
3+
class DenyScripts extends AllowScriptsCmd {
4+
static description = 'Deny install scripts for specific dependencies'
5+
static name = 'deny-scripts'
6+
static usage = ['<pkg> [<pkg> ...]', '--all']
7+
static verb = 'deny'
8+
}
9+
10+
module.exports = DenyScripts

lib/commands/exec.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { resolve } = require('node:path')
22
const libexec = require('libnpmexec')
3+
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
34
const BaseCommand = require('../base-cmd.js')
45

56
class Exec extends BaseCommand {
@@ -10,6 +11,9 @@ class Exec extends BaseCommand {
1011
'workspace',
1112
'workspaces',
1213
'include-workspace-root',
14+
'allow-scripts',
15+
'strict-allow-scripts',
16+
'dangerously-allow-all-scripts',
1317
]
1418

1519
static name = 'exec'
@@ -74,8 +78,16 @@ class Exec extends BaseCommand {
7478
throw this.usageError()
7579
}
7680

81+
// Resolve the install-script policy from the user/global .npmrc layer
82+
// only. The RFC requires exec/npx to ignore any project
83+
// package.json#allowScripts; CLI flags still apply.
84+
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm, {
85+
skipProjectConfig: true,
86+
})
87+
7788
return libexec({
7889
...flatOptions,
90+
allowScripts: allowScriptsPolicy,
7991
// we explicitly set packageLockOnly to false because if it's true when we try to install a missing package, we won't actually install it
8092
packageLockOnly: false,
8193
// what the user asked to run args[0] is run by default

lib/commands/install.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const runScript = require('@npmcli/run-script')
55
const pacote = require('pacote')
66
const checks = require('npm-install-checks')
77
const reifyFinish = require('../utils/reify-finish.js')
8+
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
9+
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
810
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
911

1012
class Install extends ArboristWorkspaceCmd {
@@ -31,6 +33,9 @@ class Install extends ArboristWorkspaceCmd {
3133
'allow-file',
3234
'allow-git',
3335
'allow-remote',
36+
'allow-scripts',
37+
'strict-allow-scripts',
38+
'dangerously-allow-all-scripts',
3439
'audit',
3540
'before',
3641
'min-release-age',
@@ -138,12 +143,14 @@ class Install extends ArboristWorkspaceCmd {
138143
}
139144

140145
const Arborist = require('@npmcli/arborist')
146+
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
141147
const opts = {
142148
...this.npm.flatOptions,
143149
auditLevel: null,
144150
path: where,
145151
add: args,
146152
workspaces: this.workspaceNames,
153+
allowScripts: allowScriptsPolicy,
147154
}
148155

149156
// Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before.
@@ -161,6 +168,7 @@ class Install extends ArboristWorkspaceCmd {
161168
}
162169

163170
const arb = new Arborist(opts)
171+
await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts })
164172
await arb.reify(opts)
165173

166174
if (runRootLifecycle) {

lib/commands/rebuild.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
const { resolve } = require('node:path')
2-
const { output } = require('proc-log')
2+
const { log, output } = require('proc-log')
33
const npa = require('npm-package-arg')
44
const semver = require('semver')
55
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
6+
const checkAllowScripts = require('../utils/check-allow-scripts.js')
7+
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
8+
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
69

710
class Rebuild extends ArboristWorkspaceCmd {
811
static description = 'Rebuild a package'
@@ -12,6 +15,9 @@ class Rebuild extends ArboristWorkspaceCmd {
1215
'bin-links',
1316
'foreground-scripts',
1417
'ignore-scripts',
18+
'allow-scripts',
19+
'strict-allow-scripts',
20+
'dangerously-allow-all-scripts',
1521
...super.params,
1622
]
1723

@@ -26,9 +32,11 @@ class Rebuild extends ArboristWorkspaceCmd {
2632
const globalTop = resolve(this.npm.globalDir, '..')
2733
const where = this.npm.global ? globalTop : this.npm.prefix
2834
const Arborist = require('@npmcli/arborist')
35+
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
2936
const arb = new Arborist({
3037
...this.npm.flatOptions,
3138
path: where,
39+
allowScripts: allowScriptsPolicy,
3240
// TODO when extending ReifyCmd
3341
// workspaces: this.workspaceNames,
3442
})
@@ -50,11 +58,28 @@ class Rebuild extends ArboristWorkspaceCmd {
5058
})
5159
const nodes = tree.inventory.filter(node => this.isNode(specs, node))
5260

61+
await strictAllowScriptsPreflight({ arb, npm: this.npm })
5362
await arb.rebuild({ nodes })
5463
} else {
64+
await arb.loadActual()
65+
await strictAllowScriptsPreflight({ arb, npm: this.npm })
5566
await arb.rebuild()
5667
}
5768

69+
// Phase 1 advisory: list any packages whose install scripts ran (or
70+
// would have run) and are not yet covered by allowScripts. Rebuild
71+
// doesn't go through reifyFinish, so the walker is invoked here.
72+
const unreviewed = await checkAllowScripts({ arb, npm: this.npm })
73+
if (unreviewed.length > 0) {
74+
const count = unreviewed.length
75+
const noun = count === 1 ? 'package has' : 'packages have'
76+
log.warn(
77+
'rebuild',
78+
`${count} ${noun} install scripts not yet covered by allowScripts. ` +
79+
'Run `npm approve-scripts --allow-scripts-pending` to review.'
80+
)
81+
}
82+
5883
output.standard('rebuilt dependencies successfully')
5984
}
6085

lib/commands/update.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const path = require('node:path')
22
const { log } = require('proc-log')
33
const reifyFinish = require('../utils/reify-finish.js')
4+
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
5+
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
46
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
57

68
class Update extends ArboristWorkspaceCmd {
@@ -19,6 +21,9 @@ class Update extends ArboristWorkspaceCmd {
1921
'package-lock',
2022
'foreground-scripts',
2123
'ignore-scripts',
24+
'allow-scripts',
25+
'strict-allow-scripts',
26+
'dangerously-allow-all-scripts',
2227
'audit',
2328
'before',
2429
'min-release-age',
@@ -51,15 +56,19 @@ class Update extends ArboristWorkspaceCmd {
5156
}
5257

5358
const Arborist = require('@npmcli/arborist')
59+
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
5460
const opts = {
5561
...this.npm.flatOptions,
5662
path: where,
5763
save,
5864
workspaces: this.workspaceNames,
65+
allowScripts: allowScriptsPolicy,
5966
}
6067
const arb = new Arborist(opts)
6168

62-
await arb.reify({ ...opts, update })
69+
const reifyOpts = { ...opts, update }
70+
await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: reifyOpts })
71+
await arb.reify(reifyOpts)
6372
await reifyFinish(this.npm, arb)
6473
}
6574
}

0 commit comments

Comments
 (0)