@npmcli/arborist 9.6.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ const PlaceDep = require('../place-dep.js')
24
24
  const debug = require('../debug.js')
25
25
  const fromPath = require('../from-path.js')
26
26
  const calcDepFlags = require('../calc-dep-flags.js')
27
+ const { isReleaseAgeExcluded, trustedSpecName } = require('../release-age-exclude.js')
27
28
  const Shrinkwrap = require('../shrinkwrap.js')
28
29
  const { defaultLockfileVersion } = Shrinkwrap
29
30
  const Node = require('../node.js')
@@ -533,7 +534,11 @@ module.exports = cls => class IdealTreeBuilder extends cls {
533
534
  // look up the names of file/directory/git specs
534
535
  if (!spec.name || isTag) {
535
536
  const _isRoot = tree.isProjectRoot || tree.isWorkspace
536
- const mani = await pacote.manifest(spec, { ...this.options, _isRoot })
537
+ const mani = await pacote.manifest(spec, {
538
+ ...this.options,
539
+ _isRoot,
540
+ before: this.#releaseAgeBefore(spec),
541
+ })
537
542
  if (isTag) {
538
543
  // translate tag to a version
539
544
  spec = npa(`${mani.name}@${mani.version}`)
@@ -777,6 +782,7 @@ This is a one-time fix-up, please be patient...
777
782
  resolved: resolved,
778
783
  integrity: integrity,
779
784
  fullMetadata: false,
785
+ before: this.#releaseAgeBefore(spec),
780
786
  })
781
787
  node.package = { ...mani, _id: `${mani.name}@${mani.version}` }
782
788
  } catch (er) {
@@ -875,7 +881,7 @@ This is a one-time fix-up, please be patient...
875
881
 
876
882
  if (hasShrinkwrap) {
877
883
  await new Arborist({ ...this.options, path })
878
- .loadVirtual({ root: node })
884
+ .loadVirtual({ root: node, subtreeOnly: true })
879
885
  }
880
886
 
881
887
  if (hasBundle) {
@@ -1279,6 +1285,19 @@ This is a one-time fix-up, please be patient...
1279
1285
  return problems
1280
1286
  }
1281
1287
 
1288
+ // The effective `before` filter for a package, applying `min-release-age-exclude`.
1289
+ // Returns null (no age filter) for an exempted package, otherwise the
1290
+ // configured `before`. The exemption is keyed on the spec's trusted registry
1291
+ // identity (alias targets are unwrapped) so an `npm:` alias key cannot disable
1292
+ // the filter for the package it actually resolves to.
1293
+ #releaseAgeBefore (spec) {
1294
+ const { before, minReleaseAgeExclude } = this.options
1295
+ if (!before) {
1296
+ return before
1297
+ }
1298
+ return isReleaseAgeExcluded(trustedSpecName(spec), minReleaseAgeExclude) ? null : before
1299
+ }
1300
+
1282
1301
  async #fetchManifest (spec, parent, edge) {
1283
1302
  // Enforce allow-* gates before consulting the manifest cache so a cached entry from a different edge cannot bypass the policy.
1284
1303
  this.#checkAllow(spec, edge)
@@ -1286,6 +1305,7 @@ This is a one-time fix-up, please be patient...
1286
1305
  ...this.options,
1287
1306
  avoid: this.#avoidRange(spec.name),
1288
1307
  fullMetadata: true,
1308
+ before: this.#releaseAgeBefore(spec),
1289
1309
  _isRoot: !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace),
1290
1310
  }
1291
1311
  // get the intended spec and stored metadata from yarn.lock file,
@@ -100,8 +100,10 @@ class Arborist extends Base {
100
100
  nodeVersion: process.version,
101
101
  ...options,
102
102
  Arborist: this.constructor,
103
+ allowScripts: options.allowScripts ?? null,
103
104
  binLinks: 'binLinks' in options ? !!options.binLinks : true,
104
105
  cache: options.cache || `${homedir()}/.npm/_cacache`,
106
+ dangerouslyAllowAllScripts: !!options.dangerouslyAllowAllScripts,
105
107
  dryRun: !!options.dryRun,
106
108
  formatPackageLock: 'formatPackageLock' in options ? !!options.formatPackageLock : true,
107
109
  force: !!options.force,
@@ -4,6 +4,7 @@ const { join } = require('node:path')
4
4
  const { depth } = require('treeverse')
5
5
  const crypto = require('node:crypto')
6
6
  const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
7
+ const nameFromFolder = require('@npmcli/name-from-folder')
7
8
 
8
9
  // generate short hash key based on the dependency tree starting at this node
9
10
  const getKey = (startNode) => {
@@ -39,9 +40,12 @@ module.exports = cls => class IsolatedReifier extends cls {
39
40
  #processedEdges = new Set()
40
41
  #workspaceProxies = new Map()
41
42
 
42
- #generateChild (node, location, pkg, isInStore, root) {
43
+ #generateChild (node, location, pkg, isInStore, root, inBundle = false) {
43
44
  const newChild = new IsolatedNode({
44
45
  isInStore,
46
+ inBundle,
47
+ isRegistryDependency: node.isRegistryDependency,
48
+ isRootDependency: node.isRootDependency,
45
49
  location,
46
50
  name: node.packageName || node.name,
47
51
  optional: node.optional,
@@ -151,11 +155,14 @@ module.exports = cls => class IsolatedReifier extends cls {
151
155
  this.#externalProxies.set(node, result)
152
156
  await this.#assignCommonProperties(node, result, !node.hasShrinkwrap)
153
157
  if (node.hasShrinkwrap) {
158
+ // strip any path traversal from package.json name fields before they hit path.join below
159
+ /* istanbul ignore next - packageName is always set for real packages */
160
+ const safeName = nameFromFolder(node.packageName || node.path)
154
161
  const dir = join(
155
162
  node.root.path,
156
163
  'node_modules',
157
164
  '.store',
158
- `${node.packageName}@${node.version}`
165
+ `${safeName}@${node.version}`
159
166
  )
160
167
  mkdirSync(dir, { recursive: true })
161
168
  // TODO this approach feels wrong and shouldn't be necessary for shrinkwraps
@@ -189,6 +196,13 @@ module.exports = cls => class IsolatedReifier extends cls {
189
196
  result.optional = node.optional
190
197
  result.resolved = node.resolved
191
198
  result.version = node.version
199
+ // Carry the source node's registry-dependency flag so the store node retains it.
200
+ // IsolatedNode has no edges to recompute it from, and reify's registry-tarball allow-remote exemption depends on it.
201
+ result.isRegistryDependency = node.isRegistryDependency
202
+ // Same reasoning for allow-remote=root: the store node has no edgesIn, so capture from the source node whether it satisfies a valid edge from the project root or a workspace.
203
+ result.isRootDependency = [...node.edgesIn].some(e =>
204
+ e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
205
+ )
192
206
  return result
193
207
  }
194
208
 
@@ -198,7 +212,8 @@ module.exports = cls => class IsolatedReifier extends cls {
198
212
  result.id = this.counter++
199
213
  /* istanbul ignore next - packageName is always set for real packages */
200
214
  result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
201
- result.packageName = node.packageName || node.name
215
+ // strip any path traversal from package.json name fields before they hit path.join below
216
+ result.packageName = nameFromFolder(node.packageName || node.path)
202
217
  result.package = { ...node.package }
203
218
  result.package.bundleDependencies = undefined
204
219
 
@@ -253,6 +268,22 @@ module.exports = cls => class IsolatedReifier extends cls {
253
268
  // local `file:` deps (non-workspace fsChildren) should be treated as local dependencies, not external, so they get symlinked directly instead of being extracted into the store.
254
269
  const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n)
255
270
  const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)
271
+
272
+ // Optional peers declared only in peerDependenciesMeta (e.g. `@types/react`) have no edge, so the materialization above misses them.
273
+ // Resolve each from the tree and link it; if nobody provides it, node.resolve finds nothing and it stays omitted.
274
+ const peerMeta = node.package.peerDependenciesMeta
275
+ if (peerMeta) {
276
+ const resolvedNames = new Set([...nonOptionalDeps, ...optionalDeps].map(n => n.name))
277
+ for (const peerName in peerMeta) {
278
+ if (!peerMeta[peerName]?.optional || resolvedNames.has(peerName)) {
279
+ continue
280
+ }
281
+ const resolved = node.resolve(peerName)?.target
282
+ if (resolved && resolved !== node && !resolved.inert && !isLocal(resolved)) {
283
+ optionalDeps.push(resolved)
284
+ }
285
+ }
286
+ }
256
287
  result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.#workspaceProxy(n)))
257
288
  result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.#externalProxy(n)))
258
289
  result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(n => this.#externalProxy(n)))
@@ -335,7 +366,8 @@ module.exports = cls => class IsolatedReifier extends cls {
335
366
  root.inventory.set(workspace.location, workspace)
336
367
  root.workspaces.set(wsName, workspace.path)
337
368
 
338
- // Create workspace Link. For root declared deps, link at root node_modules/. For undeclared deps, link at the workspace's own node_modules/ (self-link).
369
+ // Declared workspaces are symlinked at root node_modules/.
370
+ // Undeclared workspaces get a tree-only Link kept for diff/filter participation but not materialized on disk.
339
371
  const isDeclared = this.#rootDeclaredDeps.has(wsName)
340
372
  const wsLink = new IsolatedLink({
341
373
  location: isDeclared ? join('node_modules', wsName) : join(c.localLocation, 'node_modules', wsName),
@@ -348,7 +380,7 @@ module.exports = cls => class IsolatedReifier extends cls {
348
380
  target: workspace,
349
381
  })
350
382
  if (!isDeclared) {
351
- workspace.children.set(wsName, wsLink)
383
+ wsLink.isUndeclaredWorkspaceLink = true
352
384
  }
353
385
  root.children.set(wsName, wsLink)
354
386
  root.inventory.set(wsLink.location, wsLink)
@@ -366,7 +398,7 @@ module.exports = cls => class IsolatedReifier extends cls {
366
398
  })
367
399
 
368
400
  bundledTree.nodes.forEach(node => {
369
- this.#generateChild(node, node.location, node.pkg, false, root)
401
+ this.#generateChild(node, node.location, node.pkg, false, root, true)
370
402
  })
371
403
 
372
404
  bundledTree.edges.forEach(edge => {
@@ -1,4 +1,4 @@
1
- const { resolve } = require('node:path')
1
+ const { isAbsolute, resolve } = require('node:path')
2
2
  // mixin providing the loadVirtual method
3
3
  const mapWorkspaces = require('@npmcli/map-workspaces')
4
4
  const PackageJson = require('@npmcli/package-json')
@@ -17,6 +17,8 @@ const setWorkspaces = Symbol.for('setWorkspaces')
17
17
 
18
18
  module.exports = cls => class VirtualLoader extends cls {
19
19
  #rootOptionProvided
20
+ // when true, lockfile entries must stay inside `this.path`
21
+ #subtreeOnly = false
20
22
 
21
23
  // public method
22
24
  async loadVirtual (options = {}) {
@@ -28,6 +30,8 @@ module.exports = cls => class VirtualLoader extends cls {
28
30
  // XXX: deprecate separate reify() options object.
29
31
  options = { ...this.options, ...options }
30
32
 
33
+ this.#subtreeOnly = !!options.subtreeOnly
34
+
31
35
  if (options.root && options.root.meta) {
32
36
  await this.#loadFromShrinkwrap(options.root.meta, options.root)
33
37
  return treeCheck(this.virtualTree)
@@ -166,12 +170,40 @@ module.exports = cls => class VirtualLoader extends cls {
166
170
  }
167
171
  }
168
172
 
173
+ // throw if the resolved path is outside `base`, only in subtreeOnly mode
174
+ #assertContained (base, resolvedPath, location) {
175
+ if (!this.#subtreeOnly) {
176
+ return
177
+ }
178
+ if (isAbsolute(location)) {
179
+ throw Object.assign(
180
+ new Error(`invalid lockfile entry: "${location}" must be a relative location`),
181
+ { code: 'EINVALIDLOCATION', location, base }
182
+ )
183
+ }
184
+ const rel = relpath(base, resolvedPath)
185
+ if (
186
+ rel === '..' ||
187
+ rel.startsWith('../') ||
188
+ isAbsolute(rel) ||
189
+ // non-root key that collapses back to base (e.g. 'node_modules/..')
190
+ (rel === '' && location !== '')
191
+ ) {
192
+ throw Object.assign(
193
+ new Error(`invalid lockfile entry: "${location}" resolves outside of "${base}"`),
194
+ { code: 'EINVALIDLOCATION', location, base, resolvedPath }
195
+ )
196
+ }
197
+ }
198
+
169
199
  // links is the set of metadata, and nodes is the map of non-Link nodes
170
200
  // Set the targets to nodes in the set, if we have them (we might not)
171
201
  // XXX build-ideal-tree also has a #resolveLinks, is there overlap?
172
202
  async #resolveLinks (links, nodes) {
173
203
  for (const [location, meta] of links.entries()) {
174
204
  const targetPath = resolve(this.path, meta.resolved)
205
+ // check before nodes.get so we surface EINVALIDLOCATION instead of EMISSINGTARGET
206
+ this.#assertContained(this.path, targetPath, meta.resolved || location)
175
207
  const targetLoc = relpath(this.path, targetPath)
176
208
  const target = nodes.get(targetLoc)
177
209
 
@@ -230,6 +262,7 @@ To fix:
230
262
  #loadNode (location, sw, loadOverrides) {
231
263
  const p = this.virtualTree ? this.virtualTree.realpath : this.path
232
264
  const path = resolve(p, location)
265
+ this.#assertContained(p, path, location)
233
266
  // shrinkwrap doesn't include package name unless necessary
234
267
  if (!sw.name) {
235
268
  sw.name = nameFromFolder(path)
@@ -259,6 +292,7 @@ To fix:
259
292
 
260
293
  #loadLink (location, targetLoc, target) {
261
294
  const path = resolve(this.path, location)
295
+ this.#assertContained(this.path, path, location)
262
296
  const link = new Link({
263
297
  installLinks: this.installLinks,
264
298
  legacyPeerDeps: this.legacyPeerDeps,
@@ -12,6 +12,7 @@ const { isNodeGypPackage, defaultGypInstallScript } = require('@npmcli/node-gyp'
12
12
  const { promiseRetry } = require('@gar/promise-retry')
13
13
  const { log, time } = require('proc-log')
14
14
  const { resolve } = require('node:path')
15
+ const { isScriptAllowed } = require('../script-allowed.js')
15
16
 
16
17
  const boolEnv = b => b ? '1' : ''
17
18
  const sortNodes = (a, b) => (a.depth - b.depth) || localeCompare(a.path, b.path)
@@ -225,6 +226,18 @@ module.exports = cls => class Builder extends cls {
225
226
  return
226
227
  }
227
228
 
229
+ // Phase 1 allowScripts gate: a `false` verdict from the policy matcher
230
+ // means the user explicitly denied install scripts for this node, so skip
231
+ // it. `true` and `null` (unreviewed) both fall through to the existing
232
+ // detection logic — unreviewed nodes still run their scripts in Phase 1
233
+ // and are surfaced via the post-reify advisory warning. The global
234
+ // --ignore-scripts kill switch in #build() still takes precedence, and
235
+ // --dangerously-allow-all-scripts bypasses this gate entirely.
236
+ if (!this.options.dangerouslyAllowAllScripts &&
237
+ isScriptAllowed(node, this.options.allowScripts) === false) {
238
+ return
239
+ }
240
+
228
241
  if (this.#oldMeta === null) {
229
242
  const { root: { meta } } = node
230
243
  this.#oldMeta = meta && meta.loadedFromDisk &&
@@ -239,7 +239,7 @@ module.exports = cls => class Reifier extends cls {
239
239
  this.actualTree = this.idealTree
240
240
  this.idealTree = null
241
241
 
242
- if (!this.options.global) {
242
+ if (!this.options.global && !this.options.dryRun) {
243
243
  await this.actualTree.meta.save()
244
244
  const ignoreScripts = !!this.options.ignoreScripts
245
245
  // if we aren't doing a dry run or ignoring scripts and we actually made changes to the dep
@@ -642,7 +642,7 @@ module.exports = cls => class Reifier extends cls {
642
642
  .then(nodes => promiseAllRejectLate(nodes.map(node => new Arborist({
643
643
  ...this.options,
644
644
  path: node.path,
645
- }).loadVirtual({ root: node }))))
645
+ }).loadVirtual({ root: node, subtreeOnly: true }))))
646
646
  // reload the diff and sparse tree because the ideal tree changed
647
647
  .then(() => this[_diffTrees]())
648
648
  .then(() => this[_createSparseTree]())
@@ -744,7 +744,8 @@ module.exports = cls => class Reifier extends cls {
744
744
  integrity: node.integrity,
745
745
  // A node counts as "root" for allow-* enforcement if it satisfies at least one valid dependency edge declared by the project root or a workspace.
746
746
  // node.parent is unsafe here: after hoisting, transitive packages can have the project root as their tree parent.
747
- _isRoot: [...node.edgesIn].some(e =>
747
+ // In the linked strategy the store node has no edgesIn, so isolated-reifier precomputes isRootDependency from the source node's edges.
748
+ _isRoot: node.isRootDependency || [...node.edgesIn].some(e =>
748
749
  e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
749
750
  ),
750
751
  // pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
@@ -760,6 +761,12 @@ module.exports = cls => class Reifier extends cls {
760
761
  }
761
762
 
762
763
  // node.isLink
764
+
765
+ // Tree-only Link: present in the tree for diff/filter participation, never materialized on disk.
766
+ if (node.isUndeclaredWorkspaceLink) {
767
+ return
768
+ }
769
+
763
770
  await rm(node.path, { recursive: true, force: true })
764
771
 
765
772
  // symlink
@@ -875,17 +882,18 @@ module.exports = cls => class Reifier extends cls {
875
882
 
876
883
  // When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
877
884
  // pacote re-parses that with npa and gets spec.type === 'remote', so without an override the allow-remote gate would fire on every registry tarball (both =none and =root mis-fire).
878
- // Returns true only when we are confident this is a registry-mediated install: the node's inbound edges must all be registry-typed (no exotic spec smuggled the URL in) AND the resolved URL's host must match the registry npm-registry-fetch selected for this spec, so a tampered lockfile pointing at an attacker host still hits the gate.
885
+ // Returns true only when we are confident this is a registry-mediated install.
879
886
  #isRegistryResolvedTarball (node) {
880
887
  if (!node.resolved || !node.isRegistryDependency) {
881
888
  return false
882
889
  }
883
890
  try {
884
- // Hostnames are case-insensitive; lowercase both sides for safety even though WHATWG URL already normalizes.
885
- const resolvedHost = new URL(node.resolved).hostname.toLowerCase()
891
+ const resolved = new URL(node.resolved)
886
892
  // pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
887
- const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname.toLowerCase()
888
- return resolvedHost === registryHost
893
+ const registry = new URL(pickRegistry(npa(node.name), this.options))
894
+ const registryPath = registry.pathname.replace(/\/?$/, '/')
895
+ return resolved.origin === registry.origin &&
896
+ (registryPath === '/' || resolved.pathname.startsWith(registryPath))
889
897
  } catch {
890
898
  return false
891
899
  }
@@ -1356,9 +1364,27 @@ module.exports = cls => class Reifier extends cls {
1356
1364
  const nmDir = resolve(this.path, 'node_modules')
1357
1365
  const storeDir = resolve(nmDir, '.store')
1358
1366
 
1367
+ // Enumerate on-disk store entries as full keys, descending one level into each @scope directory because scoped keys nest as .store/@scope/pkg@version-hash.
1359
1368
  let entries
1360
1369
  try {
1361
- entries = await readdir(storeDir)
1370
+ const topLevel = await readdir(storeDir, { withFileTypes: true })
1371
+ entries = []
1372
+ for (const ent of topLevel) {
1373
+ if (ent.name.startsWith('@')) {
1374
+ let scoped
1375
+ try {
1376
+ scoped = await readdir(resolve(storeDir, ent.name))
1377
+ } catch {
1378
+ /* istanbul ignore next -- readdir of an entry we just listed should not fail */
1379
+ continue
1380
+ }
1381
+ for (const name of scoped) {
1382
+ entries.push(`${ent.name}/${name}`)
1383
+ }
1384
+ } else {
1385
+ entries.push(ent.name)
1386
+ }
1387
+ }
1362
1388
  } catch {
1363
1389
  entries = null
1364
1390
  }
@@ -1374,13 +1400,20 @@ module.exports = cls => class Reifier extends cls {
1374
1400
  for (const child of this.idealTree.children.values()) {
1375
1401
  const loc = child.location.replace(/\\/g, '/')
1376
1402
  if (child.isInStore) {
1377
- const key = loc.split('/')[2]
1403
+ // Store location is node_modules/.store/{key}/node_modules/{pkg}.
1404
+ // For a scoped package the key is @scope/pkg@version-hash, which spans two path segments, so reconstruct both instead of taking only the scope.
1405
+ const parts = loc.split('/')
1406
+ const key = parts[2].startsWith('@') ? `${parts[2]}/${parts[3]}` : parts[2]
1378
1407
  validKeys.add(key)
1379
1408
  continue
1380
1409
  }
1381
1410
  if (!child.isLink) {
1382
1411
  continue
1383
1412
  }
1413
+ // Tree-only Links never exist on disk; skipping them lets the sweep remove any stale self-link left by an older npm version.
1414
+ if (child.isUndeclaredWorkspaceLink) {
1415
+ continue
1416
+ }
1384
1417
  const nmIdx = loc.lastIndexOf(NM_PREFIX)
1385
1418
  if (nmIdx === -1 || loc.includes(STORE_MARKER)) {
1386
1419
  continue
@@ -1452,6 +1485,23 @@ module.exports = cls => class Reifier extends cls {
1452
1485
  er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1453
1486
  )
1454
1487
  )
1488
+ // Removing the last scoped orphan under a scope leaves an empty @scope directory behind, so prune any scope directory that is now empty.
1489
+ const scopes = new Set(
1490
+ orphaned.filter(e => e.startsWith('@')).map(e => e.split('/')[0])
1491
+ )
1492
+ await promiseAllRejectLate(
1493
+ [...scopes].map(async scope => {
1494
+ const scopeDir = resolve(storeDir, scope)
1495
+ try {
1496
+ const remaining = await readdir(scopeDir)
1497
+ if (!remaining.length) {
1498
+ await rm(scopeDir, { recursive: true, force: true })
1499
+ }
1500
+ } catch {
1501
+ /* istanbul ignore next -- readdir of a scope dir we just listed should not fail */
1502
+ }
1503
+ })
1504
+ )
1455
1505
  }
1456
1506
  }
1457
1507
 
package/lib/dep-valid.js CHANGED
@@ -9,6 +9,27 @@ const npa = require('npm-package-arg')
9
9
  const { relative } = require('node:path')
10
10
  const fromPath = require('./from-path.js')
11
11
 
12
+ // A named ref (tag or branch) resolves to a commit hash, so look up the
13
+ // committish recorded for this edge in the lockfile to detect spec changes.
14
+ const lockedGitCommittish = (child, requestor) => {
15
+ const lock = requestor.root?.meta?.data?.packages?.[requestor.location]
16
+ const spec = lock && (
17
+ lock.dependencies?.[child.name] ||
18
+ lock.optionalDependencies?.[child.name] ||
19
+ lock.devDependencies?.[child.name] ||
20
+ lock.peerDependencies?.[child.name]
21
+ )
22
+ if (!spec) {
23
+ return null
24
+ }
25
+ try {
26
+ const parsed = npa.resolve(child.name, spec, requestor.realpath)
27
+ return parsed.type === 'git' ? parsed.gitCommittish || '' : null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
12
33
  const depValid = (child, requested, requestor) => {
13
34
  // NB: we don't do much to verify 'tag' type requests.
14
35
  // Just verify that we got a remote resolution. Presumably, it
@@ -94,6 +115,14 @@ const depValid = (child, requested, requestor) => {
94
115
  }
95
116
  }
96
117
  if (!requested.gitRange) {
118
+ // a named ref can't be verified against the resolved commit offline,
119
+ // so re-resolve if it differs from the committish in the lockfile
120
+ if (!reqCommit) {
121
+ const locked = lockedGitCommittish(child, requestor)
122
+ if (locked !== null && locked !== (requested.gitCommittish || '')) {
123
+ return false
124
+ }
125
+ }
97
126
  return true
98
127
  }
99
128
  return semver.satisfies(child.package.version, requested.gitRange, {
@@ -0,0 +1,114 @@
1
+ const { isNodeGypPackage } = require('@npmcli/node-gyp')
2
+ const PackageJson = require('@npmcli/package-json')
3
+
4
+ // Returns the install-relevant lifecycle scripts that would run for a
5
+ // given arborist Node, or `{}` if there are none.
6
+ //
7
+ // Includes:
8
+ // - explicit preinstall/install/postinstall
9
+ // - prepare, but only for non-registry sources (git, file, link, remote)
10
+ // - synthetic `node-gyp rebuild`, when `binding.gyp` is present on disk
11
+ // and the package does not opt out via `gypfile: false` or define its
12
+ // own install / preinstall script
13
+
14
+ // Lifecycle-script enumeration boundary.
15
+ //
16
+ // IMPORTANT: this helper decides whether `prepare` should be included
17
+ // in the enumerated install scripts (true for non-registry sources only).
18
+ // It is NOT a policy-matching predicate. The policy matcher in
19
+ // script-allowed.js uses `isRegistryNode`, which is strictly tied to
20
+ // versionFromTgz(node.resolved). The two helpers exist separately on
21
+ // purpose:
22
+ //
23
+ // - `hasNonRegistryShape` (here): "should we consider running prepare
24
+ // on this node?" — a yes/no for what to enumerate.
25
+ // - `isRegistryNode` (script-allowed.js): "do we trust this node's
26
+ // identity enough to apply a policy entry?" — a security check.
27
+ //
28
+ // The looser fallback here (treating unknown-resolved nodes as registry,
29
+ // thus skipping `prepare`) is the safer default for enumeration: we'd
30
+ // rather omit a script we should have run than synthesise one for a
31
+ // non-registry source we couldn't confirm. The policy matcher's stricter
32
+ // behaviour is correct for its boundary; the two helpers must not be
33
+ // merged.
34
+ const hasNonRegistryShape = (node) => {
35
+ if (typeof node.isRegistryDependency === 'boolean') {
36
+ return !node.isRegistryDependency
37
+ }
38
+ if (!node.resolved) {
39
+ return false
40
+ }
41
+ return !/^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
42
+ }
43
+
44
+ const getInstallScripts = async (node) => {
45
+ /* istanbul ignore next: arborist Nodes always carry a `package` object;
46
+ defensive fallbacks for non-arborist callers. */
47
+ const pkg = node.package || {}
48
+ /* istanbul ignore next */
49
+ const scripts = pkg.scripts || {}
50
+ const collected = {}
51
+
52
+ if (scripts.preinstall) {
53
+ collected.preinstall = scripts.preinstall
54
+ }
55
+ if (scripts.install) {
56
+ collected.install = scripts.install
57
+ }
58
+ if (scripts.postinstall) {
59
+ collected.postinstall = scripts.postinstall
60
+ }
61
+ if (scripts.prepare && hasNonRegistryShape(node)) {
62
+ collected.prepare = scripts.prepare
63
+ }
64
+
65
+ const hasExplicitGypGate = !!(collected.preinstall || collected.install)
66
+ if (
67
+ !hasExplicitGypGate &&
68
+ pkg.gypfile !== false &&
69
+ await isNodeGypPackage(node.path).catch(() => false)
70
+ ) {
71
+ collected.install = 'node-gyp rebuild'
72
+ }
73
+
74
+ // Lockfile-only nodes carry `hasInstallScript: true` but no enumerated
75
+ // scripts: the lockfile records the presence flag, not the script bodies,
76
+ // so `node.package.scripts` is empty on a lockfile-driven install (`npm ci`,
77
+ // a repeat `npm install`). Before giving up, read the installed
78
+ // package.json from disk to recover the real script bodies. Builder#addToBuildSet
79
+ // does the same disk read to decide what to run, but unlike that path this
80
+ // one is read-only: we never mutate `node.package`.
81
+ if (Object.keys(collected).length === 0 && node.hasInstallScript === true) {
82
+ const { content } = await PackageJson.normalize(node.path)
83
+ .catch(() => ({ content: {} }))
84
+ /* istanbul ignore next: normalize resolves to an object with a scripts
85
+ object, or our catch fallback returns {}; defensive guard only. */
86
+ const diskScripts = content?.scripts || {}
87
+
88
+ if (diskScripts.preinstall) {
89
+ collected.preinstall = diskScripts.preinstall
90
+ }
91
+ if (diskScripts.install) {
92
+ collected.install = diskScripts.install
93
+ }
94
+ if (diskScripts.postinstall) {
95
+ collected.postinstall = diskScripts.postinstall
96
+ }
97
+ if (diskScripts.prepare && hasNonRegistryShape(node)) {
98
+ collected.prepare = diskScripts.prepare
99
+ }
100
+
101
+ // Still nothing. The package isn't on disk yet (e.g. `npm ci` before
102
+ // reify) or its package.json is unreadable. Emit a sentinel so the
103
+ // advisory and the strict-allow-scripts preflight still surface that
104
+ // install scripts are present.
105
+ if (Object.keys(collected).length === 0) {
106
+ collected.install = '(install scripts present)'
107
+ }
108
+ }
109
+
110
+ return collected
111
+ }
112
+
113
+ module.exports = getInstallScripts
114
+ module.exports.getInstallScripts = getInstallScripts
@@ -20,6 +20,9 @@ class IsolatedNode {
20
20
  integrity = null
21
21
  inventory = new IsolatedInventory()
22
22
  isInStore = false
23
+ inBundle = false
24
+ isRegistryDependency = false
25
+ isRootDependency = false
23
26
  linksIn = new Set()
24
27
  meta = { loadedFromDisk: false }
25
28
  optional = false
@@ -47,6 +50,15 @@ class IsolatedNode {
47
50
  if (options.isInStore) {
48
51
  this.isInStore = true
49
52
  }
53
+ if (options.inBundle) {
54
+ this.inBundle = true
55
+ }
56
+ if (options.isRegistryDependency) {
57
+ this.isRegistryDependency = true
58
+ }
59
+ if (options.isRootDependency) {
60
+ this.isRootDependency = true
61
+ }
50
62
  if (options.optional) {
51
63
  this.optional = true
52
64
  }
@@ -104,6 +116,11 @@ class IsolatedNode {
104
116
  return !!(hasInstallScript || install || preinstall || postinstall)
105
117
  }
106
118
 
119
+ /* istanbul ignore next -- emulate lib/node.js */
120
+ get packageName () {
121
+ return this.package.name || null
122
+ }
123
+
107
124
  get version () {
108
125
  return this.package.version
109
126
  }
@@ -9,6 +9,7 @@ const npa = require('npm-package-arg')
9
9
  const pacote = require('pacote')
10
10
  const semver = require('semver')
11
11
  const npmFetch = require('npm-registry-fetch')
12
+ const { isReleaseAgeExcluded } = require('./release-age-exclude.js')
12
13
 
13
14
  // handle results for parsed query asts, results are stored in a map that has a
14
15
  // key that points to each ast selector node and stores the resulting array of
@@ -889,8 +890,9 @@ const getPackageVersions = async (name, opts) => {
889
890
  let candidates = Object.keys(packument.versions).sort(semver.compare)
890
891
 
891
892
  // if the packument has a time property, and the user passed a before flag, then
892
- // we filter this list down to only those versions that existed before the specified date
893
- if (packument.time && opts.before) {
893
+ // we filter this list down to only those versions that existed before the specified date.
894
+ // packages matching `min-release-age-exclude` are exempt from this filter.
895
+ if (packument.time && opts.before && !isReleaseAgeExcluded(name, opts.minReleaseAgeExclude)) {
894
896
  candidates = candidates.filter((version) => {
895
897
  // this version isn't found in the times at all, drop it
896
898
  if (!packument.time[version]) {
@@ -0,0 +1,45 @@
1
+ // Determine whether a package name is exempt from the `min-release-age` /
2
+ // `before` release-age filter, based on the `min-release-age-exclude` config.
3
+ //
4
+ // Patterns are exact package names or `minimatch` globs (e.g. `@myorg/*`), and
5
+ // match against the package name only. This is a "named-only" exemption: a
6
+ // matched package's own dependencies still follow the filter unless they match
7
+ // a pattern too.
8
+ //
9
+ // Callers must match against the resolved registry identity of a package, not
10
+ // the self-reported alias or dependency-edge name. For `npm:` aliases the
11
+ // fetched package is the alias target, so run specs through `trustedSpecName`
12
+ // first; otherwise an alias key could match an exclude pattern and turn the
13
+ // filter off for the unrelated package it resolves to.
14
+ const { minimatch } = require('minimatch')
15
+
16
+ // This list only ever widens the exemption (turns the security filter off for a
17
+ // package), so disable pattern features that could silently turn it into a
18
+ // match-all: `nonegate` keeps a leading `!` literal (so a stray `!foo` exempts
19
+ // nothing instead of everything-but-foo), `nocomment` keeps a leading `#`
20
+ // literal, and `noext` disables extglobs.
21
+ const minimatchOptions = { nonegate: true, nocomment: true, noext: true }
22
+
23
+ const isReleaseAgeExcluded = (name, patterns) => {
24
+ if (!name || !Array.isArray(patterns) || patterns.length === 0) {
25
+ return false
26
+ }
27
+ return patterns.some(pattern =>
28
+ name === pattern || minimatch(name, pattern, minimatchOptions))
29
+ }
30
+
31
+ // Resolve the trusted registry name for an npa spec. For `npm:` aliases (e.g.
32
+ // `"x": "npm:other@1"`) the installed/fetched package is the alias target
33
+ // (`subSpec`), not the alias key, so the exemption must be keyed on the
34
+ // underlying package name. Mirrors `nameFromEdges` in script-allowed.js.
35
+ const trustedSpecName = (spec) => {
36
+ if (!spec) {
37
+ return undefined
38
+ }
39
+ if (spec.type === 'alias' && spec.subSpec && spec.subSpec.registry) {
40
+ return spec.subSpec.name
41
+ }
42
+ return spec.name
43
+ }
44
+
45
+ module.exports = { isReleaseAgeExcluded, trustedSpecName }
@@ -0,0 +1,372 @@
1
+ const npa = require('npm-package-arg')
2
+ const semver = require('semver')
3
+ const versionFromTgz = require('./version-from-tgz.js')
4
+
5
+ // Identity matcher for the allowScripts policy.
6
+ //
7
+ // Returns:
8
+ // - true: at least one allow entry matches and no deny entry matches
9
+ // - false: at least one deny entry matches (deny wins on conflict)
10
+ // - null: no entry matches (unreviewed)
11
+ //
12
+ // `policy` is a flat object of `spec-key -> boolean`, where spec-key is
13
+ // anything `npm-package-arg` can parse. `node` is an arborist Node.
14
+ //
15
+ // Identity rules (see RFC npm/rfcs#868):
16
+ // - registry deps match by the name+version parsed from the lockfile's
17
+ // resolved URL, NOT by `node.packageName` / `node.version`. Those two
18
+ // getters return `node.package.name` / `node.package.version`, which
19
+ // come from the tarball's own package.json and are therefore
20
+ // attacker-controlled. A package can publish a tarball claiming any
21
+ // name; the only trusted name is the one baked into the registry URL.
22
+ // - tarball / file / link / remote: exact match on node.resolved
23
+ // - git: match on hosted.ssh() plus a short-SHA prefix of the
24
+ // resolved committish
25
+
26
+ const isScriptAllowed = (node, policy) => {
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.
34
+ if (node.inBundle) {
35
+ return null
36
+ }
37
+
38
+ if (!policy || typeof policy !== 'object') {
39
+ return null
40
+ }
41
+
42
+ let anyAllow = false
43
+ let anyDeny = false
44
+
45
+ for (const [key, value] of Object.entries(policy)) {
46
+ if (!matches(node, key)) {
47
+ continue
48
+ }
49
+ if (value === false) {
50
+ anyDeny = true
51
+ continue
52
+ }
53
+ /* istanbul ignore else: policy values are strictly true/false;
54
+ defensive guard against unexpected coercions. */
55
+ if (value === true) {
56
+ anyAllow = true
57
+ }
58
+ }
59
+
60
+ if (anyDeny) {
61
+ return false
62
+ }
63
+ if (anyAllow) {
64
+ return true
65
+ }
66
+ return null
67
+ }
68
+
69
+ const matches = (node, key) => {
70
+ let parsed
71
+ try {
72
+ parsed = npa(key)
73
+ } catch {
74
+ return false
75
+ }
76
+
77
+ switch (parsed.type) {
78
+ case 'tag':
79
+ case 'range':
80
+ case 'version':
81
+ return matchRegistry(node, parsed)
82
+ case 'git':
83
+ return matchGit(node, parsed)
84
+ case 'file':
85
+ case 'directory':
86
+ return matchFileOrDir(node, parsed)
87
+ case 'remote':
88
+ return matchRemote(node, parsed)
89
+ case 'alias':
90
+ // Disallowed: aliases as policy keys do not match anything.
91
+ // The user has to address the real package name.
92
+ return false
93
+ /* istanbul ignore next: switch above covers every npa type we expect;
94
+ defensive fallback for future npa types. */
95
+ default:
96
+ return false
97
+ }
98
+ }
99
+
100
+ const resolvedSourceSpecs = (node) => {
101
+ const specs = []
102
+ const seen = new Set()
103
+ const add = (spec) => {
104
+ if (typeof spec !== 'string' || spec === '' || seen.has(spec)) {
105
+ return
106
+ }
107
+ seen.add(spec)
108
+ specs.push(spec)
109
+ }
110
+
111
+ add(node?.resolved)
112
+
113
+ if (!node?.resolved && node?.linksIn && typeof node.linksIn[Symbol.iterator] === 'function') {
114
+ let hasIncomingLink = false
115
+ for (const link of node.linksIn) {
116
+ hasIncomingLink = true
117
+ add(link.resolved)
118
+ }
119
+
120
+ if (hasIncomingLink) {
121
+ // Link targets for local directory deps are separate inventory nodes
122
+ // whose own `resolved` is null. The incoming Link carries the saved spec
123
+ // (for example `file:../pkg`, relative to node_modules), while policy
124
+ // entries written by hand often use the dependency spec from package.json
125
+ // (for example `file:pkg`, resolved by npa to this target path). Include
126
+ // the real target paths so both forms can match the same local dep.
127
+ add(node.realpath)
128
+ add(node.path)
129
+ }
130
+ }
131
+
132
+ return specs
133
+ }
134
+
135
+ const matchRegistry = (node, parsed) => {
136
+ // If this node is not a registry dep, refuse the match. A registry-style
137
+ // key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node
138
+ // even if their names happen to coincide.
139
+ if (!isRegistryNode(node)) {
140
+ return false
141
+ }
142
+
143
+ // Derive the trusted name+version from the lockfile's resolved URL.
144
+ // Never use `node.packageName` / `node.version` here: those read from
145
+ // the tarball's own package.json and can be forged by a malicious
146
+ // publisher to bypass an allowScripts entry.
147
+ const trusted = getTrustedRegistryIdentity(node)
148
+ if (!trusted || trusted.name !== parsed.name) {
149
+ return false
150
+ }
151
+
152
+ // `tag` covers `pkg@latest`. Rejected up front by validatePolicy in
153
+ // resolve-allow-scripts.js because tags look like a pin but can't be
154
+ // verified at install time. Defense-in-depth: if one slips through
155
+ // (e.g. arborist invoked directly without the resolver), don't match.
156
+ if (parsed.type === 'tag') {
157
+ /* istanbul ignore next: validatePolicy filters this; defensive */
158
+ return false
159
+ }
160
+
161
+ // `range` includes `pkg@^1`, `pkg@1 || 2`, `pkg@*`, `pkg@>=0`, and bare
162
+ // names like `pkg` (npa parses these as range with fetchSpec='*'). The
163
+ // RFC permits bare names (name-only allow) and exact versions joined by
164
+ // `||`; ranges like ^/~/>=/< are rejected because they would silently
165
+ // allow versions the user has never reviewed.
166
+ if (parsed.type === 'range') {
167
+ // Bare name or `pkg@*`: treat as name-only allow.
168
+ if (parsed.fetchSpec === '*' || parsed.rawSpec === '' || parsed.rawSpec === '*') {
169
+ return true
170
+ }
171
+ if (!trusted.version || !isExactVersionDisjunction(parsed.fetchSpec)) {
172
+ return false
173
+ }
174
+ return semver.satisfies(trusted.version, parsed.fetchSpec, { loose: true })
175
+ }
176
+
177
+ // `version` is an exact pin like `pkg@1.2.3`.
178
+ /* istanbul ignore else: parsed.type at this point is always 'version';
179
+ the istanbul-ignored fallback below handles the impossible case. */
180
+ if (parsed.type === 'version') {
181
+ return trusted.version === parsed.fetchSpec
182
+ }
183
+
184
+ /* istanbul ignore next: parsed.type is constrained to tag/range/version
185
+ by the caller; this final fallback is defensive. */
186
+ return false
187
+ }
188
+
189
+ // Derive a registry node's trusted name+version.
190
+ //
191
+ // Preferred source: the lockfile's resolved URL parsed via
192
+ // versionFromTgz. arborist records the URL when it first adds the dep,
193
+ // before any tarball is unpacked, so the URL cannot be forged by the
194
+ // package's own package.json.
195
+ //
196
+ // Fallback for lockfiles produced with omit-lockfile-registry-resolved
197
+ // (where the URL is absent): take the dep name from an incoming
198
+ // dependency edge. The edge's spec was written by the consumer (or by an
199
+ // upstream package.json), not by the installed tarball. For aliases like
200
+ // `"trusted": "npm:naughty@1.0.0"`, the underlying registered package
201
+ // name is parsed out of the alias `subSpec`. The install location
202
+ // (`node_modules/trusted`) is deliberately not consulted because for
203
+ // aliases it carries only the alias name, which would let a malicious
204
+ // publisher bypass an allowScripts entry written for the real package.
205
+ //
206
+ // Version is left null in the fallback case because the only remaining
207
+ // source for it (`node.version`) reads from the tarball.
208
+ //
209
+ // Returns `{ name, version }` or `null` if no trusted identity exists.
210
+ const getTrustedRegistryIdentity = (node) => {
211
+ if (node.resolved && typeof node.resolved === 'string') {
212
+ const parsed = versionFromTgz('', node.resolved)
213
+ /* istanbul ignore else: versionFromTgz returns either a complete
214
+ { name, version } or null; partial objects are not produced. */
215
+ if (parsed && parsed.name && parsed.version) {
216
+ return parsed
217
+ }
218
+ }
219
+ const name = nameFromEdges(node)
220
+ if (name) {
221
+ return { name, version: null }
222
+ }
223
+ return null
224
+ }
225
+
226
+ const nameFromEdges = (node) => {
227
+ if (!node.edgesIn || typeof node.edgesIn[Symbol.iterator] !== 'function') {
228
+ return null
229
+ }
230
+ for (const edge of node.edgesIn) {
231
+ let parsed
232
+ try {
233
+ parsed = npa.resolve(edge.name, edge.spec)
234
+ } catch {
235
+ continue
236
+ }
237
+ // Aliases: trust the underlying registered package, not the alias.
238
+ if (parsed.type === 'alias' && parsed.subSpec && parsed.subSpec.registry) {
239
+ return parsed.subSpec.name
240
+ }
241
+ // Non-aliased registry edge: the edge name is the package name as
242
+ // written by the consumer / upstream, which is trusted (it is not
243
+ // read from the installed tarball).
244
+ if (parsed.registry) {
245
+ return parsed.name
246
+ }
247
+ }
248
+ return null
249
+ }
250
+
251
+ // True if `rangeSpec` is one or more exact versions joined by `||`. Anything
252
+ // containing comparator operators (^, ~, >=, <, *) returns false.
253
+ const isExactVersionDisjunction = (rangeSpec) => {
254
+ /* istanbul ignore next: caller always passes parsed.fetchSpec, which
255
+ npa guarantees to be a non-empty string for range specs. */
256
+ if (typeof rangeSpec !== 'string' || rangeSpec.trim() === '') {
257
+ return false
258
+ }
259
+ const parts = rangeSpec.split('||').map(p => p.trim())
260
+ /* istanbul ignore next: String.prototype.split always returns at least
261
+ one element; defensive guard only. */
262
+ if (parts.length === 0) {
263
+ return false
264
+ }
265
+ return parts.every(p => p !== '' && semver.valid(p) !== null)
266
+ }
267
+
268
+ const matchGit = (node, parsed) => {
269
+ if (!node.resolved || !node.resolved.startsWith('git')) {
270
+ return false
271
+ }
272
+
273
+ let nodeParsed
274
+ try {
275
+ nodeParsed = npa(node.resolved)
276
+ } catch {
277
+ /* istanbul ignore next: npa parsing a git URL we already validated
278
+ starts with `git` should not throw; defensive guard only. */
279
+ return false
280
+ }
281
+
282
+ // Compare the host/repo. Both sides should resolve to the same canonical
283
+ // ssh URL.
284
+ const noCommittish = { noCommittish: true }
285
+ const keyHost = parsed.hosted?.ssh(noCommittish)
286
+ const nodeHost = nodeParsed.hosted?.ssh(noCommittish)
287
+ if (keyHost && nodeHost) {
288
+ if (keyHost !== nodeHost) {
289
+ return false
290
+ }
291
+ } else if (parsed.fetchSpec && nodeParsed.fetchSpec) {
292
+ // Non-hosted git URLs: fall back to fetch spec.
293
+ if (parsed.fetchSpec !== nodeParsed.fetchSpec) {
294
+ return false
295
+ }
296
+ } else {
297
+ return false
298
+ }
299
+
300
+ // If the policy key has no committish, name-only match.
301
+ const keyCommittish = parsed.gitCommittish || parsed.hosted?.committish
302
+ if (!keyCommittish) {
303
+ return true
304
+ }
305
+
306
+ // Match the resolved full SHA against the key's committish. Users
307
+ // typically write short SHAs in the policy; the lockfile stores 40-char
308
+ // SHAs. Direction matters: the lockfile's full SHA must START WITH the
309
+ // key's short SHA, never the reverse. A longer key matching a shorter
310
+ // resolved committish would let a malformed lockfile or a divergent
311
+ // resolver allow scripts the user never approved.
312
+ const nodeCommittish = nodeParsed.gitCommittish || nodeParsed.hosted?.committish || ''
313
+ if (!nodeCommittish) {
314
+ return false
315
+ }
316
+ return nodeCommittish.startsWith(keyCommittish)
317
+ }
318
+
319
+ const matchFileOrDir = (node, parsed) => {
320
+ return resolvedSourceSpecs(node)
321
+ .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
322
+ }
323
+
324
+ const matchRemote = (node, parsed) => {
325
+ return resolvedSourceSpecs(node)
326
+ .some(resolved => resolved === parsed.fetchSpec || resolved === parsed.saveSpec)
327
+ }
328
+
329
+ const isRegistryNode = (node) => {
330
+ // Prefer arborist's edge-based check when available (real Node objects).
331
+ // It inspects the incoming edges' specs and only returns true if every
332
+ // edge resolves to a registry spec, which is much harder to spoof than
333
+ // the URL.
334
+ if (typeof node.isRegistryDependency === 'boolean') {
335
+ return node.isRegistryDependency
336
+ }
337
+ // Fall back to URL parsing for nodes without the arborist getter
338
+ // (e.g. test fixtures, lockfiles with omit-lockfile-registry-resolved).
339
+ // Treat the node as a registry dep when:
340
+ // - resolved is missing entirely (omitLockfileRegistryResolved),
341
+ // - resolved is an https/http URL pointing at a registry tarball, or
342
+ // - resolved is undefined and the node has a version (defensive).
343
+ if (!node.resolved) {
344
+ return !!node.version
345
+ }
346
+ // Registry tarballs live at `<host>/<pkg-name>/-/<pkg-name>-<version>.tgz`.
347
+ // Require a path segment before `/-/` so an attacker can't lift a
348
+ // registry-style allow entry to a hostile URL like
349
+ // `https://evil.com/-/trusted-1.0.0.tgz`.
350
+ return /^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
351
+ }
352
+
353
+ // Trusted display identity for human-facing output (the `npm install`
354
+ // blocked-scripts summary and `npm approve-scripts --allow-scripts-pending`).
355
+ // Same as getTrustedRegistryIdentity, but for display only: version
356
+ // falls back to node.version when the URL doesn't carry one. Do not
357
+ // use for policy matching.
358
+ const trustedDisplay = (node) => {
359
+ const trusted = getTrustedRegistryIdentity(node)
360
+ /* istanbul ignore next: defensive fallbacks for nodes without name/version */
361
+ return {
362
+ name: (trusted && trusted.name) || node.name || null,
363
+ version: (trusted && trusted.version) || node.version || null,
364
+ }
365
+ }
366
+
367
+ module.exports = isScriptAllowed
368
+ module.exports.isScriptAllowed = isScriptAllowed
369
+ module.exports.isExactVersionDisjunction = isExactVersionDisjunction
370
+ module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity
371
+ module.exports.resolvedSourceSpecs = resolvedSourceSpecs
372
+ module.exports.trustedDisplay = trustedDisplay
package/lib/shrinkwrap.js CHANGED
@@ -929,8 +929,14 @@ class Shrinkwrap {
929
929
  continue
930
930
  }
931
931
  const loc = relpath(this.path, node.path)
932
- // Drop lockfile entries for extraneous nodes outside node_modules. These are stale workspace entries: the workspace was removed from package.json or its directory was deleted, so it should not be tracked in package-lock.json.
933
- if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules') {
932
+ // Drop lockfile entries for extraneous nodes outside node_modules that
933
+ // are direct fsChildren of the root (or detached link targets). These
934
+ // are stale top-level entries: a workspace or file: dep removed from
935
+ // the root manifest, or whose directory was deleted. Extraneous
936
+ // fsChildren nested under another package (e.g. a file: dep of another
937
+ // file: dep) are kept so `npm ci` can resolve the parent's dependency.
938
+ if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules' &&
939
+ (!node.fsParent || node.fsParent.isRoot)) {
934
940
  continue
935
941
  }
936
942
  const meta = Shrinkwrap.metaFromNode(
@@ -0,0 +1,94 @@
1
+ const isScriptAllowed = require('./script-allowed.js')
2
+ const getInstallScripts = require('./install-scripts.js')
3
+
4
+ // Shared allowScripts walk used by both the npm CLI
5
+ // (lib/utils/check-allow-scripts.js, lib/utils/strict-allow-scripts-preflight.js)
6
+ // and libnpmexec (npm exec / npx). It lives in arborist because that is the
7
+ // only package both callers can import.
8
+ //
9
+ // Walks a tree's inventory and returns the dep nodes that have
10
+ // install-relevant lifecycle scripts and are not yet covered (or explicitly
11
+ // denied) by the allowScripts policy.
12
+ //
13
+ // Returns an array of `{ node, scripts }` entries. `scripts` is an object
14
+ // describing the relevant lifecycle scripts that would run.
15
+ const collectUnreviewedScripts = async ({
16
+ tree,
17
+ policy,
18
+ ignoreScripts = false,
19
+ dangerouslyAllowAllScripts = false,
20
+ includeWhenIgnored = false,
21
+ } = {}) => {
22
+ // With ignore-scripts set, no scripts run, so execution callers bail out
23
+ // here. approve/deny pass includeWhenIgnored so they keep listing
24
+ // unreviewed packages, which is what you need to move from a blanket
25
+ // ignore-scripts to an allowlist. Listing never runs anything.
26
+ if ((ignoreScripts && !includeWhenIgnored) || dangerouslyAllowAllScripts) {
27
+ return []
28
+ }
29
+
30
+ if (!tree?.inventory) {
31
+ return []
32
+ }
33
+
34
+ const resolvedPolicy = policy || null
35
+
36
+ const unreviewed = []
37
+ for (const node of tree.inventory.values()) {
38
+ if (node.isProjectRoot || node.isWorkspace) {
39
+ continue
40
+ }
41
+ if (node.isLink) {
42
+ // Linked workspace dependencies are managed by the workspace owner.
43
+ continue
44
+ }
45
+ if (node.inBundle) {
46
+ // Bundled dependencies never run their install scripts and cannot be
47
+ // allowlisted, so they are never "pending". Skipping them keeps them
48
+ // out of the advisory warning and out of strict-allow-scripts. A
49
+ // package that needs a bundled dep's script must forward it as one of
50
+ // its own lifecycle scripts.
51
+ continue
52
+ }
53
+
54
+ const verdict = isScriptAllowed(node, resolvedPolicy)
55
+ if (verdict === true || verdict === false) {
56
+ continue
57
+ }
58
+
59
+ const scripts = await getInstallScripts(node)
60
+ if (Object.keys(scripts).length === 0) {
61
+ continue
62
+ }
63
+
64
+ unreviewed.push({ node, scripts })
65
+ }
66
+
67
+ return unreviewed
68
+ }
69
+
70
+ // Builds the `ESTRICTALLOWSCRIPTS` error thrown by the strict-mode preflight
71
+ // from a list of `{ node, scripts }` entries. `remediation` is the
72
+ // caller-specific guidance appended after the package list (npm install vs
73
+ // npm exec have different remediation commands).
74
+ const strictAllowScriptsError = (unreviewed, { remediation } = {}) => {
75
+ const lines = unreviewed.map(({ node, scripts }) => {
76
+ const events = Object.entries(scripts)
77
+ .map(([event, body]) => `${event}: ${body}`)
78
+ .join('; ')
79
+ const name = node.package?.name || node.name
80
+ const version = node.package?.version || ''
81
+ const label = version ? `${name}@${version}` : name
82
+ return ` ${label} (${events})`
83
+ }).join('\n')
84
+
85
+ return Object.assign(
86
+ new Error(
87
+ `--strict-allow-scripts: ${unreviewed.length} package(s) have install ` +
88
+ `scripts not covered by allowScripts:\n${lines}\n${remediation}`
89
+ ),
90
+ { code: 'ESTRICTALLOWSCRIPTS' }
91
+ )
92
+ }
93
+
94
+ module.exports = { collectUnreviewedScripts, strictAllowScriptsError }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.6.0",
3
+ "version": "9.8.0",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@gar/promise-retry": "^1.0.0",