Skip to content

Commit c14e87c

Browse files
fix: suggest --allow-scripts for global installs in unreviewed-scripts warnings (#9481)
Backport of #9469 to `release/v11`. Co-authored-by: Jamie Magee <jamie.magee@gmail.com>
1 parent 66e97c2 commit c14e87c

8 files changed

Lines changed: 156 additions & 5 deletions

File tree

docs/lib/content/commands/npm-approve-scripts.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ In the current release, this field is advisory: install scripts still run
1919
by default, but installs print a list of packages whose scripts have not
2020
been reviewed. A future release will block unreviewed install scripts.
2121

22+
This command only works inside a project that has a `package.json`. It does
23+
not apply to global installs (`npm install -g`) or one-off executions
24+
(`npm exec` / `npx`), which have no project `package.json` to write to and
25+
will fail with an `EGLOBAL` error. To allow install scripts in those
26+
contexts, use the `--allow-scripts` flag at install time (for example
27+
`npm install -g --allow-scripts=canvas,sharp`) or persist the setting with
28+
`npm config set allow-scripts=canvas,sharp --location=user`.
29+
2230
There are three modes:
2331

2432
```bash

lib/commands/rebuild.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ const { resolve } = require('node:path')
22
const { log, output } = require('proc-log')
33
const npa = require('npm-package-arg')
44
const semver = require('semver')
5+
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
56
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
67
const checkAllowScripts = require('../utils/check-allow-scripts.js')
78
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
89
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
10+
const { configSetAllowScripts } = require('../utils/allow-scripts-remediation.js')
911

1012
class Rebuild extends ArboristWorkspaceCmd {
1113
static description = 'Rebuild a package'
@@ -73,10 +75,17 @@ class Rebuild extends ArboristWorkspaceCmd {
7375
if (unreviewed.length > 0) {
7476
const count = unreviewed.length
7577
const noun = count === 1 ? 'package has' : 'packages have'
78+
// `npm approve-scripts` writes to a project package.json, which doesn't
79+
// exist for global rebuilds. Point global users at `npm config set`,
80+
// which writes the `allow-scripts` setting to their user .npmrc.
81+
const names = unreviewed.map(({ node }) => trustedDisplay(node).name)
82+
const remediation = this.npm.global
83+
? `Run \`${configSetAllowScripts(names)}\` to allow their scripts.`
84+
: 'Run `npm approve-scripts --allow-scripts-pending` to review.'
7685
log.warn(
7786
'rebuild',
7887
`${count} ${noun} install scripts not yet covered by allowScripts. ` +
79-
'Run `npm approve-scripts --allow-scripts-pending` to review.'
88+
remediation
8089
)
8190
}
8291

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Builds the `npm config set allow-scripts` command suggested to global
2+
// users, who have no project package.json for `npm approve-scripts` to
3+
// write to. `--location=user` keeps the setting in the user .npmrc instead
4+
// of trying (and, for global installs, failing) to write it to the local
5+
// project config.
6+
const configSetAllowScripts = (names) =>
7+
`npm config set allow-scripts=${names.join(',')} --location=user`
8+
9+
module.exports = { configSetAllowScripts }

lib/utils/reify-output.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const npmAuditReport = require('npm-audit-report')
1616
const { readTree: getFundingInfo } = require('libnpmfund')
1717
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
1818
const auditError = require('./audit-error.js')
19+
const { configSetAllowScripts } = require('./allow-scripts-remediation.js')
1920

2021
const reifyOutput = (npm, arb, extras = {}) => {
2122
const { diff, actualTree } = arb
@@ -243,10 +244,12 @@ const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
243244
const pkg = count === 1 ? 'package has' : 'packages have'
244245
const header = `${count} ${pkg} install scripts not yet covered by allowScripts:`
245246

247+
const names = []
246248
const lines = unreviewedScripts.map(({ node, scripts }) => {
247249
const { name, version } = trustedDisplay(node)
248250
/* istanbul ignore next: every test node has a name */
249251
const display = name || '<unknown>'
252+
names.push(display)
250253
const ver = version ? `@${version}` : ''
251254
const events = Object.entries(scripts)
252255
.map(([event, cmd]) => `${event}: ${cmd}`)
@@ -260,9 +263,28 @@ const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
260263
header,
261264
...lines,
262265
'',
263-
'Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.',
266+
...remediationLines(npm, names),
264267
].join('\n')
265268
)
266269
}
267270

271+
// `npm approve-scripts` writes to a project package.json, which doesn't
272+
// exist for global installs (it throws EGLOBAL). For those, point users at
273+
// the mechanism that does work globally: the `--allow-scripts` flag for a
274+
// one-off, or `npm config set allow-scripts` to persist it.
275+
const remediationLines = (npm, names) => {
276+
if (npm.global) {
277+
const list = names.join(',')
278+
return [
279+
`Run \`npm install -g --allow-scripts=${list}\` to allow these scripts ` +
280+
`once, or \`${configSetAllowScripts(names)}\` to allow them for ` +
281+
'all global installs.',
282+
]
283+
}
284+
return [
285+
'Run `npm approve-scripts --allow-scripts-pending` to review, ' +
286+
'or `npm approve-scripts <pkg>` to allow.',
287+
]
288+
}
289+
268290
module.exports = reifyOutput

lib/utils/strict-allow-scripts-preflight.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const checkAllowScripts = require('./check-allow-scripts.js')
2+
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
3+
const { configSetAllowScripts } = require('./allow-scripts-remediation.js')
24

35
// Pre-flight check for `--strict-allow-scripts`. Call after arborist has
46
// been constructed but before `arb.reify()` runs, so that install scripts
@@ -46,13 +48,26 @@ const strictAllowScriptsPreflight = async ({ arb, npm, idealTreeOpts }) => {
4648
return ` ${label} (${events})`
4749
}).join('\n')
4850

51+
// `npm approve-scripts` / `npm deny-scripts` write to a project
52+
// package.json, which doesn't exist for global installs. Point global
53+
// users at the `--allow-scripts` flag and `npm config set allow-scripts`,
54+
// which both work for global installs. Use the trusted display identity
55+
// so the suggested `npm config set` value matches what the policy matches
56+
// on, not the tarball's self-reported name.
57+
const names = unreviewed.map(({ node }) => trustedDisplay(node).name)
58+
const remediation = npm.global
59+
? 'Allow them with `--allow-scripts`, persist them with ' +
60+
`\`${configSetAllowScripts(names)}\`, or bypass this ` +
61+
'check with `--dangerously-allow-all-scripts`.'
62+
: 'Approve them with `npm approve-scripts`, deny them with ' +
63+
'`npm deny-scripts`, or bypass this check with ' +
64+
'`--dangerously-allow-all-scripts`.'
65+
4966
throw Object.assign(
5067
new Error(
5168
`--strict-allow-scripts: ${unreviewed.length} package(s) have install ` +
5269
`scripts not covered by allowScripts:\n${lines}\n` +
53-
'Approve them with `npm approve-scripts`, deny them with ' +
54-
'`npm deny-scripts`, or bypass this check with ' +
55-
'`--dangerously-allow-all-scripts`.'
70+
remediation
5671
),
5772
{ code: 'ESTRICTALLOWSCRIPTS' }
5873
)

test/lib/commands/rebuild.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,31 @@ t.test('emits Phase 1 advisory warning for unreviewed install scripts', async t
244244
)
245245
})
246246

247+
t.test('global advisory warning points at npm config set, not approve-scripts', async t => {
248+
const { npm, logs } = await setupMockNpm(t, {
249+
config: {
250+
global: true,
251+
},
252+
globalPrefixDir: {
253+
node_modules: {
254+
canvas: {
255+
'index.js': '',
256+
'package.json': JSON.stringify({
257+
name: 'canvas',
258+
version: '1.0.0',
259+
scripts: { install: 'echo install' },
260+
}),
261+
},
262+
},
263+
},
264+
})
265+
await npm.exec('rebuild', [])
266+
const warn = logs.warn.byTitle('rebuild').join('\n')
267+
t.match(warn, /install scripts not yet covered by allowScripts/)
268+
t.match(warn, /npm config set allow-scripts=canvas/)
269+
t.notMatch(warn, /approve-scripts/)
270+
})
271+
247272
t.test('no advisory warning when allowScripts covers the package', async t => {
248273
const { npm, logs } = await setupMockNpm(t, {
249274
prefixDir: {

test/lib/utils/reify-output.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,39 @@ t.test('prints unreviewed install scripts summary', async t => {
487487
t.match(warn, /npm approve-scripts --allow-scripts-pending/)
488488
})
489489

490+
t.test('global install suggests --allow-scripts, not approve-scripts', async t => {
491+
const mockReifyWithExtras = async (t, reify, extras, config = {}) => {
492+
const mock = await mockNpm(t, { config })
493+
reifyOutput(mock.npm, reify, extras)
494+
mock.npm.finish()
495+
return mock
496+
}
497+
498+
const baseReify = {
499+
actualTree: { name: 'host', inventory: { has: () => false } },
500+
diff: { children: [] },
501+
}
502+
503+
const unreviewedScripts = [
504+
{
505+
node: { packageName: 'canvas', name: 'canvas', version: '2.11.0', path: '/x/canvas' },
506+
scripts: { install: 'node-gyp rebuild' },
507+
},
508+
{
509+
node: { packageName: 'sharp', name: 'sharp', version: '0.33.2', path: '/x/sharp' },
510+
scripts: { preinstall: 'pre', postinstall: 'post' },
511+
},
512+
]
513+
514+
const mock = await mockReifyWithExtras(t, baseReify, { unreviewedScripts }, { global: true })
515+
const warn = mock.logs.warn.byTitle('allow-scripts').join('\n')
516+
t.match(warn, /2 packages have install scripts not yet covered/)
517+
t.match(warn, /canvas@2\.11\.0 \(install: node-gyp rebuild\)/)
518+
t.match(warn, /npm install -g --allow-scripts=canvas,sharp/)
519+
t.match(warn, /npm config set allow-scripts=canvas,sharp/)
520+
t.notMatch(warn, /approve-scripts/)
521+
})
522+
490523
t.test('single unreviewed script uses singular wording', async t => {
491524
const mockReifyWithExtras = async (t, reify, extras) => {
492525
const mock = await mockNpm(t, {})

test/lib/utils/strict-allow-scripts-preflight.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,33 @@ t.test('error label falls back to node.name when package.version is missing', as
189189
{ message: /no-version-pkg \(install: node-gyp rebuild\)/ }
190190
)
191191
})
192+
193+
t.test('project-scoped error suggests approve-scripts / deny-scripts', async t => {
194+
const arb = makeArb({ ideal: tree([node({ name: 'canvas' })]) })
195+
await t.rejects(
196+
preflight({
197+
arb,
198+
npm: { flatOptions: { strictAllowScripts: true } },
199+
idealTreeOpts: {},
200+
}),
201+
{ message: /Approve them with `npm approve-scripts`/ }
202+
)
203+
})
204+
205+
t.test('global error points at --allow-scripts, not approve-scripts', async t => {
206+
const arb = makeArb({ ideal: tree([node({ name: 'canvas' })]) })
207+
await t.rejects(
208+
preflight({
209+
arb,
210+
npm: { global: true, flatOptions: { strictAllowScripts: true } },
211+
idealTreeOpts: {},
212+
}),
213+
(err) => {
214+
t.equal(err.code, 'ESTRICTALLOWSCRIPTS')
215+
t.match(err.message, /--allow-scripts/)
216+
t.match(err.message, /npm config set allow-scripts=canvas/)
217+
t.notMatch(err.message, /approve-scripts/)
218+
return true
219+
}
220+
)
221+
})

0 commit comments

Comments
 (0)