npm
Version:
a package manager for JavaScript
304 lines (276 loc) • 10.3 kB
JavaScript
const { log, output } = require('proc-log')
const npa = require('npm-package-arg')
const semver = require('semver')
const pkgJson = require('@npmcli/package-json')
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
const checkAllowScripts = require('./check-allow-scripts.js')
const resolveAllowScripts = require('./resolve-allow-scripts.js')
const {
applyApprovalForPackage,
applyDenyForPackage,
nameKeyFor,
} = require('./allow-scripts-writer.js')
const BaseCommand = require('../base-cmd.js')
// Parse a positional arg into a name and an optional version range. A bare
// name matches every installed version; `pkg@1.2.3` or `pkg@^1` narrows by
// semver. npm-package-arg handles dotted and scoped names; fall back to the
// raw string as the name if it can't be parsed.
const parsePositional = (arg) => {
let parsed
try {
parsed = npa(arg)
} catch {
return { name: arg, range: null }
}
const name = parsed.name || arg
if (parsed.type === 'version' || parsed.type === 'range') {
const spec = parsed.fetchSpec
const range = (!spec || spec === '*' || parsed.rawSpec === '' || parsed.rawSpec === '*')
? null
: spec
return { name, range }
}
return { name, range: null }
}
// Shared implementation for `npm approve-scripts` and `npm deny-scripts`.
// Subclasses set `verb` to `'approve'` or `'deny'`.
//
// Extends `BaseCommand` rather than `ArboristCmd` on purpose. Per RFC,
// `allowScripts` is read from the workspace root's `package.json` only;
// individual workspaces don't have their own `allowScripts` field, and
// running approve/deny inside a sub-workspace is identical to running
// it at the root. There's no per-workspace targeting to do, so the
// `--workspace` / `--workspaces` / `--include-workspace-root` params
// from `ArboristCmd` would be misleading no-ops.
class AllowScriptsCmd extends BaseCommand {
static params = ['all', 'allow-scripts-pending', 'allow-scripts-pin', 'json']
static ignoreImplicitWorkspace = false
// Subclasses set `static verb = 'approve' | 'deny'`.
get verb () {
/* istanbul ignore next: every concrete subclass declares static verb */
return this.constructor.verb
}
async exec (args) {
if (this.npm.global) {
throw Object.assign(
new Error(`\`npm ${this.constructor.name}\` does not work for global installs`),
{ code: 'EGLOBAL' }
)
}
const pending = !!this.npm.config.get('allow-scripts-pending')
const all = !!this.npm.config.get('all')
if (pending && (args.length > 0 || all)) {
throw this.usageError(
'`--allow-scripts-pending` cannot be combined with positional arguments or `--all`.'
)
}
if (!pending && !all && args.length === 0) {
throw this.usageError()
}
if (this.verb === 'deny' && pending) {
throw this.usageError('`npm deny-scripts --allow-scripts-pending` is not supported.')
}
const Arborist = require('@npmcli/arborist')
const { policy } = await resolveAllowScripts(this.npm)
const arb = new Arborist({
...this.npm.flatOptions,
path: this.npm.prefix,
allowScripts: policy,
})
await arb.loadActual()
// Keep listing unreviewed packages even with ignore-scripts set, so
// you can move from a blanket ignore-scripts to an allowlist. This
// only lists; nothing runs.
const unreviewed = await checkAllowScripts({ arb, npm: this.npm, includeWhenIgnored: true })
if (pending) {
return this.runPending(unreviewed)
}
if (all) {
return this.runAll(unreviewed)
}
return this.runPositional(args, arb)
}
runPending (unreviewed) {
if (this.npm.flatOptions.json) {
output.buffer({ allowScripts: this.pendingSummary(unreviewed) })
return
}
if (unreviewed.length === 0) {
output.standard('No packages with unreviewed install scripts.')
return
}
const count = unreviewed.length
const has = count === 1 ? 'has' : 'have'
const pkg = count === 1 ? 'package' : 'packages'
output.standard(
`${count} ${pkg} ${has} install scripts not yet covered by allowScripts:`
)
for (const { node, scripts } of unreviewed) {
const { name, version } = trustedDisplay(node)
/* istanbul ignore next: every test node has a name */
const display = name || '<unknown>'
const ver = version ? `@${version}` : ''
const events = Object.entries(scripts)
.map(([event, cmd]) => `${event}: ${cmd}`)
.join('; ')
output.standard(` ${display}${ver} (${events})`)
}
output.standard('')
output.standard(
'Run `npm approve-scripts <pkg>` to allow, or `npm deny-scripts <pkg>` to deny.'
)
}
// Build the same `{ name, changes }` shape printSummary uses for writes,
// but tag every entry as `pending` since nothing is written. Names and
// versions are derived exactly like the text listing above.
pendingSummary (unreviewed) {
const groups = new Map()
for (const { node } of unreviewed) {
const { name, version } = trustedDisplay(node)
/* istanbul ignore next: every test node has a name */
const display = name || '<unknown>'
const key = version ? `${display}@${version}` : display
if (!groups.has(display)) {
groups.set(display, [])
}
groups.get(display).push({ key, change: 'pending' })
}
return [...groups].map(([name, changes]) => ({ name, changes }))
}
async runAll (unreviewed) {
if (unreviewed.length === 0) {
if (this.npm.flatOptions.json) {
output.buffer({ allowScripts: [] })
return
}
output.standard('No packages with unreviewed install scripts.')
return
}
// Bundled dependencies never appear in `unreviewed` (checkAllowScripts
// skips them because they never run their install scripts and cannot
// be allowlisted), so there is nothing extra to filter here.
const groups = this.groupByPackage(unreviewed.map(({ node }) => node))
await this.writePolicyChanges(groups)
}
async runPositional (args, arb) {
const { matched, unmatched } = this.findNodesForArgs(args, arb)
if (unmatched.length > 0) {
throw Object.assign(
new Error(`No installed packages match: ${unmatched.join(', ')}`),
{ code: 'ENOMATCH' }
)
}
const groups = this.groupByPackage(matched)
/* istanbul ignore if: matched is non-empty here; groups only empties when a
matched node has no trusted key, which groupByPackage already warns on */
if (Object.keys(groups).length === 0) {
throw Object.assign(
new Error(`No installed packages match: ${args.join(', ')}`),
{ code: 'ENOMATCH' }
)
}
await this.writePolicyChanges(groups)
}
findNodesForArgs (args, arb) {
// Match positional args against each node's trusted name. Registry deps
// use the URL-derived name; non-registry deps fall back to the dependency
// edge name. A version or range on the arg narrows the match to installed
// versions that satisfy it. Bundled deps are excluded for the same reason
// as --all. Args that match nothing are returned in `unmatched`.
const matched = []
const unmatched = []
for (const arg of args) {
const { name: wantName, range } = parsePositional(arg)
const found = []
for (const node of arb.actualTree.inventory.values()) {
if (node.isProjectRoot || node.isWorkspace || node.inBundle) {
continue
}
const { name, version } = trustedDisplay(node)
if (!name || name !== wantName) {
continue
}
if (range && (!version || !semver.satisfies(version, range, { loose: true }))) {
continue
}
found.push(node)
}
if (found.length === 0) {
unmatched.push(arg)
} else {
matched.push(...found)
}
}
return { matched, unmatched }
}
get logTitle () {
return this.constructor.name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}
groupByPackage (nodes) {
const groups = {}
for (const node of nodes) {
const key = nameKeyFor(node)
/* istanbul ignore if: callers prefilter via inBundle and trustedDisplay so untrusted nodes don't reach here */
if (!key) {
log.warn(
this.logTitle,
`skipping ${node.name || '<unknown>'}: no trusted identity for policy key`
)
continue
}
if (!groups[key]) {
groups[key] = []
}
groups[key].push(node)
}
return groups
}
async writePolicyChanges (groups) {
const pin = this.npm.config.get('allow-scripts-pin') !== false
const pkg = await pkgJson.load(this.npm.prefix)
const content = pkg.content
const existing = content.allowScripts && typeof content.allowScripts === 'object'
? content.allowScripts
: {}
let updated = existing
const summary = []
for (const [name, nodes] of Object.entries(groups)) {
const result = this.verb === 'approve'
? applyApprovalForPackage(updated, nodes, { pin })
: applyDenyForPackage(updated, nodes)
if (result.warning) {
log.warn(this.logTitle, result.warning)
}
updated = result.allowScripts
summary.push({ name, changes: result.changes })
}
/* istanbul ignore else: writePolicyChanges only called when changes are expected */
if (updated !== existing) {
pkg.update({ allowScripts: updated })
await pkg.save()
}
this.printSummary(summary)
}
printSummary (summary) {
if (this.npm.flatOptions.json) {
output.buffer({ allowScripts: summary })
return
}
const verb = this.verb === 'approve' ? 'Approved' : 'Denied'
let touched = 0
for (const { name, changes } of summary) {
if (changes.length === 0) {
continue
}
touched++
output.standard(`${verb} ${name}:`)
for (const { key, change } of changes) {
output.standard(` ${change} ${key}`)
}
}
if (touched === 0) {
output.standard(`Nothing to ${this.verb}; allowScripts unchanged.`)
}
}
}
module.exports = AllowScriptsCmd