UNPKG

npm

Version:

a package manager for JavaScript

341 lines (313 loc) 12.4 kB
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 cannot be allowlisted in Phase 1. The RFC defers // allowlisting them to a follow-up RFC because matching by name@version // from the bundled tarball would reintroduce manifest confusion (a // bundled tarball can claim any name and version). Returning null here // marks bundled deps as unreviewed regardless of any policy entries, so // their install scripts surface in the Phase 1 advisory warning and // (eventually) get blocked at the install-time gate. 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 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) => { if (!node.resolved) { return false } return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec } const matchRemote = (node, parsed) => { if (!node.resolved) { return false } return node.resolved === parsed.fetchSpec || node.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 (`npm install` // advisory, `npm approve-scripts --allow-scripts-pending`). Same idea as // getTrustedRegistryIdentity, but for DISPLAY only — version falls back // to node.version when the URL doesn't carry one. Must never be used // 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.trustedDisplay = trustedDisplay