Skip to content

Commit

Permalink
audit: add subcommand to automatically fix vulns (#20569)
Browse files Browse the repository at this point in the history
PR-URL: npm/npm#20569
Credit: @zkat
Reviewed-By: @iarna
  • Loading branch information
zkat authored May 17, 2018
1 parent 7c2076d commit 3800a66
Show file tree
Hide file tree
Showing 5 changed files with 900 additions and 18 deletions.
57 changes: 54 additions & 3 deletions doc/cli/npm-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,64 @@ npm-audit(1) -- Run a security audit
## SYNOPSIS

npm audit [--json]
npm audit fix [--force|--package-lock-only|--dry-run|--production|--only=dev]

## DESCRIPTION
## EXAMPLES

Scan your project for vulnerabilities and automatically install any compatible
updates to vulnerable dependencies:
```
$ npm audit fix
```

Run `audit fix` without modifying `node_modules`, but still updating the
pkglock:
```
$ npm audit fix --package-lock-only
```

Skip updating `devDependencies`:
```
$ npm audit fix --only=prod
```

Have `audit fix` install semver-major updates to toplevel dependencies, not just
semver-compatible ones:
```
$ npm audit fix --force
```

Do a dry run to get an idea of what `audit fix` will do, and _also_ output
install information in JSON format:
```
$ npm audit fix --dry-run --json
```

Scan your project for vulnerabilities and just show the details, without fixing
anything:
```
$ npm audit
```

Get the detailed audit report in JSON format:
```
$ npm audit --json
```

## DESCRIPTION

The audit command submits a description of the dependencies configured in
your project to your default registry and asks for a report of known
vulnerabilities. The report returned includes instructions on how to act on
vulnerabilities. The report returned includes instructions on how to act on
this information.

You can also have npm automatically fix the vulnerabilities by running `npm
audit fix`. Note that some vulnerabilities cannot be fixed automatically and
will require manual intervention or review. Also note that since `npm audit fix`
runs a full-fledged `npm install` under the hood, all configs that apply to the
installer will also apply to `npm install` -- so things like `npm audit fix
--package-lock-only` will work as expected.

## CONTENT SUBMITTED

* npm_version
Expand All @@ -29,7 +79,7 @@ the following dependency types:

* Any module referencing a scope that is configured for a non-default
registry has its name scrubbed. (That is, a scope you did a `npm login --scope=@ourscope` for.)
* All git dependencies have their names and specifiers scrubbed.
* All git dependencies have their names and specifiers scrubbed.
* All remote tarball dependencies have their names and specifiers scrubbed.
* All local directory and tarball dependencies have their names and specifiers scrubbed.

Expand All @@ -40,4 +90,5 @@ different between runs.
## SEE ALSO

* npm-install(1)
* package-locks(5)
* config(7)
183 changes: 172 additions & 11 deletions lib/audit.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
'use strict'
const fs = require('graceful-fs')

const Bluebird = require('bluebird')

const audit = require('./install/audit.js')
const npm = require('./npm.js')
const fs = require('graceful-fs')
const Installer = require('./install.js').Installer
const lockVerify = require('lock-verify')
const log = require('npmlog')
const npa = require('npm-package-arg')
const npm = require('./npm.js')
const output = require('./utils/output.js')
const parseJson = require('json-parse-better-errors')
const lockVerify = require('lock-verify')

const readFile = Bluebird.promisify(fs.readFile)

module.exports = auditCmd

auditCmd.usage =
'npm audit\n'
'npm audit\n' +
'npm audit fix\n'

auditCmd.completion = function (opts, cb) {
const argv = opts.conf.argv.remain
Expand All @@ -25,6 +31,63 @@ auditCmd.completion = function (opts, cb) {
}
}

class Auditor extends Installer {
constructor (where, dryrun, args, opts) {
super(where, dryrun, args, opts)
this.deepArgs = (opts && opts.deepArgs) || []
this.runId = opts.runId || ''
this.audit = false
}

loadAllDepsIntoIdealTree (cb) {
Bluebird.fromNode(cb => super.loadAllDepsIntoIdealTree(cb)).then(() => {
if (this.deepArgs && this.deepArgs.length) {
this.deepArgs.forEach(arg => {
arg.reduce((acc, child, ii) => {
if (!acc) {
// We might not always be able to find `target` through the given
// path. If we can't we'll just ignore it.
return
}
const spec = npa(child)
const target = (
acc.requires.find(n => n.package.name === spec.name) ||
acc.requires.find(
n => audit.scrub(n.package.name, this.runId) === spec.name
)
)
if (target && ii === arg.length - 1) {
target.loaded = false
// This kills `hasModernMeta()` and forces a re-fetch
target.package = {
name: spec.name,
version: spec.fetchSpec,
_requested: target.package._requested
}
delete target.fakeChild
let parent = target.parent
while (parent) {
parent.loaded = false
parent = parent.parent
}
target.requiredBy.forEach(par => {
par.loaded = false
delete par.fakeChild
})
}
return target
}, this.idealTree)
})
return Bluebird.fromNode(cb => super.loadAllDepsIntoIdealTree(cb))
}
}).nodeify(cb)
}

// no top level lifecycles on audit
runPreinstallTopLevelLifecycles (cb) { cb() }
runPostinstallTopLevelLifecycles (cb) { cb() }
}

function maybeReadFile (name) {
const file = `${npm.prefix}/${name}`
return readFile(file)
Expand All @@ -43,12 +106,29 @@ function maybeReadFile (name) {
})
}

function filterEnv (action) {
const includeDev = npm.config.get('dev') ||
(!/^prod(uction)?$/.test(npm.config.get('only')) && !npm.config.get('production')) ||
/^dev(elopment)?$/.test(npm.config.get('only')) ||
/^dev(elopment)?$/.test(npm.config.get('also'))
const includeProd = !/^dev(elopment)?$/.test(npm.config.get('only'))
const resolves = action.resolves.filter(({dev}) => {
return (dev && includeDev) || (!dev && includeProd)
})
if (resolves.length) {
return Object.assign({}, action, {resolves})
}
}

function auditCmd (args, cb) {
if (npm.config.get('global')) {
const err = new Error('`npm audit` does not support testing globals')
err.code = 'EAUDITGLOBAL'
throw err
}
if (args.length && args[0] !== 'fix') {
return cb(new Error('Invalid audit subcommand: `' + args[0] + '`\n\nUsage:\n' + auditCmd.usage))
}
return Bluebird.all([
maybeReadFile('npm-shrinkwrap.json'),
maybeReadFile('package-lock.json'),
Expand Down Expand Up @@ -92,12 +172,93 @@ function auditCmd (args, cb) {
}
throw err
}).then((auditResult) => {
const vulns =
auditResult.metadata.vulnerabilities.low +
auditResult.metadata.vulnerabilities.moderate +
auditResult.metadata.vulnerabilities.high +
auditResult.metadata.vulnerabilities.critical
if (vulns > 0) process.exitCode = 1
return audit.printFullReport(auditResult)
if (args[0] === 'fix') {
const actions = (auditResult.actions || []).reduce((acc, action) => {
action = filterEnv(action)
if (!action) { return acc }
if (action.isMajor) {
acc.major.add(`${action.module}@${action.target}`)
action.resolves.forEach(({id, path}) => acc.majorFixes.add(`${id}::${path}`))
} else if (action.action === 'install') {
acc.install.add(`${action.module}@${action.target}`)
action.resolves.forEach(({id, path}) => acc.installFixes.add(`${id}::${path}`))
} else if (action.action === 'update') {
const name = action.module
const version = action.target
action.resolves.forEach(vuln => {
acc.updateFixes.add(`${vuln.id}::${vuln.path}`)
const modPath = vuln.path.split('>')
const newPath = modPath.slice(
0, modPath.indexOf(name)
).concat(`${name}@${version}`)
if (newPath.length === 1) {
acc.install.add(newPath[0])
} else {
acc.update.add(newPath.join('>'))
}
})
} else if (action.action === 'review') {
action.resolves.forEach(({id, path}) => acc.review.add(`${id}::${path}`))
}
return acc
}, {
install: new Set(),
installFixes: new Set(),
update: new Set(),
updateFixes: new Set(),
major: new Set(),
majorFixes: new Set(),
review: new Set()
})
return Bluebird.try(() => {
const installMajor = npm.config.get('force')
const installCount = actions.install.size + (installMajor ? actions.major.size : 0) + actions.update.size
const vulnFixCount = new Set([...actions.installFixes, ...actions.updateFixes, ...(installMajor ? actions.majorFixes : [])]).size
const metavuln = auditResult.metadata.vulnerabilities
const total = Object.keys(metavuln).reduce((acc, key) => acc + metavuln[key], 0)
if (installCount) {
log.verbose(
'audit',
'installing',
[...actions.install, ...(installMajor ? actions.major : []), ...actions.update]
)
}
return Bluebird.fromNode(cb => {
new Auditor(
npm.prefix,
!!npm.config.get('dry-run'),
[...actions.install, ...(installMajor ? actions.major : [])],
{
runId: auditResult.runId,
deepArgs: [...actions.update].map(u => u.split('>'))
}
).run(cb)
}).then(() => {
const numScanned = auditResult.metadata.totalDependencies
if (!npm.config.get('json') && !npm.config.get('parseable')) {
output(`fixed ${vulnFixCount} of ${total} vulnerabilit${total === 1 ? 'y' : 'ies'} in ${numScanned} scanned package${numScanned === 1 ? '' : 's'}`)
if (actions.review.size) {
output(` ${actions.review.size} vulnerabilit${actions.review.size === 1 ? 'y' : 'ies'} required manual review and could not be updated`)
}
if (actions.major.size) {
output(` ${actions.major.size} package update${actions.major.size === 1 ? '' : 's'} for ${actions.majorFixes.size} vuln${actions.majorFixes.size === 1 ? '' : 's'} involved breaking changes`)
if (installMajor) {
output(' (installed due to `--force` option)')
} else {
output(' (use `npm audit fix --force` to install breaking changes; or do it by hand)')
}
}
}
})
})
} else {
const vulns =
auditResult.metadata.vulnerabilities.low +
auditResult.metadata.vulnerabilities.moderate +
auditResult.metadata.vulnerabilities.high +
auditResult.metadata.vulnerabilities.critical
if (vulns > 0) process.exitCode = 1
return audit.printFullReport(auditResult)
}
}).asCallback(cb)
}
6 changes: 4 additions & 2 deletions lib/install/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function submitForFullReport (auditData) {
return response.json()
}).then(result => {
perf.emit('timeEnd', 'audit body')
result.runId = runId
return result
})
})
Expand Down Expand Up @@ -207,8 +208,9 @@ function scrubSpec (name, spec) {
}
}

function scrub (value) {
return ssri.fromData(runId + ' ' + value, {algorithms: ['sha256']}).hexDigest()
module.exports.scrub = scrub
function scrub (value, rid) {
return ssri.fromData((rid || runId) + ' ' + value, {algorithms: ['sha256']}).hexDigest()
}

function generateMetadata () {
Expand Down
3 changes: 1 addition & 2 deletions lib/install/save.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict'

const createShrinkwrap = require('../shrinkwrap.js').createShrinkwrap
const deepSortObject = require('../utils/deep-sort-object.js')
const detectIndent = require('detect-indent')
const detectNewline = require('detect-newline')
Expand Down Expand Up @@ -48,7 +47,7 @@ function saveShrinkwrap (tree, next) {
if (!npm.config.get('shrinkwrap') || !npm.config.get('package-lock')) {
return next()
}
createShrinkwrap(tree, {silent: false}, next)
require('../shrinkwrap.js').createShrinkwrap(tree, {silent: false}, next)
}

function savePackageJson (tree, next) {
Expand Down
Loading

0 comments on commit 3800a66

Please sign in to comment.