@npmcli/arborist
Advanced tools
| 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 |
+111
| // 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 } |
@@ -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, |
+53
-0
@@ -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') |
@@ -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') { |
+29
-0
@@ -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 @@ } |
+10
-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 @@ |
+10
-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 |
+1
-0
@@ -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 |
+7
-0
@@ -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 |
+3
-0
@@ -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 |
+47
-2
@@ -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 @@ |
+4
-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 @@ } |
+32
-26
| { | ||
| "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
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
565404
13.98%69
11.29%14467
11.65%36
5.88%23
21.05%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated