Skip to content
Merged
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
93 changes: 93 additions & 0 deletions docs/lib/content/commands/npm-install-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
title: npm-install-scripts
section: 1
description: Manage install-script approvals for dependencies
---

### Synopsis

<!-- AUTOGENERATED USAGE DESCRIPTIONS -->

### Description

Manages the `allowScripts` field in your project's `package.json`, which
records which of your dependencies are permitted to run install scripts
(`preinstall`, `install`, `postinstall`, and `prepare` for non-registry
sources). This is the recommended way to maintain that field.

Dependency install scripts are blocked by default. Install commands
silently skip lifecycle scripts for any dependency that does not have a
matching entry in `allowScripts`, and end with a list of the packages
whose scripts were skipped so you can review them here.

This command only works inside a project that has a `package.json`. Running
it with `--global` (`-g`) fails with an `EGLOBAL` error, since global
installs (`npm install -g`) and one-off executions (`npm exec` / `npx`) have
no project `package.json` to write to. To allow install scripts in those
contexts, use the `--allow-scripts` flag at install time (for example
`npm install -g --allow-scripts=canvas,sharp`) or persist the setting with
`npm config set allow-scripts=canvas,sharp --location=user`.

There are three subcommands:

```bash
npm install-scripts approve <pkg> [<pkg> ...]
npm install-scripts approve --all
npm install-scripts deny <pkg> [<pkg> ...]
npm install-scripts deny --all
npm install-scripts ls
```

`approve` allows install scripts for the named packages. `<pkg>` matches
every installed version of that package. By default it writes pinned entries
(`pkg@1.2.3`), which keep their approval narrowed to the specific version you
reviewed. Pass `--no-allow-scripts-pin` to write name-only entries that allow
any future version. `--all` approves every package with unreviewed install
scripts in one go.

`deny` records an explicit denial for the named packages (a name-only `false`
entry), which survives `npm install-scripts approve --all` and excludes the
package from any future blanket approval. `--all` denies every package with
unreviewed install scripts.

`ls` is read-only: it lists every package whose install scripts are not yet
covered by `allowScripts`, without modifying `package.json`.

`approve` honours the asymmetric pin rule: if you re-approve a package whose
installed version has changed, the existing pin is rewritten to track the new
installed version. Multi-version statements (`pkg@1 || 2`) are left alone,
since they likely capture intent that the command cannot infer. Existing
`false` entries always win; `approve` will not silently re-allow a package you
previously denied.

The standalone commands [`npm approve-scripts`](/commands/npm-approve-scripts)
and [`npm deny-scripts`](/commands/npm-deny-scripts) are aliases for
`npm install-scripts approve` and `npm install-scripts deny`.

### Examples

```bash
# Approve all currently-installed install scripts after reviewing them
npm install-scripts approve --all

# Approve specific packages, pinned to their installed version
npm install-scripts approve canvas sharp

# Deny a package so it stays blocked
npm install-scripts deny telemetry-pkg

# Preview which packages still need review
npm install-scripts ls
```

### Configuration

<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->

### See Also

* [npm approve-scripts](/commands/npm-approve-scripts)
* [npm deny-scripts](/commands/npm-deny-scripts)
* [npm install](/commands/npm-install)
* [npm rebuild](/commands/npm-rebuild)
* [package.json](/configuring-npm/package-json)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@
- title: npm install-ci-test
url: /commands/npm-install-ci-test
description: Install a project with a clean slate and run tests
- title: npm install-scripts
url: /commands/npm-install-scripts
description: Manage install-script approvals for dependencies
- title: npm install-test
url: /commands/npm-install-test
description: Install package(s) and run tests
Expand Down
49 changes: 49 additions & 0 deletions lib/commands/install-scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const AllowScriptsCmd = require('../utils/allow-scripts-cmd.js')

// Namespaced front-end for managing install-script approvals.
// `approve` and `deny` write the `allowScripts` policy; `ls` lists packages with unreviewed install scripts.
// The standalone `npm approve-scripts` and `npm deny-scripts` commands remain as aliases for `approve` and `deny`.
class InstallScripts extends AllowScriptsCmd {
static description = 'Manage install-script approvals for dependencies'
static name = 'install-scripts'
static usage = [
'approve <pkg> [<pkg> ...]',
'approve --all',
'deny <pkg> [<pkg> ...]',
'deny --all',
'ls',
]

static params = ['all', 'allow-scripts-pin', 'json']

static async completion (opts) {
const argv = opts.conf.argv.remain
const subcommands = ['approve', 'deny', 'ls']
if (argv.length === 2) {
return subcommands
}
if (subcommands.includes(argv[2])) {
return []
}
throw new Error(`${argv[2]} not recognized`)
}

async exec (args) {
const [sub, ...rest] = args
switch (sub) {
case 'approve':
return this.runMode('approve', rest)
case 'deny':
return this.runMode('deny', rest)
case 'ls':
case 'list':
return this.runMode('list', rest)
default:
throw this.usageError(
sub ? `\`${sub}\` is not a recognized subcommand.` : undefined
)
}
}
}

module.exports = InstallScripts
4 changes: 2 additions & 2 deletions lib/commands/rebuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ class Rebuild extends ArboristWorkspaceCmd {
if (unreviewed.length > 0) {
const count = unreviewed.length
const noun = count === 1 ? 'package has' : 'packages have'
// `npm approve-scripts` writes to a project package.json, which doesn't
// `npm install-scripts` writes to a project package.json, which doesn't
// exist for global rebuilds. Point global users at `npm config set`,
// which writes the `allow-scripts` setting to their user .npmrc.
const names = unreviewed.map(({ node }) => trustedDisplay(node).name)
const remediation = this.npm.global
? `Run \`${configSetAllowScripts(names)}\` to allow their scripts.`
: 'Run `npm approve-scripts --allow-scripts-pending` to review.'
: 'Run `npm install-scripts ls` to review.'
log.warn(
'rebuild',
`${count} ${noun} install scripts not yet covered by allowScripts. ` +
Expand Down
46 changes: 33 additions & 13 deletions lib/utils/allow-scripts-cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ const parsePositional = (arg) => {
return { name, range: null }
}

// Shared implementation for `npm approve-scripts` and `npm deny-scripts`.
// Subclasses set `verb` to `'approve'` or `'deny'`.
// Shared implementation for `npm approve-scripts`, `npm deny-scripts`, and the `npm install-scripts` namespace.
// `npm install-scripts` dispatches to `runMode('approve' | 'deny' | 'list', ...)`.
// The standalone commands set `static verb` and run through the default `exec`.
//
// Extends `BaseCommand` rather than `ArboristCmd` on purpose. Per RFC,
// `allowScripts` is read from the workspace root's `package.json` only;
Expand All @@ -48,33 +49,51 @@ class AllowScriptsCmd extends BaseCommand {
static params = ['all', 'allow-scripts-pending', 'allow-scripts-pin', 'json']
static ignoreImplicitWorkspace = false

// Subclasses set `static verb = 'approve' | 'deny'`.
// Mode of the current run, set by runMode.
// One of 'approve', 'deny', or 'list'.
#mode = null

// verb drives the writers and summaries, which only run in the two write modes, so it is never read while listing.
get verb () {
/* istanbul ignore next: every concrete subclass declares static verb */
return this.constructor.verb
return this.#mode
}

// Standalone `npm approve-scripts` / `npm deny-scripts` pick their mode from `static verb`.
async exec (args) {
return this.runMode(this.constructor.verb, args)
}

async runMode (mode, args) {
this.#mode = mode

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')
// `--allow-scripts-pending` is only honored by commands that declare it; the namespace lists via `ls` instead.
const pending = this.constructor.params.includes('allow-scripts-pending') &&
!!this.npm.config.get('allow-scripts-pending')
const all = !!this.npm.config.get('all')
// The `ls` subcommand lists, and so does `--allow-scripts-pending` on the write commands.
const list = mode === 'list' || pending

if (pending && (args.length > 0 || all)) {
if (list && (args.length > 0 || all)) {
const what = mode === 'list' ? '`npm install-scripts ls`' : '`--allow-scripts-pending`'
throw this.usageError(
'`--allow-scripts-pending` cannot be combined with positional arguments or `--all`.'
`${what} cannot be combined with positional arguments or \`--all\`.`
)
}
if (!pending && !all && args.length === 0) {
if (!list && !all && args.length === 0) {
throw this.usageError()
}
if (this.verb === 'deny' && pending) {
throw this.usageError('`npm deny-scripts --allow-scripts-pending` is not supported.')
if (mode === 'deny' && pending) {
throw this.usageError(
'`npm deny-scripts --allow-scripts-pending` is not supported; ' +
'run `npm install-scripts ls` to list unreviewed packages.'
)
}

const Arborist = require('@npmcli/arborist')
Expand All @@ -91,7 +110,7 @@ class AllowScriptsCmd extends BaseCommand {
// only lists; nothing runs.
const unreviewed = await checkAllowScripts({ arb, npm: this.npm, includeWhenIgnored: true })

if (pending) {
if (list) {
return this.runPending(unreviewed)
}

Expand Down Expand Up @@ -129,7 +148,8 @@ class AllowScriptsCmd extends BaseCommand {
}
output.standard('')
output.standard(
'Run `npm approve-scripts <pkg>` to allow, or `npm deny-scripts <pkg>` to deny.'
'Run `npm install-scripts approve <pkg>` to allow, ' +
'or `npm install-scripts deny <pkg>` to deny.'
)
}

Expand Down
6 changes: 3 additions & 3 deletions lib/utils/allow-scripts-writer.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ const isSingleVersionPin = (key) => {
// an approval. Per RFC, a name-only deny ("pkg": false) is widest and
// the only remediation is to remove the entry. A versioned deny
// ("pkg@1.2.3": false or a disjunction) blocks only specific versions;
// the user can either widen it via `npm deny-scripts <name>` or remove
// it to approve the currently-installed version only.
// the user can either widen it via `npm install-scripts deny <name>` or
// remove it to approve the currently-installed version only.
const denyWarning = (key, subject, name) => {
if (isNameOnlyKey(key)) {
return `${key} is denied; remove the entry from allowScripts to approve ${subject}.`
}
/* istanbul ignore next: name fallback is defensive; callers pass nameKeyFor(sample) */
const widenTarget = name || 'this package'
return `${key} is a versioned deny; run \`npm deny-scripts ${widenTarget}\` ` +
return `${key} is a versioned deny; run \`npm install-scripts deny ${widenTarget}\` ` +
`to widen the deny to all versions of ${widenTarget}, or remove the entry ` +
`to approve ${subject}.`
}
Expand Down
1 change: 1 addition & 0 deletions lib/utils/cmd-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const commands = [
'init',
'install',
'install-ci-test',
'install-scripts',
'install-test',
'link',
'll',
Expand Down
6 changes: 3 additions & 3 deletions lib/utils/reify-output.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
)
}

// `npm approve-scripts` writes to a project package.json, which doesn't
// `npm install-scripts` writes to a project package.json, which doesn't
// exist for global installs (it throws EGLOBAL). For those, point users at
// the mechanism that does work globally: the `--allow-scripts` flag for a
// one-off, or `npm config set allow-scripts` to persist it.
Expand All @@ -282,8 +282,8 @@ const remediationLines = (npm, names) => {
]
}
return [
'Run `npm approve-scripts --allow-scripts-pending` to review, ' +
'or `npm approve-scripts <pkg>` to allow.',
'Run `npm install-scripts ls` to review, ' +
'or `npm install-scripts approve <pkg>` to allow.',
]
}

Expand Down
16 changes: 8 additions & 8 deletions lib/utils/strict-allow-scripts-preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,19 @@ const strictAllowScriptsPreflight = async ({ arb, npm, idealTreeOpts }) => {
return ` ${label} (${events})`
}).join('\n')

// `npm approve-scripts` / `npm deny-scripts` write to a project
// package.json, which doesn't exist for global installs. Point global
// users at the `--allow-scripts` flag and `npm config set allow-scripts`,
// which both work for global installs. Use the trusted display identity
// so the suggested `npm config set` value matches what the policy matches
// on, not the tarball's self-reported name.
// `npm install-scripts` writes to a project package.json, which doesn't
// exist for global installs. Point global users at the `--allow-scripts`
// flag and `npm config set allow-scripts`, which both work for global
// installs. Use the trusted display identity so the suggested `npm config
// set` value matches what the policy matches on, not the tarball's
// self-reported name.
const names = unreviewed.map(({ node }) => trustedDisplay(node).name)
const remediation = npm.global
? 'Allow them with `--allow-scripts`, persist them with ' +
`\`${configSetAllowScripts(names)}\`, or bypass this ` +
'check with `--dangerously-allow-all-scripts`.'
: 'Approve them with `npm approve-scripts`, deny them with ' +
'`npm deny-scripts`, or bypass this check with ' +
: 'Approve them with `npm install-scripts approve`, deny them with ' +
'`npm install-scripts deny`, or bypass this check with ' +
'`--dangerously-allow-all-scripts`.'

throw Object.assign(
Expand Down
13 changes: 7 additions & 6 deletions smoke-tests/tap-snapshots/test/index.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ All commands:
completion, config, dedupe, deny-scripts, deprecate, diff,
dist-tag, docs, doctor, edit, exec, explain, explore,
find-dupes, fund, get, help, help-search, init, install,
install-ci-test, install-test, link, ll, login, logout, ls,
org, outdated, owner, pack, ping, pkg, prefix, profile,
prune, publish, query, rebuild, repo, restart, root, run,
sbom, search, set, shrinkwrap, stage, star, stars, start,
stop, team, test, token, trust, undeprecate, uninstall,
unpublish, unstar, update, version, view, whoami
install-ci-test, install-scripts, install-test, link, ll,
login, logout, ls, org, outdated, owner, pack, ping, pkg,
prefix, profile, prune, publish, query, rebuild, repo,
restart, root, run, sbom, search, set, shrinkwrap, stage,
star, stars, start, stop, team, test, token, trust,
undeprecate, uninstall, unpublish, unstar, update, version,
view, whoami

Specify configs in the ini-formatted file:
{NPM}/{TESTDIR}/home/.npmrc
Expand Down
1 change: 1 addition & 0 deletions tap-snapshots/test/lib/commands/publish.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Object {
"man/man1/npm-help.1",
"man/man1/npm-init.1",
"man/man1/npm-install-ci-test.1",
"man/man1/npm-install-scripts.1",
"man/man1/npm-install-test.1",
"man/man1/npm-install.1",
"man/man1/npm-link.1",
Expand Down
Loading
Loading