🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@npmcli/arborist

Package Overview
Dependencies
Maintainers
4
Versions
231
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
10.0.0-pre.0.0
to
10.0.0-pre.1
+114
lib/install-scripts.js
const { isNodeGypPackage } = require('@npmcli/node-gyp')
const PackageJson = require('@npmcli/package-json')
// Returns the install-relevant lifecycle scripts that would run for a
// given arborist Node, or `{}` if there are none.
//
// Includes:
// - explicit preinstall/install/postinstall
// - prepare, but only for non-registry sources (git, file, link, remote)
// - synthetic `node-gyp rebuild`, when `binding.gyp` is present on disk
// and the package does not opt out via `gypfile: false` or define its
// own install / preinstall script
// Lifecycle-script enumeration boundary.
//
// IMPORTANT: this helper decides whether `prepare` should be included
// in the enumerated install scripts (true for non-registry sources only).
// It is NOT a policy-matching predicate. The policy matcher in
// script-allowed.js uses `isRegistryNode`, which is strictly tied to
// versionFromTgz(node.resolved). The two helpers exist separately on
// purpose:
//
// - `hasNonRegistryShape` (here): "should we consider running prepare
// on this node?" — a yes/no for what to enumerate.
// - `isRegistryNode` (script-allowed.js): "do we trust this node's
// identity enough to apply a policy entry?" — a security check.
//
// The looser fallback here (treating unknown-resolved nodes as registry,
// thus skipping `prepare`) is the safer default for enumeration: we'd
// rather omit a script we should have run than synthesise one for a
// non-registry source we couldn't confirm. The policy matcher's stricter
// behaviour is correct for its boundary; the two helpers must not be
// merged.
const hasNonRegistryShape = (node) => {
if (typeof node.isRegistryDependency === 'boolean') {
return !node.isRegistryDependency
}
if (!node.resolved) {
return false
}
return !/^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
}
const getInstallScripts = async (node) => {
/* istanbul ignore next: arborist Nodes always carry a `package` object;
defensive fallbacks for non-arborist callers. */
const pkg = node.package || {}
/* istanbul ignore next */
const scripts = pkg.scripts || {}
const collected = {}
if (scripts.preinstall) {
collected.preinstall = scripts.preinstall
}
if (scripts.install) {
collected.install = scripts.install
}
if (scripts.postinstall) {
collected.postinstall = scripts.postinstall
}
if (scripts.prepare && hasNonRegistryShape(node)) {
collected.prepare = scripts.prepare
}
const hasExplicitGypGate = !!(collected.preinstall || collected.install)
if (
!hasExplicitGypGate &&
pkg.gypfile !== false &&
await isNodeGypPackage(node.path).catch(() => false)
) {
collected.install = 'node-gyp rebuild'
}
// 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) {
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)'
}
}
return collected
}
module.exports = getInstallScripts
module.exports.getInstallScripts = getInstallScripts
// Root-owned `packageExtensions`: declarative repairs to third-party manifests applied before Arborist reads a candidate's dependency edges.
// See RFC: https://github.com/npm/rfcs/pull/889
// This module is pure: it parses and validates the root rule set, matches a candidate manifest by name and version, and returns an extended manifest copy plus minimal provenance.
// It never mutates the input manifest or any shared cache object.
const semver = require('semver')
const ssri = require('ssri')
const validateName = require('validate-npm-package-name')
// The only manifest fields a package extension may add or correct, because they are the fields that affect dependency and peer resolution.
const EXTENSION_FIELDS = [
'dependencies',
'optionalDependencies',
'peerDependencies',
'peerDependenciesMeta',
]
// The two normal dependency fields; a name may exist in only one of them.
const NORMAL_DEP_FIELDS = ['dependencies', 'optionalDependencies']
const err = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })
// Parse a selector key into { name, range }, where range is null for a name-only key.
// Selectors are a package name with an optional semver range; dist-tags, git, file, directory, url, and alias specs are rejected.
const parseSelector = key => {
if (typeof key !== 'string' || !key) {
throw err(`Invalid packageExtensions selector: ${JSON.stringify(key)}`, 'EEXTENSIONSELECTOR')
}
// The separator @ is the first @ after a leading scope @.
const at = key.indexOf('@', key.startsWith('@') ? 1 : 0)
const name = at === -1 ? key : key.slice(0, at)
const range = at === -1 ? null : key.slice(at + 1)
const { validForOldPackages, validForNewPackages } = validateName(name)
if (!validForOldPackages && !validForNewPackages) {
throw err(`Invalid package name in packageExtensions selector: "${key}"`, 'EEXTENSIONSELECTOR', { selector: key })
}
// A blank range such as "foo@" is malformed; the name-only form "foo" is how you match every version.
if (range !== null && range.trim() === '') {
throw err(
`Invalid packageExtensions selector: "${key}". Use the name only to match every version.`,
'EEXTENSIONSELECTOR', { selector: key })
}
// A versioned selector must be a valid semver range, which rejects dist-tags, git, file, url, and alias specs.
if (range !== null && semver.validRange(range, { loose: true }) === null) {
throw err(
`Invalid version range in packageExtensions selector: "${key}". Selectors accept a package name with an optional semver range only.`,
'EEXTENSIONSELECTOR', { selector: key })
}
return { name, range }
}
// A selector matches a candidate manifest by its own name and version.
// Name-only selectors match every version, including non-semver versions.
// Versioned selectors only match versions that parse as semver and satisfy the range.
const rangeMatches = (range, version) => {
if (range === null) {
return true
}
return semver.valid(version, { loose: true }) !== null &&
semver.satisfies(version, range, { loose: true })
}
// Validate a single selector's extension object before it is ever applied.
const validateExtensionObject = (key, ext) => {
if (ext === null || typeof ext !== 'object' || Array.isArray(ext)) {
throw err(`packageExtensions["${key}"] must be an object`, 'EEXTENSIONVALUE', { selector: key })
}
for (const field of Object.keys(ext)) {
if (!EXTENSION_FIELDS.includes(field)) {
throw err(
`packageExtensions["${key}"] has unsupported field "${field}". Supported fields: ${EXTENSION_FIELDS.join(', ')}.`,
'EEXTENSIONFIELD', { selector: key, field })
}
const val = ext[field]
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
throw err(`packageExtensions["${key}"].${field} must be an object`, 'EEXTENSIONVALUE', { selector: key, field })
}
}
// Deletion is not supported in v1, so a null, false, or "-" value is an error.
for (const field of [...NORMAL_DEP_FIELDS, 'peerDependencies']) {
for (const [name, spec] of Object.entries(ext[field] || {})) {
if (spec === null || spec === false || spec === '-') {
throw err(
`packageExtensions["${key}"].${field}.${name} attempts deletion, which is not supported.`,
'EEXTENSIONDELETE', { selector: key, field, name })
}
}
}
// Each peerDependenciesMeta entry must be a non-null metadata object, never a deletion sentinel or primitive.
for (const [name, meta] of Object.entries(ext.peerDependenciesMeta || {})) {
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
throw err(
`packageExtensions["${key}"].peerDependenciesMeta.${name} must be an object`,
'EEXTENSIONVALUE', { selector: key, field: 'peerDependenciesMeta', name })
}
}
}
// Apply a matched extension to a manifest, returning { pkg, applied } where pkg is a copy with extended fields and applied is minimal provenance.
// The input manifest is never mutated.
const applyExtension = (pkg, { key, ext }) => {
const applied = { selector: key }
// Clone only the fields we may touch; the rest of the manifest is shared by reference since it is never mutated.
const next = { ...pkg }
for (const field of EXTENSION_FIELDS) {
if (pkg[field] && typeof pkg[field] === 'object') {
next[field] = field === 'peerDependenciesMeta'
? Object.fromEntries(Object.entries(pkg[field]).map(([n, m]) => [n, { ...m }]))
: { ...pkg[field] }
}
}
// dependencies and optionalDependencies add missing names only.
// A name already declared in either normal dependency field is an error, which also prevents moving a name between the fields.
for (const field of NORMAL_DEP_FIELDS) {
const adds = ext[field]
if (!adds) {
continue
}
for (const [name, spec] of Object.entries(adds)) {
for (const existingField of NORMAL_DEP_FIELDS) {
if (next[existingField] && name in next[existingField]) {
throw err(
`packageExtensions["${key}"].${field}.${name} conflicts with the package's existing ${existingField}.${name}. Use overrides to change a dependency version; packageExtensions only adds missing dependencies.`,
'EEXTENSIONDUPDEP', { selector: key, field, name, existingField })
}
}
next[field] = next[field] || {}
next[field][name] = spec
;(applied[field] = applied[field] || []).push(name)
}
}
// peerDependencies shallow-merges by peer name, and the extension value replaces an existing range.
if (ext.peerDependencies) {
next.peerDependencies = next.peerDependencies || {}
for (const [name, spec] of Object.entries(ext.peerDependencies)) {
next.peerDependencies[name] = spec
;(applied.peerDependencies = applied.peerDependencies || []).push(name)
}
}
// peerDependenciesMeta merges by peer name, then shallow-merges each meta object so an extension can add optional without dropping other meta keys.
if (ext.peerDependenciesMeta) {
next.peerDependenciesMeta = next.peerDependenciesMeta || {}
for (const [name, meta] of Object.entries(ext.peerDependenciesMeta)) {
next.peerDependenciesMeta[name] = { ...next.peerDependenciesMeta[name], ...meta }
;(applied.peerDependenciesMeta = applied.peerDependenciesMeta || []).push(name)
// Every peerDependenciesMeta entry an extension adds must correspond to a peerDependencies entry present after extension application.
if (!next.peerDependencies || !(name in next.peerDependencies)) {
throw err(
`packageExtensions["${key}"].peerDependenciesMeta.${name} has no corresponding peerDependencies.${name} after extension application.`,
'EEXTENSIONORPHANMETA', { selector: key, name })
}
}
}
return { pkg: next, applied }
}
// Deterministic JSON for hashing: keys sorted lexicographically at every level, string and number values preserved exactly, no insignificant whitespace.
const canonicalStringify = val => {
if (Array.isArray(val)) {
return `[${val.map(canonicalStringify).join(',')}]`
}
if (val && typeof val === 'object') {
return `{${Object.keys(val).sort()
.map(k => `${JSON.stringify(k)}:${canonicalStringify(val[k])}`)
.join(',')}}`
}
return JSON.stringify(val)
}
// Hash the canonical form of the root packageExtensions object using npm's existing lockfile digest encoding.
const canonicalHash = packageExtensions =>
ssri.fromData(canonicalStringify(packageExtensions), { algorithms: ['sha512'] }).toString()
class PackageExtensions {
constructor (raw) {
this.raw = raw
this.present = raw !== undefined
this.selectors = []
this.hash = null
if (!this.present) {
return
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
throw err('packageExtensions must be an object', 'EEXTENSIONROOT')
}
for (const [key, ext] of Object.entries(raw)) {
const { name, range } = parseSelector(key)
validateExtensionObject(key, ext)
this.selectors.push({ key, name, range, ext })
}
this.hash = canonicalHash(raw)
}
// Non-throwing check used for warnings: whether any selector matches the candidate.
wouldMatch (name, version) {
return this.selectors.some(s => s.name === name && rangeMatches(s.range, version))
}
// Return the single selector matching a candidate manifest, or null.
// Throws EEXTENSIONCONFLICT when more than one selector matches the same candidate.
match (name, version) {
const matches = this.selectors.filter(s => s.name === name && rangeMatches(s.range, version))
if (matches.length > 1) {
const keys = matches.map(s => `"${s.key}"`).join(', ')
throw err(
`Multiple packageExtensions selectors match ${name}@${version}: ${keys}. Narrow or remove one of the overlapping rules.`,
'EEXTENSIONCONFLICT', { name, version, selectors: matches.map(s => s.key) })
}
return matches[0] || null
}
// Apply the matching extension to a manifest copy, returning { pkg, applied } or null when no selector matches.
// Throws on selector conflict or invalid merge.
apply (pkg) {
if (!this.present || !this.selectors.length || !pkg || !pkg.name) {
return null
}
const sel = this.match(pkg.name, pkg.version)
return sel ? applyExtension(pkg, sel) : null
}
}
module.exports = PackageExtensions
module.exports.PackageExtensions = PackageExtensions
module.exports.parseSelector = parseSelector
module.exports.rangeMatches = rangeMatches
module.exports.canonicalHash = canonicalHash
module.exports.canonicalStringify = canonicalStringify
module.exports.EXTENSION_FIELDS = EXTENSION_FIELDS
// Native dependency patching helpers shared across build-ideal-tree and reify.
// Patches are plain unified diffs (git apply-compatible) applied with jsdiff using a fuzz factor of 0 so that any context drift fails loudly.
const { applyPatch, parsePatch } = require('diff')
const ssri = require('ssri')
const fs = require('node:fs')
const { promises: fsp } = fs
const { resolve, relative, dirname, isAbsolute } = require('node:path')
// Compute the SSRI integrity of a patch file's contents.
// Accepts a string or Buffer and returns a sha512 SSRI string.
const patchIntegrity = data =>
ssri.fromData(Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'), {
algorithms: ['sha512'],
}).toString()
// Strip a leading git-style "a/" or "b/" prefix from a diff path.
const stripPrefix = file => file.replace(/^[ab]\//, '')
// True when a diff path points at /dev/null, signalling a file add or delete.
const isDevNull = file => !file || file === '/dev/null' || /(^|\/)\.dev\/null$/.test(file)
const patchError = (message, code, file) =>
Object.assign(new Error(message), { code, file })
// Resolve a diff path under cwd and refuse anything that escapes the package directory.
const containedTarget = (cwd, file) => {
const target = resolve(cwd, file)
const rel = relative(cwd, target)
if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
throw patchError(`patch path escapes the package directory: ${file}`, 'EPATCHUNSAFE', file)
}
return target
}
// Run a parsed file patch against a source string with fuzz 0.
// Returns the patched text, or throws EPATCHFAILED on any context mismatch.
const strictApply = (source, filePatch, file) => {
const patched = applyPatch(source, filePatch, { fuzzFactor: 0 })
if (patched === false) {
throw patchError(`patch could not be applied to ${file}`, 'EPATCHFAILED', file)
}
return patched
}
// Apply a single parsed file patch under cwd.
// Handles modified, added (--- /dev/null) and deleted (+++ /dev/null) files.
const applyFilePatch = async (filePatch, cwd) => {
const isAdd = isDevNull(filePatch.oldFileName)
const isDelete = isDevNull(filePatch.newFileName)
if (isDelete) {
const file = stripPrefix(filePatch.oldFileName)
const target = containedTarget(cwd, file)
// verify the file still matches the diff before removing it
const source = await fsp.readFile(target, 'utf8').catch(() => {
throw patchError(`patch target to delete is missing: ${file}`, 'EPATCHFAILED', file)
})
strictApply(source, filePatch, file)
await fsp.rm(target, { force: true })
return
}
const file = stripPrefix(filePatch.newFileName)
const target = containedTarget(cwd, file)
if (isAdd) {
// a new file must not already exist, otherwise the tarball drifted
if (fs.existsSync(target)) {
throw patchError(`patch adds a file that already exists: ${file}`, 'EPATCHFAILED', file)
}
const created = strictApply('', filePatch, file)
await fsp.mkdir(dirname(target), { recursive: true })
await fsp.writeFile(target, created)
return
}
const source = await fsp.readFile(target, 'utf8').catch(() => {
throw patchError(`patch target is missing: ${file}`, 'EPATCHFAILED', file)
})
const mode = (await fsp.stat(target)).mode
const patched = strictApply(source, filePatch, file)
await fsp.writeFile(target, patched)
await fsp.chmod(target, mode)
}
// Apply a unified diff to the package extracted at `cwd`.
// `patch` is the raw diff contents (string or Buffer).
// Throws with code EPATCHFAILED on any hunk or file that cannot be applied.
const applyPatchToDir = async ({ patch, cwd }) => {
const filePatches = parsePatch(patch.toString('utf8'))
for (const filePatch of filePatches) {
// jsdiff emits an empty trailing patch for some inputs; skip those.
if (!filePatch.hunks.length && isDevNull(filePatch.oldFileName) && isDevNull(filePatch.newFileName)) {
continue
}
try {
await applyFilePatch(filePatch, cwd)
} catch (er) {
// re-code raw filesystem errors so a patch failure is never mistaken for an optional-install skip
if (typeof er?.code === 'string' && er.code.startsWith('EPATCH')) {
throw er
}
throw Object.assign(new Error(`failed to apply patch: ${er.message}`), { code: 'EPATCHFAILED', cause: er })
}
}
}
module.exports = {
applyPatchToDir,
patchIntegrity,
}
// Resolve the root patchedDependencies map against an ideal tree.
// Attaches node.patched = { path, integrity } to each matched node.
// Enforces the failure modes (workspace-member entry, missing file, unused patch, non-registry target, ambiguous selectors) as hard errors.
const semver = require('semver')
const npa = require('npm-package-arg')
const { log } = require('proc-log')
const { resolve, relative, isAbsolute } = require('node:path')
const { readFile } = require('node:fs/promises')
const { patchIntegrity } = require('./patch.js')
// Split a selector key into { name, spec }. spec is null for a name-only key.
const parseSelector = key => {
const at = key.indexOf('@', 1)
return at === -1
? { name: key, spec: null }
: { name: key.slice(0, at), spec: key.slice(at + 1) }
}
const err = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })
// Pick the most specific range among several that all match a version.
// Returns the strict subset, or throws when ordering is ambiguous.
// semver.subset is transitive, so the running minimum is a subset of every range it did not throw on.
const pickRange = (ranges, name, version) => {
let best = ranges[0]
for (const r of ranges.slice(1)) {
if (semver.subset(r.spec, best.spec, { loose: true })) {
best = r
} else if (!semver.subset(best.spec, r.spec, { loose: true })) {
throw err(
`Ambiguous patch selectors for ${name}@${version}: ` +
`"${name}@${best.spec}" and "${name}@${r.spec}" overlap but neither ` +
`is a subset. Add an exact "${name}@${version}" entry to disambiguate.`,
'EPATCHAMBIGUOUS'
)
}
}
return best
}
// Choose the winning selector for a node: exact > range subset > name-only.
const matchSelector = (selectors, node) => {
const { name, version } = node
const matches = selectors.filter(s => s.name === name)
if (!matches.length) {
return null
}
const exact = matches.find(s =>
s.spec && semver.valid(s.spec) && semver.eq(s.spec, version, { loose: true }))
if (exact) {
return exact
}
const ranges = matches.filter(s =>
s.spec && !semver.valid(s.spec) && semver.satisfies(version, s.spec, { loose: true }))
if (ranges.length) {
return pickRange(ranges, name, version)
}
return matches.find(s => s.spec === null) || null
}
const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches, rm }) => {
const patchedDependencies = tree.package?.patchedDependencies || {}
const selectors = Object.entries(patchedDependencies)
.map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath }))
// cache patch file integrity by path so shared diffs are read once
const integrityCache = new Map()
const readPatch = async patchPath => {
if (integrityCache.has(patchPath)) {
return integrityCache.get(patchPath)
}
// patch files must live inside the project so the patch set stays auditable
const abs = resolve(path, patchPath)
const rel = relative(path, abs)
if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
throw err(`patch path escapes the project: ${patchPath}`, 'EPATCHUNSAFE', { path: patchPath })
}
let contents
try {
contents = await readFile(abs)
} catch {
throw err(`patch file not found: ${patchPath}`, 'EPATCHNOTFOUND', { path: patchPath })
}
const integrity = patchIntegrity(contents)
integrityCache.set(patchPath, integrity)
return integrity
}
const usedKeys = new Set()
for (const node of tree.inventory.values()) {
// patchedDependencies is honoured only in the root manifest
if (node.isWorkspace) {
// Link.package already delegates to its target's package
const pkg = node.package
if (pkg?.patchedDependencies && Object.keys(pkg.patchedDependencies).length) {
throw err(
`patchedDependencies is only supported in the root package.json, ` +
`but was found in workspace "${node.name}". Move the entry to the root.`,
'EPATCHWORKSPACE',
{ workspace: node.name }
)
}
continue
}
if (node.isProjectRoot) {
continue
}
const selector = matchSelector(selectors, node)
if (!selector) {
// a node that was patched but no longer matches a selector must be re-extracted to revert its files
if (node.patched) {
node.patchRemoved = true
}
node.patched = null
continue
}
// a non-registry consumer edge (file:, git:, http(s)) means there is no registry tarball to patch; npm: aliases stay registry.
// checking edges (not isRegistryDependency) avoids rejecting an edgeless node, which is still a registry dep.
if ([...node.edgesIn].some(e => e.spec && !npa(e.spec).registry)) {
throw err(
`Cannot patch non-registry dependency ${node.name}@${node.version} ` +
`(selector "${selector.key}"). Only registry dependencies can be patched.`,
'EPATCHNONREGISTRY',
{ node: node.name }
)
}
const integrity = await readPatch(selector.patchPath)
node.patched = { path: selector.patchPath, integrity }
usedKeys.add(selector.key)
}
if (selectors.length) {
const unused = selectors.filter(s => !usedKeys.has(s.key))
// an explicit `npm uninstall <name>` orphans that package's patch entry, so drop it instead of failing
const removed = new Set(rm)
const dropped = unused.filter(s => removed.has(s.name))
if (dropped.length) {
const patched = { ...tree.package.patchedDependencies }
for (const s of dropped) {
delete patched[s.key]
log.notice('patch', `Removing patch entry "${s.key}" for uninstalled ${s.name}; left ${s.patchPath} in place.`)
}
// undefined drops the key entirely when reify writes package.json
tree.package.patchedDependencies = Object.keys(patched).length ? patched : undefined
}
const stillUnused = unused.filter(s => !removed.has(s.name))
if (stillUnused.length && !allowUnusedPatches) {
throw err(
`The following patches were registered but matched no installed ` +
`package:\n${stillUnused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` +
`Use --allow-unused-patches to install anyway.`,
'EPATCHUNUSED',
{ unused: stillUnused.map(s => s.key) }
)
}
}
}
module.exports = { resolvePatchedDependencies, matchSelector, parseSelector }
// 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 npa = require('npm-package-arg')
const semver = require('semver')
const versionFromTgz = require('./version-from-tgz.js')
// Identity matcher for the allowScripts policy.
//
// Returns:
// - true: at least one allow entry matches and no deny entry matches
// - false: at least one deny entry matches (deny wins on conflict)
// - null: no entry matches (unreviewed)
//
// `policy` is a flat object of `spec-key -> boolean`, where spec-key is
// anything `npm-package-arg` can parse. `node` is an arborist Node.
//
// Identity rules (see RFC npm/rfcs#868):
// - registry deps match by the name+version parsed from the lockfile's
// resolved URL, NOT by `node.packageName` / `node.version`. Those two
// getters return `node.package.name` / `node.package.version`, which
// come from the tarball's own package.json and are therefore
// attacker-controlled. A package can publish a tarball claiming any
// name; the only trusted name is the one baked into the registry URL.
// - tarball / file / link / remote: exact match on node.resolved
// - git: match on hosted.ssh() plus a short-SHA prefix of the
// resolved committish
const isScriptAllowed = (node, policy) => {
// 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) {
return null
}
if (!policy || typeof policy !== 'object') {
return null
}
let anyAllow = false
let anyDeny = false
for (const [key, value] of Object.entries(policy)) {
if (!matches(node, key)) {
continue
}
if (value === false) {
anyDeny = true
continue
}
/* istanbul ignore else: policy values are strictly true/false;
defensive guard against unexpected coercions. */
if (value === true) {
anyAllow = true
}
}
if (anyDeny) {
return false
}
if (anyAllow) {
return true
}
return null
}
const matches = (node, key) => {
let parsed
try {
parsed = npa(key)
} catch {
return false
}
switch (parsed.type) {
case 'tag':
case 'range':
case 'version':
return matchRegistry(node, parsed)
case 'git':
return matchGit(node, parsed)
case 'file':
case 'directory':
return matchFileOrDir(node, parsed)
case 'remote':
return matchRemote(node, parsed)
case 'alias':
// Disallowed: aliases as policy keys do not match anything.
// The user has to address the real package name.
return false
/* istanbul ignore next: switch above covers every npa type we expect;
defensive fallback for future npa types. */
default:
return false
}
}
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) => {
// If this node is not a registry dep, refuse the match. A registry-style
// key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node
// even if their names happen to coincide.
if (!isRegistryNode(node)) {
return false
}
// Derive the trusted name+version from the lockfile's resolved URL.
// Never use `node.packageName` / `node.version` here: those read from
// the tarball's own package.json and can be forged by a malicious
// publisher to bypass an allowScripts entry.
const trusted = getTrustedRegistryIdentity(node)
if (!trusted || trusted.name !== parsed.name) {
return false
}
// `tag` covers `pkg@latest`. Rejected up front by validatePolicy in
// resolve-allow-scripts.js because tags look like a pin but can't be
// verified at install time. Defense-in-depth: if one slips through
// (e.g. arborist invoked directly without the resolver), don't match.
if (parsed.type === 'tag') {
/* istanbul ignore next: validatePolicy filters this; defensive */
return false
}
// `range` includes `pkg@^1`, `pkg@1 || 2`, `pkg@*`, `pkg@>=0`, and bare
// names like `pkg` (npa parses these as range with fetchSpec='*'). The
// RFC permits bare names (name-only allow) and exact versions joined by
// `||`; ranges like ^/~/>=/< are rejected because they would silently
// allow versions the user has never reviewed.
if (parsed.type === 'range') {
// Bare name or `pkg@*`: treat as name-only allow.
if (parsed.fetchSpec === '*' || parsed.rawSpec === '' || parsed.rawSpec === '*') {
return true
}
if (!trusted.version || !isExactVersionDisjunction(parsed.fetchSpec)) {
return false
}
return semver.satisfies(trusted.version, parsed.fetchSpec, { loose: true })
}
// `version` is an exact pin like `pkg@1.2.3`.
/* istanbul ignore else: parsed.type at this point is always 'version';
the istanbul-ignored fallback below handles the impossible case. */
if (parsed.type === 'version') {
return trusted.version === parsed.fetchSpec
}
/* istanbul ignore next: parsed.type is constrained to tag/range/version
by the caller; this final fallback is defensive. */
return false
}
// Derive a registry node's trusted name+version.
//
// Preferred source: the lockfile's resolved URL parsed via
// versionFromTgz. arborist records the URL when it first adds the dep,
// before any tarball is unpacked, so the URL cannot be forged by the
// package's own package.json.
//
// Fallback for lockfiles produced with omit-lockfile-registry-resolved
// (where the URL is absent): take the dep name from an incoming
// dependency edge. The edge's spec was written by the consumer (or by an
// upstream package.json), not by the installed tarball. For aliases like
// `"trusted": "npm:naughty@1.0.0"`, the underlying registered package
// name is parsed out of the alias `subSpec`. The install location
// (`node_modules/trusted`) is deliberately not consulted because for
// aliases it carries only the alias name, which would let a malicious
// publisher bypass an allowScripts entry written for the real package.
//
// Version is left null in the fallback case because the only remaining
// source for it (`node.version`) reads from the tarball.
//
// Returns `{ name, version }` or `null` if no trusted identity exists.
const getTrustedRegistryIdentity = (node) => {
if (node.resolved && typeof node.resolved === 'string') {
const parsed = versionFromTgz('', node.resolved)
/* istanbul ignore else: versionFromTgz returns either a complete
{ name, version } or null; partial objects are not produced. */
if (parsed && parsed.name && parsed.version) {
return parsed
}
}
const name = nameFromEdges(node)
if (name) {
return { name, version: null }
}
return null
}
const nameFromEdges = (node) => {
if (!node.edgesIn || typeof node.edgesIn[Symbol.iterator] !== 'function') {
return null
}
for (const edge of node.edgesIn) {
let parsed
try {
parsed = npa.resolve(edge.name, edge.spec)
} catch {
continue
}
// Aliases: trust the underlying registered package, not the alias.
if (parsed.type === 'alias' && parsed.subSpec && parsed.subSpec.registry) {
return parsed.subSpec.name
}
// Non-aliased registry edge: the edge name is the package name as
// written by the consumer / upstream, which is trusted (it is not
// read from the installed tarball).
if (parsed.registry) {
return parsed.name
}
}
return null
}
// True if `rangeSpec` is one or more exact versions joined by `||`. Anything
// containing comparator operators (^, ~, >=, <, *) returns false.
const isExactVersionDisjunction = (rangeSpec) => {
/* istanbul ignore next: caller always passes parsed.fetchSpec, which
npa guarantees to be a non-empty string for range specs. */
if (typeof rangeSpec !== 'string' || rangeSpec.trim() === '') {
return false
}
const parts = rangeSpec.split('||').map(p => p.trim())
/* istanbul ignore next: String.prototype.split always returns at least
one element; defensive guard only. */
if (parts.length === 0) {
return false
}
return parts.every(p => p !== '' && semver.valid(p) !== null)
}
const matchGit = (node, parsed) => {
if (!node.resolved || !node.resolved.startsWith('git')) {
return false
}
let nodeParsed
try {
nodeParsed = npa(node.resolved)
} catch {
/* istanbul ignore next: npa parsing a git URL we already validated
starts with `git` should not throw; defensive guard only. */
return false
}
// Compare the host/repo. Both sides should resolve to the same canonical
// ssh URL.
const noCommittish = { noCommittish: true }
const keyHost = parsed.hosted?.ssh(noCommittish)
const nodeHost = nodeParsed.hosted?.ssh(noCommittish)
if (keyHost && nodeHost) {
if (keyHost !== nodeHost) {
return false
}
} else if (parsed.fetchSpec && nodeParsed.fetchSpec) {
// Non-hosted git URLs: fall back to fetch spec.
if (parsed.fetchSpec !== nodeParsed.fetchSpec) {
return false
}
} else {
return false
}
// If the policy key has no committish, name-only match.
const keyCommittish = parsed.gitCommittish || parsed.hosted?.committish
if (!keyCommittish) {
return true
}
// Match the resolved full SHA against the key's committish. Users
// typically write short SHAs in the policy; the lockfile stores 40-char
// SHAs. Direction matters: the lockfile's full SHA must START WITH the
// key's short SHA, never the reverse. A longer key matching a shorter
// resolved committish would let a malformed lockfile or a divergent
// resolver allow scripts the user never approved.
const nodeCommittish = nodeParsed.gitCommittish || nodeParsed.hosted?.committish || ''
if (!nodeCommittish) {
return false
}
return nodeCommittish.startsWith(keyCommittish)
}
const matchFileOrDir = (node, parsed) => {
return resolvedSourceSpecs(node)
.some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
}
const matchRemote = (node, parsed) => {
return resolvedSourceSpecs(node)
.some(resolved => resolved === parsed.fetchSpec || resolved === parsed.saveSpec)
}
const isRegistryNode = (node) => {
// Prefer arborist's edge-based check when available (real Node objects).
// It inspects the incoming edges' specs and only returns true if every
// edge resolves to a registry spec, which is much harder to spoof than
// the URL.
if (typeof node.isRegistryDependency === 'boolean') {
return node.isRegistryDependency
}
// Fall back to URL parsing for nodes without the arborist getter
// (e.g. test fixtures, lockfiles with omit-lockfile-registry-resolved).
// Treat the node as a registry dep when:
// - resolved is missing entirely (omitLockfileRegistryResolved),
// - resolved is an https/http URL pointing at a registry tarball, or
// - resolved is undefined and the node has a version (defensive).
if (!node.resolved) {
return !!node.version
}
// Registry tarballs live at `<host>/<pkg-name>/-/<pkg-name>-<version>.tgz`.
// Require a path segment before `/-/` so an attacker can't lift a
// registry-style allow entry to a hostile URL like
// `https://evil.com/-/trusted-1.0.0.tgz`.
return /^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
}
// 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) => {
const trusted = getTrustedRegistryIdentity(node)
/* istanbul ignore next: defensive fallbacks for nodes without name/version */
return {
name: (trusted && trusted.name) || node.name || null,
version: (trusted && trusted.version) || node.version || null,
}
}
module.exports = isScriptAllowed
module.exports.isScriptAllowed = isScriptAllowed
module.exports.isExactVersionDisjunction = isExactVersionDisjunction
module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity
module.exports.resolvedSourceSpecs = resolvedSourceSpecs
module.exports.trustedDisplay = trustedDisplay
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 }
+2
-0

@@ -103,4 +103,6 @@ // The arborist manages three trees:

Arborist: this.constructor,
allowScripts: options.allowScripts ?? null,
binLinks: 'binLinks' in options ? !!options.binLinks : true,
cache: options.cache || `${homedir()}/.npm/_cacache`,
dangerouslyAllowAllScripts: !!options.dangerouslyAllowAllScripts,
dryRun: !!options.dryRun,

@@ -107,0 +109,0 @@ formatPackageLock: 'formatPackageLock' in options ? !!options.formatPackageLock : true,

@@ -5,2 +5,3 @@ const { join } = require('node:path')

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

@@ -16,3 +17,6 @@ // generate short hash key based on the dependency tree starting at this node

branch.push(`${node.packageName}@${node.version}`)
deps.push(`${branch.join('->')}::${node.resolved}`)
// a patch changes the materialized contents, so it must change the store key.
// the patch segment is only appended when present, so unpatched keys are unchanged.
const patch = node.patched ? `::patch:${node.patched.integrity}` : ''
deps.push(`${branch.join('->')}::${node.resolved}${patch}`)
},

@@ -32,3 +36,5 @@ leave: () => {

.replace(/=+$/m, '')
return `${startNode.packageName}@${startNode.version}-${hash}`
// a patched entry gets a distinct, identifiable side-store key so unpatched consumers keep sharing the original
const patchSuffix = startNode.patched ? '+patch' : ''
return `${startNode.packageName}@${startNode.version}-${hash}${patchSuffix}`
}

@@ -43,5 +49,8 @@

#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,

@@ -52,2 +61,3 @@ name: node.packageName || node.name,

parent: root,
patched: node.patched,
path: join(this.idealGraph.localPath, location),

@@ -159,2 +169,9 @@ resolved: node.resolved,

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

@@ -169,3 +186,5 @@ }

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.patched = node.patched
result.package = { ...node.package }

@@ -221,2 +240,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)))

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

// Create workspace Link. For root declared deps, link at root node_modules/. For undeclared deps, link at the workspace's own node_modules/ (self-link).
// Declared workspaces are symlinked at root node_modules/.
// Undeclared workspaces get a tree-only Link kept for diff/filter participation but not materialized on disk.
const isDeclared = this.#rootDeclaredDeps.has(wsName)

@@ -318,3 +354,3 @@ const wsLink = new IsolatedLink({

if (!isDeclared) {
workspace.children.set(wsName, wsLink)
wsLink.isUndeclaredWorkspaceLink = true
}

@@ -337,3 +373,3 @@ root.children.set(wsName, wsLink)

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

@@ -340,0 +376,0 @@

@@ -16,2 +16,3 @@ // mix-in implementing the loadActual method

const realpath = require('../realpath.js')
const PackageExtensions = require('../package-extensions.js')

@@ -177,2 +178,4 @@ // public symbols

this.#applyPackageExtensions()
if (!ignoreMissing) {

@@ -357,2 +360,30 @@ await this.#findMissingEdges()

// packageExtensions never rewrite a package's package.json, so a filesystem-scanned actual tree lacks the extension-created edges and provenance.
// Re-derive them from the root rule set, as buildIdealTree does.
// This is always required under the linked strategy, whose store layout forces the filesystem-scan path.
#applyPackageExtensions () {
const rootPkg = this.#actualTree.target?.package
const pe = new PackageExtensions(rootPkg?.packageExtensions)
if (!pe.present || !pe.selectors.length) {
return
}
for (const node of this.#actualTree.inventory.values()) {
// only installed dependencies are extended, never the root or a workspace
if (node.isLink || node.isProjectRoot || !node.name || !node.inNodeModules()) {
continue
}
const res = pe.apply(node.package)
if (res) {
node.package = res.pkg
node.packageExtensionsApplied = res.applied
}
}
// mirror the provenance onto links so the logical tree location reports it too
for (const node of this.#actualTree.inventory.values()) {
if (node.isLink && node.target?.packageExtensionsApplied) {
node.packageExtensionsApplied = node.target.packageExtensionsApplied
}
}
}
async #findMissingEdges () {

@@ -359,0 +390,0 @@ // try to resolve any missing edges by walking up the directory tree,

@@ -245,2 +245,4 @@ const { resolve } = require('node:path')

integrity: sw.integrity,
patched: sw.patched,
packageExtensionsApplied: sw.packageExtensionsApplied,
resolved: consistentResolve(sw.resolved, this.path, path),

@@ -247,0 +249,0 @@ pkg: sw,

@@ -14,3 +14,4 @@ // Arborist.rebuild({path = this.path}) will do all the binlinks and

const { log, time } = require('proc-log')
const { resolve } = require('node:path')
const { resolve, delimiter } = require('node:path')
const { isScriptAllowed } = require('../script-allowed.js')

@@ -202,6 +203,24 @@ const boolEnv = b => b ? '1' : ''

const tests = { bin, preinstall, install, postinstall, prepare }
// allowScripts gate (RFC npm/rfcs#868): `true` runs lifecycle
// scripts; `false` and `null` (unreviewed) block. Bypassed by
// --dangerously-allow-all-scripts and workspaces (owner-managed).
// --ignore-scripts still wins (in #build); bins are never gated.
//
// Checked on node.target, not the Link: a Link's `resolved` is
// node_modules-relative (`file:../../dep`) so it can't match a
// project-root-relative policy key; the target carries the realpath
// and link specs that script-allowed.js matches on (npm/cli#9498).
// For non-links node.target === node, so registry deps are unaffected.
const scriptsAllowed =
this.options.dangerouslyAllowAllScripts ||
node.isWorkspace ||
isScriptAllowed(node.target, this.options.allowScripts) === true
for (const [key, has] of Object.entries(tests)) {
if (has) {
this.#queues[key].push(node)
if (!has) {
continue
}
if (key !== 'bin' && !scriptsAllowed) {
continue
}
this.#queues[key].push(node)
}

@@ -293,2 +312,3 @@ }

path,
name,
integrity,

@@ -302,2 +322,3 @@ resolved,

location,
isInStore,
} = node.target

@@ -324,2 +345,8 @@

}
// In the linked strategy a store package's dependencies are symlinked siblings in its store node_modules.
// A separate bin invoked by the script (e.g. napi-postinstall) resolves modules from its own realpath in the store and cannot see those deps, so expose them via NODE_PATH.
if (isInStore) {
const storeNodeModules = resolve(path, ...name.split('/').map(() => '..'))
env.NODE_PATH = [storeNodeModules, process.env.NODE_PATH].filter(Boolean).join(delimiter)
}
const runOpts = {

@@ -326,0 +353,0 @@ event,

@@ -9,2 +9,3 @@ // an object representing the set of vulnerabilities in a tree

const Calculator = require('@npmcli/metavuln-calculator')
const { isReleaseAgeExcluded } = require('./release-age-exclude.js')

@@ -177,2 +178,7 @@ const { log, time } = require('proc-log')

vuln.topNodes.add(dep)
} else {
// An in-range fix exists, but a `min-release-age`/`before`
// window may put the patched version out of reach, leaving the
// vulnerable version installed.
vuln.fixBlockedByReleaseAge = this.#fixBlockedByReleaseAge(vuln, spec)
}

@@ -256,2 +262,49 @@ } else {

// A fix that `#fixAvailable` reports as installable in-range can still be
// unreachable when a release-age window (`before` / `min-release-age`) is set,
// because the patched version was published after the cutoff. `npm audit fix`
// then resolves back to a version that is still vulnerable, so detect that
// here and let callers warn about it. Only meaningful when an in-range fix
// exists (fixAvailable === true). Returns the blocked fix as
// `{ version, before }`, or `false` when nothing is blocked.
#fixBlockedByReleaseAge (vuln, spec) {
const { before, minReleaseAgeExclude } = this.options
// No window, or this package is explicitly exempt from it.
if (!before || isReleaseAgeExcluded(vuln.name, minReleaseAgeExclude)) {
return false
}
// For `npm:` aliases the fix resolves against the alias target, so feed
// pickManifest the underlying range rather than the alias spec (which it
// rejects). Mirrors `#fixAvailable`.
const specObj = npa(spec)
if (specObj.subSpec) {
spec = specObj.subSpec.rawSpec
}
// The version that would be installed if the window were lifted.
const { version } = pickManifest(vuln.packument, spec, {
...this.options,
before: null,
avoid: vuln.range,
})
// What resolution can actually reach within the window. This mirrors how
// `build-ideal-tree` re-resolves the edge (non-strict avoid + `before`).
let windowed
try {
windowed = pickManifest(vuln.packument, spec, {
...this.options,
avoid: vuln.range,
})
} catch {
// Nothing is old enough to install at all: the fix is blocked.
return { version, before }
}
// `_shouldAvoid` means the best version available within the window is still
// in the vulnerable range, so the only non-vulnerable fix is too new.
return windowed._shouldAvoid ? { version, before } : false
}
set () {

@@ -258,0 +311,0 @@ throw new Error('do not call AuditReport.set() directly')

+1
-1

@@ -29,3 +29,3 @@ // take a path and a resolved value, and turn it into a resolution from

if (hosted) {
return `git+${hosted.auth ? hosted.https(hostedOpt) : hosted.sshurl(hostedOpt)}`
return `git+${(hosted.auth || hosted.default === 'https') ? hosted.https(hostedOpt) : hosted.sshurl(hostedOpt)}`
}

@@ -32,0 +32,0 @@ if (type === 'git') {

@@ -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 @@ }

@@ -133,2 +133,12 @@ // a tree representing the difference between two trees

// a change in patch state requires re-extracting and re-applying
if ((ideal.patched?.integrity || null) !== (actual.patched?.integrity || null)) {
return 'CHANGE'
}
// a node whose patch was just removed must be re-extracted to revert the patched files
if (ideal.patchRemoved) {
return 'CHANGE'
}
const binsExist = ideal.binPaths.every((path) => existsSync(path))

@@ -135,0 +145,0 @@

@@ -163,2 +163,12 @@ // An edge in the dependency graph

explanation.from = this.#from.explain(null, seen)
// note when this edge was created by a root packageExtensions repair on the from node
const applied = this.#from.packageExtensionsApplied
if (applied) {
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) {
if (applied[field]?.includes(this.#name)) {
explanation.packageExtensions = { selector: applied.selector, field }
break
}
}
}
}

@@ -165,0 +175,0 @@ this.#explanation = explanation

@@ -7,1 +7,2 @@ module.exports = require('./arborist/index.js')

module.exports.Shrinkwrap = require('./shrinkwrap.js')
module.exports.PackageExtensions = require('./package-extensions.js')

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

isInStore = false
inBundle = false
isRegistryDependency = false
isRootDependency = false
linksIn = new Set()
meta = { loadedFromDisk: false }
optional = false
patched = null
parent = null

@@ -50,5 +54,17 @@ root = null

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

@@ -108,2 +124,7 @@

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

@@ -110,0 +131,0 @@ return this.package.version

@@ -96,3 +96,5 @@ // inventory, path, realpath, root, and parent

overrides,
packageExtensionsApplied = null,
parent,
patched = null,
path,

@@ -173,2 +175,7 @@ peer = true,

this.integrity = integrity || this.package._integrity || null
// Patch record { path, integrity } or null, set from patchedDependencies or the lockfile.
this.patched = patched || null
// Provenance for a root packageExtensions repair applied to this node's manifest, or null.
// Shape: { selector, dependencies?, optionalDependencies?, peerDependencies?, peerDependenciesMeta? }.
this.packageExtensionsApplied = packageExtensionsApplied
this.installLinks = installLinks

@@ -175,0 +182,0 @@ this.legacyPeerDeps = legacyPeerDeps

@@ -250,2 +250,5 @@ // Given a dep, a node that depends on it, and the edge representing that

error: this.dep.errors[0],
...(this.dep.packageExtensionsApplied
? { packageExtensionsApplied: this.dep.packageExtensionsApplied }
: {}),
...(this.dep.overrides ? { overrides: this.dep.overrides } : {}),

@@ -252,0 +255,0 @@ ...(this.dep.isLink ? { target: this.dep.target, realpath: this.dep.realpath } : {}),

@@ -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

@@ -13,2 +13,8 @@ // a module that manages a lockfile (package-lock.json).

const defaultLockfileVersion = 3
// Bumped to 4 only when a node carries a patch record, so older clients abort.
const patchedLockfileVersion = 4
// packageExtensions provenance also forces lockfileVersion 4 so older clients abort rather than silently dropping the repaired graph.
// Both features share version 4: they are root-owned graph repairs an old npm must not drop.
const packageExtensionsLockfileVersion = 4
const maxLockfileVersion = 4

@@ -111,2 +117,4 @@ // for comparing nodes to yarn.lock entries

'hasInstallScript',
'patched',
'packageExtensionsApplied',
]

@@ -352,2 +360,3 @@

this.#awaitingUpdate = new Map()
this.packageExtensionsHash = null
const lockfileVersion = this.lockfileVersion || defaultLockfileVersion

@@ -464,2 +473,9 @@ this.originalLockfileVersion = lockfileVersion

}
// refuse lockfiles newer than we understand so we never drop a patched or repaired graph we cannot read
if (data.lockfileVersion > maxLockfileVersion) {
throw Object.assign(
new Error(`Unsupported lockfileVersion ${data.lockfileVersion}. This npm only supports up to ${maxLockfileVersion}. Please upgrade npm.`),
{ code: 'ELOCKFILEVERSION' }
)
}
// auto convert v1 lockfiles to v3

@@ -485,2 +501,5 @@ // leave v2 in place unless configured

// the canonical packageExtensions hash, if the lockfile recorded one on its root entry
this.packageExtensionsHash = data.packages?.['']?.packageExtensionsHash || null
// use default if it wasn't explicitly set, and the current file is

@@ -903,2 +922,6 @@ // less than our default. otherwise, keep whatever is in the file,

this.resolveOptions)
// record the canonical packageExtensions hash on the root entry so npm ci can detect stale extension state
if (this.packageExtensionsHash) {
root.packageExtensionsHash = this.packageExtensionsHash
}
this.data.packages = {}

@@ -914,4 +937,10 @@ if (Object.keys(root).length) {

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

@@ -944,2 +973,18 @@ }

}
// patched nodes force lockfileVersion 4 so older clients abort the install
// the hidden lockfile is an internal cache pinned to version 3, so it never drives this upgrade
const hasPatched = !this.hiddenLockfile &&
Object.values(this.data.packages).some(p => p.patched)
if (hasPatched && this.lockfileVersion < patchedLockfileVersion) {
log.warn('shrinkwrap', `patchedDependencies requires lockfileVersion ${patchedLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`)
this.lockfileVersion = patchedLockfileVersion
}
// packageExtensions state likewise forces lockfileVersion 4 so older clients abort instead of dropping the repaired graph
const hasExtensionState = !this.hiddenLockfile &&
(this.packageExtensionsHash ||
Object.values(this.data.packages).some(p => p.packageExtensionsApplied))
if (hasExtensionState && this.lockfileVersion < packageExtensionsLockfileVersion) {
log.warn('shrinkwrap', `packageExtensions requires lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`)
this.lockfileVersion = packageExtensionsLockfileVersion
}
this.data.lockfileVersion = this.lockfileVersion

@@ -946,0 +991,0 @@

@@ -44,2 +44,3 @@ // An object representing a vulnerability either as the result of an

this.nodes = new Set()
this.fixBlockedByReleaseAge = false
this.addAdvisory(advisory)

@@ -130,2 +131,5 @@ this.packument = advisory.packument

fixAvailable: this.#fixAvailable,
...(this.fixBlockedByReleaseAge
? { fixBlockedByReleaseAge: this.fixBlockedByReleaseAge }
: {}),
}

@@ -132,0 +136,0 @@ }

{
"name": "@npmcli/arborist",
"version": "10.0.0-pre.0.0",
"version": "10.0.0-pre.1",
"description": "Manage node_modules trees",

@@ -8,27 +8,28 @@ "dependencies": {

"@isaacs/string-locale-compare": "^1.1.0",
"@npmcli/fs": "^5.0.0",
"@npmcli/installed-package-contents": "^4.0.0",
"@npmcli/map-workspaces": "^5.0.0",
"@npmcli/metavuln-calculator": "^9.0.2",
"@npmcli/name-from-folder": "^4.0.0",
"@npmcli/node-gyp": "^5.0.0",
"@npmcli/package-json": "^7.0.0",
"@npmcli/fs": "^6.0.0",
"@npmcli/installed-package-contents": "^5.0.0",
"@npmcli/map-workspaces": "^6.0.0",
"@npmcli/metavuln-calculator": "^10.0.0",
"@npmcli/name-from-folder": "^5.0.0",
"@npmcli/node-gyp": "^6.0.0",
"@npmcli/package-json": "^8.0.0",
"@npmcli/query": "^5.0.0",
"@npmcli/redact": "^4.0.0",
"@npmcli/run-script": "^10.0.0",
"bin-links": "^6.0.0",
"cacache": "^20.0.1",
"@npmcli/redact": "^5.0.0",
"@npmcli/run-script": "^11.0.0",
"bin-links": "^7.0.0",
"cacache": "^21.0.1",
"common-ancestor-path": "^2.0.0",
"hosted-git-info": "^9.0.0",
"diff": "^8.0.2",
"hosted-git-info": "^10.1.1",
"json-stringify-nice": "^1.1.4",
"lru-cache": "^11.2.1",
"minimatch": "^10.0.3",
"nopt": "^9.0.0",
"npm-install-checks": "^8.0.0",
"npm-package-arg": "^13.0.0",
"npm-pick-manifest": "^11.0.1",
"npm-registry-fetch": "^19.0.0",
"pacote": "^21.0.2",
"parse-conflict-json": "^5.0.1",
"proc-log": "^6.0.0",
"nopt": "^10.0.1",
"npm-install-checks": "^9.0.0",
"npm-package-arg": "^14.0.0",
"npm-pick-manifest": "^12.0.0",
"npm-registry-fetch": "^20.0.1",
"pacote": "^22.0.0",
"parse-conflict-json": "^6.0.0",
"proc-log": "^7.0.0",
"proggy": "^4.0.0",

@@ -38,4 +39,5 @@ "promise-all-reject-late": "^1.0.0",

"semver": "^7.3.7",
"ssri": "^13.0.0",
"ssri": "^14.0.0",
"treeverse": "^3.0.0",
"validate-npm-package-name": "^7.0.2",
"walk-up-path": "^4.0.0"

@@ -46,6 +48,6 @@ },

"@npmcli/mock-registry": "^1.0.0",
"@npmcli/template-oss": "4.29.0",
"@npmcli/template-oss": "5.1.1",
"benchmark": "^2.1.4",
"minify-registry-metadata": "^4.0.0",
"nock": "^13.3.3",
"nock": "^14.0.0",
"tap": "^16.3.8",

@@ -88,2 +90,6 @@ "tar-stream": "^3.0.0",

],
"node-arg": [
"--require",
"../../scripts/disable-agent-for-tests.js"
],
"timeout": "720",

@@ -96,9 +102,9 @@ "nyc-arg": [

"engines": {
"node": "^20.17.0 || >=22.9.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"templateOSS": {
"//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
"version": "4.29.0",
"version": "5.1.1",
"content": "../../scripts/template-oss/index.js"
}
}

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

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