From ecff78b5adeae4231b38cdd33149087b87626000 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Thu, 20 Aug 2026 15:16:26 +0500 Subject: [PATCH 1/2] fix(arborist): match allowScripts keys for local paths --- workspaces/arborist/lib/script-allowed.js | 9 ++++++++- workspaces/arborist/test/script-allowed.js | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/script-allowed.js b/workspaces/arborist/lib/script-allowed.js index 8c9b3fe118a8e..0ac18869f09b0 100644 --- a/workspaces/arborist/lib/script-allowed.js +++ b/workspaces/arborist/lib/script-allowed.js @@ -328,8 +328,15 @@ const matchGit = (node, parsed) => { } const matchFileOrDir = (node, parsed) => { + // A local dep's `resolved` is built by consistent-resolve.js as `file:` + // glued onto the absolute fetchSpec, so it keeps the platform separators. + // npa's saveSpec stays relative when the key is relative and is always + // forward-slashed, so neither form matches it on Windows. + const resolvedSpec = `file:${parsed.fetchSpec}` return resolvedSourceSpecs(node) - .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec) + .some(resolved => resolved === parsed.saveSpec || + resolved === parsed.fetchSpec || + resolved === resolvedSpec) } const matchRemote = (node, parsed) => { diff --git a/workspaces/arborist/test/script-allowed.js b/workspaces/arborist/test/script-allowed.js index 218ccf1e28888..8f316863bcbef 100644 --- a/workspaces/arborist/test/script-allowed.js +++ b/workspaces/arborist/test/script-allowed.js @@ -196,6 +196,21 @@ t.test('local tarball key — npa parses *.tgz paths as type=file', t => { t.end() }) +t.test('local tarball key — relative key matches an absolute resolved', t => { + // The installed tree carries the absolutized `file:` spec that + // consistent-resolve.js builds, with platform-native separators, while + // the key in package.json is the relative one from the lockfile. + const tgzNode = node({ + name: 'local-pkg', + packageName: 'local-pkg', + version: '1.0.0', + resolved: `file:${require('node:path').resolve('local-pkg.tgz')}`, + }) + t.equal(isScriptAllowed(tgzNode, { 'file:local-pkg.tgz': true }), true) + t.equal(isScriptAllowed(tgzNode, { 'file:other-pkg.tgz': true }), null) + t.end() +}) + t.test('remote tarball — exact resolved match', t => { const remoteNode = node({ name: 'pkg', From ac3a144de4f9f2e1c4d97ecbe649d3afe08278ab Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Thu, 20 Aug 2026 15:31:09 +0500 Subject: [PATCH 2/2] fix: reuse the arborist resolved-source matcher when writing allowScripts --- lib/utils/allow-scripts-writer.js | 4 ++-- workspaces/arborist/lib/script-allowed.js | 24 +++++++++------------- workspaces/arborist/test/script-allowed.js | 10 +++++---- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/lib/utils/allow-scripts-writer.js b/lib/utils/allow-scripts-writer.js index 6964279f2f2e0..5e2e1a8220d08 100644 --- a/lib/utils/allow-scripts-writer.js +++ b/lib/utils/allow-scripts-writer.js @@ -2,6 +2,7 @@ const npa = require('npm-package-arg') const { log } = require('proc-log') const { getTrustedRegistryIdentity, + matchesResolvedSource, resolvedSourceSpecs, } = require('@npmcli/arborist/lib/script-allowed.js') @@ -180,8 +181,7 @@ const keyTargetsNode = (key, node) => { case 'file': case 'directory': case 'remote': - return resolvedSourceSpecs(node) - .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec) + return matchesResolvedSource(node, parsed) default: return false } diff --git a/workspaces/arborist/lib/script-allowed.js b/workspaces/arborist/lib/script-allowed.js index 0ac18869f09b0..729056fa388c7 100644 --- a/workspaces/arborist/lib/script-allowed.js +++ b/workspaces/arborist/lib/script-allowed.js @@ -85,9 +85,8 @@ const matches = (node, key, failClosed) => { return matchGit(node, parsed) case 'file': case 'directory': - return matchFileOrDir(node, parsed) case 'remote': - return matchRemote(node, parsed) + return matchesResolvedSource(node, parsed) case 'alias': // Disallowed: aliases as policy keys do not match anything. // The user has to address the real package name. @@ -327,21 +326,17 @@ const matchGit = (node, parsed) => { return nodeCommittish.startsWith(keyCommittish) } -const matchFileOrDir = (node, parsed) => { - // A local dep's `resolved` is built by consistent-resolve.js as `file:` - // glued onto the absolute fetchSpec, so it keeps the platform separators. - // npa's saveSpec stays relative when the key is relative and is always - // forward-slashed, so neither form matches it on Windows. - const resolvedSpec = `file:${parsed.fetchSpec}` +// A local dep's `resolved` is built by consistent-resolve.js as `file:` +// glued onto the absolute fetchSpec. npa keeps saveSpec in the form the key +// was written in and always forward-slashes it, so a relative key never +// equals that spec, and on Windows no key form does. Relative keys resolve +// against the cwd npa was called from, the project root for `npm install`. +const matchesResolvedSource = (node, parsed) => { + const absoluteFileSpec = `file:${parsed.fetchSpec}` return resolvedSourceSpecs(node) .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec || - resolved === resolvedSpec) -} - -const matchRemote = (node, parsed) => { - return resolvedSourceSpecs(node) - .some(resolved => resolved === parsed.fetchSpec || resolved === parsed.saveSpec) + resolved === absoluteFileSpec) } const isRegistryNode = (node) => { @@ -388,4 +383,5 @@ module.exports.matches = matches module.exports.isExactVersionDisjunction = isExactVersionDisjunction module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity module.exports.resolvedSourceSpecs = resolvedSourceSpecs +module.exports.matchesResolvedSource = matchesResolvedSource module.exports.trustedDisplay = trustedDisplay diff --git a/workspaces/arborist/test/script-allowed.js b/workspaces/arborist/test/script-allowed.js index 8f316863bcbef..816aaa62584d1 100644 --- a/workspaces/arborist/test/script-allowed.js +++ b/workspaces/arborist/test/script-allowed.js @@ -185,7 +185,7 @@ t.test('directory key — npa parses absolute paths as type=directory', t => { t.test('local tarball key — npa parses *.tgz paths as type=file', t => { // npa treats `*.tgz` paths as { type: 'file' }, separate from - // 'directory'. Both share the matchFileOrDir body. + // 'directory'. Both share the matchesResolvedSource body. const tgzNode = node({ name: 'local-pkg', packageName: 'local-pkg', @@ -198,15 +198,17 @@ t.test('local tarball key — npa parses *.tgz paths as type=file', t => { t.test('local tarball key — relative key matches an absolute resolved', t => { // The installed tree carries the absolutized `file:` spec that - // consistent-resolve.js builds, with platform-native separators, while - // the key in package.json is the relative one from the lockfile. + // consistent-resolve.js builds, while the allowScripts key keeps the + // relative form the user wrote. + const tgzPath = require('node:path').resolve('local-pkg.tgz') const tgzNode = node({ name: 'local-pkg', packageName: 'local-pkg', version: '1.0.0', - resolved: `file:${require('node:path').resolve('local-pkg.tgz')}`, + resolved: `file:${tgzPath}`, }) t.equal(isScriptAllowed(tgzNode, { 'file:local-pkg.tgz': true }), true) + t.equal(isScriptAllowed(tgzNode, { [`file:${tgzPath}`]: true }), true) t.equal(isScriptAllowed(tgzNode, { 'file:other-pkg.tgz': true }), null) t.end() })