@npmcli/arborist 9.7.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,
@@ -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)))
@@ -367,7 +398,7 @@ module.exports = cls => class IsolatedReifier extends cls {
367
398
  })
368
399
 
369
400
  bundledTree.nodes.forEach(node => {
370
- this.#generateChild(node, node.location, node.pkg, false, root)
401
+ this.#generateChild(node, node.location, node.pkg, false, root, true)
371
402
  })
372
403
 
373
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,
@@ -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.
@@ -881,17 +882,18 @@ module.exports = cls => class Reifier extends cls {
881
882
 
882
883
  // When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
883
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).
884
- // 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.
885
886
  #isRegistryResolvedTarball (node) {
886
887
  if (!node.resolved || !node.isRegistryDependency) {
887
888
  return false
888
889
  }
889
890
  try {
890
- // Hostnames are case-insensitive; lowercase both sides for safety even though WHATWG URL already normalizes.
891
- const resolvedHost = new URL(node.resolved).hostname.toLowerCase()
891
+ const resolved = new URL(node.resolved)
892
892
  // pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
893
- const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname.toLowerCase()
894
- 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))
895
897
  } catch {
896
898
  return false
897
899
  }
@@ -1362,9 +1364,27 @@ module.exports = cls => class Reifier extends cls {
1362
1364
  const nmDir = resolve(this.path, 'node_modules')
1363
1365
  const storeDir = resolve(nmDir, '.store')
1364
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.
1365
1368
  let entries
1366
1369
  try {
1367
- 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
+ }
1368
1388
  } catch {
1369
1389
  entries = null
1370
1390
  }
@@ -1380,7 +1400,10 @@ module.exports = cls => class Reifier extends cls {
1380
1400
  for (const child of this.idealTree.children.values()) {
1381
1401
  const loc = child.location.replace(/\\/g, '/')
1382
1402
  if (child.isInStore) {
1383
- 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]
1384
1407
  validKeys.add(key)
1385
1408
  continue
1386
1409
  }
@@ -1462,6 +1485,23 @@ module.exports = cls => class Reifier extends cls {
1462
1485
  er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1463
1486
  )
1464
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
+ )
1465
1505
  }
1466
1506
  }
1467
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, {
@@ -1,4 +1,5 @@
1
1
  const { isNodeGypPackage } = require('@npmcli/node-gyp')
2
+ const PackageJson = require('@npmcli/package-json')
2
3
 
3
4
  // Returns the install-relevant lifecycle scripts that would run for a
4
5
  // given arborist Node, or `{}` if there are none.
@@ -70,15 +71,40 @@ const getInstallScripts = async (node) => {
70
71
  collected.install = 'node-gyp rebuild'
71
72
  }
72
73
 
73
- // Lockfile-only nodes (e.g. `npm ci` before reify) carry
74
- // `hasInstallScript: true` but no enumerated scripts: the lockfile
75
- // records the presence flag but never the script bodies. Without this
76
- // fallback the strict-allow-scripts preflight would miss them entirely
77
- // and let postinstall run. We can't recover the real script body
78
- // without fetching the manifest, so emit a sentinel describing that
79
- // install scripts are present.
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`.
80
81
  if (Object.keys(collected).length === 0 && node.hasInstallScript === true) {
81
- collected.install = '(install scripts present)'
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
+ }
82
108
  }
83
109
 
84
110
  return collected
@@ -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 }
@@ -24,13 +24,13 @@ const versionFromTgz = require('./version-from-tgz.js')
24
24
  // resolved committish
25
25
 
26
26
  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.
34
34
  if (node.inBundle) {
35
35
  return null
36
36
  }
@@ -97,6 +97,41 @@ const matches = (node, key) => {
97
97
  }
98
98
  }
99
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
+
100
135
  const matchRegistry = (node, parsed) => {
101
136
  // If this node is not a registry dep, refuse the match. A registry-style
102
137
  // key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node
@@ -282,17 +317,13 @@ const matchGit = (node, parsed) => {
282
317
  }
283
318
 
284
319
  const matchFileOrDir = (node, parsed) => {
285
- if (!node.resolved) {
286
- return false
287
- }
288
- return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
320
+ return resolvedSourceSpecs(node)
321
+ .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
289
322
  }
290
323
 
291
324
  const matchRemote = (node, parsed) => {
292
- if (!node.resolved) {
293
- return false
294
- }
295
- return node.resolved === parsed.fetchSpec || node.resolved === parsed.saveSpec
325
+ return resolvedSourceSpecs(node)
326
+ .some(resolved => resolved === parsed.fetchSpec || resolved === parsed.saveSpec)
296
327
  }
297
328
 
298
329
  const isRegistryNode = (node) => {
@@ -319,11 +350,11 @@ const isRegistryNode = (node) => {
319
350
  return /^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
320
351
  }
321
352
 
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 only version falls back
325
- // to node.version when the URL doesn't carry one. Must never be used
326
- // for policy matching.
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.
327
358
  const trustedDisplay = (node) => {
328
359
  const trusted = getTrustedRegistryIdentity(node)
329
360
  /* istanbul ignore next: defensive fallbacks for nodes without name/version */
@@ -337,4 +368,5 @@ module.exports = isScriptAllowed
337
368
  module.exports.isScriptAllowed = isScriptAllowed
338
369
  module.exports.isExactVersionDisjunction = isExactVersionDisjunction
339
370
  module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity
371
+ module.exports.resolvedSourceSpecs = resolvedSourceSpecs
340
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.7.0",
3
+ "version": "9.8.0",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@gar/promise-retry": "^1.0.0",