Skip to content

Commit 3b20a3b

Browse files
committed
feat: allowScripts tooling and inBundle hardening (backportable)
Behavior-neutral additive tooling split out of #9424 so it can land on v11 without the v12 default-deny flip: - arborist: add collectUnreviewedScripts() + strictAllowScriptsError (ESTRICTALLOWSCRIPTS) helpers in unreviewed-scripts.js - arborist: isScriptAllowed() returns null for bundled deps; propagate inBundle through isolated reifier / isolated-classes - libnpmexec: opt-in strict-allow-scripts preflight (only under --strict-allow-scripts); no default behavior change - cli: rewrite check-allow-scripts as a wrapper over collectUnreviewedScripts; exclude bundled deps from rebuild/runAll - tests for all of the above No default install-script behavior changes; the default-deny gate stays in #9424 for v12.
1 parent fe820b6 commit 3b20a3b

16 files changed

Lines changed: 548 additions & 167 deletions

lib/commands/rebuild.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class Rebuild extends ArboristWorkspaceCmd {
5656

5757
return spec
5858
})
59-
const nodes = tree.inventory.filter(node => this.isNode(specs, node))
59+
const nodes = [...tree.inventory.filter(node => this.isNode(specs, node))]
6060

6161
await strictAllowScriptsPreflight({ arb, npm: this.npm })
6262
await arb.rebuild({ nodes })
@@ -84,6 +84,13 @@ class Rebuild extends ArboristWorkspaceCmd {
8484
}
8585

8686
isNode (specs, node) {
87+
// Bundled dependencies are never selected by name. Their identity comes
88+
// from the bundling parent's tarball (a bundled folder can call itself
89+
// anything), so `npm rebuild bcrypt` must never target a bundled
90+
// `node_modules/bcrypt`. Their install scripts never run regardless.
91+
if (node.inBundle) {
92+
return false
93+
}
8794
return specs.some(spec => {
8895
if (spec.type === 'directory') {
8996
return node.path === spec.fetchSpec

lib/utils/allow-scripts-cmd.js

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -107,27 +107,10 @@ class AllowScriptsCmd extends BaseCommand {
107107
output.standard('No packages with unreviewed install scripts.')
108108
return
109109
}
110-
// Bundled dependencies cannot be allowlisted in Phase 1 (RFC defers
111-
// this to a follow-up because matching by name@version from the
112-
// bundled tarball would reintroduce manifest confusion). Exclude
113-
// them from `--all` so we don't silently write a policy entry under
114-
// attacker-controlled identity.
115-
const candidates = unreviewed.filter(({ node }) => !node.inBundle)
116-
const skipped = unreviewed.length - candidates.length
117-
if (skipped > 0) {
118-
/* istanbul ignore next: plural variant covered separately */
119-
const noun = skipped === 1 ? 'dependency' : 'dependencies'
120-
log.warn(
121-
this.logTitle,
122-
`Skipping ${skipped} bundled ${noun}; bundled deps with install ` +
123-
'scripts cannot be allowlisted in this release.'
124-
)
125-
}
126-
if (candidates.length === 0) {
127-
output.standard('No packages eligible for approval.')
128-
return
129-
}
130-
const groups = this.groupByPackage(candidates.map(({ node }) => node))
110+
// Bundled dependencies never appear in `unreviewed` (checkAllowScripts
111+
// skips them because they never run their install scripts and cannot
112+
// be allowlisted), so there is nothing extra to filter here.
113+
const groups = this.groupByPackage(unreviewed.map(({ node }) => node))
131114
await this.writePolicyChanges(groups)
132115
}
133116

lib/utils/check-allow-scripts.js

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,22 @@
1-
const isScriptAllowed = require('@npmcli/arborist/lib/script-allowed.js')
2-
const getInstallScripts = require('@npmcli/arborist/lib/install-scripts.js')
1+
const { collectUnreviewedScripts } = require('@npmcli/arborist/lib/unreviewed-scripts.js')
32

4-
// Walks arb.actualTree.inventory and returns the list of dep nodes that
5-
// have install-relevant lifecycle scripts and are not yet covered (or
6-
// explicitly denied) by the allowScripts policy.
3+
// Walks a tree's inventory and returns the list of dep nodes that have
4+
// install-relevant lifecycle scripts and are not yet covered (or explicitly
5+
// denied) by the allowScripts policy.
6+
//
7+
// Thin wrapper around arborist's shared `collectUnreviewedScripts`, mapping
8+
// the CLI's `({ arb, npm, tree })` shape onto the shared walk. Defaults to
9+
// `arb.actualTree` (post-reify) but accepts an explicit tree so callers can
10+
// pre-flight against the idealTree before scripts run.
711
//
812
// Returns an array of `{ node, scripts }` entries. `scripts` is an object
913
// describing the relevant lifecycle scripts that would run.
10-
11-
const checkAllowScripts = async ({ arb, npm, tree }) => {
12-
const ignoreScripts = !!arb.options?.ignoreScripts
13-
const dangerouslyAllowAll = !!npm?.flatOptions?.dangerouslyAllowAllScripts
14-
15-
if (ignoreScripts || dangerouslyAllowAll) {
16-
return []
17-
}
18-
19-
// Defaults to actualTree (post-reify) but accepts an explicit tree so
20-
// callers can pre-flight against the idealTree before scripts run.
21-
const targetTree = tree || arb.actualTree
22-
if (!targetTree?.inventory) {
23-
return []
24-
}
25-
26-
const policy = arb.options?.allowScripts || null
27-
28-
const unreviewed = []
29-
for (const node of targetTree.inventory.values()) {
30-
if (node.isProjectRoot || node.isWorkspace) {
31-
continue
32-
}
33-
if (node.isLink) {
34-
// Linked workspace dependencies are managed by the workspace owner.
35-
continue
36-
}
37-
38-
const verdict = isScriptAllowed(node, policy)
39-
if (verdict === true || verdict === false) {
40-
continue
41-
}
42-
43-
const scripts = await getInstallScripts(node)
44-
if (Object.keys(scripts).length === 0) {
45-
continue
46-
}
47-
48-
unreviewed.push({ node, scripts })
49-
}
50-
51-
return unreviewed
52-
}
14+
const checkAllowScripts = async ({ arb, npm, tree }) =>
15+
collectUnreviewedScripts({
16+
tree: tree || arb.actualTree,
17+
policy: arb.options?.allowScripts || null,
18+
ignoreScripts: !!arb.options?.ignoreScripts,
19+
dangerouslyAllowAllScripts: !!npm?.flatOptions?.dangerouslyAllowAllScripts,
20+
})
5321

5422
module.exports = checkAllowScripts

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

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const checkAllowScripts = require('./check-allow-scripts.js')
2+
const { strictAllowScriptsError } = require('@npmcli/arborist/lib/unreviewed-scripts.js')
23

34
// Pre-flight check for `--strict-allow-scripts`. Call after arborist has
45
// been constructed but before `arb.reify()` runs, so that install scripts
@@ -36,26 +37,12 @@ const strictAllowScriptsPreflight = async ({ arb, npm, idealTreeOpts }) => {
3637
return
3738
}
3839

39-
const lines = unreviewed.map(({ node, scripts }) => {
40-
const events = Object.entries(scripts)
41-
.map(([event, body]) => `${event}: ${body}`)
42-
.join('; ')
43-
const name = node.package?.name || node.name
44-
const version = node.package?.version || ''
45-
const label = version ? `${name}@${version}` : name
46-
return ` ${label} (${events})`
47-
}).join('\n')
48-
49-
throw Object.assign(
50-
new Error(
51-
`--strict-allow-scripts: ${unreviewed.length} package(s) have install ` +
52-
`scripts not covered by allowScripts:\n${lines}\n` +
40+
throw strictAllowScriptsError(unreviewed, {
41+
remediation:
5342
'Approve them with `npm approve-scripts`, deny them with ' +
5443
'`npm deny-scripts`, or bypass this check with ' +
55-
'`--dangerously-allow-all-scripts`.'
56-
),
57-
{ code: 'ESTRICTALLOWSCRIPTS' }
58-
)
44+
'`--dangerously-allow-all-scripts`.',
45+
})
5946
}
6047

6148
module.exports = strictAllowScriptsPreflight

test/lib/commands/approve-scripts.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -385,10 +385,10 @@ t.test('approve-scripts --pending lists packages that only have binding.gyp', as
385385
t.match(out, /install: node-gyp rebuild/, 'synthetic node-gyp install is named')
386386
})
387387

388-
t.test('approve-scripts --all skips bundled deps with a notice', async t => {
389-
// Bundled deps cannot be allowlisted in Phase 1 (RFC defers their
390-
// allowlisting to a follow-up). --all must not silently write a key
391-
// derived from the bundled tarball's self-claimed identity.
388+
t.test('approve-scripts --all never approves bundled deps', async t => {
389+
// Bundled deps never run their install scripts and cannot be
390+
// allowlisted. They never reach the unreviewed list, so --all must not
391+
// write a key derived from the bundled tarball's self-claimed identity.
392392
const { npm, logs, prefix } = await _mockNpm(t, {
393393
prefixDir: {
394394
'package.json': JSON.stringify({
@@ -445,7 +445,8 @@ t.test('approve-scripts --all skips bundled deps with a notice', async t => {
445445
'non-bundled parent gets approved')
446446
t.notOk(Object.keys(pkg.allowScripts).some(k => k.startsWith('inner')),
447447
'bundled inner is not approved')
448-
t.match(logs.warn.byTitle('approve-scripts'), [/Skipping 1 bundled dependency/])
448+
t.strictSame(logs.warn.byTitle('approve-scripts'), [],
449+
'no warning; bundled deps are excluded upstream')
449450
})
450451

451452
t.test('approve-scripts <bundled-pkg> positional is ignored', async t => {
@@ -505,7 +506,7 @@ t.test('approve-scripts <bundled-pkg> positional is ignored', async t => {
505506
)
506507
})
507508

508-
t.test('approve-scripts --all with only bundled deps prints "no eligible" notice', async t => {
509+
t.test('approve-scripts --all with only bundled deps has nothing to review', async t => {
509510
const { npm, logs, joinedOutput, prefix } = await _mockNpm(t, {
510511
prefixDir: {
511512
'package.json': JSON.stringify({
@@ -554,8 +555,9 @@ t.test('approve-scripts --all with only bundled deps prints "no eligible" notice
554555
config: { all: true },
555556
})
556557
await npm.exec('approve-scripts', [])
557-
t.match(joinedOutput(), /No packages eligible for approval/)
558-
t.match(logs.warn.byTitle('approve-scripts'), [/Skipping 1 bundled dependency/])
558+
t.match(joinedOutput(), /No packages with unreviewed install scripts/)
559+
t.strictSame(logs.warn.byTitle('approve-scripts'), [],
560+
'no warning; bundled deps are excluded upstream')
559561
// Ensure no policy entry was written.
560562
const pkg = JSON.parse(fs.readFileSync(resolve(prefix, 'package.json'), 'utf8'))
561563
t.notOk(pkg.allowScripts, 'no allowScripts written')

test/lib/commands/rebuild.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,49 @@ t.test('no advisory warning when allowScripts covers the package', async t => {
281281
await npm.exec('rebuild', [])
282282
t.strictSame(logs.warn.byTitle('rebuild'), [])
283283
})
284+
285+
t.test('rebuild <name> never targets a bundled dependency', async t => {
286+
const { npm, prefix: path } = await setupMockNpm(t, {
287+
prefixDir: {
288+
'package.json': JSON.stringify({
289+
name: 'host',
290+
version: '1.0.0',
291+
dependencies: { parent: '1.0.0' },
292+
}),
293+
node_modules: {
294+
parent: {
295+
'index.js': '',
296+
'package.json': JSON.stringify({
297+
name: 'parent',
298+
version: '1.0.0',
299+
bundleDependencies: ['bcrypt'],
300+
dependencies: { bcrypt: '1.0.0' },
301+
}),
302+
node_modules: {
303+
bcrypt: {
304+
'index.js': '',
305+
'package.json': JSON.stringify({
306+
name: 'bcrypt',
307+
version: '1.0.0',
308+
bin: 'index.js',
309+
scripts: {
310+
install: "node -e \"require('fs').writeFileSync('ran', '')\"",
311+
},
312+
}),
313+
},
314+
},
315+
},
316+
},
317+
},
318+
})
319+
320+
const ranFile = resolve(path, 'node_modules/parent/node_modules/bcrypt/ran')
321+
t.throws(() => fs.statSync(ranFile))
322+
323+
await npm.exec('rebuild', ['bcrypt'])
324+
325+
t.throws(
326+
() => fs.statSync(ranFile),
327+
'bundled bcrypt install script must not run'
328+
)
329+
})

test/lib/utils/check-allow-scripts.js

Lines changed: 5 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -135,46 +135,6 @@ t.test('prepare counts for non-registry sources only', async t => {
135135
t.equal(result[0].node.name, 'git-pkg')
136136
})
137137

138-
t.test('detects synthetic node-gyp via binding.gyp runtime check', async t => {
139-
const checkAllowScripts = mockCheck(t, {
140-
'@npmcli/arborist/lib/install-scripts.js': async (n) => {
141-
if (n.path === '/has-bindings') {
142-
return { install: 'node-gyp rebuild' }
143-
}
144-
return {}
145-
},
146-
})
147-
148-
const result = await checkAllowScripts({
149-
arb: arb({
150-
nodes: [
151-
node({ name: 'native', path: '/has-bindings' }),
152-
node({ name: 'pure-js', path: '/no-bindings' }),
153-
],
154-
}),
155-
npm: { flatOptions: {} },
156-
})
157-
t.equal(result.length, 1)
158-
t.equal(result[0].node.name, 'native')
159-
t.strictSame(result[0].scripts, { install: 'node-gyp rebuild' })
160-
})
161-
162-
t.test('skips node-gyp detection when gypfile is explicitly false', async t => {
163-
// Mock returns no scripts to simulate the gypfile:false short-circuit
164-
// inside getInstallScripts.
165-
const checkAllowScripts = mockCheck(t, {
166-
'@npmcli/arborist/lib/install-scripts.js': async () => ({}),
167-
})
168-
169-
const result = await checkAllowScripts({
170-
arb: arb({
171-
nodes: [node({ name: 'opt-out', gypfile: false })],
172-
}),
173-
npm: { flatOptions: {} },
174-
})
175-
t.strictSame(result, [])
176-
})
177-
178138
t.test('skips approved nodes', async t => {
179139
const checkAllowScripts = mockCheck(t)
180140
const result = await checkAllowScripts({
@@ -238,7 +198,7 @@ t.test('survives missing actualTree', async t => {
238198
t.strictSame(result, [])
239199
})
240200

241-
t.test('bundled dep with install scripts is reported as unreviewed regardless of policy', async t => {
201+
t.test('bundled dep with install scripts is never reported (never runs, never pending)', async t => {
242202
const checkAllowScripts = mockCheck(t)
243203
const bundled = node({
244204
name: 'bundled-pkg',
@@ -251,13 +211,12 @@ t.test('bundled dep with install scripts is reported as unreviewed regardless of
251211
const result = await checkAllowScripts({
252212
arb: arb({
253213
nodes: [bundled],
254-
// Policy explicitly allows the bundled name — the matcher should
255-
// still return null and the walker should still flag the bundled
256-
// dep as unreviewed.
214+
// Even with an explicit allow entry, a bundled dep never runs its
215+
// install scripts and is never counted as pending, so the walker
216+
// must not flag it.
257217
allowScripts: { 'bundled-pkg': true },
258218
}),
259219
npm: { flatOptions: {} },
260220
})
261-
t.equal(result.length, 1, 'bundled dep flagged despite explicit allow entry')
262-
t.equal(result[0].node, bundled)
221+
t.strictSame(result, [], 'bundled dep never flagged')
263222
})

workspaces/arborist/lib/arborist/isolated-reifier.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ module.exports = cls => class IsolatedReifier extends cls {
3838
#processedEdges = new Set()
3939
#workspaceProxies = new Map()
4040

41-
#generateChild (node, location, pkg, isInStore, root) {
41+
#generateChild (node, location, pkg, isInStore, root, inBundle = false) {
4242
const newChild = new IsolatedNode({
4343
isInStore,
44+
inBundle,
4445
location,
4546
name: node.packageName || node.name,
4647
optional: node.optional,
@@ -327,7 +328,7 @@ module.exports = cls => class IsolatedReifier extends cls {
327328
})
328329

329330
bundledTree.nodes.forEach(node => {
330-
this.#generateChild(node, node.location, node.pkg, false, root)
331+
this.#generateChild(node, node.location, node.pkg, false, root, true)
331332
})
332333

333334
bundledTree.edges.forEach(edge => {

workspaces/arborist/lib/isolated-classes.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class IsolatedNode {
1919
integrity = null
2020
inventory = new IsolatedInventory()
2121
isInStore = false
22+
inBundle = false
2223
linksIn = new Set()
2324
meta = { loadedFromDisk: false }
2425
optional = false
@@ -46,6 +47,9 @@ class IsolatedNode {
4647
if (options.isInStore) {
4748
this.isInStore = true
4849
}
50+
if (options.inBundle) {
51+
this.inBundle = true
52+
}
4953
if (options.optional) {
5054
this.optional = true
5155
}

0 commit comments

Comments
 (0)