Skip to content
Open
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
139 changes: 139 additions & 0 deletions docs/lib/content/commands/npm-safeinstall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: npm-safeinstall
section: 1
description: Install a package, confirming names and install scripts first
---

### Synopsis

<!-- AUTOGENERATED USAGE DESCRIPTIONS -->

### 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

<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->

`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)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
229 changes: 229 additions & 0 deletions lib/commands/safeinstall.js
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/utils/cmd-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const commands = [
'restart',
'root',
'run',
'safeinstall',
'sbom',
'search',
'set',
Expand Down
Loading