Sitelet https://github.com/JamieMagee/cli-1/commit/bc76211eff074908f40606525ff1f9726d62de91
Skip to content

Commit bc76211

Browse files
committed
feat(arborist): add identity matcher for allowScripts policy
A pure isScriptAllowed(node, policy) helper in workspaces/arborist/lib/script-allowed.js. Used by the install-time warning walker and by the approve-scripts / deny-scripts commands. Matching rules follow the RFC: - registry deps: name + optional semver (range or exact) - git deps: canonical ssh-url match plus short-SHA prefix - file / directory / remote tarball: exact resolved string match - alias spec keys are ignored entirely; a user must address the real package name, not the alias - matching uses node.packageName, never node.name, so an alias install cannot be approved by writing its alias name Conflict resolution: any matching false wins over any matching true. No match returns null (unreviewed). Pure function, no I/O. 15 test cases cover alias safety and omitLockfileRegistryResolved. Refs: npm/rfcs#868
1 parent fca1e96 commit bc76211

2 files changed

Lines changed: 928 additions & 0 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
const npa = require('npm-package-arg')
2+
const semver = require('semver')
3+
const versionFromTgz = require('./version-from-tgz.js')
4+
5+
// Identity matcher for the allowScripts policy.
6+
//
7+
// Returns:
8+
// - true: at least one allow entry matches and no deny entry matches
9+
// - false: at least one deny entry matches (deny wins on conflict)
10+
// - null: no entry matches (unreviewed)
11+
//
12+
// `policy` is a flat object of `spec-key -> boolean`, where spec-key is
13+
// anything `npm-package-arg` can parse. `node` is an arborist Node.
14+
//
15+
// Identity rules (see RFC npm/rfcs#868):
16+
// - registry deps match by the name+version parsed from the lockfile's
17+
// resolved URL, NOT by `node.packageName` / `node.version`. Those two
18+
// getters return `node.package.name` / `node.package.version`, which
19+
// come from the tarball's own package.json and are therefore
20+
// attacker-controlled. A package can publish a tarball claiming any
21+
// name; the only trusted name is the one baked into the registry URL.
22+
// - tarball / file / link / remote: exact match on node.resolved
23+
// - git: match on hosted.ssh() plus a short-SHA prefix of the
24+
// resolved committish
25+
26+
const isScriptAllowed = (node, policy) => {
27+
// Bundled dependencies cannot be allowlisted in Phase 1. The RFC defers
28+
// allowlisting them to a follow-up RFC because matching by name@version
29+
// from the bundled tarball would reintroduce manifest confusion (a
30+
// bundled tarball can claim any name and version). Returning null here
31+
// marks bundled deps as unreviewed regardless of any policy entries, so
32+
// their install scripts surface in the Phase 1 advisory warning and
33+
// (eventually) get blocked at the install-time gate.
34+
if (node.inBundle) {
35+
return null
36+
}
37+
38+
if (!policy || typeof policy !== 'object') {
39+
return null
40+
}
41+
42+
let anyAllow = false
43+
let anyDeny = false
44+
45+
for (const [key, value] of Object.entries(policy)) {
46+
if (!matches(node, key)) {
47+
continue
48+
}
49+
if (value === false) {
50+
anyDeny = true
51+
continue
52+
}
53+
/* istanbul ignore else: policy values are strictly true/false;
54+
defensive guard against unexpected coercions. */
55+
if (value === true) {
56+
anyAllow = true
57+
}
58+
}
59+
60+
if (anyDeny) {
61+
return false
62+
}
63+
if (anyAllow) {
64+
return true
65+
}
66+
return null
67+
}
68+
69+
const matches = (node, key) => {
70+
let parsed
71+
try {
72+
parsed = npa(key)
73+
} catch {
74+
return false
75+
}
76+
77+
switch (parsed.type) {
78+
case 'tag':
79+
case 'range':
80+
case 'version':
81+
return matchRegistry(node, parsed)
82+
case 'git':
83+
return matchGit(node, parsed)
84+
case 'file':
85+
case 'directory':
86+
return matchFileOrDir(node, parsed)
87+
case 'remote':
88+
return matchRemote(node, parsed)
89+
case 'alias':
90+
// Disallowed: aliases as policy keys do not match anything.
91+
// The user has to address the real package name.
92+
return false
93+
/* istanbul ignore next: switch above covers every npa type we expect;
94+
defensive fallback for future npa types. */
95+
default:
96+
return false
97+
}
98+
}
99+
100+
const matchRegistry = (node, parsed) => {
101+
// If this node is not a registry dep, refuse the match. A registry-style
102+
// key (`pkg`, `pkg@1`, `pkg@1 || 2`) must not match a tarball or git node
103+
// even if their names happen to coincide.
104+
if (!isRegistryNode(node)) {
105+
return false
106+
}
107+
108+
// Derive the trusted name+version from the lockfile's resolved URL.
109+
// Never use `node.packageName` / `node.version` here: those read from
110+
// the tarball's own package.json and can be forged by a malicious
111+
// publisher to bypass an allowScripts entry.
112+
const trusted = getTrustedRegistryIdentity(node)
113+
if (!trusted || trusted.name !== parsed.name) {
114+
return false
115+
}
116+
117+
// `tag` covers `pkg@latest`. Treat as name-only allow.
118+
if (parsed.type === 'tag') {
119+
return true
120+
}
121+
122+
// `range` includes `pkg@^1`, `pkg@1 || 2`, `pkg@*`, `pkg@>=0`, and bare
123+
// names like `pkg` (npa parses these as range with fetchSpec='*'). The
124+
// RFC permits bare names (name-only allow) and exact versions joined by
125+
// `||`; ranges like ^/~/>=/< are rejected because they would silently
126+
// allow versions the user has never reviewed.
127+
if (parsed.type === 'range') {
128+
// Bare name or `pkg@*`: treat as name-only allow.
129+
if (parsed.fetchSpec === '*' || parsed.rawSpec === '' || parsed.rawSpec === '*') {
130+
return true
131+
}
132+
if (!trusted.version || !isExactVersionDisjunction(parsed.fetchSpec)) {
133+
return false
134+
}
135+
return semver.satisfies(trusted.version, parsed.fetchSpec, { loose: true })
136+
}
137+
138+
// `version` is an exact pin like `pkg@1.2.3`.
139+
/* istanbul ignore else: parsed.type at this point is always 'version';
140+
the istanbul-ignored fallback below handles the impossible case. */
141+
if (parsed.type === 'version') {
142+
return trusted.version === parsed.fetchSpec
143+
}
144+
145+
/* istanbul ignore next: parsed.type is constrained to tag/range/version
146+
by the caller; this final fallback is defensive. */
147+
return false
148+
}
149+
150+
// Derive a registry node's trusted name+version.
151+
//
152+
// Preferred source: the lockfile's resolved URL parsed via
153+
// versionFromTgz. arborist records the URL when it first adds the dep,
154+
// before any tarball is unpacked, so the URL cannot be forged by the
155+
// package's own package.json.
156+
//
157+
// Fallback for lockfiles produced with omit-lockfile-registry-resolved
158+
// (where the URL is absent): take the dep name from an incoming
159+
// dependency edge. The edge's spec was written by the consumer (or by an
160+
// upstream package.json), not by the installed tarball. For aliases like
161+
// `"trusted": "npm:naughty@1.0.0"`, the underlying registered package
162+
// name is parsed out of the alias `subSpec`. The install location
163+
// (`node_modules/trusted`) is deliberately not consulted because for
164+
// aliases it carries only the alias name, which would let a malicious
165+
// publisher bypass an allowScripts entry written for the real package.
166+
//
167+
// Version is left null in the fallback case because the only remaining
168+
// source for it (`node.version`) reads from the tarball.
169+
//
170+
// Returns `{ name, version }` or `null` if no trusted identity exists.
171+
const getTrustedRegistryIdentity = (node) => {
172+
if (node.resolved && typeof node.resolved === 'string') {
173+
const parsed = versionFromTgz('', node.resolved)
174+
/* istanbul ignore else: versionFromTgz returns either a complete
175+
{ name, version } or null; partial objects are not produced. */
176+
if (parsed && parsed.name && parsed.version) {
177+
return parsed
178+
}
179+
}
180+
const name = nameFromEdges(node)
181+
if (name) {
182+
return { name, version: null }
183+
}
184+
return null
185+
}
186+
187+
const nameFromEdges = (node) => {
188+
if (!node.edgesIn || typeof node.edgesIn[Symbol.iterator] !== 'function') {
189+
return null
190+
}
191+
for (const edge of node.edgesIn) {
192+
let parsed
193+
try {
194+
parsed = npa.resolve(edge.name, edge.spec)
195+
} catch {
196+
continue
197+
}
198+
// Aliases: trust the underlying registered package, not the alias.
199+
if (parsed.type === 'alias' && parsed.subSpec && parsed.subSpec.registry) {
200+
return parsed.subSpec.name
201+
}
202+
// Non-aliased registry edge: the edge name is the package name as
203+
// written by the consumer / upstream, which is trusted (it is not
204+
// read from the installed tarball).
205+
if (parsed.registry) {
206+
return parsed.name
207+
}
208+
}
209+
return null
210+
}
211+
212+
// True if `rangeSpec` is one or more exact versions joined by `||`. Anything
213+
// containing comparator operators (^, ~, >=, <, *) returns false.
214+
const isExactVersionDisjunction = (rangeSpec) => {
215+
/* istanbul ignore next: caller always passes parsed.fetchSpec, which
216+
npa guarantees to be a non-empty string for range specs. */
217+
if (typeof rangeSpec !== 'string' || rangeSpec.trim() === '') {
218+
return false
219+
}
220+
const parts = rangeSpec.split('||').map(p => p.trim())
221+
/* istanbul ignore next: String.prototype.split always returns at least
222+
one element; defensive guard only. */
223+
if (parts.length === 0) {
224+
return false
225+
}
226+
return parts.every(p => p !== '' && semver.valid(p) !== null)
227+
}
228+
229+
const matchGit = (node, parsed) => {
230+
if (!node.resolved || !node.resolved.startsWith('git')) {
231+
return false
232+
}
233+
234+
let nodeParsed
235+
try {
236+
nodeParsed = npa(node.resolved)
237+
} catch {
238+
/* istanbul ignore next: npa parsing a git URL we already validated
239+
starts with `git` should not throw; defensive guard only. */
240+
return false
241+
}
242+
243+
// Compare the host/repo. Both sides should resolve to the same canonical
244+
// ssh URL.
245+
const noCommittish = { noCommittish: true }
246+
const keyHost = parsed.hosted?.ssh(noCommittish)
247+
const nodeHost = nodeParsed.hosted?.ssh(noCommittish)
248+
if (keyHost && nodeHost) {
249+
if (keyHost !== nodeHost) {
250+
return false
251+
}
252+
} else if (parsed.fetchSpec && nodeParsed.fetchSpec) {
253+
// Non-hosted git URLs: fall back to fetch spec.
254+
if (parsed.fetchSpec !== nodeParsed.fetchSpec) {
255+
return false
256+
}
257+
} else {
258+
return false
259+
}
260+
261+
// If the policy key has no committish, name-only match.
262+
const keyCommittish = parsed.gitCommittish || parsed.hosted?.committish
263+
if (!keyCommittish) {
264+
return true
265+
}
266+
267+
// Match the resolved full SHA against the key's committish. Users
268+
// typically write short SHAs in the policy; the lockfile stores 40-char
269+
// SHAs. Direction matters: the lockfile's full SHA must START WITH the
270+
// key's short SHA, never the reverse. A longer key matching a shorter
271+
// resolved committish would let a malformed lockfile or a divergent
272+
// resolver allow scripts the user never approved.
273+
const nodeCommittish = nodeParsed.gitCommittish || nodeParsed.hosted?.committish || ''
274+
if (!nodeCommittish) {
275+
return false
276+
}
277+
return nodeCommittish.startsWith(keyCommittish)
278+
}
279+
280+
const matchFileOrDir = (node, parsed) => {
281+
if (!node.resolved) {
282+
return false
283+
}
284+
return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
285+
}
286+
287+
const matchRemote = (node, parsed) => {
288+
if (!node.resolved) {
289+
return false
290+
}
291+
return node.resolved === parsed.fetchSpec || node.resolved === parsed.saveSpec
292+
}
293+
294+
const isRegistryNode = (node) => {
295+
// Prefer arborist's edge-based check when available (real Node objects).
296+
// It inspects the incoming edges' specs and only returns true if every
297+
// edge resolves to a registry spec, which is much harder to spoof than
298+
// the URL.
299+
if (typeof node.isRegistryDependency === 'boolean') {
300+
return node.isRegistryDependency
301+
}
302+
// Fall back to URL parsing for nodes without the arborist getter
303+
// (e.g. test fixtures, lockfiles with omit-lockfile-registry-resolved).
304+
// Treat the node as a registry dep when:
305+
// - resolved is missing entirely (omitLockfileRegistryResolved),
306+
// - resolved is an https/http URL pointing at a registry tarball, or
307+
// - resolved is undefined and the node has a version (defensive).
308+
if (!node.resolved) {
309+
return !!node.version
310+
}
311+
// Registry tarballs live at `<host>/<pkg-name>/-/<pkg-name>-<version>.tgz`.
312+
// Require a path segment before `/-/` so an attacker can't lift a
313+
// registry-style allow entry to a hostile URL like
314+
// `https://evil.com/-/trusted-1.0.0.tgz`.
315+
return /^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(node.resolved)
316+
}
317+
318+
module.exports = isScriptAllowed
319+
module.exports.isScriptAllowed = isScriptAllowed
320+
module.exports.isExactVersionDisjunction = isExactVersionDisjunction
321+
module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity

0 commit comments

Comments
 (0)