Skip to content

Commit 4fa81df

Browse files
fix: recognize allowScripts for local link targets (#9497)
Backport of #9490 to `release/v11`. Co-authored-by: Rayan Salhab <r.salhab@aiyexpertsolutions.com> Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
1 parent 95cf2e9 commit 4fa81df

5 files changed

Lines changed: 163 additions & 15 deletions

File tree

lib/utils/allow-scripts-writer.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
const npa = require('npm-package-arg')
22
const { log } = require('proc-log')
3-
const { getTrustedRegistryIdentity } = require('@npmcli/arborist/lib/script-allowed.js')
3+
const {
4+
getTrustedRegistryIdentity,
5+
resolvedSourceSpecs,
6+
} = require('@npmcli/arborist/lib/script-allowed.js')
47

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

18+
const primaryResolvedSource = (node) => resolvedSourceSpecs(node)[0] || ''
19+
1520
// Convert an arborist Node into the spec string used for a versioned policy
1621
// entry. Returns `null` if the node cannot be represented as a versioned key
1722
// derived from trusted sources (lockfile URL for registry, hosted shortcut
@@ -21,8 +26,7 @@ const versionedKeyFor = (node) => {
2126
if (!node) {
2227
return null
2328
}
24-
/* istanbul ignore next: callers guarantee a string resolved */
25-
const resolved = typeof node.resolved === 'string' ? node.resolved : ''
29+
const resolved = primaryResolvedSource(node)
2630
if (resolved.startsWith('git')) {
2731
try {
2832
const parsed = npa(resolved)
@@ -69,8 +73,7 @@ const nameKeyFor = (node) => {
6973
if (!node) {
7074
return null
7175
}
72-
/* istanbul ignore next: callers guarantee a string resolved */
73-
const resolved = typeof node.resolved === 'string' ? node.resolved : ''
76+
const resolved = primaryResolvedSource(node)
7477
if (resolved.startsWith('git')) {
7578
try {
7679
const parsed = npa(resolved)
@@ -164,7 +167,8 @@ const keyTargetsNode = (key, node) => {
164167
case 'git': {
165168
let resolvedParsed
166169
try {
167-
resolvedParsed = node.resolved ? npa(node.resolved) : null
170+
const resolved = primaryResolvedSource(node)
171+
resolvedParsed = resolved ? npa(resolved) : null
168172
} catch {
169173
/* istanbul ignore next */
170174
return false
@@ -176,7 +180,8 @@ const keyTargetsNode = (key, node) => {
176180
case 'file':
177181
case 'directory':
178182
case 'remote':
179-
return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
183+
return resolvedSourceSpecs(node)
184+
.some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
180185
default:
181186
return false
182187
}

test/lib/utils/allow-scripts-writer.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,48 @@ t.test('nameKeyFor / versionedKeyFor — file', async t => {
4545
t.equal(versionedKeyFor(n), 'file:../local')
4646
})
4747

48+
t.test('nameKeyFor / versionedKeyFor — local directory link target', async t => {
49+
const targetPath = path.resolve('local')
50+
const n = {
51+
name: 'local',
52+
packageName: 'local',
53+
version: '1.0.0',
54+
resolved: null,
55+
path: targetPath,
56+
realpath: targetPath,
57+
linksIn: new Set([{ resolved: 'file:../local' }]),
58+
}
59+
60+
t.equal(nameKeyFor(n), 'file:../local')
61+
t.equal(versionedKeyFor(n), 'file:../local')
62+
63+
t.strictSame(
64+
applyApprovalForPackage({}, [n], { pin: true }).allowScripts,
65+
{ 'file:../local': true }
66+
)
67+
t.match(
68+
applyApprovalForPackage({ 'file:local': false }, [n], { pin: true }).warning,
69+
/denied|versioned deny/
70+
)
71+
})
72+
73+
t.test('nameKeyFor / versionedKeyFor — empty link target has no portable file key', async t => {
74+
const targetPath = path.resolve('local')
75+
const n = {
76+
name: 'local',
77+
packageName: 'local',
78+
version: '1.0.0',
79+
resolved: null,
80+
path: targetPath,
81+
realpath: targetPath,
82+
linksIn: new Set(),
83+
}
84+
85+
t.equal(nameKeyFor(n), null)
86+
t.equal(versionedKeyFor(n), null)
87+
t.strictSame(applyApprovalForPackage({}, [n], { pin: true }).allowScripts, {})
88+
})
89+
4890
t.test('isSingleVersionPin', async t => {
4991
t.ok(isSingleVersionPin('pkg@1.2.3'))
5092
t.notOk(isSingleVersionPin('pkg'))

workspaces/arborist/lib/script-allowed.js

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,41 @@ const matches = (node, key) => {
9797
}
9898
}
9999

100+
const resolvedSourceSpecs = (node) => {
101+
const specs = []
102+
const seen = new Set()
103+
const add = (spec) => {
104+
if (typeof spec !== 'string' || spec === '' || seen.has(spec)) {
105+
return
106+
}
107+
seen.add(spec)
108+
specs.push(spec)
109+
}
110+
111+
add(node?.resolved)
112+
113+
if (!node?.resolved && node?.linksIn && typeof node.linksIn[Symbol.iterator] === 'function') {
114+
let hasIncomingLink = false
115+
for (const link of node.linksIn) {
116+
hasIncomingLink = true
117+
add(link.resolved)
118+
}
119+
120+
if (hasIncomingLink) {
121+
// Link targets for local directory deps are separate inventory nodes
122+
// whose own `resolved` is null. The incoming Link carries the saved spec
123+
// (for example `file:../pkg`, relative to node_modules), while policy
124+
// entries written by hand often use the dependency spec from package.json
125+
// (for example `file:pkg`, resolved by npa to this target path). Include
126+
// the real target paths so both forms can match the same local dep.
127+
add(node.realpath)
128+
add(node.path)
129+
}
130+
}
131+
132+
return specs
133+
}
134+
100135
const matchRegistry = (node, parsed) => {
101136
// If this node is not a registry dep, refuse the match. A registry-style
102137
// key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node
@@ -282,17 +317,13 @@ const matchGit = (node, parsed) => {
282317
}
283318

284319
const matchFileOrDir = (node, parsed) => {
285-
if (!node.resolved) {
286-
return false
287-
}
288-
return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
320+
return resolvedSourceSpecs(node)
321+
.some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
289322
}
290323

291324
const matchRemote = (node, parsed) => {
292-
if (!node.resolved) {
293-
return false
294-
}
295-
return node.resolved === parsed.fetchSpec || node.resolved === parsed.saveSpec
325+
return resolvedSourceSpecs(node)
326+
.some(resolved => resolved === parsed.fetchSpec || resolved === parsed.saveSpec)
296327
}
297328

298329
const isRegistryNode = (node) => {
@@ -337,4 +368,5 @@ module.exports = isScriptAllowed
337368
module.exports.isScriptAllowed = isScriptAllowed
338369
module.exports.isExactVersionDisjunction = isExactVersionDisjunction
339370
module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity
371+
module.exports.resolvedSourceSpecs = resolvedSourceSpecs
340372
module.exports.trustedDisplay = trustedDisplay

workspaces/arborist/test/script-allowed.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,56 @@ t.test('file path — exact resolved match', t => {
118118
t.end()
119119
})
120120

121+
t.test('file path — link target matches incoming link source', t => {
122+
const targetPath = require('node:path').resolve('local-pkg')
123+
const target = node({
124+
name: 'local-pkg',
125+
packageName: 'local-pkg',
126+
version: '1.0.0',
127+
})
128+
target.resolved = null
129+
target.path = targetPath
130+
target.realpath = targetPath
131+
target.linksIn = new Set([{ resolved: 'file:../local-pkg' }])
132+
133+
t.equal(isScriptAllowed(target, { 'file:../local-pkg': true }), true)
134+
t.equal(isScriptAllowed(target, { 'file:local-pkg': true }), true)
135+
t.equal(isScriptAllowed(target, { 'file:../local-pkg': false }), false)
136+
t.equal(isScriptAllowed(target, { 'file:../other': true }), null)
137+
t.end()
138+
})
139+
140+
t.test('file path — registry nodes do not match by install path', t => {
141+
const reg = node({
142+
name: 'sharp',
143+
packageName: 'sharp',
144+
version: '0.33.0',
145+
path: 'node_modules/sharp',
146+
realpath: require('node:path').resolve('node_modules/sharp'),
147+
linksIn: new Set(),
148+
})
149+
150+
t.equal(isScriptAllowed(reg, { 'file:node_modules/sharp': true }), null)
151+
t.end()
152+
})
153+
154+
t.test('file path — empty link sets do not add install paths', t => {
155+
const targetPath = require('node:path').resolve('local-pkg')
156+
const target = node({
157+
name: 'local-pkg',
158+
packageName: 'local-pkg',
159+
version: '1.0.0',
160+
})
161+
target.resolved = null
162+
target.path = targetPath
163+
target.realpath = targetPath
164+
target.linksIn = new Set()
165+
166+
t.equal(isScriptAllowed(target, { 'file:local-pkg': true }), null)
167+
t.equal(isScriptAllowed(target, { [targetPath]: true }), null)
168+
t.end()
169+
})
170+
121171
t.test('directory key — npa parses absolute paths as type=directory', t => {
122172
// npa treats absolute paths as { type: 'directory' }, which the
123173
// matcher shares with the 'file' case. path.resolve produces a

workspaces/arborist/test/unreviewed-scripts.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ t.test('collectUnreviewedScripts', async t => {
118118
t.equal(result[0].node.name, 'pending')
119119
})
120120

121+
t.test('skips reviewed local directory link targets', async t => {
122+
const target = node({ name: 'local', scripts: { install: 'x' } })
123+
target.resolved = null
124+
target.isRegistryDependency = false
125+
target.path = require('node:path').resolve('local')
126+
target.realpath = target.path
127+
target.linksIn = new Set([{ resolved: 'file:../local' }])
128+
129+
t.strictSame(await collectUnreviewedScripts({
130+
tree: tree([target]),
131+
policy: { 'file:../local': false },
132+
}), [])
133+
134+
t.strictSame(await collectUnreviewedScripts({
135+
tree: tree([target]),
136+
policy: { 'file:local': true },
137+
}), [])
138+
})
139+
121140
t.test('detects synthetic node-gyp via binding.gyp runtime check', async t => {
122141
const collect = mockCollect(t, async (n) => {
123142
if (n.path === '/has-bindings') {

0 commit comments

Comments
 (0)