@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.
- package/lib/arborist/build-ideal-tree.js +22 -2
- package/lib/arborist/isolated-reifier.js +35 -4
- package/lib/arborist/load-virtual.js +35 -1
- package/lib/arborist/reify.js +49 -9
- package/lib/dep-valid.js +29 -0
- package/lib/install-scripts.js +34 -8
- package/lib/isolated-classes.js +17 -0
- package/lib/query-selector-all.js +4 -2
- package/lib/release-age-exclude.js +45 -0
- package/lib/script-allowed.js +52 -20
- package/lib/shrinkwrap.js +8 -2
- package/lib/unreviewed-scripts.js +94 -0
- package/package.json +1 -1
|
@@ -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, {
|
|
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
|
-
`${
|
|
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
|
-
|
|
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,
|
package/lib/arborist/reify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
894
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
package/lib/install-scripts.js
CHANGED
|
@@ -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
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
|
|
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
|
package/lib/isolated-classes.js
CHANGED
|
@@ -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
|
-
|
|
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 }
|
package/lib/script-allowed.js
CHANGED
|
@@ -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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
//
|
|
324
|
-
// getTrustedRegistryIdentity, but for
|
|
325
|
-
// to node.version when the URL doesn't carry one.
|
|
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
|
|
933
|
-
|
|
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 }
|