Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@npmcli/arborist

Package Overview
Dependencies
Maintainers
4
Versions
226
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@npmcli/arborist - npm Package Compare versions

Comparing version
9.7.0
to
9.8.0
+45
lib/release-age-exclude.js
// Determine whether a package name is exempt from the `min-release-age` /
// `before` release-age filter, based on the `min-release-age-exclude` config.
//
// Patterns are exact package names or `minimatch` globs (e.g. `@myorg/*`), and
// match against the package name only. This is a "named-only" exemption: a
// matched package's own dependencies still follow the filter unless they match
// a pattern too.
//
// Callers must match against the resolved registry identity of a package, not
// the self-reported alias or dependency-edge name. For `npm:` aliases the
// fetched package is the alias target, so run specs through `trustedSpecName`
// first; otherwise an alias key could match an exclude pattern and turn the
// filter off for the unrelated package it resolves to.
const { minimatch } = require('minimatch')
// This list only ever widens the exemption (turns the security filter off for a
// package), so disable pattern features that could silently turn it into a
// match-all: `nonegate` keeps a leading `!` literal (so a stray `!foo` exempts
// nothing instead of everything-but-foo), `nocomment` keeps a leading `#`
// literal, and `noext` disables extglobs.
const minimatchOptions = { nonegate: true, nocomment: true, noext: true }
const isReleaseAgeExcluded = (name, patterns) => {
if (!name || !Array.isArray(patterns) || patterns.length === 0) {
return false
}
return patterns.some(pattern =>
name === pattern || minimatch(name, pattern, minimatchOptions))
}
// Resolve the trusted registry name for an npa spec. For `npm:` aliases (e.g.
// `"x": "npm:other@1"`) the installed/fetched package is the alias target
// (`subSpec`), not the alias key, so the exemption must be keyed on the
// underlying package name. Mirrors `nameFromEdges` in script-allowed.js.
const trustedSpecName = (spec) => {
if (!spec) {
return undefined
}
if (spec.type === 'alias' && spec.subSpec && spec.subSpec.registry) {
return spec.subSpec.name
}
return spec.name
}
module.exports = { isReleaseAgeExcluded, trustedSpecName }
const isScriptAllowed = require('./script-allowed.js')
const getInstallScripts = require('./install-scripts.js')
// Shared allowScripts walk used by both the npm CLI
// (lib/utils/check-allow-scripts.js, lib/utils/strict-allow-scripts-preflight.js)
// and libnpmexec (npm exec / npx). It lives in arborist because that is the
// only package both callers can import.
//
// Walks a tree's inventory and returns the dep nodes that have
// install-relevant lifecycle scripts and are not yet covered (or explicitly
// denied) by the allowScripts policy.
//
// Returns an array of `{ node, scripts }` entries. `scripts` is an object
// describing the relevant lifecycle scripts that would run.
const collectUnreviewedScripts = async ({
tree,
policy,
ignoreScripts = false,
dangerouslyAllowAllScripts = false,
includeWhenIgnored = false,
} = {}) => {
// With ignore-scripts set, no scripts run, so execution callers bail out
// here. approve/deny pass includeWhenIgnored so they keep listing
// unreviewed packages, which is what you need to move from a blanket
// ignore-scripts to an allowlist. Listing never runs anything.
if ((ignoreScripts && !includeWhenIgnored) || dangerouslyAllowAllScripts) {
return []
}
if (!tree?.inventory) {
return []
}
const resolvedPolicy = policy || null
const unreviewed = []
for (const node of tree.inventory.values()) {
if (node.isProjectRoot || node.isWorkspace) {
continue
}
if (node.isLink) {
// Linked workspace dependencies are managed by the workspace owner.
continue
}
if (node.inBundle) {
// Bundled dependencies never run their install scripts and cannot be
// allowlisted, so they are never "pending". Skipping them keeps them
// out of the advisory warning and out of strict-allow-scripts. A
// package that needs a bundled dep's script must forward it as one of
// its own lifecycle scripts.
continue
}
const verdict = isScriptAllowed(node, resolvedPolicy)
if (verdict === true || verdict === false) {
continue
}
const scripts = await getInstallScripts(node)
if (Object.keys(scripts).length === 0) {
continue
}
unreviewed.push({ node, scripts })
}
return unreviewed
}
// Builds the `ESTRICTALLOWSCRIPTS` error thrown by the strict-mode preflight
// from a list of `{ node, scripts }` entries. `remediation` is the
// caller-specific guidance appended after the package list (npm install vs
// npm exec have different remediation commands).
const strictAllowScriptsError = (unreviewed, { remediation } = {}) => {
const lines = unreviewed.map(({ node, scripts }) => {
const events = Object.entries(scripts)
.map(([event, body]) => `${event}: ${body}`)
.join('; ')
const name = node.package?.name || node.name
const version = node.package?.version || ''
const label = version ? `${name}@${version}` : name
return ` ${label} (${events})`
}).join('\n')
return Object.assign(
new Error(
`--strict-allow-scripts: ${unreviewed.length} package(s) have install ` +
`scripts not covered by allowScripts:\n${lines}\n${remediation}`
),
{ code: 'ESTRICTALLOWSCRIPTS' }
)
}
module.exports = { collectUnreviewedScripts, strictAllowScriptsError }
+22
-2

@@ -27,2 +27,3 @@ // mixin implementing the buildIdealTree method

const calcDepFlags = require('../calc-dep-flags.js')
const { isReleaseAgeExcluded, trustedSpecName } = require('../release-age-exclude.js')
const Shrinkwrap = require('../shrinkwrap.js')

@@ -537,3 +538,7 @@ const { defaultLockfileVersion } = Shrinkwrap

const _isRoot = tree.isProjectRoot || tree.isWorkspace
const mani = await pacote.manifest(spec, { ...this.options, _isRoot })
const mani = await pacote.manifest(spec, {
...this.options,
_isRoot,
before: this.#releaseAgeBefore(spec),
})
if (isTag) {

@@ -782,2 +787,3 @@ // translate tag to a version

fullMetadata: false,
before: this.#releaseAgeBefore(spec),
})

@@ -881,3 +887,3 @@ node.package = { ...mani, _id: `${mani.name}@${mani.version}` }

await new Arborist({ ...this.options, path })
.loadVirtual({ root: node })
.loadVirtual({ root: node, subtreeOnly: true })
}

@@ -1286,2 +1292,15 @@

// The effective `before` filter for a package, applying `min-release-age-exclude`.
// Returns null (no age filter) for an exempted package, otherwise the
// configured `before`. The exemption is keyed on the spec's trusted registry
// identity (alias targets are unwrapped) so an `npm:` alias key cannot disable
// the filter for the package it actually resolves to.
#releaseAgeBefore (spec) {
const { before, minReleaseAgeExclude } = this.options
if (!before) {
return before
}
return isReleaseAgeExcluded(trustedSpecName(spec), minReleaseAgeExclude) ? null : before
}
async #fetchManifest (spec, parent, edge) {

@@ -1294,2 +1313,3 @@ // Enforce allow-* gates before consulting the manifest cache so a cached entry from a different edge cannot bypass the policy.

fullMetadata: true,
before: this.#releaseAgeBefore(spec),
_isRoot: !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace),

@@ -1296,0 +1316,0 @@ }

@@ -7,2 +7,3 @@ const { mkdirSync } = require('node:fs')

const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
const nameFromFolder = require('@npmcli/name-from-folder')

@@ -43,5 +44,8 @@ // generate short hash key based on the dependency tree starting at this node

#generateChild (node, location, pkg, isInStore, root) {
#generateChild (node, location, pkg, isInStore, root, inBundle = false) {
const newChild = new IsolatedNode({
isInStore,
inBundle,
isRegistryDependency: node.isRegistryDependency,
isRootDependency: node.isRootDependency,
location,

@@ -156,2 +160,5 @@ name: node.packageName || node.name,

if (node.hasShrinkwrap) {
// strip any path traversal from package.json name fields before they hit path.join below
/* istanbul ignore next - packageName is always set for real packages */
const safeName = nameFromFolder(node.packageName || node.path)
const dir = join(

@@ -161,3 +168,3 @@ node.root.path,

'.store',
`${node.packageName}@${node.version}`
`${safeName}@${node.version}`
)

@@ -196,2 +203,9 @@ mkdirSync(dir, { recursive: true })

result.version = node.version
// Carry the source node's registry-dependency flag so the store node retains it.
// IsolatedNode has no edges to recompute it from, and reify's registry-tarball allow-remote exemption depends on it.
result.isRegistryDependency = node.isRegistryDependency
// 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.
result.isRootDependency = [...node.edgesIn].some(e =>
e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
)
return result

@@ -206,3 +220,4 @@ }

result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
result.packageName = node.packageName || node.name
// strip any path traversal from package.json name fields before they hit path.join below
result.packageName = nameFromFolder(node.packageName || node.path)
result.package = { ...node.package }

@@ -262,2 +277,18 @@ result.package.bundleDependencies = undefined

const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)
// Optional peers declared only in peerDependenciesMeta (e.g. `@types/react`) have no edge, so the materialization above misses them.
// Resolve each from the tree and link it; if nobody provides it, node.resolve finds nothing and it stays omitted.
const peerMeta = node.package.peerDependenciesMeta
if (peerMeta) {
const resolvedNames = new Set([...nonOptionalDeps, ...optionalDeps].map(n => n.name))
for (const peerName in peerMeta) {
if (!peerMeta[peerName]?.optional || resolvedNames.has(peerName)) {
continue
}
const resolved = node.resolve(peerName)?.target
if (resolved && resolved !== node && !resolved.inert && !isLocal(resolved)) {
optionalDeps.push(resolved)
}
}
}
result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.#workspaceProxy(n)))

@@ -377,3 +408,3 @@ result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.#externalProxy(n)))

bundledTree.nodes.forEach(node => {
this.#generateChild(node, node.location, node.pkg, false, root)
this.#generateChild(node, node.location, node.pkg, false, root, true)
})

@@ -380,0 +411,0 @@

@@ -1,2 +0,2 @@

const { resolve } = require('node:path')
const { isAbsolute, resolve } = require('node:path')
// mixin providing the loadVirtual method

@@ -20,2 +20,4 @@ const mapWorkspaces = require('@npmcli/map-workspaces')

#rootOptionProvided
// when true, lockfile entries must stay inside `this.path`
#subtreeOnly = false

@@ -32,2 +34,4 @@ // public method

this.#subtreeOnly = !!options.subtreeOnly
if (options.root && options.root.meta) {

@@ -171,2 +175,28 @@ await this.#loadFromShrinkwrap(options.root.meta, options.root)

// throw if the resolved path is outside `base`, only in subtreeOnly mode
#assertContained (base, resolvedPath, location) {
if (!this.#subtreeOnly) {
return
}
if (isAbsolute(location)) {
throw Object.assign(
new Error(`invalid lockfile entry: "${location}" must be a relative location`),
{ code: 'EINVALIDLOCATION', location, base }
)
}
const rel = relpath(base, resolvedPath)
if (
rel === '..' ||
rel.startsWith('../') ||
isAbsolute(rel) ||
// non-root key that collapses back to base (e.g. 'node_modules/..')
(rel === '' && location !== '')
) {
throw Object.assign(
new Error(`invalid lockfile entry: "${location}" resolves outside of "${base}"`),
{ code: 'EINVALIDLOCATION', location, base, resolvedPath }
)
}
}
// links is the set of metadata, and nodes is the map of non-Link nodes

@@ -178,2 +208,4 @@ // Set the targets to nodes in the set, if we have them (we might not)

const targetPath = resolve(this.path, meta.resolved)
// check before nodes.get so we surface EINVALIDLOCATION instead of EMISSINGTARGET
this.#assertContained(this.path, targetPath, meta.resolved || location)
const targetLoc = relpath(this.path, targetPath)

@@ -237,2 +269,3 @@ const target = nodes.get(targetLoc)

const path = resolve(p, location)
this.#assertContained(p, path, location)
// shrinkwrap doesn't include package name unless necessary

@@ -267,2 +300,3 @@ if (!sw.name) {

const path = resolve(this.path, location)
this.#assertContained(this.path, path, location)
const link = new Link({

@@ -269,0 +303,0 @@ installLinks: this.installLinks,

@@ -12,2 +12,23 @@ // Do not rely on package._fields, so that we don't throw

// A named ref (tag or branch) resolves to a commit hash, so look up the
// committish recorded for this edge in the lockfile to detect spec changes.
const lockedGitCommittish = (child, requestor) => {
const lock = requestor.root?.meta?.data?.packages?.[requestor.location]
const spec = lock && (
lock.dependencies?.[child.name] ||
lock.optionalDependencies?.[child.name] ||
lock.devDependencies?.[child.name] ||
lock.peerDependencies?.[child.name]
)
if (!spec) {
return null
}
try {
const parsed = npa.resolve(child.name, spec, requestor.realpath)
return parsed.type === 'git' ? parsed.gitCommittish || '' : null
} catch {
return null
}
}
const depValid = (child, requested, requestor) => {

@@ -98,2 +119,10 @@ // NB: we don't do much to verify 'tag' type requests.

if (!requested.gitRange) {
// a named ref can't be verified against the resolved commit offline,
// so re-resolve if it differs from the committish in the lockfile
if (!reqCommit) {
const locked = lockedGitCommittish(child, requestor)
if (locked !== null && locked !== (requested.gitCommittish || '')) {
return false
}
}
return true

@@ -100,0 +129,0 @@ }

const { isNodeGypPackage } = require('@npmcli/node-gyp')
const PackageJson = require('@npmcli/package-json')

@@ -73,11 +74,36 @@ // Returns the install-relevant lifecycle scripts that would run for a

// Lockfile-only nodes (e.g. `npm ci` before reify) carry
// `hasInstallScript: true` but no enumerated scripts: the lockfile
// records the presence flag but never the script bodies. Without this
// fallback the strict-allow-scripts preflight would miss them entirely
// and let postinstall run. We can't recover the real script body
// without fetching the manifest, so emit a sentinel describing that
// install scripts are present.
// Lockfile-only nodes carry `hasInstallScript: true` but no enumerated
// scripts: the lockfile records the presence flag, not the script bodies,
// so `node.package.scripts` is empty on a lockfile-driven install (`npm ci`,
// a repeat `npm install`). Before giving up, read the installed
// package.json from disk to recover the real script bodies. Builder#addToBuildSet
// does the same disk read to decide what to run, but unlike that path this
// one is read-only: we never mutate `node.package`.
if (Object.keys(collected).length === 0 && node.hasInstallScript === true) {
collected.install = '(install scripts present)'
const { content } = await PackageJson.normalize(node.path)
.catch(() => ({ content: {} }))
/* istanbul ignore next: normalize resolves to an object with a scripts
object, or our catch fallback returns {}; defensive guard only. */
const diskScripts = content?.scripts || {}
if (diskScripts.preinstall) {
collected.preinstall = diskScripts.preinstall
}
if (diskScripts.install) {
collected.install = diskScripts.install
}
if (diskScripts.postinstall) {
collected.postinstall = diskScripts.postinstall
}
if (diskScripts.prepare && hasNonRegistryShape(node)) {
collected.prepare = diskScripts.prepare
}
// Still nothing. The package isn't on disk yet (e.g. `npm ci` before
// reify) or its package.json is unreadable. Emit a sentinel so the
// advisory and the strict-allow-scripts preflight still surface that
// install scripts are present.
if (Object.keys(collected).length === 0) {
collected.install = '(install scripts present)'
}
}

@@ -84,0 +110,0 @@

@@ -23,2 +23,5 @@ // Alternate versions of different classes that we use for isolated mode

isInStore = false
inBundle = false
isRegistryDependency = false
isRootDependency = false
linksIn = new Set()

@@ -51,2 +54,11 @@ meta = { loadedFromDisk: false }

}
if (options.inBundle) {
this.inBundle = true
}
if (options.isRegistryDependency) {
this.isRegistryDependency = true
}
if (options.isRootDependency) {
this.isRootDependency = true
}
if (options.optional) {

@@ -109,2 +121,7 @@ this.optional = true

/* istanbul ignore next -- emulate lib/node.js */
get packageName () {
return this.package.name || null
}
get version () {

@@ -111,0 +128,0 @@ return this.package.version

+4
-2

@@ -12,2 +12,3 @@ 'use strict'

const npmFetch = require('npm-registry-fetch')
const { isReleaseAgeExcluded } = require('./release-age-exclude.js')

@@ -893,4 +894,5 @@ // handle results for parsed query asts, results are stored in a map that has a

// if the packument has a time property, and the user passed a before flag, then
// we filter this list down to only those versions that existed before the specified date
if (packument.time && opts.before) {
// we filter this list down to only those versions that existed before the specified date.
// packages matching `min-release-age-exclude` are exempt from this filter.
if (packument.time && opts.before && !isReleaseAgeExcluded(name, opts.minReleaseAgeExclude)) {
candidates = candidates.filter((version) => {

@@ -897,0 +899,0 @@ // this version isn't found in the times at all, drop it

@@ -27,9 +27,9 @@ const npa = require('npm-package-arg')

const isScriptAllowed = (node, policy) => {
// Bundled dependencies cannot be allowlisted in Phase 1. The RFC defers
// allowlisting them to a follow-up RFC because matching by name@version
// from the bundled tarball would reintroduce manifest confusion (a
// bundled tarball can claim any name and version). Returning null here
// marks bundled deps as unreviewed regardless of any policy entries, so
// their install scripts surface in the Phase 1 advisory warning and
// (eventually) get blocked at the install-time gate.
// Bundled dependencies never run their install scripts and cannot be
// allowlisted. Matching by name@version from the bundled tarball would
// reintroduce manifest confusion (a bundled tarball can claim any name
// and version). Returning null marks them as not-allowed regardless of
// any policy entry, so their install scripts are blocked by the
// install-time gate. A package that needs a bundled dep's script must
// forward it as one of its own lifecycle scripts.
if (node.inBundle) {

@@ -101,2 +101,37 @@ return null

const resolvedSourceSpecs = (node) => {
const specs = []
const seen = new Set()
const add = (spec) => {
if (typeof spec !== 'string' || spec === '' || seen.has(spec)) {
return
}
seen.add(spec)
specs.push(spec)
}
add(node?.resolved)
if (!node?.resolved && node?.linksIn && typeof node.linksIn[Symbol.iterator] === 'function') {
let hasIncomingLink = false
for (const link of node.linksIn) {
hasIncomingLink = true
add(link.resolved)
}
if (hasIncomingLink) {
// Link targets for local directory deps are separate inventory nodes
// whose own `resolved` is null. The incoming Link carries the saved spec
// (for example `file:../pkg`, relative to node_modules), while policy
// entries written by hand often use the dependency spec from package.json
// (for example `file:pkg`, resolved by npa to this target path). Include
// the real target paths so both forms can match the same local dep.
add(node.realpath)
add(node.path)
}
}
return specs
}
const matchRegistry = (node, parsed) => {

@@ -287,13 +322,9 @@ // If this node is not a registry dep, refuse the match. A registry-style

const matchFileOrDir = (node, parsed) => {
if (!node.resolved) {
return false
}
return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
return resolvedSourceSpecs(node)
.some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
}
const matchRemote = (node, parsed) => {
if (!node.resolved) {
return false
}
return node.resolved === parsed.fetchSpec || node.resolved === parsed.saveSpec
return resolvedSourceSpecs(node)
.some(resolved => resolved === parsed.fetchSpec || resolved === parsed.saveSpec)
}

@@ -325,7 +356,7 @@

// Trusted display identity for human-facing output (`npm install`
// advisory, `npm approve-scripts --allow-scripts-pending`). Same idea as
// getTrustedRegistryIdentity, but for DISPLAY only — version falls back
// to node.version when the URL doesn't carry one. Must never be used
// for policy matching.
// Trusted display identity for human-facing output (the `npm install`
// blocked-scripts summary and `npm approve-scripts --allow-scripts-pending`).
// Same as getTrustedRegistryIdentity, but for display only: version
// falls back to node.version when the URL doesn't carry one. Do not
// use for policy matching.
const trustedDisplay = (node) => {

@@ -344,2 +375,3 @@ const trusted = getTrustedRegistryIdentity(node)

module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity
module.exports.resolvedSourceSpecs = resolvedSourceSpecs
module.exports.trustedDisplay = trustedDisplay

@@ -932,4 +932,10 @@ // a module that manages a shrinkwrap file (npm-shrinkwrap.json or

const loc = relpath(this.path, node.path)
// Drop lockfile entries for extraneous nodes outside node_modules. These are stale workspace entries: the workspace was removed from package.json or its directory was deleted, so it should not be tracked in package-lock.json.
if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules') {
// Drop lockfile entries for extraneous nodes outside node_modules that
// are direct fsChildren of the root (or detached link targets). These
// are stale top-level entries: a workspace or file: dep removed from
// the root manifest, or whose directory was deleted. Extraneous
// fsChildren nested under another package (e.g. a file: dep of another
// file: dep) are kept so `npm ci` can resolve the parent's dependency.
if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules' &&
(!node.fsParent || node.fsParent.isRoot)) {
continue

@@ -936,0 +942,0 @@ }

{
"name": "@npmcli/arborist",
"version": "9.7.0",
"version": "9.8.0",
"description": "Manage node_modules trees",

@@ -5,0 +5,0 @@ "dependencies": {

Sorry, the diff of this file is too big to display