@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.
- package/lib/arborist/build-ideal-tree.js +22 -2
- package/lib/arborist/index.js +2 -0
- package/lib/arborist/isolated-reifier.js +38 -6
- package/lib/arborist/load-virtual.js +35 -1
- package/lib/arborist/rebuild.js +13 -0
- package/lib/arborist/reify.js +60 -10
- package/lib/dep-valid.js +29 -0
- package/lib/install-scripts.js +114 -0
- 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 +372 -0
- 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,
|
package/lib/arborist/index.js
CHANGED
|
@@ -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
|
-
`${
|
|
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)))
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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,
|
package/lib/arborist/rebuild.js
CHANGED
|
@@ -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 &&
|
package/lib/arborist/reify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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 }
|
|
@@ -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
|
|
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 }
|