diff --git a/docs/lib/content/commands/npm-safeinstall.md b/docs/lib/content/commands/npm-safeinstall.md new file mode 100644 index 0000000000000..abb024a89a3c7 --- /dev/null +++ b/docs/lib/content/commands/npm-safeinstall.md @@ -0,0 +1,139 @@ +--- +title: npm-safeinstall +section: 1 +description: Install a package, confirming names and install scripts first +--- + +### Synopsis + + + +### Description + +`npm safeinstall` takes the same arguments and the same configuration as +[`npm install`](/commands/npm-install), and installs the same way, but it +stops and asks before it does anything irreversible. + +It exists because the two things that go wrong during an install are easy to +miss on a fast read of the command line: a mistyped package name, and a +package that runs code on your machine while it is being installed. The +checks live in a separate command rather than in `npm install` so that they +are opt in. Nothing about the ordinary install path changes, and there is no +extra prompt waiting in front of it. + +The checks run in this order, and the install is only handed to Arborist once +all of them pass: + +1. **Name check.** Every registry package name on the command line is + compared against a list of widely used packages. A name that is one or two + characters away from a well known package, such as `expres` for `express`, + stops the install. A package registered under that near miss name is + exactly what a typosquatter publishes, and you cannot tell the two apart by + looking at the name. + +2. **Privilege check.** Only runs with `--check-privileges`. Each requested + package's manifest is read from the registry and its `preinstall`, + `install`, and `postinstall` scripts are printed. Nothing is downloaded + for a package you go on to reject. + +3. **Install.** The specs are handed to the regular install pipeline. + +Because both prompts are blocking, `npm safeinstall` needs a terminal. It +fails with `ESAFEINSTALLNOTTY` when standard input is not a TTY rather than +waiting on input that will never come, which makes it unsuitable for CI. Use +[`npm ci`](/commands/npm-ci) there, and +[`npm approve-scripts`](/commands/npm-approve-scripts) to review the install +scripts in an existing tree. + +### Confirming a name + +A name that is close to a well known package prints the name, the distance, +and anything else it is close to, then waits for `CONFIRM`: + +```bash +$ npm safeinstall expres +expres is not express, but it is 1 character away. + +A package with that name may exist, but it is not the package you want. +Type CONFIRM to install it anyway. Anything else cancels the install. +CONFIRM +``` + +Only the exact word `CONFIRM` continues, in capitals. `y`, `yes`, an empty +line, and `confirm` all cancel with `ESAFEINSTALLCONFIRM`. Surrounding +whitespace is trimmed first, so a stray space is not read as a refusal. A +name that is not within two characters of a well known package is not +questioned at all, so the prompt only appears when there is a real reason to +read it. Names shorter than four characters are only compared against a +single character difference, since two edits on a three character name is +not much of a match. + +### Reviewing install scripts + +```bash +$ npm safeinstall --check-privileges canvas + +canvas@3.2.0 runs code during install: + preinstall: node-pre-gyp install --fallback-to-build + install: node-pre-gyp install --fallback-to-build + postinstall: node-pre-gyp install --fallback-to-build + +Do you explicitly grant these privileges? (y/N) +``` + +Answering `y` continues, anything else cancels with +`ESAFEINSTALLPRIVILEGES`. A package that declares none of the three scripts +prints nothing and is installed without a prompt. + +Only the packages you named are inspected. Their dependencies are not, since +resolving them is the install's job. Note that `--check-privileges` is a +report, not a policy: answering `y` does not record anything, and it does not +change what the `allowScripts` field in your `package.json` permits on later +installs. Use [`npm approve-scripts`](/commands/npm-approve-scripts) for +that. + +### Non-registry specs + +Local paths, tarballs, git urls, and aliases have no registry name to +compare and no manifest to read, so they skip both checks. The name check +ignores them silently, and the privilege check logs each one at `info` level +before moving on. + +### Cancellation + +A cancelled check throws before Arborist is constructed, so `package.json`, +`package-lock.json`, and `node_modules` are left exactly as they were. The +exit code is non-zero. + +### Examples + +Install a package, being asked about any near miss of a well known name: + +```bash +npm safeinstall lodash +``` + +Install a package and read its install scripts first: + +```bash +npm safeinstall --check-privileges sharp +``` + +Install into a workspace, with the same flags `npm install` would take: + +```bash +npm safeinstall --check-privileges -w packages/api lodash +``` + +### Configuration + + + +`check-privileges` is defined for the whole of npm, so it is accepted by every +command, but only `npm safeinstall` reads it. + +### See Also + +* [`npm install`](/commands/npm-install) +* [`npm ci`](/commands/npm-ci) +* [`npm approve-scripts`](/commands/npm-approve-scripts) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index ddaf80d9d4b40..c8115f0da1f6f 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -162,6 +162,9 @@ - title: npm run url: /commands/npm-run description: Run arbitrary package scripts + - title: npm safeinstall + url: /commands/npm-safeinstall + description: Install a package, confirming names and install scripts first - title: npm sbom url: /commands/npm-sbom description: Generate a Software Bill of Materials (SBOM) diff --git a/lib/commands/safeinstall.js b/lib/commands/safeinstall.js new file mode 100644 index 0000000000000..fbd2fff24e6ea --- /dev/null +++ b/lib/commands/safeinstall.js @@ -0,0 +1,229 @@ +const readline = require('node:readline/promises') +const { log, output, input } = require('proc-log') +const npa = require('npm-package-arg') +const pacote = require('pacote') +const { distance } = require('fastest-levenshtein') +const Install = require('./install.js') + +// packages that get installed by name often enough that a near miss is far +// more likely to be a typo than a deliberate request. a package that exists +// under a similar name is exactly what a typosquatter registers, so the +// distance check below stops the install before the name is resolved. +const POPULAR_PACKAGES = [ + 'axios', + 'chalk', + 'commander', + 'cross-env', + 'debug', + 'dotenv', + 'eslint', + 'express', + 'glob', + 'jest', + 'lodash', + 'moment', + 'mongoose', + 'prettier', + 'react', + 'react-dom', + 'request', + 'rimraf', + 'rollup', + 'typescript', + 'webpack', + 'yargs', +] + +// lifecycle scripts that run arbitrary code while the tree is being built +const PRIVILEGED_SCRIPTS = ['preinstall', 'install', 'postinstall'] + +// the only answer that lets a suspected typo through. `y` is deliberately not +// accepted: a one key confirmation is easy to send without reading the +// package name that is about to be fetched. +const CONFIRM_TYPO = 'CONFIRM' + +// how far a name may be from a well known package before we stop asking. +// short names are held to a tighter bound, since an edit distance of two on a +// four character name is not much of a match. +const MAX_TYPO_DISTANCE = 2 +const SHORT_NAME_LENGTH = 4 + +// the list is normalized once per process and then reused, so a run that +// installs fifty packages still only normalizes it once +let popularCache = null +const popularPackages = () => { + if (popularCache === null) { + popularCache = new Set(POPULAR_PACKAGES.map(name => name.toLowerCase())) + } + return popularCache +} + +class SafeInstall extends Install { + static name = 'safeinstall' + static description = 'Install a package, confirming names and install scripts first' + + // `check-privileges` is the only new flag. Everything else comes from + // `npm install` so that both commands take the same options. + static params = ['check-privileges', ...Install.params] + + async exec (args) { + // a bare `npm safeinstall` installs whatever the current package.json + // already asks for, so there is no requested name to check + if (!args.length) { + log.notice('safeinstall', 'No packages given, skipping name and privilege checks') + return super.exec(args) + } + + await this.#validateNames(args) + + // the registry round trip is only worth paying for when it was asked for + if (this.npm.config.get('check-privileges')) { + await this.#validatePrivileges(args) + } + + // hand the specs over to the regular install pipeline + return super.exec(args) + } + + // The package name a spec resolves to, or null when the spec is not a + // registry dependency. Local paths, tarballs, git urls and aliases are + // skipped because there is no registry name to compare against. + #requestedName (spec) { + let parsed + try { + parsed = npa(spec, { where: this.npm.prefix }) + } catch { + // let `npm install` be the one that reports an unparseable spec + return null + } + // an empty spec parses as a range with no name attached + if (!parsed.registry || !parsed.name) { + return null + } + return parsed.name.toLowerCase() + } + + // well known packages within an edit or two of `name`, closest first + #typoCandidates (name) { + // an exact match is never a typo + if (popularPackages().has(name)) { + return [] + } + + const max = name.length < SHORT_NAME_LENGTH ? 1 : MAX_TYPO_DISTANCE + const candidates = [] + for (const known of popularPackages()) { + const d = distance(name, known) + if (d > 0 && d <= max) { + candidates.push([known, d]) + } + } + return candidates.sort((a, b) => a[1] - b[1]) + } + + async #validateNames (args) { + // keyed by the spec as it was typed, so the error names what was asked for + const suspects = new Map() + + for (const spec of args) { + const name = this.#requestedName(spec) + if (name === null) { + continue + } + const candidates = this.#typoCandidates(name) + if (candidates.length) { + suspects.set(spec, [name, candidates]) + } + } + + if (!suspects.size) { + return + } + + output.standard('') + for (const [name, candidates] of suspects.values()) { + const [closest] = candidates + output.standard( + `${name} is not ${closest[0]}, but it is ${closest[1]} ` + + `character${closest[1] === 1 ? '' : 's'} away.` + ) + const others = candidates.slice(1) + if (others.length) { + output.standard('Other close names: ' + others.map(([n]) => n).join(', ')) + } + } + output.standard('') + output.standard('A package with that name may exist, but it is not the package you want.') + output.standard(`Type ${CONFIRM_TYPO} to install it anyway. Anything else cancels the install.`) + + const answer = await this.#ask('') + if (answer !== CONFIRM_TYPO) { + const names = [...suspects.keys()].join(', ') + throw Object.assign( + new Error(`Install cancelled: ${names} did not match a known package`), + { code: 'ESAFEINSTALLCONFIRM' } + ) + } + } + + async #validatePrivileges (args) { + for (const spec of args) { + const name = this.#requestedName(spec) + if (name === null) { + // nothing to fetch, `npm install` will resolve it from disk or git + log.info('safeinstall', `Skipping privilege check for ${spec}, not a registry package`) + continue + } + + // `pacote.manifest` reads the manifest out of the packument and stops + // there, so no tarball is downloaded for a package that gets rejected + const manifest = await pacote.manifest(spec, this.npm.flatOptions) + const scripts = manifest.scripts || {} + const requested = PRIVILEGED_SCRIPTS.filter(s => typeof scripts[s] === 'string') + + if (!requested.length) { + log.verbose('safeinstall', `${manifest.name} declares no install scripts`) + continue + } + + output.standard('') + output.standard(`${manifest.name}@${manifest.version} runs code during install:`) + for (const script of requested) { + output.standard(` ${script}: ${scripts[script]}`) + } + output.standard('') + + const answer = await this.#ask('Do you explicitly grant these privileges? (y/N) ') + if (!/^y(es)?$/i.test(answer)) { + throw Object.assign( + new Error(`Install cancelled: install scripts for ${manifest.name} were not granted`), + { code: 'ESAFEINSTALLPRIVILEGES' } + ) + } + } + } + + // Prompts have to go through `input.read` so that the display layer can + // pause the progress bar and flush buffered output around the question. + async #ask (query) { + if (!process.stdin.isTTY) { + throw Object.assign( + new Error('npm safeinstall needs an interactive terminal, none was available'), + { code: 'ESAFEINSTALLNOTTY' } + ) + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + terminal: true, + }) + try { + return (await input.read(() => rl.question(query))).trim() + } finally { + rl.close() + } + } +} + +module.exports = SafeInstall diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 1654075197f9a..195f296a05284 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -54,6 +54,7 @@ const commands = [ 'restart', 'root', 'run', + 'safeinstall', 'sbom', 'search', 'set', diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index c92e2d942e34d..d46edee342896 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -37,6 +37,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "cafile": null, "call": "", "cert": null, + "check-privileges": false, "cidr": null, "commit-hooks": true, "cpu": null, @@ -234,6 +235,7 @@ cache-min = 0 cafile = null call = "" cert = null +check-privileges = false cidr = null ; color = {COLOR} commit-hooks = true diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 3908dedbcf181..30296d3f326e8 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -147,6 +147,7 @@ Array [ "restart", "root", "run", + "safeinstall", "sbom", "search", "set", @@ -535,6 +536,26 @@ npm exec --package yo --package generator-node --call "yo node" +#### \`check-privileges\` + +* Default: false +* Type: Boolean + +If \`true\`, \`npm safeinstall\` reads the \`package.json\` of each package being +installed and lists the \`preinstall\`, \`install\`, and \`postinstall\` scripts +it declares before anything is installed. The install is cancelled unless +you answer \`y\` at the prompt. + +The manifest is fetched from the registry and no tarball is downloaded for a +package you go on to reject. Only the requested packages are checked, not +their transitive dependencies, so use [\`npm +approve-scripts\`](/commands/npm-approve-scripts) to review a dependency you +already have in the tree. + +This has no effect on \`npm install\` or on any other command. + + + #### \`cidr\` * Default: null @@ -2628,6 +2649,7 @@ Array [ "cafile", "call", "cert", + "check-privileges", "cidr", "color", "commit-hooks", @@ -2960,6 +2982,7 @@ Array [ exports[`test/lib/docs.js TAP config > keys that are not flattened 1`] = ` Array [ + "check-privileges", "expect-result-count", "expect-results", "init-author-email", @@ -6119,6 +6142,185 @@ aliases: run-script, rum, urn #### \`script-shell\` ` +exports[`test/lib/docs.js TAP usage safeinstall > must match snapshot 1`] = ` +Install a package, confirming names and install scripts first + +Usage: +npm safeinstall [ ...] + +Options: +[--check-privileges] +[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle] +[-E|--save-exact] [-g|--global] +[--install-strategy ] [--legacy-bundling] +[--global-style] [--omit [--omit ...]] +[--include [--include ...]] +[--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only] +[--foreground-scripts] [--ignore-scripts] [--allow-directory ] +[--allow-file ] [--allow-git ] +[--allow-remote ] +[--allow-scripts [--allow-scripts ...]] +[--strict-allow-scripts] [--dangerously-allow-all-scripts] [--no-audit] +[--before ] [--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] +[-w|--workspace [-w|--workspace ...]] +[--workspaces] [--include-workspace-root] [--install-links] + + --check-privileges + If \`true\`, \`npm safeinstall\` reads the \`package.json\` of each package + + -S|--save + Save installed packages to a \`package.json\` file as dependencies. + + -E|--save-exact + Dependencies saved to package.json will be configured with an exact + + -g|--global + Operates in "global" mode, so that packages are installed into the + + --install-strategy + Sets the strategy for installing packages in node_modules. + + --legacy-bundling + Instead of hoisting package installs in \`node_modules\`, install packages + + --global-style + Only install direct dependencies in the top level \`node_modules\`, + + --omit + Dependency types to omit from the installation tree on disk. + + --include + Option that allows for defining which types of dependencies to install. + + --strict-peer-deps + If set to \`true\`, and \`--legacy-peer-deps\` is not set, then _any_ + + --prefer-dedupe + Prefer to deduplicate packages if possible, rather than + + --package-lock + If set to false, then ignore \`package-lock.json\` files when installing. + + --package-lock-only + If set to true, the current operation will only use the \`package-lock.json\`, + + --foreground-scripts + Run all build scripts (ie, \`preinstall\`, \`install\`, and + + --ignore-scripts + If true, npm does not run scripts specified in package.json files. + + --allow-directory + Limits the ability for npm to install dependencies from directories. + + --allow-file + Limits the ability for npm to install dependencies from tarball files. + + --allow-git + Limits the ability for npm to fetch dependencies from git references. + + --allow-remote + Limits the ability for npm to fetch dependencies from urls. + + --allow-scripts + Comma-separated list of packages whose install-time lifecycle scripts + + --strict-allow-scripts + If \`true\`, turn the install-script policy from a warning into a hard + + --dangerously-allow-all-scripts + If \`true\`, bypass the \`allowScripts\` policy entirely and run every + + --audit + When "true" submit audit reports alongside the current npm command to the + + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --min-release-age + If set, npm will build the npm tree such that only versions that were + + --min-release-age-exclude + A list of package names or \`minimatch\` glob patterns that are exempt + + --bin-links + Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package + + --fund + When "true" displays the message at the end of each \`npm install\` + + --dry-run + Indicates that you don't want npm to make any changes and that it should + + --cpu + Override CPU architecture of native modules to install. + + --os + Override OS of native modules to install. + + --libc + Override libc of native modules to install. + + -w|--workspace + Enable running a command in the context of the configured workspaces of the + + --workspaces + Set to true to run the command in the context of **all** configured + + --include-workspace-root + Include the workspace root when workspaces are enabled for a command. + + --install-links + When set file: protocol dependencies will be packed and installed as + + +Run "npm help safeinstall" for more info + +\`\`\`bash +npm safeinstall [ ...] +\`\`\` + +#### \`check-privileges\` +#### \`save\` +#### \`save-exact\` +#### \`global\` +#### \`install-strategy\` +#### \`legacy-bundling\` +#### \`global-style\` +#### \`omit\` +#### \`include\` +#### \`strict-peer-deps\` +#### \`prefer-dedupe\` +#### \`package-lock\` +#### \`package-lock-only\` +#### \`foreground-scripts\` +#### \`ignore-scripts\` +#### \`allow-directory\` +#### \`allow-file\` +#### \`allow-git\` +#### \`allow-remote\` +#### \`allow-scripts\` +#### \`strict-allow-scripts\` +#### \`dangerously-allow-all-scripts\` +#### \`audit\` +#### \`before\` +#### \`min-release-age\` +#### \`min-release-age-exclude\` +#### \`bin-links\` +#### \`fund\` +#### \`dry-run\` +#### \`cpu\` +#### \`os\` +#### \`libc\` +#### \`workspace\` +#### \`workspaces\` +#### \`include-workspace-root\` +#### \`install-links\` +` + exports[`test/lib/docs.js TAP usage sbom > must match snapshot 1`] = ` Generate a Software Bill of Materials (SBOM) diff --git a/tap-snapshots/test/lib/npm.js.test.cjs b/tap-snapshots/test/lib/npm.js.test.cjs index 9e1ed153d1a22..788bf8c83a3d5 100644 --- a/tap-snapshots/test/lib/npm.js.test.cjs +++ b/tap-snapshots/test/lib/npm.js.test.cjs @@ -38,9 +38,9 @@ All commands: install-ci-test, install-scripts, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, - restart, root, run, sbom, search, set, stage, start, stop, - team, test, token, trust, undeprecate, uninstall, unpublish, - update, version, view, whoami + restart, root, run, safeinstall, sbom, search, set, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -89,13 +89,13 @@ All commands: prefix, profile, prune, publish, query, rebuild, repo, restart, root, - run, sbom, search, set, - stage, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + run, safeinstall, sbom, + search, set, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -144,13 +144,13 @@ All commands: prefix, profile, prune, publish, query, rebuild, repo, restart, root, - run, sbom, search, set, - stage, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + run, safeinstall, sbom, + search, set, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -185,9 +185,9 @@ All commands: install-ci-test, install-scripts, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, - restart, root, run, sbom, search, set, stage, start, stop, - team, test, token, trust, undeprecate, uninstall, unpublish, - update, version, view, whoami + restart, root, run, safeinstall, sbom, search, set, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -236,13 +236,13 @@ All commands: prefix, profile, prune, publish, query, rebuild, repo, restart, root, - run, sbom, search, set, - stage, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + run, safeinstall, sbom, + search, set, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -291,13 +291,13 @@ All commands: prefix, profile, prune, publish, query, rebuild, repo, restart, root, - run, sbom, search, set, - stage, start, stop, - team, test, token, - trust, undeprecate, - uninstall, unpublish, - update, version, view, - whoami + run, safeinstall, sbom, + search, set, stage, + start, stop, team, test, + token, trust, + undeprecate, uninstall, + unpublish, update, + version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -344,7 +344,8 @@ All commands: patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, - restart, root, run, sbom, + restart, root, run, + safeinstall, sbom, search, set, stage, start, stop, team, test, token, trust, @@ -385,9 +386,9 @@ All commands: install-ci-test, install-scripts, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, - restart, root, run, sbom, search, set, stage, start, stop, - team, test, token, trust, undeprecate, uninstall, - unpublish, update, version, view, whoami + restart, root, run, safeinstall, sbom, search, set, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -422,9 +423,9 @@ All commands: install-ci-test, install-scripts, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, - restart, root, run, sbom, search, set, stage, start, stop, - team, test, token, trust, undeprecate, uninstall, unpublish, - update, version, view, whoami + restart, root, run, safeinstall, sbom, search, set, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -459,9 +460,9 @@ All commands: install-ci-test, install-scripts, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, - restart, root, run, sbom, search, set, stage, start, stop, - team, test, token, trust, undeprecate, uninstall, unpublish, - update, version, view, whoami + restart, root, run, safeinstall, sbom, search, set, stage, + start, stop, team, test, token, trust, undeprecate, + uninstall, unpublish, update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} diff --git a/test/lib/commands/safeinstall.js b/test/lib/commands/safeinstall.js new file mode 100644 index 0000000000000..f6843cc29aecd --- /dev/null +++ b/test/lib/commands/safeinstall.js @@ -0,0 +1,378 @@ +const t = require('tap') +const { join } = require('node:path') +const mockGlobals = require('@npmcli/mock-globals') +const tmock = require('../../fixtures/tmock.js') +const BaseCommand = require('../../../lib/base-cmd.js') + +const PREFIX = '/some/prefix' + +// A stand in for `npm install`, so these tests can assert what safeinstall +// hands over without running a reify. It extends the real BaseCommand, so the +// constructor is the same code the real command runs. +let installCalls = [] + +class FakeInstall extends BaseCommand { + static name = 'install' + static description = 'Install a package' + static params = ['save', 'save-exact', 'global', 'dry-run'] + static usage = ['[ ...]'] + + async exec (args) { + installCalls.push(args) + return 'installed' + } +} + +const mockNpm = ({ checkPrivileges = false } = {}) => ({ + config: { + argv: ['safeinstall'], + validate: () => {}, + get: (key) => { + if (key === 'check-privileges') { + return checkPrivileges + } + if (key === 'workspace') { + return [] + } + if (key === 'workspaces') { + return false + } + }, + isDefault: () => true, + set: () => {}, + find: () => undefined, + }, + prefix: PREFIX, + localPrefix: PREFIX, + globalDir: '/some/global', + global: false, + flatOptions: { registry: 'https://registry.npmjs.org/' }, +}) + +// Captures what the command prints and asks, and answers prompts from a queue +// so a test can say exactly what the user typed. Only the modules safeinstall +// requires are mocked here, so npm's own internals keep the real ones. +const load = async (t, { answers = [], checkPrivileges = false, isTTY = true, manifest } = {}) => { + const questions = [] + const printed = [] + const logs = [] + const closed = [] + + const mocks = { + '{LIB}/commands/install.js': FakeInstall, + pacote: { + manifest: manifest || (() => { + throw new Error('pacote.manifest should not have been called') + }), + }, + 'node:readline/promises': { + createInterface: () => ({ + question: (query) => { + questions.push(query) + return Promise.resolve(answers.length ? answers.shift() : '') + }, + close: () => closed.push(true), + }), + }, + 'proc-log': { + log: new Proxy({}, { + get: (_target, level) => (...args) => logs.push([level, ...args]), + }), + output: { + standard: (...args) => printed.push(args.join(' ')), + }, + input: { + // the real signature is input.read(fn); the display layer calls fn and + // resolves with whatever it returns + read: (fn) => Promise.resolve().then(fn), + }, + }, + } + + mockGlobals(t, { 'process.stdin.isTTY': isTTY }) + + const SafeInstall = tmock(t, '{LIB}/commands/safeinstall.js', mocks) + const cmd = new SafeInstall(mockNpm({ checkPrivileges })) + + return { cmd, questions, printed, logs, closed } +} + +const manifestWith = scripts => async spec => ({ + name: spec, + version: '1.0.0', + scripts, +}) + +t.beforeEach(() => { + installCalls = [] +}) + +t.test('usage', t => { + const SafeInstall = require('../../../lib/commands/safeinstall.js') + t.equal(SafeInstall.name, 'safeinstall') + t.equal(SafeInstall.description, 'Install a package, confirming names and install scripts first') + t.equal(SafeInstall.params[0], 'check-privileges') + t.match(SafeInstall.describeUsage, /npm safeinstall \[ \.\.\.\]/) + t.match(SafeInstall.describeUsage, /--check-privileges/) + t.match(SafeInstall.describeUsage, /--save-exact/, 'inherits the install params') + t.end() +}) + +t.test('a name that is not close to anything installs without a prompt', async t => { + const { cmd, questions, printed } = await load(t) + t.equal(await cmd.exec(['left-pad']), 'installed') + t.strictSame(installCalls, [['left-pad']]) + t.strictSame(questions, [], 'no prompt') + t.strictSame(printed, [], 'nothing printed') +}) + +t.test('an exact match on a well known name is never a typo', async t => { + const { cmd, questions } = await load(t) + t.equal(await cmd.exec(['express', 'lodash@4', '@babel/core@7.0.0']), 'installed') + t.strictSame(questions, []) + t.strictSame(installCalls, [['express', 'lodash@4', '@babel/core@7.0.0']]) +}) + +t.test('a typo is cancelled unless the answer is CONFIRM', async t => { + const { cmd, questions, printed } = await load(t, { answers: ['y'] }) + await t.rejects(cmd.exec(['expres']), { + code: 'ESAFEINSTALLCONFIRM', + message: 'Install cancelled: expres did not match a known package', + }) + t.strictSame(installCalls, [], 'never reached install') + t.strictSame(questions, [''], 'asked once, with no query text') + t.match(printed.join('\n'), 'expres is not express, but it is 1 character away.') + t.match(printed.join('\n'), 'Type CONFIRM to install it anyway. Anything else cancels the install.') +}) + +t.test('an empty answer cancels', async t => { + const { cmd } = await load(t, { answers: [''] }) + await t.rejects(cmd.exec(['requsts']), { code: 'ESAFEINSTALLCONFIRM' }) + t.strictSame(installCalls, []) +}) + +t.test('a lowercase confirm does not count', async t => { + const { cmd } = await load(t, { answers: ['confirm'] }) + await t.rejects(cmd.exec(['expres']), { code: 'ESAFEINSTALLCONFIRM' }) + t.strictSame(installCalls, []) +}) + +t.test('surrounding whitespace is trimmed off the answer', async t => { + const { cmd } = await load(t, { answers: [' CONFIRM '] }) + // the word still has to be CONFIRM, but a stray space is not a refusal + t.equal(await cmd.exec(['expres']), 'installed') + t.strictSame(installCalls, [['expres']]) +}) + +t.test('CONFIRM installs the requested name', async t => { + const { cmd, closed } = await load(t, { answers: ['CONFIRM'] }) + t.equal(await cmd.exec(['lodashh']), 'installed') + t.strictSame(installCalls, [['lodashh']]) + t.strictSame(closed, [true], 'closes the readline interface') +}) + +t.test('the warning names the package, the error names the spec', async t => { + const { cmd, printed } = await load(t, { answers: ['y'] }) + await t.rejects(cmd.exec(['expres@^4.0.0']), { + code: 'ESAFEINSTALLCONFIRM', + message: 'Install cancelled: expres@^4.0.0 did not match a known package', + }) + t.match(printed.join('\n'), 'expres is not express') +}) + +t.test('every suspect is listed before one prompt', async t => { + const { cmd, questions, printed } = await load(t, { answers: ['y'] }) + await t.rejects(cmd.exec(['expres', 'left-pad', 'lodahs']), { + code: 'ESAFEINSTALLCONFIRM', + message: 'Install cancelled: expres, lodahs did not match a known package', + }) + t.strictSame(questions, [''], 'one prompt, not one per package') + t.match(printed.join('\n'), 'expres is not express, but it is 1 character away.') + t.match(printed.join('\n'), 'lodahs is not lodash, but it is 2 characters away.') + t.notMatch(printed.join('\n'), 'left-pad') +}) + +t.test('a second close name is listed', async t => { + const { cmd, printed } = await load(t, { answers: ['y'] }) + // contrived on purpose: aesct is two edits from both jest and react, and a + // real typo almost never lands that way + await t.rejects(cmd.exec(['aesct']), { code: 'ESAFEINSTALLCONFIRM' }) + t.match(printed.join('\n'), 'aesct is not jest, but it is 2 characters away.') + t.match(printed.join('\n'), 'Other close names: react') +}) + +t.test('a short name is only compared one edit out', async t => { + const { cmd, questions } = await load(t, { answers: ['CONFIRM'] }) + // two edits from react, three from the next closest, and short enough that + // only a single edit counts + t.equal(await cmd.exec(['act']), 'installed') + t.strictSame(questions, [], 'act is two edits from react, which is not close enough to ask') +}) + +t.test('non registry specs are skipped', async t => { + const { cmd, questions, logs } = await load(t, { + checkPrivileges: true, + manifest: () => t.fail('should not fetch a manifest'), + }) + // these all behave the same on every platform. A local path is left out + // because npa rejects it outright on windows, where the test would then be + // asserting a different set of log lines than it does elsewhere. + const specs = [ + 'git+https://github.com/npm/cli.git', + 'https://example.com/pkg.tgz', + 'node_modules/foo', + 'workspace:*', + '', + ] + t.equal(await cmd.exec(specs), 'installed') + t.strictSame(installCalls, [specs]) + t.strictSame(questions, [], 'nothing to ask about') + t.strictSame( + logs.filter(([level]) => level === 'info').map(([, , msg]) => msg), + [ + 'Skipping privilege check for git+https://github.com/npm/cli.git, not a registry package', + 'Skipping privilege check for https://example.com/pkg.tgz, not a registry package', + 'Skipping privilege check for node_modules/foo, not a registry package', + 'Skipping privilege check for workspace:*, not a registry package', + 'Skipping privilege check for , not a registry package', + ], + 'the spec is reported as it was typed, since there is no name to report' + ) +}) + +t.test('a local path is left to npm install to resolve', async t => { + const { cmd, questions } = await load(t, { + checkPrivileges: true, + manifest: () => t.fail('should not fetch a manifest'), + }) + t.equal(await cmd.exec(['./local-dir']), 'installed') + t.strictSame(installCalls, [['./local-dir']]) + t.strictSame(questions, []) +}) + +t.test('no arguments skips both checks', async t => { + const { cmd, logs } = await load(t, { checkPrivileges: true }) + t.equal(await cmd.exec([]), 'installed') + t.strictSame(installCalls, [[]]) + t.match(logs, [['notice', 'safeinstall', 'No packages given, skipping name and privilege checks']]) +}) + +t.test('manifests are not fetched without the flag', async t => { + const { cmd } = await load(t, { manifest: () => t.fail('should not fetch a manifest') }) + t.equal(await cmd.exec(['lodash']), 'installed') + t.strictSame(installCalls, [['lodash']]) +}) + +t.test('install scripts are listed and can be declined', async t => { + const { cmd, questions, printed } = await load(t, { + checkPrivileges: true, + answers: ['n'], + manifest: manifestWith({ + preinstall: 'node-pre-gyp install --fallback-to-build', + install: 'node-pre-gyp install --fallback-to-build', + postinstall: 'node-pre-gyp install --fallback-to-build', + test: 'tap', + }), + }) + await t.rejects(cmd.exec(['canvas']), { + code: 'ESAFEINSTALLPRIVILEGES', + message: 'Install cancelled: install scripts for canvas were not granted', + }) + t.strictSame(installCalls, []) + t.strictSame(questions, ['Do you explicitly grant these privileges? (y/N) ']) + t.match(printed.join('\n'), 'canvas@1.0.0 runs code during install:') + t.match(printed.join('\n'), ' preinstall: node-pre-gyp install --fallback-to-build') + t.match(printed.join('\n'), ' postinstall: node-pre-gyp install --fallback-to-build') + t.notMatch(printed.join('\n'), 'test: tap', 'only the privileged scripts are shown') +}) + +t.test('granting continues the install', async t => { + const { cmd } = await load(t, { + checkPrivileges: true, + answers: ['y'], + manifest: manifestWith({ install: 'node-gyp rebuild' }), + }) + t.equal(await cmd.exec(['canvas']), 'installed') + t.strictSame(installCalls, [['canvas']]) +}) + +t.test('yes is accepted too', async t => { + const { cmd } = await load(t, { + checkPrivileges: true, + answers: ['Yes'], + manifest: manifestWith({ install: 'node-gyp rebuild' }), + }) + t.equal(await cmd.exec(['canvas']), 'installed') + t.strictSame(installCalls, [['canvas']]) +}) + +t.test('a package with no privileged scripts is not asked about', async t => { + const { cmd, questions, logs } = await load(t, { + checkPrivileges: true, + manifest: manifestWith({ test: 'tap' }), + }) + t.equal(await cmd.exec(['lodash']), 'installed') + t.strictSame(questions, []) + t.match(logs, [['verbose', 'safeinstall', 'lodash declares no install scripts']]) +}) + +t.test('a manifest with no scripts field at all is not asked about', async t => { + const { cmd, questions } = await load(t, { + checkPrivileges: true, + manifest: async spec => ({ name: spec, version: '1.0.0' }), + }) + t.equal(await cmd.exec(['lodash']), 'installed') + t.strictSame(questions, []) +}) + +t.test('every requested package is checked for privileges', async t => { + const fetched = [] + const { cmd, questions } = await load(t, { + checkPrivileges: true, + answers: ['y', 'y'], + manifest: async (spec, opts) => { + fetched.push([spec, opts]) + return { name: spec, version: '1.0.0', scripts: { postinstall: 'node x' } } + }, + }) + t.equal(await cmd.exec(['aaa', 'bbb']), 'installed') + t.strictSame(fetched, [ + ['aaa', { registry: 'https://registry.npmjs.org/' }], + ['bbb', { registry: 'https://registry.npmjs.org/' }], + ], 'each manifest is fetched with the flat options') + t.strictSame(questions.length, 2) +}) + +t.test('the name check runs before the privilege check', async t => { + const { cmd } = await load(t, { + checkPrivileges: true, + answers: ['y', 'y'], + manifest: () => t.fail('the manifest for a typo should never be read'), + }) + await t.rejects(cmd.exec(['expres']), { code: 'ESAFEINSTALLCONFIRM' }) + t.strictSame(installCalls, []) +}) + +t.test('a prompt without a terminal fails instead of hanging', async t => { + const { cmd, questions } = await load(t, { isTTY: false }) + await t.rejects(cmd.exec(['expres']), { + code: 'ESAFEINSTALLNOTTY', + message: 'npm safeinstall needs an interactive terminal, none was available', + }) + t.strictSame(questions, []) + t.strictSame(installCalls, []) +}) + +t.test('no terminal is fine when nothing needs asking', async t => { + const { cmd } = await load(t, { isTTY: false }) + t.equal(await cmd.exec(['left-pad']), 'installed') + t.strictSame(installCalls, [['left-pad']]) +}) + +t.test('the join helper resolves paths that tap cannot mock', t => { + // tmock only rewrites keys that start with . or {, so a bare 'tap' or + // 'node:readline/promises' has to resolve for real + t.equal(require.resolve('node:readline/promises') !== undefined, true) + t.equal(join(__dirname, '../../../lib').endsWith(join('lib')), true) + t.end() +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index bd5c82363937c..948b6717d660f 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -538,6 +538,24 @@ const definitions = { `, flatten, }), + 'check-privileges': new Definition('check-privileges', { + default: false, + type: Boolean, + description: ` + If \`true\`, \`npm safeinstall\` reads the \`package.json\` of each package + being installed and lists the \`preinstall\`, \`install\`, and + \`postinstall\` scripts it declares before anything is installed. The + install is cancelled unless you answer \`y\` at the prompt. + + The manifest is fetched from the registry and no tarball is downloaded + for a package you go on to reject. Only the requested packages are + checked, not their transitive dependencies, so use + [\`npm approve-scripts\`](/commands/npm-approve-scripts) to review a + dependency you already have in the tree. + + This has no effect on \`npm install\` or on any other command. + `, + }), cidr: new Definition('cidr', { default: null, type: [null, String, Array],