Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions lib/utils/allow-scripts-writer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const npa = require('npm-package-arg')
const { log } = require('proc-log')
const { getTrustedRegistryIdentity } = require('@npmcli/arborist/lib/script-allowed.js')
const {
getTrustedRegistryIdentity,
resolvedSourceSpecs,
} = require('@npmcli/arborist/lib/script-allowed.js')

// Pure helpers that implement the RFC's pin-mismatch table for
// `npm approve-scripts` and `npm deny-scripts`.
Expand All @@ -12,6 +15,8 @@ const { getTrustedRegistryIdentity } = require('@npmcli/arborist/lib/script-allo
// Denying always writes `"<name>": false`, regardless of `--allow-scripts-pin`, per the
// RFC's asymmetric-pin rule.

const primaryResolvedSource = (node) => resolvedSourceSpecs(node)[0] || ''
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same root cause leaks into the writer. If a link target has an empty linksIn (so resolved is null), resolvedSourceSpecs(node)[0] is node.realpath, an absolute path. versionedKeyFor/nameKeyFor then take the startsWith('/') branch and write something like /proj/node_modules/A into allowScripts instead of file:../A. Better to prefer a file: candidate over a bare path, or return null so we don't persist a machine-specific key. Fixing comment 1 with the size > 0 guard mostly covers this too.


// Convert an arborist Node into the spec string used for a versioned policy
// entry. Returns `null` if the node cannot be represented as a versioned key
// derived from trusted sources (lockfile URL for registry, hosted shortcut
Expand All @@ -21,8 +26,7 @@ const versionedKeyFor = (node) => {
if (!node) {
return null
}
/* istanbul ignore next: callers guarantee a string resolved */
const resolved = typeof node.resolved === 'string' ? node.resolved : ''
const resolved = primaryResolvedSource(node)
if (resolved.startsWith('git')) {
try {
const parsed = npa(resolved)
Expand Down Expand Up @@ -69,8 +73,7 @@ const nameKeyFor = (node) => {
if (!node) {
return null
}
/* istanbul ignore next: callers guarantee a string resolved */
const resolved = typeof node.resolved === 'string' ? node.resolved : ''
const resolved = primaryResolvedSource(node)
if (resolved.startsWith('git')) {
try {
const parsed = npa(resolved)
Expand Down Expand Up @@ -164,7 +167,8 @@ const keyTargetsNode = (key, node) => {
case 'git': {
let resolvedParsed
try {
resolvedParsed = node.resolved ? npa(node.resolved) : null
const resolved = primaryResolvedSource(node)
resolvedParsed = resolved ? npa(resolved) : null
} catch {
/* istanbul ignore next */
return false
Expand All @@ -176,7 +180,8 @@ const keyTargetsNode = (key, node) => {
case 'file':
case 'directory':
case 'remote':
return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
return resolvedSourceSpecs(node)
.some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
default:
return false
}
Expand Down
25 changes: 25 additions & 0 deletions test/lib/utils/allow-scripts-writer.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,31 @@ t.test('nameKeyFor / versionedKeyFor — file', async t => {
t.equal(versionedKeyFor(n), 'file:../local')
})

t.test('nameKeyFor / versionedKeyFor — local directory link target', async t => {
const targetPath = path.resolve('local')
const n = {
name: 'local',
packageName: 'local',
version: '1.0.0',
resolved: null,
path: targetPath,
realpath: targetPath,
linksIn: new Set([{ resolved: 'file:../local' }]),
}

t.equal(nameKeyFor(n), 'file:../local')
t.equal(versionedKeyFor(n), 'file:../local')

t.strictSame(
applyApprovalForPackage({}, [n], { pin: true }).allowScripts,
{ 'file:../local': true }
)
t.match(
applyApprovalForPackage({ 'file:local': false }, [n], { pin: true }).warning,
/denied|versioned deny/
)
})

t.test('isSingleVersionPin', async t => {
t.ok(isSingleVersionPin('pkg@1.2.3'))
t.notOk(isSingleVersionPin('pkg'))
Expand Down
44 changes: 36 additions & 8 deletions workspaces/arborist/lib/script-allowed.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ const matches = (node, key) => {
}
}

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?.linksIn && typeof node.linksIn[Symbol.iterator] === 'function') {
for (const link of node.linksIn) {
add(link.resolved)
}

// 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)
Comment on lines +124 to +125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to @owlstronaut. The guard on line 113 is always true since linksIn is always a Set, so these two lines add realpath/path for every node, not just link targets. A registry package's install path then becomes a match candidate for file:/directory keys. I checked locally: a registry node returns true for {"file:node_modules/<name>": true} where base returns null.

Can we gate the paths on the real link-target case, e.g. only add them when !node.resolved && node.linksIn.size > 0?

}

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
Expand Down Expand Up @@ -282,17 +313,13 @@ const matchGit = (node, parsed) => {
}

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

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

const isRegistryNode = (node) => {
Expand Down Expand Up @@ -337,4 +364,5 @@ module.exports = isScriptAllowed
module.exports.isScriptAllowed = isScriptAllowed
module.exports.isExactVersionDisjunction = isExactVersionDisjunction
module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity
module.exports.resolvedSourceSpecs = resolvedSourceSpecs
module.exports.trustedDisplay = trustedDisplay
19 changes: 19 additions & 0 deletions workspaces/arborist/test/script-allowed.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ t.test('file path — exact resolved match', t => {
t.end()
})

t.test('file path — link target matches incoming link source', t => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a negative case here? A registry node (resolved = tarball URL, empty linksIn) shouldn't match a file: key by its install path: t.equal(isScriptAllowed(reg, { 'file:node_modules/<name>': true }), null). That pins down the boundary @owlstronaut flagged. Covering an empty linksIn Set and a link with resolved === null would help too.

const targetPath = require('node:path').resolve('local-pkg')
const target = node({
name: 'local-pkg',
packageName: 'local-pkg',
version: '1.0.0',
})
target.resolved = null
target.path = targetPath
target.realpath = targetPath
target.linksIn = new Set([{ resolved: 'file:../local-pkg' }])

t.equal(isScriptAllowed(target, { 'file:../local-pkg': true }), true)
t.equal(isScriptAllowed(target, { 'file:local-pkg': true }), true)
t.equal(isScriptAllowed(target, { 'file:../local-pkg': false }), false)
t.equal(isScriptAllowed(target, { 'file:../other': true }), null)
t.end()
})

t.test('directory key — npa parses absolute paths as type=directory', t => {
// npa treats absolute paths as { type: 'directory' }, which the
// matcher shares with the 'file' case. path.resolve produces a
Expand Down
19 changes: 19 additions & 0 deletions workspaces/arborist/test/unreviewed-scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ t.test('collectUnreviewedScripts', async t => {
t.equal(result[0].node.name, 'pending')
})

t.test('skips reviewed local directory link targets', async t => {
const target = node({ name: 'local', scripts: { install: 'x' } })
target.resolved = null
target.isRegistryDependency = false
target.path = require('node:path').resolve('local')
target.realpath = target.path
target.linksIn = new Set([{ resolved: 'file:../local' }])

t.strictSame(await collectUnreviewedScripts({
tree: tree([target]),
policy: { 'file:../local': false },
}), [])

t.strictSame(await collectUnreviewedScripts({
tree: tree([target]),
policy: { 'file:local': true },
}), [])
})

t.test('detects synthetic node-gyp via binding.gyp runtime check', async t => {
const collect = mockCollect(t, async (n) => {
if (n.path === '/has-bindings') {
Expand Down
Loading