Skip to content

Commit 8ff3e48

Browse files
feat: allowScripts tooling and inBundle hardening (#9483)
Backport of #9480 to `release/v11`. Co-authored-by: Jamie Magee <jamie.magee@gmail.com>
1 parent 9dd219b commit 8ff3e48

15 files changed

Lines changed: 553 additions & 154 deletions

lib/commands/rebuild.js

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

5959
return spec
6060
})
61-
const nodes = tree.inventory.filter(node => this.isNode(specs, node))
61+
const nodes = [...tree.inventory.filter(node => this.isNode(specs, node))]
6262

6363
await strictAllowScriptsPreflight({ arb, npm: this.npm })
6464
await arb.rebuild({ nodes })
@@ -93,6 +93,13 @@ class Rebuild extends ArboristWorkspaceCmd {
9393
}
9494

9595
isNode (specs, node) {
96+
// Bundled dependencies are never selected by name. Their identity comes
97+
// from the bundling parent's tarball (a bundled folder can call itself
98+
// anything), so `npm rebuild bcrypt` must never target a bundled
99+
// `node_modules/bcrypt`. Their install scripts never run regardless.
100+
if (node.inBundle) {
101+
return false
102+
}
96103
return specs.some(spec => {
97104
if (spec.type === 'directory') {
98105
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
@@ -110,27 +110,10 @@ class AllowScriptsCmd extends BaseCommand {
110110
output.standard('No packages with unreviewed install scripts.')
111111
return
112112
}
113-
// Bundled dependencies cannot be allowlisted in Phase 1 (RFC defers
114-
// this to a follow-up because matching by name@version from the
115-
// bundled tarball would reintroduce manifest confusion). Exclude
116-
// them from `--all` so we don't silently write a policy entry under
117-
// attacker-controlled identity.
118-
const candidates = unreviewed.filter(({ node }) => !node.inBundle)
119-
const skipped = unreviewed.length - candidates.length
120-
if (skipped > 0) {
121-
/* istanbul ignore next: plural variant covered separately */
122-
const noun = skipped === 1 ? 'dependency' : 'dependencies'
123-
log.warn(
124-
this.logTitle,
125-
`Skipping ${skipped} bundled ${noun}; bundled deps with install ` +
126-
'scripts cannot be allowlisted in this release.'
127-
)
128-
}
129-
if (candidates.length === 0) {
130-
output.standard('No packages eligible for approval.')
131-
return
132-
}
133-
const groups = this.groupByPackage(candidates.map(({ node }) => node))
113+
// Bundled dependencies never appear in `unreviewed` (checkAllowScripts
114+
// skips them because they never run their install scripts and cannot
115+
// be allowlisted), so there is nothing extra to filter here.
116+
const groups = this.groupByPackage(unreviewed.map(({ node }) => node))
134117
await this.writePolicyChanges(groups)
135118
}
136119

lib/utils/check-allow-scripts.js

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,27 @@
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, includeWhenIgnored = false }) => {
12-
const ignoreScripts = !!arb.options?.ignoreScripts
13-
const dangerouslyAllowAll = !!npm?.flatOptions?.dangerouslyAllowAllScripts
14-
15-
// With ignore-scripts set, no scripts run, so execution callers
16-
// (install, rebuild, strict preflight) bail out here. approve/deny pass
17-
// includeWhenIgnored so they keep listing unreviewed packages, which is
18-
// what you need to move from a blanket ignore-scripts to an allowlist.
19-
// Listing never runs anything.
20-
if ((ignoreScripts && !includeWhenIgnored) || dangerouslyAllowAll) {
21-
return []
22-
}
23-
24-
// Defaults to actualTree (post-reify) but accepts an explicit tree so
25-
// callers can pre-flight against the idealTree before scripts run.
26-
const targetTree = tree || arb.actualTree
27-
if (!targetTree?.inventory) {
28-
return []
29-
}
30-
31-
const policy = arb.options?.allowScripts || null
32-
33-
const unreviewed = []
34-
for (const node of targetTree.inventory.values()) {
35-
if (node.isProjectRoot || node.isWorkspace) {
36-
continue
37-
}
38-
if (node.isLink) {
39-
// Linked workspace dependencies are managed by the workspace owner.
40-
continue
41-
}
42-
43-
const verdict = isScriptAllowed(node, policy)
44-
if (verdict === true || verdict === false) {
45-
continue
46-
}
47-
48-
const scripts = await getInstallScripts(node)
49-
if (Object.keys(scripts).length === 0) {
50-
continue
51-
}
52-
53-
unreviewed.push({ node, scripts })
54-
}
55-
56-
return unreviewed
57-
}
14+
//
15+
// `includeWhenIgnored` keeps listing unreviewed packages even when
16+
// ignore-scripts is set, so approve/deny can show what you'd move from a
17+
// blanket ignore-scripts to an allowlist. Execution callers leave it false.
18+
const checkAllowScripts = async ({ arb, npm, tree, includeWhenIgnored = false }) =>
19+
collectUnreviewedScripts({
20+
tree: tree || arb.actualTree,
21+
policy: arb.options?.allowScripts || null,
22+
ignoreScripts: !!arb.options?.ignoreScripts,
23+
dangerouslyAllowAllScripts: !!npm?.flatOptions?.dangerouslyAllowAllScripts,
24+
includeWhenIgnored,
25+
})
5826

5927
module.exports = checkAllowScripts

test/lib/commands/approve-scripts.js

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

400-
t.test('approve-scripts --all skips bundled deps with a notice', async t => {
401-
// Bundled deps cannot be allowlisted in Phase 1 (RFC defers their
402-
// allowlisting to a follow-up). --all must not silently write a key
403-
// derived from the bundled tarball's self-claimed identity.
400+
t.test('approve-scripts --all never approves bundled deps', async t => {
401+
// Bundled deps never run their install scripts and cannot be
402+
// allowlisted. They never reach the unreviewed list, so --all must not
403+
// write a key derived from the bundled tarball's self-claimed identity.
404404
const { npm, logs, prefix } = await _mockNpm(t, {
405405
prefixDir: {
406406
'package.json': JSON.stringify({
@@ -457,7 +457,8 @@ t.test('approve-scripts --all skips bundled deps with a notice', async t => {
457457
'non-bundled parent gets approved')
458458
t.notOk(Object.keys(pkg.allowScripts).some(k => k.startsWith('inner')),
459459
'bundled inner is not approved')
460-
t.match(logs.warn.byTitle('approve-scripts'), [/Skipping 1 bundled dependency/])
460+
t.strictSame(logs.warn.byTitle('approve-scripts'), [],
461+
'no warning; bundled deps are excluded upstream')
461462
})
462463

463464
t.test('approve-scripts <bundled-pkg> positional is ignored', async t => {
@@ -517,7 +518,7 @@ t.test('approve-scripts <bundled-pkg> positional is ignored', async t => {
517518
)
518519
})
519520

520-
t.test('approve-scripts --all with only bundled deps prints "no eligible" notice', async t => {
521+
t.test('approve-scripts --all with only bundled deps has nothing to review', async t => {
521522
const { npm, logs, joinedOutput, prefix } = await _mockNpm(t, {
522523
prefixDir: {
523524
'package.json': JSON.stringify({
@@ -566,8 +567,9 @@ t.test('approve-scripts --all with only bundled deps prints "no eligible" notice
566567
config: { all: true },
567568
})
568569
await npm.exec('approve-scripts', [])
569-
t.match(joinedOutput(), /No packages eligible for approval/)
570-
t.match(logs.warn.byTitle('approve-scripts'), [/Skipping 1 bundled dependency/])
570+
t.match(joinedOutput(), /No packages with unreviewed install scripts/)
571+
t.strictSame(logs.warn.byTitle('approve-scripts'), [],
572+
'no warning; bundled deps are excluded upstream')
571573
// Ensure no policy entry was written.
572574
const pkg = JSON.parse(fs.readFileSync(resolve(prefix, 'package.json'), 'utf8'))
573575
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
@@ -306,3 +306,49 @@ t.test('no advisory warning when allowScripts covers the package', async t => {
306306
await npm.exec('rebuild', [])
307307
t.strictSame(logs.warn.byTitle('rebuild'), [])
308308
})
309+
310+
t.test('rebuild <name> never targets a bundled dependency', async t => {
311+
const { npm, prefix: path } = await setupMockNpm(t, {
312+
prefixDir: {
313+
'package.json': JSON.stringify({
314+
name: 'host',
315+
version: '1.0.0',
316+
dependencies: { parent: '1.0.0' },
317+
}),
318+
node_modules: {
319+
parent: {
320+
'index.js': '',
321+
'package.json': JSON.stringify({
322+
name: 'parent',
323+
version: '1.0.0',
324+
bundleDependencies: ['bcrypt'],
325+
dependencies: { bcrypt: '1.0.0' },
326+
}),
327+
node_modules: {
328+
bcrypt: {
329+
'index.js': '',
330+
'package.json': JSON.stringify({
331+
name: 'bcrypt',
332+
version: '1.0.0',
333+
bin: 'index.js',
334+
scripts: {
335+
install: "node -e \"require('fs').writeFileSync('ran', '')\"",
336+
},
337+
}),
338+
},
339+
},
340+
},
341+
},
342+
},
343+
})
344+
345+
const ranFile = resolve(path, 'node_modules/parent/node_modules/bcrypt/ran')
346+
t.throws(() => fs.statSync(ranFile))
347+
348+
await npm.exec('rebuild', ['bcrypt'])
349+
350+
t.throws(
351+
() => fs.statSync(ranFile),
352+
'bundled bcrypt install script must not run'
353+
)
354+
})

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

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

152-
t.test('detects synthetic node-gyp via binding.gyp runtime check', async t => {
153-
const checkAllowScripts = mockCheck(t, {
154-
'@npmcli/arborist/lib/install-scripts.js': async (n) => {
155-
if (n.path === '/has-bindings') {
156-
return { install: 'node-gyp rebuild' }
157-
}
158-
return {}
159-
},
160-
})
161-
162-
const result = await checkAllowScripts({
163-
arb: arb({
164-
nodes: [
165-
node({ name: 'native', path: '/has-bindings' }),
166-
node({ name: 'pure-js', path: '/no-bindings' }),
167-
],
168-
}),
169-
npm: { flatOptions: {} },
170-
})
171-
t.equal(result.length, 1)
172-
t.equal(result[0].node.name, 'native')
173-
t.strictSame(result[0].scripts, { install: 'node-gyp rebuild' })
174-
})
175-
176-
t.test('skips node-gyp detection when gypfile is explicitly false', async t => {
177-
// Mock returns no scripts to simulate the gypfile:false short-circuit
178-
// inside getInstallScripts.
179-
const checkAllowScripts = mockCheck(t, {
180-
'@npmcli/arborist/lib/install-scripts.js': async () => ({}),
181-
})
182-
183-
const result = await checkAllowScripts({
184-
arb: arb({
185-
nodes: [node({ name: 'opt-out', gypfile: false })],
186-
}),
187-
npm: { flatOptions: {} },
188-
})
189-
t.strictSame(result, [])
190-
})
191-
192152
t.test('skips approved nodes', async t => {
193153
const checkAllowScripts = mockCheck(t)
194154
const result = await checkAllowScripts({
@@ -252,7 +212,7 @@ t.test('survives missing actualTree', async t => {
252212
t.strictSame(result, [])
253213
})
254214

255-
t.test('bundled dep with install scripts is reported as unreviewed regardless of policy', async t => {
215+
t.test('bundled dep with install scripts is never reported (never runs, never pending)', async t => {
256216
const checkAllowScripts = mockCheck(t)
257217
const bundled = node({
258218
name: 'bundled-pkg',
@@ -265,13 +225,12 @@ t.test('bundled dep with install scripts is reported as unreviewed regardless of
265225
const result = await checkAllowScripts({
266226
arb: arb({
267227
nodes: [bundled],
268-
// Policy explicitly allows the bundled name — the matcher should
269-
// still return null and the walker should still flag the bundled
270-
// dep as unreviewed.
228+
// Even with an explicit allow entry, a bundled dep never runs its
229+
// install scripts and is never counted as pending, so the walker
230+
// must not flag it.
271231
allowScripts: { 'bundled-pkg': true },
272232
}),
273233
npm: { flatOptions: {} },
274234
})
275-
t.equal(result.length, 1, 'bundled dep flagged despite explicit allow entry')
276-
t.equal(result[0].node, bundled)
235+
t.strictSame(result, [], 'bundled dep never flagged')
277236
})

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

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

43-
#generateChild (node, location, pkg, isInStore, root) {
43+
#generateChild (node, location, pkg, isInStore, root, inBundle = false) {
4444
const newChild = new IsolatedNode({
4545
isInStore,
46+
inBundle,
4647
location,
4748
name: node.packageName || node.name,
4849
optional: node.optional,
@@ -372,7 +373,7 @@ module.exports = cls => class IsolatedReifier extends cls {
372373
})
373374

374375
bundledTree.nodes.forEach(node => {
375-
this.#generateChild(node, node.location, node.pkg, false, root)
376+
this.#generateChild(node, node.location, node.pkg, false, root, true)
376377
})
377378

378379
bundledTree.edges.forEach(edge => {

workspaces/arborist/lib/isolated-classes.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class IsolatedNode {
2020
integrity = null
2121
inventory = new IsolatedInventory()
2222
isInStore = false
23+
inBundle = false
2324
linksIn = new Set()
2425
meta = { loadedFromDisk: false }
2526
optional = false
@@ -47,6 +48,9 @@ class IsolatedNode {
4748
if (options.isInStore) {
4849
this.isInStore = true
4950
}
51+
if (options.inBundle) {
52+
this.inBundle = true
53+
}
5054
if (options.optional) {
5155
this.optional = true
5256
}

workspaces/arborist/lib/script-allowed.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ const versionFromTgz = require('./version-from-tgz.js')
2424
// resolved committish
2525

2626
const isScriptAllowed = (node, policy) => {
27-
// Bundled dependencies cannot be allowlisted in Phase 1. The RFC defers
28-
// allowlisting them to a follow-up RFC because matching by name@version
29-
// from the bundled tarball would reintroduce manifest confusion (a
30-
// bundled tarball can claim any name and version). Returning null here
31-
// marks bundled deps as unreviewed regardless of any policy entries, so
32-
// their install scripts surface in the Phase 1 advisory warning and
33-
// (eventually) get blocked at the install-time gate.
27+
// Bundled dependencies never run their install scripts and cannot be
28+
// allowlisted. Matching by name@version from the bundled tarball would
29+
// reintroduce manifest confusion (a bundled tarball can claim any name
30+
// and version). Returning null marks them as not-allowed regardless of
31+
// any policy entry, so their install scripts are blocked by the
32+
// install-time gate. A package that needs a bundled dep's script must
33+
// forward it as one of its own lifecycle scripts.
3434
if (node.inBundle) {
3535
return null
3636
}
@@ -319,11 +319,11 @@ const isRegistryNode = (node) => {
319319
return /^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
320320
}
321321

322-
// Trusted display identity for human-facing output (`npm install`
323-
// advisory, `npm approve-scripts --allow-scripts-pending`). Same idea as
324-
// getTrustedRegistryIdentity, but for DISPLAY onlyversion falls back
325-
// to node.version when the URL doesn't carry one. Must never be used
326-
// for policy matching.
322+
// Trusted display identity for human-facing output (the `npm install`
323+
// blocked-scripts summary and `npm approve-scripts --allow-scripts-pending`).
324+
// Same as getTrustedRegistryIdentity, but for display only: version
325+
// falls back to node.version when the URL doesn't carry one. Do not
326+
// use for policy matching.
327327
const trustedDisplay = (node) => {
328328
const trusted = getTrustedRegistryIdentity(node)
329329
/* istanbul ignore next: defensive fallbacks for nodes without name/version */

0 commit comments

Comments
 (0)