diff --git a/commands/report.js b/commands/report.js
index d1dae3f..b17b1e6 100644
--- a/commands/report.js
+++ b/commands/report.js
@@ -14,6 +14,7 @@ const {
} = require('../lib/report/util')
const longReport = require('../lib/report/long')
const shortReport = require('../lib/report/short')
+const htmlReport = require('../lib/report/html')
const { helpHeader } = require('../lib/help')
const {
COLORS,
@@ -30,7 +31,8 @@ module.exports.optionsList = optionsList
async function report (argv, _dir) {
const {
- long
+ long,
+ html
} = argv
let { dir = _dir } = argv
if (!dir) dir = process.cwd()
@@ -162,6 +164,7 @@ async function report (argv, _dir) {
if (!long) shortReport(pkgScores, whitelisted, dir, argv)
if (long) longReport(pkgScores, whitelisted, dir, argv)
+ if (html) htmlReport(pkgScores, whitelisted, dir, html, argv)
if (hasFailures) process.exitCode = 1
}
diff --git a/lib/report/html-template.js b/lib/report/html-template.js
new file mode 100644
index 0000000..77e3f3b
--- /dev/null
+++ b/lib/report/html-template.js
@@ -0,0 +1,256 @@
+'use strict'
+
+const {
+ filterVulns,
+ SEVERITY_RMAP
+} = require('./util')
+
+module.exports = renderHTML
+
+function renderHTML (title, summary, report, reportLength, whitelist, whitelistLength, formattedFilterOptions) {
+ const { riskCount, securityCount, insecureModules, complianceCount } = summary
+
+ let alternate = false
+ let whitelistInfo = ''
+ for (const pkg of whitelist) {
+ whitelistInfo += segment(pkg)
+ }
+
+ alternate = false
+ let pkgInfo = ''
+ for (const pkg of report) {
+ pkgInfo += segment(pkg)
+ }
+
+ function segment (pkg) {
+ const { name, version, maxSeverity, failures, license } = pkg
+ const pkgVulns = filterVulns(failures).map((v, i) => v !== 0
+ ? `
+ ${v} ${['Low', 'Medium', 'High', 'Critical'][i]}
+
`
+ : ''
+ )
+ const pkgLicense = license && license.data && license.data.spdx ? license.data.spdx : 'UNKNOWN'
+ const pkgLicensePass = license && license.pass === true
+ const pkgSeverity = `
+
+
+ ${['', '| ', '| | ', '| | | ', '| | | |'][maxSeverity]}${['| | | |', '| | |', '| |', '|', ''][maxSeverity]} ${SEVERITY_RMAP[maxSeverity]}
+
`
+
+ alternate = !alternate
+ return `
+
+ |
+ ${name}@${version}
+ |
+
+ ${pkgSeverity}
+ |
+
+ ✓' : 'red">X'} ${pkgLicense}
+
+ |
+
+
+ ${pkgVulns.join('').length === 0
+ ? '✓ 0'
+ : pkgVulns.reverse().join(' ')}
+
+ |
+
+ `
+ }
+
+ const template = `
+
+
+
+
+ NCM Report > ${title}
+
+
+
+
+
+
+
+
+
+
+ NCM Project Report
+ >
+ ${title}
+
+
+
+
+
+
+
Summary
+
${reportLength} packages checked
+
+
${riskCount[4]} Critical Risk
+
${riskCount[3]} High Risk
+
${riskCount[2]} Medium Risk
+
${riskCount[1]} Low Risk
+
+
+ ${(securityCount > 0
+ ? `!
+ ${securityCount}
+ security vulnerabilities found across
+ ${insecureModules} modules`
+ : '✓ No security vulnerabilities found')}
+
+
+ ${(complianceCount > 0
+ ? `!
+ ${complianceCount} noncompliant modules found`
+ : '✓ All modules compliant')}
+
+ ${(whitelistLength > 0
+ ? `
+ !
+ ${whitelistLength} used modules whitelisted
+
`
+ : '')}
+
+
+ ${(whitelistLength > 0
+ ? `
+
+
+
+ Whitelisted
+ ${(formattedFilterOptions.length > 9
+ ? 'Filtered'
+ : '')}
+ Modules
+
+ ${(formattedFilterOptions.length > 9
+ ? `(${formattedFilterOptions})`
+ : '')}
+
+
+
+
+
+ | Module Name |
+ Risk |
+ License |
+ Security |
+
+ ${whitelistInfo}
+
+
+ `
+ : '')}
+
+
+
+
+ ${(whitelistLength > 0
+ ? 'Non-Whitelisted'
+ : '')}
+ ${(formattedFilterOptions.length > 9
+ ? 'Filtered'
+ : '')}
+ Modules
+
+ ${(formattedFilterOptions.length > 9
+ ? `( ${formattedFilterOptions} )`
+ : '')}
+
+
+
+
+ | Module Name |
+ Risk |
+ License |
+ Security |
+
+ ${pkgInfo}
+
+
+
+
+ `
+
+ return template
+}
diff --git a/lib/report/html.js b/lib/report/html.js
new file mode 100644
index 0000000..d5c37c6
--- /dev/null
+++ b/lib/report/html.js
@@ -0,0 +1,73 @@
+'use strict'
+
+const path = require('path')
+const { promisify } = require('util')
+const writeFile = promisify(require('fs').writeFile)
+const {
+ success,
+ formatError
+} = require('../ncm-style')
+const {
+ summaryInfo,
+ moduleSort,
+ filterReport,
+ parseFilterOptions,
+ formatFilterOptions
+} = require('./util')
+const renderTemplate = require('./html-template')
+const L = console.log
+
+module.exports = htmlReport
+
+async function htmlReport (report, whitelist, dir, output, argv) {
+ /* Output may only use the `.html` file format */
+ if (output !== true && !(/^.*\.html$/.test(path.basename(output)))) {
+ L()
+ L(formatError('Invalid file extension to write the HTML report. Please use `*.html`.'))
+ L()
+ process.exitCode = 1
+ return
+ }
+
+ const title = `${path.basename(dir) || 'NCM'}`
+ const summary = summaryInfo(report) // { riskCount, insecureModules, complianceCount, securityCount }
+ const reportLength = report.length
+ const whitelistLength = whitelist.length
+
+ const filterOptions = parseFilterOptions(argv)
+ const formattedfilterOptions = formatFilterOptions(filterOptions)
+ report = filterReport(report, filterOptions)
+ report = moduleSort(report)
+
+ whitelist = filterReport(whitelist, filterOptions)
+ whitelist = moduleSort(whitelist)
+
+ const htmlData = renderTemplate(
+ title,
+ summary,
+ report,
+ reportLength,
+ whitelist,
+ whitelistLength,
+ formattedfilterOptions
+ )
+
+ /*
+ No write location was specified.
+ Setting output location to current working directory with generated report name.
+ */
+ if (output === true) output = path.join(process.cwd(), `${title.toLowerCase()}-report-${Date.now()}.html`)
+
+ /* Write report to file */
+ try {
+ await writeFile(output, htmlData)
+ L()
+ L(success(`Wrote HTML report to: ${output}`))
+ L()
+ } catch (error) {
+ L()
+ L(formatError(`Unable to write HTML report to: ${output}`, error))
+ L()
+ process.exitCode = 1
+ }
+}
diff --git a/lib/report/short.js b/lib/report/short.js
index 243743a..e3bab76 100644
--- a/lib/report/short.js
+++ b/lib/report/short.js
@@ -3,7 +3,11 @@
module.exports = shortReport
const summary = require('./summary')
-const { moduleList, SEVERITY_RMAP } = require('./util')
+const {
+ moduleList,
+ parseFilterOptions,
+ formatFilterOptions
+} = require('./util')
const {
COLORS,
@@ -13,39 +17,7 @@ const chalk = require('chalk')
const L = console.log
function shortReport (report, whitelist, dir, argv) {
- let filterSecurity = argv ? !!argv.security : false
- let filterCompliance = argv ? !!argv.compliance : false
- let filterLevel = SEVERITY_RMAP.indexOf('NONE')
-
- if (argv.filter) {
- const segments = argv.filter.split(',')
- .map(s => s.trim().toLowerCase())
-
- if (segments.includes('compliance')) {
- filterCompliance = true
- }
- if (segments.includes('security')) {
- filterSecurity = true
- }
- if (segments.includes('c') || segments.includes('critical')) {
- filterLevel = SEVERITY_RMAP.indexOf('CRITICAL')
- }
- if (segments.includes('h') || segments.includes('high')) {
- filterLevel = SEVERITY_RMAP.indexOf('HIGH')
- }
- if (segments.includes('m') || segments.includes('medium')) {
- filterLevel = SEVERITY_RMAP.indexOf('MEDIUM')
- }
- if (segments.includes('l') || segments.includes('low')) {
- filterLevel = SEVERITY_RMAP.indexOf('LOW')
- }
- }
-
- const filterOptions = {
- filterCompliance: filterCompliance,
- filterSecurity: filterSecurity,
- filterLevel: filterLevel
- }
+ const filterOptions = parseFilterOptions(argv)
summary(report, dir, filterOptions)
@@ -55,7 +27,7 @@ function shortReport (report, whitelist, dir, argv) {
L()
}
- if (filterCompliance || filterSecurity || filterLevel > 0) {
+ if (filterOptions.filterCompliance || filterOptions.filterSecurity || filterOptions.filterLevel > 0) {
const filterFormat = formatFilterOptions(filterOptions)
if (whitelist.length > 0) {
moduleList(
@@ -80,20 +52,3 @@ function shortReport (report, whitelist, dir, argv) {
moduleList(report.slice(0, 5), 'Top 5: Highest Risk Modules')
}
}
-
-function formatFilterOptions (filterOptions) {
- let str = '--filter='
- if (filterOptions.filterCompliance) {
- str += 'compliance,'
- }
- if (filterOptions.filterSecurity) {
- str += 'security,'
- }
- if (filterOptions.filterLevel) {
- str += SEVERITY_RMAP[filterOptions.filterLevel] + ','
- }
- if (str[str.length - 1] === ',') {
- str = str.slice(0, str.length - 1)
- }
- return str
-}
diff --git a/lib/report/summary.js b/lib/report/summary.js
index 75d366b..ff59fd4 100644
--- a/lib/report/summary.js
+++ b/lib/report/summary.js
@@ -7,7 +7,7 @@ const {
tooltip
} = require('../ncm-style')
const {
- SEVERITY_RMAP
+ summaryInfo
} = require('./util')
const L = console.log
const chalk = require('chalk')
@@ -19,27 +19,7 @@ function summary (report, dir, filterOptions) {
L(chalk`${report.length} {${COLORS.light1} packages checked}`)
L()
- const riskCount = [0, 0, 0, 0, 0]
- let insecureModules = 0
- let complianceCount = 0
- let securityCount = 0
-
- for (const pkg of report) {
- let insecure = false
- let pkgMaxSeverity = 0
- for (const score of pkg.scores) {
- if (score.group === 'quality') continue
- if (score.group === 'compliance' && !score.pass) complianceCount++
- if (score.group === 'security' && !score.pass) {
- securityCount++
- insecure = true
- }
- const scoreIndex = SEVERITY_RMAP.indexOf(score.severity)
- pkgMaxSeverity = scoreIndex > pkgMaxSeverity ? scoreIndex : pkgMaxSeverity
- }
- riskCount[pkgMaxSeverity]++
- if (insecure) insecureModules++
- }
+ const { riskCount, insecureModules, complianceCount, securityCount } = summaryInfo(report)
L(chalk` {${COLORS.red} ! ${riskCount[4]}} critical risk`)
L(chalk` {${COLORS.orange} ${riskCount[3]}} high risk`)
diff --git a/lib/report/util.js b/lib/report/util.js
index ca65523..f465e2e 100644
--- a/lib/report/util.js
+++ b/lib/report/util.js
@@ -40,7 +40,12 @@ module.exports = {
severityTextLabel,
shortVulnerabilityList,
moduleList,
- moduleSort
+ moduleSort,
+ summaryInfo,
+ filterVulns,
+ filterReport,
+ parseFilterOptions,
+ formatFilterOptions
}
function filterReport (report, options) {
@@ -121,15 +126,7 @@ function moduleList (report, title, options) {
: chalk`{${COLORS.red} X}`
/* security badge */
- const vulns = [0, 0, 0, 0]
- for (const { group, severity } of pkg.failures) {
- if (group === 'security') {
- if (severity === 'CRITICAL') vulns[3]++
- if (severity === 'HIGH') vulns[2]++
- if (severity === 'MEDIUM') vulns[1]++
- if (severity === 'LOW') vulns[0]++
- }
- }
+ const vulns = filterVulns(pkg.failures)
const securityBadges = [
vulns.reduce((a, b) => a + b, 0) === 0
? chalk`{${COLORS.green} ✓} 0` : chalk`{${COLORS.red} X} `,
@@ -254,3 +251,98 @@ function severityTextLabel (severity) {
const color = severity === 'NONE' ? COLORS.base : COLORS.light1
return chalk`{${color} ${severityLabel[severity]}}`
}
+
+function summaryInfo (report) {
+ const riskCount = [0, 0, 0, 0, 0]
+ let insecureModules = 0
+ let complianceCount = 0
+ let securityCount = 0
+
+ for (const pkg of report) {
+ let insecure = false
+ let pkgMaxSeverity = 0
+ for (const score of pkg.scores) {
+ if (score.group === 'quality') continue
+ if (score.group === 'compliance' && !score.pass) complianceCount++
+ if (score.group === 'security' && !score.pass) {
+ securityCount++
+ insecure = true
+ }
+ const scoreIndex = SEVERITY_RMAP.indexOf(score.severity)
+ pkgMaxSeverity = scoreIndex > pkgMaxSeverity ? scoreIndex : pkgMaxSeverity
+ }
+ riskCount[pkgMaxSeverity]++
+ if (insecure) insecureModules++
+ }
+
+ return { riskCount, insecureModules, complianceCount, securityCount }
+}
+
+function filterVulns (failures) {
+ const vulns = [0, 0, 0, 0]
+ for (const { group, severity } of failures) {
+ if (group === 'security') {
+ if (severity === 'CRITICAL') vulns[3]++
+ if (severity === 'HIGH') vulns[2]++
+ if (severity === 'MEDIUM') vulns[1]++
+ if (severity === 'LOW') vulns[0]++
+ }
+ }
+
+ return vulns
+}
+
+function parseFilterOptions (argv) {
+ let filterSecurity = argv ? !!argv.security : false
+ let filterCompliance = argv ? !!argv.compliance : false
+ let filterLevel = SEVERITY_RMAP.indexOf('NONE')
+
+ if (argv.filter) {
+ const segments = argv.filter.split(',')
+ .map(s => s.trim().toLowerCase())
+
+ if (segments.includes('compliance')) {
+ filterCompliance = true
+ }
+ if (segments.includes('security')) {
+ filterSecurity = true
+ }
+ if (segments.includes('c') || segments.includes('critical')) {
+ filterLevel = SEVERITY_RMAP.indexOf('CRITICAL')
+ }
+ if (segments.includes('h') || segments.includes('high')) {
+ filterLevel = SEVERITY_RMAP.indexOf('HIGH')
+ }
+ if (segments.includes('m') || segments.includes('medium')) {
+ filterLevel = SEVERITY_RMAP.indexOf('MEDIUM')
+ }
+ if (segments.includes('l') || segments.includes('low')) {
+ filterLevel = SEVERITY_RMAP.indexOf('LOW')
+ }
+ }
+
+ const filterOptions = {
+ filterCompliance: filterCompliance,
+ filterSecurity: filterSecurity,
+ filterLevel: filterLevel
+ }
+
+ return filterOptions
+}
+
+function formatFilterOptions (filterOptions) {
+ let str = '--filter='
+ if (filterOptions.filterCompliance) {
+ str += 'compliance,'
+ }
+ if (filterOptions.filterSecurity) {
+ str += 'security,'
+ }
+ if (filterOptions.filterLevel) {
+ str += SEVERITY_RMAP[filterOptions.filterLevel] + ','
+ }
+ if (str[str.length - 1] === ',') {
+ str = str.slice(0, str.length - 1)
+ }
+ return str
+}