Skip to content

Commit

Permalink
audit: configurable audit level for non-zero exit (#31)
Browse files Browse the repository at this point in the history
`npm audit` currently exits with exit code 1 if any vulnerabilities are found of any level.

Add a flag of `--audit-level` to `npm audit` to allow it to pass if only vulnerabilities below a certain level are found.

Example: `npm audit --audit-level=high` will exit with 0 if only low or moderate level vulns are detected.

Fixes: https://npm.community/t/245
PR-URL: #31
Credit: @lennym
Reviewed-By: @zkat
  • Loading branch information
lennym authored and zkat committed Aug 3, 2018
1 parent 32e6947 commit 792c8c7
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 5 deletions.
8 changes: 8 additions & 0 deletions doc/misc/npm-config.md
Expand Up @@ -164,6 +164,14 @@ When "true" submit audit reports alongside `npm install` runs to the default
registry and all registries configured for scopes. See the documentation
for npm-audit(1) for details on what is submitted.

### audit-level

* Default: `"low"`
* Type: `'low'`, `'moderate'`, `'high'`, `'critical'`

The minimum level of vulnerability for `npm audit` to exit with
a non-zero exit code.

### auth-type

* Default: `'legacy'`
Expand Down
10 changes: 5 additions & 5 deletions lib/audit.js
Expand Up @@ -257,11 +257,11 @@ function auditCmd (args, cb) {
})
})
} else {
const vulns =
auditResult.metadata.vulnerabilities.low +
auditResult.metadata.vulnerabilities.moderate +
auditResult.metadata.vulnerabilities.high +
auditResult.metadata.vulnerabilities.critical
const levels = ['low', 'moderate', 'high', 'critical']
const minLevel = levels.indexOf(npm.config.get('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 (npm.config.get('parseable')) {
return audit.printParseableReport(auditResult)
Expand Down
2 changes: 2 additions & 0 deletions lib/config/defaults.js
Expand Up @@ -110,6 +110,7 @@ Object.defineProperty(exports, 'defaults', {get: function () {
'always-auth': false,
also: null,
audit: true,
'audit-level': 'low',
'auth-type': 'legacy',

'bin-links': true,
Expand Down Expand Up @@ -257,6 +258,7 @@ exports.types = {
'always-auth': Boolean,
also: [null, 'dev', 'development'],
audit: Boolean,
'audit-level': ['low', 'moderate', 'high', 'critical'],
'auth-type': ['legacy', 'sso', 'saml', 'oauth'],
'bin-links': Boolean,
browser: [null, String],
Expand Down
268 changes: 268 additions & 0 deletions test/tap/audit.js
@@ -0,0 +1,268 @@
'use strict'

const BB = require('bluebird')

const common = BB.promisifyAll(require('../common-tap.js'))
const mr = BB.promisify(require('npm-registry-mock'))
const path = require('path')
const rimraf = BB.promisify(require('rimraf'))
const Tacks = require('tacks')
const tap = require('tap')
const test = tap.test

const Dir = Tacks.Dir
const File = Tacks.File
const testDir = path.join(__dirname, path.basename(__filename, '.js'))

const EXEC_OPTS = { cwd: testDir }

tap.tearDown(function () {
process.chdir(__dirname)
try {
rimraf.sync(testDir)
} catch (e) {
if (process.platform !== 'win32') {
throw e
}
}
})

function tmock (t) {
return mr({port: common.port}).then(s => {
t.tearDown(function () {
s.done()
s.close()
rimraf.sync(testDir)
})
return s
})
}

test('exits with zero exit code for vulnerabilities below the `audit-level` flag', t => {
const fixture = new Tacks(new Dir({
'package.json': new File({
name: 'foo',
version: '1.0.0',
dependencies: {
baddep: '1.0.0'
}
})
}))
fixture.create(testDir)
return tmock(t).then(srv => {
srv.filteringRequestBody(req => 'ok')
srv.post('/-/npm/v1/security/audits/quick', 'ok').reply(200, 'yeah')
srv.get('/baddep').twice().reply(200, {
name: 'baddep',
'dist-tags': {
'latest': '1.2.3'
},
versions: {
'1.0.0': {
name: 'baddep',
version: '1.0.0',
_hasShrinkwrap: false,
dist: {
shasum: 'deadbeef',
tarball: common.registry + '/idk/-/idk-1.0.0.tgz'
}
},
'1.2.3': {
name: 'baddep',
version: '1.2.3',
_hasShrinkwrap: false,
dist: {
shasum: 'deadbeef',
tarball: common.registry + '/idk/-/idk-1.2.3.tgz'
}
}
}
})
return common.npm([
'install',
'--audit',
'--json',
'--package-lock-only',
'--registry', common.registry,
'--cache', path.join(testDir, 'npm-cache')
], EXEC_OPTS).then(([code, stdout, stderr]) => {
srv.filteringRequestBody(req => 'ok')
srv.post('/-/npm/v1/security/audits', 'ok').reply(200, {
actions: [{
action: 'update',
module: 'baddep',
target: '1.2.3',
resolves: [{path: 'baddep'}]
}],
metadata: {
vulnerabilities: {
low: 1
}
}
})
return common.npm([
'audit',
'--audit-level', 'high',
'--json',
'--registry', common.registry,
'--cache', path.join(testDir, 'npm-cache')
], EXEC_OPTS).then(([code, stdout, stderr]) => {
t.equal(code, 0, 'exited OK')
})
})
})
})

test('exits with non-zero exit code for vulnerabilities at the `audit-level` flag', t => {
const fixture = new Tacks(new Dir({
'package.json': new File({
name: 'foo',
version: '1.0.0',
dependencies: {
baddep: '1.0.0'
}
})
}))
fixture.create(testDir)
return tmock(t).then(srv => {
srv.filteringRequestBody(req => 'ok')
srv.post('/-/npm/v1/security/audits/quick', 'ok').reply(200, 'yeah')
srv.get('/baddep').twice().reply(200, {
name: 'baddep',
'dist-tags': {
'latest': '1.2.3'
},
versions: {
'1.0.0': {
name: 'baddep',
version: '1.0.0',
_hasShrinkwrap: false,
dist: {
shasum: 'deadbeef',
tarball: common.registry + '/idk/-/idk-1.0.0.tgz'
}
},
'1.2.3': {
name: 'baddep',
version: '1.2.3',
_hasShrinkwrap: false,
dist: {
shasum: 'deadbeef',
tarball: common.registry + '/idk/-/idk-1.2.3.tgz'
}
}
}
})
return common.npm([
'install',
'--audit',
'--json',
'--package-lock-only',
'--registry', common.registry,
'--cache', path.join(testDir, 'npm-cache')
], EXEC_OPTS).then(([code, stdout, stderr]) => {
srv.filteringRequestBody(req => 'ok')
srv.post('/-/npm/v1/security/audits', 'ok').reply(200, {
actions: [{
action: 'update',
module: 'baddep',
target: '1.2.3',
resolves: [{path: 'baddep'}]
}],
metadata: {
vulnerabilities: {
high: 1
}
}
})
return common.npm([
'audit',
'--audit-level', 'high',
'--json',
'--registry', common.registry,
'--cache', path.join(testDir, 'npm-cache')
], EXEC_OPTS).then(([code, stdout, stderr]) => {
t.equal(code, 1, 'exited OK')
})
})
})
})

test('exits with non-zero exit code for vulnerabilities at the `audit-level` flag', t => {
const fixture = new Tacks(new Dir({
'package.json': new File({
name: 'foo',
version: '1.0.0',
dependencies: {
baddep: '1.0.0'
}
})
}))
fixture.create(testDir)
return tmock(t).then(srv => {
srv.filteringRequestBody(req => 'ok')
srv.post('/-/npm/v1/security/audits/quick', 'ok').reply(200, 'yeah')
srv.get('/baddep').twice().reply(200, {
name: 'baddep',
'dist-tags': {
'latest': '1.2.3'
},
versions: {
'1.0.0': {
name: 'baddep',
version: '1.0.0',
_hasShrinkwrap: false,
dist: {
shasum: 'deadbeef',
tarball: common.registry + '/idk/-/idk-1.0.0.tgz'
}
},
'1.2.3': {
name: 'baddep',
version: '1.2.3',
_hasShrinkwrap: false,
dist: {
shasum: 'deadbeef',
tarball: common.registry + '/idk/-/idk-1.2.3.tgz'
}
}
}
})
return common.npm([
'install',
'--audit',
'--json',
'--package-lock-only',
'--registry', common.registry,
'--cache', path.join(testDir, 'npm-cache')
], EXEC_OPTS).then(([code, stdout, stderr]) => {
srv.filteringRequestBody(req => 'ok')
srv.post('/-/npm/v1/security/audits', 'ok').reply(200, {
actions: [{
action: 'update',
module: 'baddep',
target: '1.2.3',
resolves: [{path: 'baddep'}]
}],
metadata: {
vulnerabilities: {
high: 1
}
}
})
return common.npm([
'audit',
'--audit-level', 'moderate',
'--json',
'--registry', common.registry,
'--cache', path.join(testDir, 'npm-cache')
], EXEC_OPTS).then(([code, stdout, stderr]) => {
t.equal(code, 1, 'exited OK')
})
})
})
})

test('cleanup', t => {
return rimraf(testDir)
})

0 comments on commit 792c8c7

Please sign in to comment.