diff --git a/lib/utils.js b/lib/utils.js index 3b521a5..443c087 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,13 +1,8 @@ 'use strict' -exports.severityLabel = severityLabel -exports.color = color -exports.totalVulnCount = totalVulnCount -exports.severities = severities - const ccs = require('console-control-strings') -const severityColors = { +const severityMetadata = { critical: { color: 'brightMagenta', label: 'Critical' @@ -29,28 +24,96 @@ const severityColors = { label: 'Info' } } +const severityOrder = ['critical', 'high', 'moderate', 'low', 'info'] +severityOrder.forEach((severity, order) => { + severityMetadata[severity].order = order +}) -function color (value, colorName, withColor) { +const color = exports.color = function (value, colorName, withColor) { return (colorName && withColor) ? ccs.color(colorName) + value + ccs.color('reset') : value } -function severityLabel (sev, withColor, bold) { - if (!(sev in severityColors)) return sev.charAt(0).toUpperCase() + sev.substr(1).toLowerCase() - let colorName = severityColors[sev].color +const severityLabel = exports.severityLabel = function (sev, withColor, bold) { + if (!(sev in severityMetadata)) return sev.charAt(0).toUpperCase() + sev.substr(1).toLowerCase() + let colorName = severityMetadata[sev].color if (bold) colorName = [colorName, 'bold'] - return color(severityColors[sev].label, colorName, withColor) + return color(severityMetadata[sev].label, colorName, withColor) +} + +const severityCompare = exports.severityCompare = function (a, b) { + a = a.severity || a + b = b.severity || b + const oA = a in severityMetadata ? severityMetadata[a].order : -1 + const oB = b in severityMetadata ? severityMetadata[b].order : -1 + return oA - oB } -function totalVulnCount (vulns) { - return Object.keys(vulns).reduce((accumulator, key) => { - const vulnCount = vulns[key] +exports.vulnTotal = function (vulns) { + return Object.keys(vulns).reduce((accumulator, severity) => { + const vulnCount = vulns[severity] accumulator += vulnCount return accumulator }, 0) } -function severities (vulns) { +exports.vulnSummary = function (vulns, config) { + config = Object.assign({ + excludeDev: false, + excludeProd: false, + severityThreshold: 'info' + }, config) + const summary = { + total: 0 + } + + const sev = Object.keys(vulns).reduce((accumulator, severity) => { + const excludeSeverity = severityCompare(config.severityThreshold, severity) < 0 + + if (!excludeSeverity) { + const vulnCount = vulns[severity] + if (vulnCount > 0) { + summary.total += vulnCount + accumulator.push(`${vulnCount} ${severityLabel(severity, config.withColor).toLowerCase()}`) + } + } + + return accumulator + }, []) + + if (summary.total === 0) { + summary.msg = `found ${color('0', 'brightGreen', config.withColor)} vulnerabilities` + } else if (sev.length === 1) { + summary.msg = `found ${sev[0]} severity vulnerabilit${summary.total === 1 ? 'y' : 'ies'}` + } else { + summary.msg = `found ${color(summary.total, 'brightRed', config.withColor)} vulnerabilities (${sev.join(', ')})` + } + + return summary +} + +exports.vulnFilter = function (data, config) { + config = Object.assign({ + excludeDev: false, + excludeProd: false, + severityThreshold: 'info' + }, config) + data.actions.forEach((action) => { + action.resolves = action.resolves.filter(({id, dev}) => { + const severity = data.advisories[id].severity + const excludeSeverity = severityCompare(config.severityThreshold, severity) < 0 + const exclude = config[dev ? 'excludeDev' : 'excludeProd'] || excludeSeverity + if (exclude) { + data.metadata.vulnerabilities[severity]-- + data.metadata.excluded = data.metadata.excluded || {} + data.metadata.excluded[severity] = (data.metadata.excluded[severity] || 0) + 1 + } + return !exclude + }) + }) +} + +exports.severities = function (vulns) { return Object.keys(vulns).reduce((accumulator, severity) => { const vulnCount = vulns[severity] if (vulnCount > 0) accumulator.push([severity, vulnCount]) @@ -58,3 +121,19 @@ function severities (vulns) { return accumulator }, []) } + +exports.getRecommendation = function (action, config) { + if (action.action === 'install') { + const isDev = action.resolves[0].dev + + return { + cmd: `npm install ${isDev ? '--save-dev ' : ''}${action.module}@${action.target}`, + isBreaking: action.isMajor + } + } else { + return { + cmd: `npm update ${action.module} --depth ${action.depth}`, + isBreaking: false + } + } +} diff --git a/package-lock.json b/package-lock.json index 4627602..6dfeb62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "npm-audit-report", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -60,6 +60,7 @@ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", "dev": true, + "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -1043,12 +1044,18 @@ "dev": true }, "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } } }, "debug-log": { @@ -1286,12 +1293,27 @@ "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", "dev": true }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, "lodash": { "version": "4.17.10", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", "dev": true }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, "strip-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", @@ -2196,7 +2218,8 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true + "dev": true, + "optional": true }, "is-builtin-module": { "version": "1.0.0", @@ -2455,6 +2478,7 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "dev": true, + "optional": true, "requires": { "is-buffer": "^1.1.5" } @@ -2556,7 +2580,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true + "dev": true, + "optional": true }, "loose-envify": { "version": "1.3.1", @@ -2873,6 +2898,7 @@ "version": "0.1.4", "bundled": true, "dev": true, + "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -4055,7 +4081,8 @@ "longest": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "loose-envify": { "version": "1.3.1", @@ -5893,7 +5920,8 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true + "dev": true, + "optional": true }, "repeating": { "version": "2.0.1", diff --git a/package.json b/package.json index 5302afa..7b22053 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "npm-audit-report", - "version": "1.3.2", + "version": "1.3.3", "description": "Given a response from the npm security api, render it into a variety of security reports", "main": "index.js", "scripts": { diff --git a/reporters/detail.js b/reporters/detail.js index f6e822e..cd6a108 100644 --- a/reporters/detail.js +++ b/reporters/detail.js @@ -1,6 +1,5 @@ 'use strict' -const summary = require('./install.js').summary const Table = require('cli-table3') const Utils = require('../lib/utils') @@ -29,21 +28,91 @@ const report = function (data, options) { const config = Object.assign({}, defaults, options) - let output = '' - let exit = 0 + let output = [] const log = function (value) { - output = output + value + '\n' + output.push(value) } - const footer = function (data) { - const total = Utils.totalVulnCount(data.metadata.vulnerabilities) + Utils.vulnFilter(data, config) + const summary = Utils.vulnSummary(data.metadata.vulnerabilities, config) - if (total > 0) { - exit = 1 + const header = function () { + const tableOptions = { + colWidths: [78] } - log(`${summary(data, config)} in ${data.metadata.totalDependencies} scanned package${data.metadata.totalDependencies === 1 ? '' : 's'}`) - if (total) { + tableOptions.chars = blankChars + const table = new Table(tableOptions) + table.push([{ + content: '=== npm audit security report ===', + vAlign: 'center', + hAlign: 'center' + }]) + log(table.toString()) + } + + const actions = function (data, config) { + let reviewFlag = false + + data.actions.forEach((action) => { + if (action.action === 'update' || action.action === 'install') { + const recommendation = Utils.getRecommendation(action, config) + const label = action.resolves.length === 1 ? 'vulnerability' : 'vulnerabilities' + log(`# Run ${Utils.color(' ' + recommendation.cmd + ' ', 'inverse', config.withColor)} to resolve ${action.resolves.length} ${label}`) + if (recommendation.isBreaking) { + log(`SEMVER WARNING: Recommended action is a potentially breaking change`) + } + } + if (action.action === 'review' && !reviewFlag) { + const tableOptions = { + colWidths: [78] + } + if (!config.withUnicode) { + tableOptions.chars = blankChars + } + const table = new Table(tableOptions) + table.push([{ + content: 'Manual Review\nSome vulnerabilities require your attention to resolve\n\nVisit https://go.npm.me/audit-guide for additional guidance', + vAlign: 'center', + hAlign: 'center' + }]) + + log(table.toString()) + reviewFlag = true + } + + action.resolves.forEach((resolution) => { + const advisory = data.advisories[resolution.id] + const tableOptions = { + colWidths: [15, 62], + wordWrap: true + } + if (!config.withUnicode) { + tableOptions.chars = blankChars + } + const table = new Table(tableOptions) + + table.push( + {[Utils.severityLabel(advisory.severity, config.withColor, true)]: Utils.color(advisory.title, 'bold', config.withColor)}, + {'Package': advisory.module_name}, + {'Dependency of': `${resolution.path.split('>')[0]} ${resolution.dev ? '[dev]' : ''}`}, + {'Path': `${resolution.path.split('>').join(Utils.color(' > ', 'grey', config.withColor))}`}, + {'More info': 'https://nodesecurity.io/advisories/' + advisory.id} + ) + + if (action.action === 'review') { + const patchedIn = advisory.patched_versions.replace(' ', '') === '<0.0.0' ? 'No patch available' : advisory.patched_versions + table.splice(2, 0, {'Patched in': patchedIn}) + } + + log(table.toString() + '\n\n') + }) + }) + } + + const footer = function (data) { + log(`${summary.msg} in ${data.metadata.totalDependencies} scanned package${data.metadata.totalDependencies === 1 ? '' : 's'}`) + if (summary.total) { const counts = data.actions.reduce((acc, {action, isMajor, resolves}) => { if (action === 'update' || (action === 'install' && !isMajor)) { resolves.forEach(({id, path}) => acc.advisories.add(`${id}::${path}`)) @@ -70,127 +139,15 @@ const report = function (data, options) { } } - const reportTitle = function () { - const tableOptions = { - colWidths: [78] - } - tableOptions.chars = blankChars - const table = new Table(tableOptions) - table.push([{ - content: '=== npm audit security report ===', - vAlign: 'center', - hAlign: 'center' - }]) - log(table.toString()) - } - - const actions = function (data, config) { - reportTitle() - - if (Object.keys(data.advisories).length !== 0) { - // vulns found display a report. - - let reviewFlag = false - - data.actions.forEach((action) => { - if (action.action === 'update' || action.action === 'install') { - const recommendation = getRecommendation(action, config) - const label = action.resolves.length === 1 ? 'vulnerability' : 'vulnerabilities' - log(`# Run ${Utils.color(' ' + recommendation.cmd + ' ', 'inverse', config.withColor)} to resolve ${action.resolves.length} ${label}`) - if (recommendation.isBreaking) { - log(`SEMVER WARNING: Recommended action is a potentially breaking change`) - } - - action.resolves.forEach((resolution) => { - const advisory = data.advisories[resolution.id] - const tableOptions = { - colWidths: [15, 62], - wordWrap: true - } - if (!config.withUnicode) { - tableOptions.chars = blankChars - } - const table = new Table(tableOptions) - - table.push( - {[Utils.severityLabel(advisory.severity, config.withColor, true)]: Utils.color(advisory.title, 'bold', config.withColor)}, - {'Package': advisory.module_name}, - {'Dependency of': `${resolution.path.split('>')[0]} ${resolution.dev ? '[dev]' : ''}`}, - {'Path': `${resolution.path.split('>').join(Utils.color(' > ', 'grey', config.withColor))}`}, - {'More info': advisory.url || `https://www.npmjs.com/advisories/${advisory.id}`} - ) - - log(table.toString() + '\n\n') - }) - } - if (action.action === 'review') { - if (!reviewFlag) { - const tableOptions = { - colWidths: [78] - } - if (!config.withUnicode) { - tableOptions.chars = blankChars - } - const table = new Table(tableOptions) - table.push([{ - content: 'Manual Review\nSome vulnerabilities require your attention to resolve\n\nVisit https://go.npm.me/audit-guide for additional guidance', - vAlign: 'center', - hAlign: 'center' - }]) - - log(table.toString()) - } - reviewFlag = true - - action.resolves.forEach((resolution) => { - const advisory = data.advisories[resolution.id] - const tableOptions = { - colWidths: [15, 62], - wordWrap: true - } - if (!config.withUnicode) { - tableOptions.chars = blankChars - } - const table = new Table(tableOptions) - const patchedIn = advisory.patched_versions.replace(' ', '') === '<0.0.0' ? 'No patch available' : advisory.patched_versions - - table.push( - {[Utils.severityLabel(advisory.severity, config.withColor, true)]: Utils.color(advisory.title, 'bold', config.withColor)}, - {'Package': advisory.module_name}, - {'Patched in': patchedIn}, - {'Dependency of': `${resolution.path.split('>')[0]} ${resolution.dev ? '[dev]' : ''}`}, - {'Path': `${resolution.path.split('>').join(Utils.color(' > ', 'grey', config.withColor))}`}, - {'More info': advisory.url || `https://www.npmjs.com/advisories/${advisory.id}`} - ) - log(table.toString()) - }) - } - }) - } + header() + if (summary.total) { // vulns found display a report. + actions(data, config) } - - actions(data, config) footer(data) return { - report: output.trim(), - exitCode: exit - } -} - -const getRecommendation = function (action, config) { - if (action.action === 'install') { - const isDev = action.resolves[0].dev - - return { - cmd: `npm install ${isDev ? '--save-dev ' : ''}${action.module}@${action.target}`, - isBreaking: action.isMajor - } - } else { - return { - cmd: `npm update ${action.module} --depth ${action.depth}`, - isBreaking: false - } + report: output.join('\n').trim(), + exitCode: summary.total ? 1 : 0 } } diff --git a/reporters/install.js b/reporters/install.js index 96ea12b..d33a2a3 100644 --- a/reporters/install.js +++ b/reporters/install.js @@ -43,7 +43,7 @@ function summary (data, options) { log(`${green('0')} vulnerabilities`) return output } else { - const total = Utils.totalVulnCount(data.metadata.vulnerabilities) + const total = Utils.vulnTotal(data.metadata.vulnerabilities) const sev = Utils.severities(data.metadata.vulnerabilities) if (sev.length > 1) { diff --git a/reporters/parseable.js b/reporters/parseable.js index 1d46ef2..ef6b53d 100644 --- a/reporters/parseable.js +++ b/reporters/parseable.js @@ -1,13 +1,14 @@ 'use strict' +const Utils = require('../lib/utils') + const report = function (data, options) { - const defaults = { - severityThreshold: 'info' - } + const defaults = {} const config = Object.assign({}, defaults, options) - let exit = 0 + Utils.vulnFilter(data, config) + const vulnTotal = Utils.vulnTotal(data.metadata.vulnerabilities) const actions = function (data, config) { let accumulator = { @@ -17,82 +18,47 @@ const report = function (data, options) { low: '' } - if (Object.keys(data.advisories).length !== 0) { - data.actions.forEach((action) => { - let l = {} - // Start with install/update actions - if (action.action === 'update' || action.action === 'install') { - const recommendation = getRecommendation(action, config) - l.recommendation = recommendation.cmd - l.breaking = recommendation.isBreaking ? 'Y' : 'N' - - action.resolves.forEach((resolution) => { - const advisory = data.advisories[resolution.id] - - l.sevLevel = advisory.severity - l.severity = advisory.title - l.package = advisory.module_name - l.moreInfo = advisory.url || `https://www.npmjs.com/advisories/${advisory.id}` - l.path = resolution.path - - accumulator[advisory.severity] += [action.action, l.package, l.sevLevel, l.recommendation, l.severity, l.moreInfo, l.path, l.breaking] - .join('\t') + '\n' - }) // forEach resolves + data.actions.forEach(action => { + const recommendation = + action.action === 'update' || action.action === 'install' + ? Utils.getRecommendation(action, config) + : null + if (recommendation) { + recommendation.breaking = recommendation.isBreaking ? 'Y' : 'N' + } + + action.resolves.forEach(resolution => { + const advisory = data.advisories[resolution.id] + + const line = [action.action, advisory.module_name, advisory.severity] + + if (recommendation) { + line.push(recommendation.cmd) + } else { + line.push( + advisory.patched_versions.replace(' ', '') === '<0.0.0' + ? 'No patch available' + : advisory.patched_versions + ) + } + line.push( + advisory.title, + 'https://nodesecurity.io/advisories/' + advisory.id, + resolution.path + ) + if (recommendation) { + line.push(recommendation.breaking) } - if (action.action === 'review') { - action.resolves.forEach((resolution) => { - const advisory = data.advisories[resolution.id] - - l.sevLevel = advisory.severity - l.severity = advisory.title - l.package = advisory.module_name - l.moreInfo = advisory.url || `https://www.npmjs.com/advisories/${advisory.id}` - l.patchedIn = advisory.patched_versions.replace(' ', '') === '<0.0.0' ? 'No patch available' : advisory.patched_versions - l.path = resolution.path - - accumulator[advisory.severity] += [action.action, l.package, l.sevLevel, l.patchedIn, l.severity, l.moreInfo, l.path].join('\t') + '\n' - }) // forEach resolves - } // is review - }) // forEach actions - } + accumulator[advisory.severity] += line.join('\t') + '\n' + }) + }) return accumulator['critical'] + accumulator['high'] + accumulator['moderate'] + accumulator['low'] } - const exitCode = function (metadata) { - let total = 0 - const keys = Object.keys(metadata.vulnerabilities) - for (let key of keys) { - const value = metadata.vulnerabilities[key] - total = total + value - } - - if (total > 0) { - exit = 1 - } - } - - exitCode(data.metadata) - return { - report: actions(data, config), - exitCode: exit - } -} - -const getRecommendation = function (action, config) { - if (action.action === 'install') { - const isDev = action.resolves[0].dev - - return { - cmd: `npm install ${isDev ? '--save-dev ' : ''}${action.module}@${action.target}`, - isBreaking: action.isMajor - } - } else { - return { - cmd: `npm update ${action.module} --depth ${action.depth}`, - isBreaking: false - } + report: vulnTotal ? actions(data, config) : '', + exitCode: vulnTotal ? 1 : 0 } } diff --git a/reporters/quiet.js b/reporters/quiet.js index d6f5c58..11c3bfc 100644 --- a/reporters/quiet.js +++ b/reporters/quiet.js @@ -2,12 +2,19 @@ const Utils = require('../lib/utils') -const report = function (data) { - const totalVulnCount = Utils.totalVulnCount(data.metadata.vulnerabilities) +const report = function (data, options) { + const defaults = { + severityThreshold: 'info' + } + + const config = Object.assign({}, defaults, options) + + Utils.vulnFilter(data, config) + const vulnTotal = Utils.vulnTotal(data.metadata.vulnerabilities) return { report: '', - exitCode: totalVulnCount === 0 ? 0 : 1 + exitCode: vulnTotal ? 1 : 0 } } diff --git a/test/detail-report-test.js b/test/detail-report-test.js index 8e4d255..124b5be 100644 --- a/test/detail-report-test.js +++ b/test/detail-report-test.js @@ -6,7 +6,7 @@ const fixtures = require('./lib/test-fixtures') tap.test('it generates a detail report with no vulns', function (t) { return Report(fixtures['no-vulns'], {reporter: 'detail', withColor: false}).then((report) => { - t.match(report.exitCode, 0, 'successful exit code') + t.equal(report.exitCode, 0, 'successful exit code') t.match(report.report, /found 0 vulnerabilities/, 'no vulns reported') t.match(report.report, /918 scanned packages/, 'reports scanned count') }) @@ -100,3 +100,43 @@ tap.test('it generates a detail report with review vulns, no unicode', function t.match(report.report, /1 low, 1 moderate, 1 critical/, 'severity breakdown reported') }) }) + +tap.test('it generates a detail report with no vulns when a dev dep has a vuln and dev deps are excluded', function (t) { + return Report(fixtures['one-vuln-dev'], {reporter: 'detail', excludeDev: true, withColor: false}).then((report) => { + t.equal(report.exitCode, 0, 'successful exit code') + t.match(report.report, /found 0 vulnerabilities/, 'no vulns reported') + t.match(report.report, /918 scanned packages/, 'reports scanned count') + }) +}) + +tap.test('it generates a detail report with fewer vulns when a severity threshold higher than some vulns is set', function (t) { + return Report(fixtures['all-severity-vulns'], {reporter: 'detail', severityThreshold: 'high', withColor: false}).then((report) => { + t.equal(report.exitCode, 1, 'non-zero exit code') + t.match(report.report, /found 3 vulnerabilities/, 'reports vuln count') + t.match(report.report, /2 high, 1 critical/, 'severity breakdown reported') + }) +}) + +tap.test('it generates a detail report with no vulns when a severity threshold higher than all vulns is set', function (t) { + return Report(fixtures['some-vulns'], {reporter: 'detail', severityThreshold: 'critical', withColor: false}).then((report) => { + t.equal(report.exitCode, 0, 'successful exit code') + t.match(report.report, /found 0 vulnerabilities/, 'no vulns reported') + t.match(report.report, /918 scanned packages/, 'reports scanned count') + }) +}) + +tap.test('it generates a detail report with one severity threshold', function (t) { + return Report(fixtures['all-severity-vulns'], {reporter: 'detail', severityThreshold: 'critical', withColor: false}).then((report) => { + t.equal(report.exitCode, 1, 'non-zero exit code') + t.match(report.report, /found 1 critical severity vulnerability/, 'one vuln reported') + t.match(report.report, /918 scanned packages/, 'reports scanned count') + }) +}) + +tap.test('it generates a detail report with one severity threshold', function (t) { + return Report(fixtures['all-severity-vulns-two-crit'], {reporter: 'detail', severityThreshold: 'critical', withColor: false}).then((report) => { + t.equal(report.exitCode, 1, 'non-zero exit code') + t.match(report.report, /found 2 critical severity vulnerabilities/, 'two vulns one sev') + t.match(report.report, /918 scanned packages/, 'reports scanned count') + }) +}) diff --git a/test/fixtures/all-severity-vulns-two-crit.json b/test/fixtures/all-severity-vulns-two-crit.json new file mode 100644 index 0000000..663ed12 --- /dev/null +++ b/test/fixtures/all-severity-vulns-two-crit.json @@ -0,0 +1,310 @@ +{ + "actions": [ + { + "action": "update", + "module": "tough-cookie", + "depth": 6, + "target": "2.3.4", + "resolves": [ + { + "id": 525, + "path": "@npm/spife>chokidar>fsevents>node-pre-gyp>request>tough-cookie", + "dev": false, + "optional": false + } + ] + }, + { + "action": "update", + "module": "debug", + "depth": 6, + "target": "2.6.9", + "resolves": [ + { + "id": 534, + "path": "standard>eslint>debug", + "dev": true, + "optional": false + }, + { + "id": 534, + "path": "standard>eslint-plugin-import>debug", + "dev": true, + "optional": false + }, + { + "id": 534, + "path": "standard>eslint-plugin-import>eslint-import-resolver-node>debug", + "dev": true, + "optional": false + }, + { + "id": 534, + "path": "tap>tap-mocha-reporter>debug", + "dev": true, + "optional": false + }, + { + "id": 534, + "path": "@npm/spife>chokidar>fsevents>node-pre-gyp>tar-pack>debug", + "dev": false, + "optional": false + } + ] + }, + { + "action": "update", + "module": "tunnel-ssh", + "depth": 2, + "target": "4.1.4", + "resolves": [ + { + "id": 534, + "path": "db-migrate>tunnel-ssh>debug", + "dev": true, + "optional": false + } + ] + }, + { + "action": "update", + "module": "test-exclude", + "depth": 3, + "target": "4.2.1", + "resolves": [ + { + "id": 157, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + } + ] + }, + { + "action": "review", + "module": "ws", + "resolves": [ + { + "id": 550, + "path": "@npm/spife>numbat-emitter>ws", + "dev": false, + "optional": false + }, + { + "id": 550, + "path": "@npm/spife>numbat-process>numbat-emitter>ws", + "dev": false, + "optional": false + } + ] + }, + { + "action": "review", + "module": "randomatic", + "resolves": [ + { + "id": 157, + "path": "@npm/spife>chokidar>anymatch>micromatch>braces>expand-range>fill-range>randomatic", + "dev": false, + "optional": false + } + ] + } + ], + "advisories": { + "157": { + "findings": [ + { + "version": "1.1.7", + "paths": [ + "tap>nyc>micromatch>braces>expand-range>fill-range>randomatic", + "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic" + ], + "dev": true, + "optional": false + }, + { + "version": "1.1.7", + "paths": [ + "@npm/spife>chokidar>anymatch>micromatch>braces>expand-range>fill-range>randomatic" + ], + "dev": false, + "optional": false + } + ], + "id": 157, + "created": "2016-11-09T20:03:19.000Z", + "updated": "2018-03-02T23:21:42.009Z", + "deleted": null, + "title": "Cryptographically Weak PRNG", + "found_by": { + "name": "Sven Slootweg" + }, + "reported_by": { + "name": "Sven Slootweg" + }, + "module_name": "randomatic", + "cves": [ + "CVE-2017-16028" + ], + "vulnerable_versions": "<3.0.0", + "patched_versions": ">=3.0.0", + "overview": "Affected versions of `randomatic` generate random values using a cryptographically weak psuedo-random number generator. This may result in predictable values instead of random values as intended.\r\n\r\n", + "recommendation": "Update to version 3.0.0 or later.\r\n", + "references": "[Commit #4a52695](https://github.com/jonschlinkert/randomatic/commit/4a526959b3a246ae8e4a82f9c182180907227fe1#diff-b9cfc7f2cdf78a7f4b91a753d10865a2)", + "access": "public", + "severity": "low", + "cwe": "CWE-330", + "metadata": { + "module_type": "Multi.Library", + "exploitability": 5, + "affected_components": "" + } + }, + "525": { + "findings": [ + { + "version": "2.3.2", + "paths": [ + "@npm/spife>chokidar>fsevents>node-pre-gyp>request>tough-cookie" + ], + "dev": false, + "optional": false + } + ], + "id": 525, + "created": "2017-09-08T18:07:02.061Z", + "updated": "2017-09-22T16:26:08.422Z", + "deleted": null, + "title": "Regular Expression Denial of Service", + "found_by": { + "name": "Cristian-Alexandru Staicu" + }, + "reported_by": { + "name": "Cristian-Alexandru Staicu" + }, + "module_name": "tough-cookie", + "cves": [ + "CVE-2017-16112" + ], + "vulnerable_versions": "<2.3.3", + "patched_versions": ">=2.3.3", + "overview": "The tough-cookie module is vulnerable to regular expression denial of service. Input of around 50k characters is required for a slow down of around 2 seconds.\n\nUnless node was compiled using the -DHTTP_MAX_HEADER_SIZE= option the default header max length is 80kb so the impact of the ReDoS is limited to around 7.3 seconds of blocking.\n\nAt the time of writing all version <=2.3.2 are vulnerable", + "recommendation": "Please update to version 2.3.3 or greater", + "references": "- https://github.com/salesforce/tough-cookie/issues/92", + "access": "public", + "severity": "high", + "cwe": "CWE-400", + "metadata": { + "module_type": "", + "exploitability": 5, + "affected_components": "" + } + }, + "534": { + "findings": [ + { + "version": "2.6.0", + "paths": [ + "db-migrate>tunnel-ssh>debug", + "standard>eslint>debug", + "standard>eslint-plugin-import>debug", + "standard>eslint-plugin-import>eslint-import-resolver-node>debug", + "tap>tap-mocha-reporter>debug" + ], + "dev": true, + "optional": false + }, + { + "version": "2.6.8", + "paths": [ + "@npm/spife>chokidar>fsevents>node-pre-gyp>tar-pack>debug" + ], + "dev": false, + "optional": false + } + ], + "id": 534, + "created": "2017-09-25T18:55:55.956Z", + "updated": "2017-09-27T18:24:24.491Z", + "deleted": null, + "title": "Regular Expression Denial of Service", + "found_by": { + "name": "Cristian-Alexandru Staicu" + }, + "reported_by": { + "name": "Cristian-Alexandru Staicu" + }, + "module_name": "debug", + "cves": [ + "CVE-2017-16137" + ], + "vulnerable_versions": "<= 2.6.8 || >= 3.0.0 <= 3.0.1", + "patched_versions": ">= 2.6.9 < 3.0.0 || >= 3.1.0", + "overview": "The debug module is vulnerable to regular expression denial of service when untrusted user input is passed into the `o` formatter. It takes around 50k characters to block for 2 seconds making this a low severity issue.", + "recommendation": "Upgrade to version 2.6.9 or greater if you are on the 2.6.x series or 3.1.0 or greater.", + "references": "- https://github.com/visionmedia/debug/issues/501\n- https://github.com/visionmedia/debug/pull/504", + "access": "public", + "severity": "low", + "cwe": "CWE-400", + "metadata": { + "module_type": "", + "exploitability": 5, + "affected_components": "" + } + }, + "550": { + "findings": [ + { + "version": "3.1.0", + "paths": [ + "@npm/spife>numbat-emitter>ws", + "@npm/spife>numbat-process>numbat-emitter>ws" + ], + "dev": false, + "optional": false + } + ], + "id": 550, + "created": "2017-11-08T19:25:17.211Z", + "updated": "2017-11-10T17:26:26.645Z", + "deleted": null, + "title": "Denial of Service", + "found_by": { + "name": "Nick Starke, Ryan Knell" + }, + "reported_by": { + "name": "Nick Starke, Ryan Knell" + }, + "module_name": "ws", + "cves": null, + "vulnerable_versions": "<=99.999.99999", + "patched_versions": "< 0.0.0", + "overview": "A specially crafted value of the `Sec-WebSocket-Extensions` header that used `Object.prototype` property names as extension or parameter names could be used to make a ws server crash.\n\nProof of concept:\n\n```\nconst WebSocket = require('ws');\nconst net = require('net');\n\nconst wss = new WebSocket.Server({ port: 3000 }, function () {\n const payload = 'constructor'; // or ',;constructor'\n\n const request = [\n 'GET / HTTP/1.1',\n 'Connection: Upgrade',\n 'Sec-WebSocket-Key: test',\n 'Sec-WebSocket-Version: 8',\n `Sec-WebSocket-Extensions: ${payload}`,\n 'Upgrade: websocket',\n '\\r\\n'\n ].join('\\r\\n');\n\n const socket = net.connect(3000, function () {\n socket.resume();\n socket.write(request);\n });\n});\n```", + "recommendation": "Upgrade to version 3.3.1 or greater", + "references": "- https://github.com/websockets/ws/commit/c4fe46608acd61fbf7397eadc47378903f95b78a\n- https://github.com/websockets/ws/releases/tag/3.3.1", + "access": "public", + "severity": "high", + "cwe": "CWE-20", + "metadata": { + "module_type": "", + "exploitability": 5, + "affected_components": "" + } + } + }, + "muted": [], + "metadata": { + "vulnerabilities": { + "info": 16, + "low": 8, + "moderate": 4, + "high": 2, + "critical": 2 + }, + "dependencies": 375, + "devDependencies": 466, + "optionalDependencies": 87, + "totalDependencies": 918 + } +} diff --git a/test/fixtures/all-severity-vulns.json b/test/fixtures/all-severity-vulns.json index fd5adf0..5e9c5ce 100644 --- a/test/fixtures/all-severity-vulns.json +++ b/test/fixtures/all-severity-vulns.json @@ -20,6 +20,18 @@ "depth": 6, "target": "2.6.9", "resolves": [ + { + "id": 534, + "path": "standard>eslint>debug", + "dev": true, + "optional": false + }, + { + "id": 534, + "path": "standard>eslint>debug", + "dev": true, + "optional": false + }, { "id": 534, "path": "standard>eslint>debug", @@ -72,6 +84,114 @@ "depth": 3, "target": "4.2.1", "resolves": [ + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 1, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 157, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, + { + "id": 157, + "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", + "dev": true, + "optional": false + }, { "id": 157, "path": "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic", @@ -112,6 +232,55 @@ } ], "advisories": { + "1": { + "findings": [ + { + "version": "1.1.7", + "paths": [ + "tap>nyc>micromatch>braces>expand-range>fill-range>randomatic", + "tap>nyc>test-exclude>micromatch>braces>expand-range>fill-range>randomatic" + ], + "dev": true, + "optional": false + }, + { + "version": "1.1.7", + "paths": [ + "@npm/spife>chokidar>anymatch>micromatch>braces>expand-range>fill-range>randomatic" + ], + "dev": false, + "optional": false + } + ], + "id": 1, + "created": "2016-11-09T20:03:19.000Z", + "updated": "2018-03-02T23:21:42.009Z", + "deleted": null, + "title": "Cryptographically Weak PRNG", + "found_by": { + "name": "Sven Slootweg" + }, + "reported_by": { + "name": "Sven Slootweg" + }, + "module_name": "randomatic", + "cves": [ + "CVE-2017-16028" + ], + "vulnerable_versions": "<3.0.0", + "patched_versions": ">=3.0.0", + "overview": "Affected versions of `randomatic` generate random values using a cryptographically weak psuedo-random number generator. This may result in predictable values instead of random values as intended.\r\n\r\n", + "recommendation": "Update to version 3.0.0 or later.\r\n", + "references": "[Commit #4a52695](https://github.com/jonschlinkert/randomatic/commit/4a526959b3a246ae8e4a82f9c182180907227fe1#diff-b9cfc7f2cdf78a7f4b91a753d10865a2)", + "access": "public", + "severity": "info", + "cwe": "CWE-330", + "metadata": { + "module_type": "Multi.Library", + "exploitability": 5, + "affected_components": "" + } + }, "157": { "findings": [ { @@ -153,7 +322,7 @@ "recommendation": "Update to version 3.0.0 or later.\r\n", "references": "[Commit #4a52695](https://github.com/jonschlinkert/randomatic/commit/4a526959b3a246ae8e4a82f9c182180907227fe1#diff-b9cfc7f2cdf78a7f4b91a753d10865a2)", "access": "public", - "severity": "low", + "severity": "moderate", "cwe": "CWE-330", "metadata": { "module_type": "Multi.Library", @@ -193,7 +362,7 @@ "recommendation": "Please update to version 2.3.3 or greater", "references": "- https://github.com/salesforce/tough-cookie/issues/92", "access": "public", - "severity": "high", + "severity": "critical", "cwe": "CWE-400", "metadata": { "module_type": "", diff --git a/test/reporters-test.js b/test/reporters-test.js index a505977..a59340f 100644 --- a/test/reporters-test.js +++ b/test/reporters-test.js @@ -3,24 +3,25 @@ const tap = require('tap') const Report = require('../') const Keyfob = require('keyfob') -const {totalVulnCount, severities} = require('../lib/utils') const fixtures = Keyfob.load({path: 'test/fixtures', fn: require}) +const {vulnTotal, severities} = require('../lib/utils') + tap.test('total vuln count is 0 with no vulns', function (t) { return Report(fixtures['no-vulns'], {reporter: 'json'}).then((reportRaw) => { - t.equal(totalVulnCount(JSON.parse(reportRaw.report).metadata.vulnerabilities), 0) + t.equal(vulnTotal(JSON.parse(reportRaw.report).metadata.vulnerabilities), 0) }) }) tap.test('total vuln count is calculated with some vulns', function (t) { return Report(fixtures['some-vulns'], {reporter: 'json'}).then((reportRaw) => { - t.equal(totalVulnCount(JSON.parse(reportRaw.report).metadata.vulnerabilities), 12) + t.equal(vulnTotal(JSON.parse(reportRaw.report).metadata.vulnerabilities), 12) }) }) tap.test('total vuln count is calculated with all severity vulns', function (t) { return Report(fixtures['all-severity-vulns'], {reporter: 'json'}).then((reportRaw) => { - t.equal(totalVulnCount(JSON.parse(reportRaw.report).metadata.vulnerabilities), 31) + t.equal(vulnTotal(JSON.parse(reportRaw.report).metadata.vulnerabilities), 31) }) }) diff --git a/test/utils-test.js b/test/utils-test.js index cba2a7c..79e1e74 100644 --- a/test/utils-test.js +++ b/test/utils-test.js @@ -1,7 +1,7 @@ 'use strict' const tap = require('tap') -const {severityLabel, color} = require('../lib/utils') +const {color, severityLabel, severityCompare} = require('../lib/utils') tap.test('color does just return the value if colorName AND withColor are missing', function (t) { t.equal(color('Low'), 'Low') @@ -17,19 +17,73 @@ tap.test('color returns a formatted string if all params are set', function (t) t.done() }) -tap.test('severity does not throw an error if given severity is not defined', function (t) { +tap.test('severityLabel does not throw an error if given severity is not defined', function (t) { t.doesNotThrow(function () { severityLabel('me-no-exist') }) t.done() }) -tap.test('severity returns a label if there is no color mapping', function (t) { +tap.test('severityLabel returns a label if there is no color mapping', function (t) { t.equal(severityLabel('me-no-exist'), 'Me-no-exist') t.equal(severityLabel('w000000t'), 'W000000t') t.done() }) -tap.test('severity returns a label with color formatting ', function (t) { +tap.test('severityLabel returns a label with color formatting ', function (t) { t.equal(severityLabel('high', true, true), '\u001b[91;1mHigh\u001b[0m') t.equal(severityLabel('low', true, true), '\u001b[1;1mLow\u001b[0m') t.done() }) + +tap.test('severityCompare does not throw an error if given severity is not defined', function (t) { + t.doesNotThrow(function () { severityCompare('low', 'me-no-exist') }) + t.doesNotThrow(function () { severityCompare('me-no-exist', 'low') }) + t.done() +}) + +tap.test('severityCompare returns 0 when comparing the same severity', function (t) { + t.equal(severityCompare('critical', 'critical'), 0) + t.equal(severityCompare('high', 'high'), 0) + t.equal(severityCompare('moderate', 'moderate'), 0) + t.equal(severityCompare('low', 'low'), 0) + t.equal(severityCompare('info', 'info'), 0) + t.equal(severityCompare('me-no-exist', 'me-no-exist'), 0) + t.done() +}) + +tap.test('severityCompare returns <0 only when first severity is more severe than 2nd severity', function (t) { + t.ok(severityCompare('me-no-exist', 'critical') < 0) + t.ok(severityCompare('me-no-exist', 'high') < 0) + t.ok(severityCompare('me-no-exist', 'moderate') < 0) + t.ok(severityCompare('me-no-exist', 'low') < 0) + t.ok(severityCompare('me-no-exist', 'info') < 0) + t.ok(severityCompare('critical', 'high') < 0) + t.ok(severityCompare('critical', 'moderate') < 0) + t.ok(severityCompare('critical', 'low') < 0) + t.ok(severityCompare('critical', 'info') < 0) + t.ok(severityCompare('high', 'moderate') < 0) + t.ok(severityCompare('high', 'low') < 0) + t.ok(severityCompare('high', 'info') < 0) + t.ok(severityCompare('moderate', 'low') < 0) + t.ok(severityCompare('moderate', 'info') < 0) + t.ok(severityCompare('low', 'info') < 0) + t.done() +}) + +tap.test('severityCompare returns >0 only when first severity is less severe than 2nd severity', function (t) { + t.ok(severityCompare('info', 'low') > 0) + t.ok(severityCompare('info', 'moderate') > 0) + t.ok(severityCompare('info', 'high') > 0) + t.ok(severityCompare('info', 'critical') > 0) + t.ok(severityCompare('info', 'me-no-exist') > 0) + t.ok(severityCompare('low', 'moderate') > 0) + t.ok(severityCompare('low', 'high') > 0) + t.ok(severityCompare('low', 'critical') > 0) + t.ok(severityCompare('low', 'me-no-exist') > 0) + t.ok(severityCompare('moderate', 'high') > 0) + t.ok(severityCompare('moderate', 'critical') > 0) + t.ok(severityCompare('moderate', 'me-no-exist') > 0) + t.ok(severityCompare('high', 'critical') > 0) + t.ok(severityCompare('high', 'me-no-exist') > 0) + t.ok(severityCompare('critical', 'me-no-exist') > 0) + t.done() +})