306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
'use strict'
|
|
|
|
const Bluebird = require('bluebird')
|
|
|
|
const audit = require('./install/audit.js')
|
|
const figgyPudding = require('figgy-pudding')
|
|
const fs = require('graceful-fs')
|
|
const Installer = require('./install.js').Installer
|
|
const lockVerify = require('lock-verify')
|
|
const log = require('npmlog')
|
|
const npa = require('libnpm/parse-arg')
|
|
const npm = require('./npm.js')
|
|
const npmConfig = require('./config/figgy-config.js')
|
|
const output = require('./utils/output.js')
|
|
const parseJson = require('json-parse-better-errors')
|
|
|
|
const readFile = Bluebird.promisify(fs.readFile)
|
|
|
|
const AuditConfig = figgyPudding({
|
|
also: {},
|
|
'audit-level': {},
|
|
deepArgs: 'deep-args',
|
|
'deep-args': {},
|
|
dev: {},
|
|
force: {},
|
|
'dry-run': {},
|
|
global: {},
|
|
json: {},
|
|
only: {},
|
|
parseable: {},
|
|
prod: {},
|
|
production: {},
|
|
registry: {},
|
|
runId: {}
|
|
})
|
|
|
|
module.exports = auditCmd
|
|
|
|
const usage = require('./utils/usage')
|
|
auditCmd.usage = usage(
|
|
'audit',
|
|
'\nnpm audit [--json] [--production]' +
|
|
'\nnpm audit fix ' +
|
|
'[--force|--package-lock-only|--dry-run|--production|--only=(dev|prod)]'
|
|
)
|
|
|
|
auditCmd.completion = function (opts, cb) {
|
|
const argv = opts.conf.argv.remain
|
|
|
|
switch (argv[2]) {
|
|
case 'audit':
|
|
return cb(null, [])
|
|
default:
|
|
return cb(new Error(argv[2] + ' not recognized'))
|
|
}
|
|
}
|
|
|
|
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)
|
|
.then((data) => {
|
|
try {
|
|
return parseJson(data)
|
|
} catch (ex) {
|
|
ex.code = 'EJSONPARSE'
|
|
throw ex
|
|
}
|
|
})
|
|
.catch({code: 'ENOENT'}, () => null)
|
|
.catch((ex) => {
|
|
ex.file = file
|
|
throw ex
|
|
})
|
|
}
|
|
|
|
function filterEnv (action, opts) {
|
|
const includeDev = opts.dev ||
|
|
(!/^prod(uction)?$/.test(opts.only) && !opts.production) ||
|
|
/^dev(elopment)?$/.test(opts.only) ||
|
|
/^dev(elopment)?$/.test(opts.also)
|
|
const includeProd = !/^dev(elopment)?$/.test(opts.only)
|
|
const resolves = action.resolves.filter(({dev}) => {
|
|
return (dev && includeDev) || (!dev && includeProd)
|
|
})
|
|
if (resolves.length) {
|
|
return Object.assign({}, action, {resolves})
|
|
}
|
|
}
|
|
|
|
function auditCmd (args, cb) {
|
|
const opts = AuditConfig(npmConfig())
|
|
if (opts.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'),
|
|
maybeReadFile('package.json')
|
|
]).spread((shrinkwrap, lockfile, pkgJson) => {
|
|
const sw = shrinkwrap || lockfile
|
|
if (!pkgJson) {
|
|
const err = new Error('No package.json found: Cannot audit a project without a package.json')
|
|
err.code = 'EAUDITNOPJSON'
|
|
throw err
|
|
}
|
|
if (!sw) {
|
|
const err = new Error('Neither npm-shrinkwrap.json nor package-lock.json found: Cannot audit a project without a lockfile')
|
|
err.code = 'EAUDITNOLOCK'
|
|
throw err
|
|
} else if (shrinkwrap && lockfile) {
|
|
log.warn('audit', 'Both npm-shrinkwrap.json and package-lock.json exist, using npm-shrinkwrap.json.')
|
|
}
|
|
const requires = Object.assign(
|
|
{},
|
|
(pkgJson && pkgJson.dependencies) || {},
|
|
(!opts.production && pkgJson && pkgJson.devDependencies) || {}
|
|
)
|
|
return lockVerify(npm.prefix).then((result) => {
|
|
if (result.status) return audit.generate(sw, requires)
|
|
|
|
const lockFile = shrinkwrap ? 'npm-shrinkwrap.json' : 'package-lock.json'
|
|
const err = new Error(`Errors were found in your ${lockFile}, run npm install to fix them.\n ` +
|
|
result.errors.join('\n '))
|
|
err.code = 'ELOCKVERIFY'
|
|
throw err
|
|
})
|
|
}).then((auditReport) => {
|
|
return audit.submitForFullReport(auditReport)
|
|
}).catch((err) => {
|
|
if (err.statusCode >= 400) {
|
|
let msg
|
|
if (err.statusCode === 401) {
|
|
msg = `Either your login credentials are invalid or your registry (${opts.registry}) does not support audit.`
|
|
} else if (err.statusCode === 404) {
|
|
msg = `Your configured registry (${opts.registry}) does not support audit requests.`
|
|
} else {
|
|
msg = `Your configured registry (${opts.registry}) may not support audit requests, or the audit endpoint may be temporarily unavailable.`
|
|
}
|
|
if (err.body.length) {
|
|
msg += '\nThe server said: ' + err.body
|
|
}
|
|
const ne = new Error(msg)
|
|
ne.code = 'ENOAUDIT'
|
|
ne.wrapped = err
|
|
throw ne
|
|
}
|
|
throw err
|
|
}).then((auditResult) => {
|
|
if (args[0] === 'fix') {
|
|
const actions = (auditResult.actions || []).reduce((acc, action) => {
|
|
action = filterEnv(action, opts)
|
|
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 = opts.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,
|
|
!!opts['dry-run'],
|
|
[...actions.install, ...(installMajor ? actions.major : [])],
|
|
opts.concat({
|
|
runId: auditResult.runId,
|
|
deepArgs: [...actions.update].map(u => u.split('>'))
|
|
}).toJSON()
|
|
).run(cb)
|
|
}).then(() => {
|
|
const numScanned = auditResult.metadata.totalDependencies
|
|
if (!opts.json && !opts.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} vulnerabilit${actions.majorFixes.size === 1 ? 'y' : 'ies'} involved breaking changes`)
|
|
if (installMajor) {
|
|
output(' (installed due to `--force` option)')
|
|
} else {
|
|
output(' (use `npm audit fix --force` to install breaking changes;' +
|
|
' or refer to `npm audit` for steps to fix these manually)')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
} else {
|
|
const levels = ['low', 'moderate', 'high', 'critical']
|
|
const minLevel = levels.indexOf(opts['audit-level'])
|
|
const vulns = levels.reduce((count, level, i) => {
|
|
return i < minLevel ? count : count + (auditResult.metadata.vulnerabilities[level] || 0)
|
|
}, 0)
|
|
if (vulns > 0) process.exitCode = 1
|
|
if (opts.parseable) {
|
|
return audit.printParseableReport(auditResult)
|
|
} else {
|
|
return audit.printFullReport(auditResult)
|
|
}
|
|
}
|
|
}).asCallback(cb)
|
|
}
|