UNPKG

publish-please

Version:

Safe and highly functional replacement for `npm publish`.

445 lines (397 loc) 15.4 kB
'use strict'; const exec = require('cp-sugar').exec; const pathJoin = require('path').join; const readFile = require('fs').readFileSync; const unlink = require('fs').unlinkSync; const sep = require('path').sep; const tempFolder = require('osenv').tmpdir(); const path = require('path'); // npm audit error codes /** * Missing package-lock.json */ const EAUDITNOLOCK = 'EAUDITNOLOCK'; /** * Audit the project in directory projectDir * @param {(string | undefined)} projectDir - project directory to be analyzed by npm audit */ module.exports = function audit(projectDir) { try { const options = getDefaultOptionsFor(projectDir); process.chdir(options.directoryToAudit); const command = `npm audit --json > ${options.auditLogFilepath}`; return exec(command).then(() => createResponseFromAuditLog(options.auditLogFilepath)).catch(err => createResponseFromAuditLogOrFromError(options.auditLogFilepath, err)).then(response => { if (packageLockHasBeenFound(response)) { return Promise.resolve(response).then(result => removeIgnoredVulnerabilities(result, options)).then(result => removeIgnoredLevels(result, options)); } const createLockFileCommand = `npm i --package-lock-only > ${options.createLockLogFilepath}`; return exec(createLockFileCommand).then(() => exec(command)).then(() => createResponseFromAuditLog(options.auditLogFilepath)).catch(err => createResponseFromAuditLogOrFromError(options.auditLogFilepath, err)).then(result => processResult(result).whenErrorIs(EAUDITNOLOCK)).then(result => removePackageLockFrom(options.directoryToAudit, result)).then(result => removeIgnoredVulnerabilities(result, options)).then(result => removeIgnoredLevels(result, options)); }); } catch (error) { return Promise.reject(error.message); } }; function packageLockHasNotBeenFound(response) { return response && response.error && response.error.code === EAUDITNOLOCK; } function packageLockHasBeenFound(response) { return !packageLockHasNotBeenFound(response); } function createResponseFromAuditLog(logFilePath) { try { const response = JSON.parse(readFile(logFilePath).toString()); return response; } catch (err) { return { error: { summary: err.message } }; } } function createResponseFromAuditLogOrFromError(logFilePath, err) { try { const response = JSON.parse(readFile(logFilePath).toString()); return response; } catch (err2) { // prettier-ignore return { // prettier-ignore error: { summary: err && err.message ? err.message : err2.message } }; } } /** * Middleware that intercept any input error object. * This middleware may modify the inital error message * @param {Object} result - result of the npm audit command */ function processResult(result) { return { whenErrorIs: errorCode => { if (errorCode === EAUDITNOLOCK && packageLockHasNotBeenFound(result)) { const summary = result.error.summary || ''; result.error.summary = `package.json file is missing or is badly formatted. ${summary}`; return result; } return result; } }; } /** * Middleware that removes the auto-generated package-lock.json * @param {string} projectDir - folder where the package-lock.json file has been generated * @param {*} response - result of the npm audit command (eventually modified by previous middlewares execution) * @returns input response if file removal is ok * In case of error it adds or updates into the input response object the property 'internalErrors' */ function removePackageLockFrom(projectDir, response) { try { const file = pathJoin(projectDir, 'package-lock.json'); unlink(file); return response; } catch (error) { if (error.code === 'ENOENT') { return response; } if (response) { response.internalErrors = response.internalErrors || []; response.internalErrors.push(error); return response; } return response; } } /** * exported for testing purposes */ module.exports.removePackageLockFrom = removePackageLockFrom; /** * Remove, from npm audit command response, the vulnerabilities defined in .auditignore file * @param {*} response - result of the npm audit command (eventually modified by previous middlewares execution) * @param {DefaultOptions} options - options */ function removeIgnoredVulnerabilities(response, options) { try { const ignoredVulnerabilities = getIgnoredVulnerabilities(options); if (ignoredVulnerabilities && ignoredVulnerabilities.length === 0) { return response; } const filteredResponse = JSON.parse(JSON.stringify(response, null, 2)); /* prettier-ignore */ filteredResponse.actions = filteredResponse.actions ? filteredResponse.actions.map(action => { action.resolves = action.resolves.filter(resolve => ignoredVulnerabilities.indexOf(`${resolve.id}`) < 0); return action; }).filter(action => action.resolves.length > 0) : []; ignoredVulnerabilities.forEach(ignoredVulnerability => delete filteredResponse.advisories[ignoredVulnerability]); const vulnerabilitiesMetadata = { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }; const severities = {}; const advisories = filteredResponse.advisories; for (const key in advisories) { if (advisories.hasOwnProperty(key)) { const advisory = advisories[key]; severities[`${advisory.id}`] = advisory.severity; } } filteredResponse.actions.forEach(action => { action.resolves.forEach(resolve => { vulnerabilitiesMetadata[severities[`${resolve.id}`]] += 1; }); }); filteredResponse.metadata.vulnerabilities = vulnerabilitiesMetadata; return filteredResponse; } catch (error) { if (response) { response.internalErrors = response.internalErrors || []; response.internalErrors.push(error); return response; } return response; } } /** * exported for testing purposes */ module.exports.removeIgnoredVulnerabilities = removeIgnoredVulnerabilities; /** * @typedef DefaultOptions * @type {Object} * @property {string} directoryToAudit - Folder to audit. Defaults to process.cwd() * @property {string} auditLogFilepath - Path of the file that will receive all output of the npm-audit command. * @property {string} createLockLogFilepath - Path of the file that will receive all output of the 'npm i --package-lock-only' command. */ /** * Get default options of this module * @param {string} projectDir - path to the directory that will be analyzed by npm audit * @returns {DefaultOptions} */ function getDefaultOptionsFor(projectDir) { const directoryToAudit = projectDir || process.cwd(); const projectName = directoryToAudit.split(sep).pop() || ''; const auditLogFilename = `npm-audit-${projectName.trim()}.log`; const auditLogFilepath = path.resolve(tempFolder, auditLogFilename); const createLockLogFilename = `npm-audit-${projectName.trim()}-create-lock.log`; const createLockLogFilepath = path.resolve(tempFolder, createLockLogFilename); return { directoryToAudit, auditLogFilepath, createLockLogFilepath }; } function getIgnoredVulnerabilities(options) { try { const auditIgnoreFile = pathJoin(options.directoryToAudit, '.auditignore'); const content = readFile(auditIgnoreFile).toString(); return content.split(/\n|\r/).filter(vulnerabilityUri => vulnerabilityUri.includes('https://')).map(vulnerabilityUri => vulnerabilityUri.split('/').pop()).map(id => id.trim()); } catch (error) { return []; } } /** * exported for testing purposes */ module.exports.getNpmAuditOptions = getNpmAuditOptions; /** * Get all command-line options defined in the audit.opts file. * This method is for the moment restricted to read the --audit-level option only * @param {DefaultOptions} options - default options of this module * @returns {NpmAuditOptions} */ function getNpmAuditOptions(options) { try { const auditOptionsFile = pathJoin(options.directoryToAudit, 'audit.opts'); const content = readFile(auditOptionsFile).toString(); return content.split(/\n|\r/).filter(commandLineOption => commandLineOption.includes('--audit-level')).map(commandLineOption => commandLineOption.replace(/[\t]/g, ' ')).map(commandLineOption => commandLineOption.trim()).reduce((result, commandLineOption) => { const keyValue = getNpmAuditOptionFrom(commandLineOption); if (keyValue.key) { result[keyValue.key] = keyValue.value; } enforceValidValueForAuditLevelIn(result); return result; }, { '--audit-level': 'low' }); } catch (error) { return defaultNpmAuditOptions; } } /** * @enum {string} */ const auditLevel = { low: 'low', moderate: 'moderate', high: 'high', critical: 'critical' }; /** * exported for testing purposes */ module.exports.auditLevel = auditLevel; /** * @typedef NpmAuditOptions * @type {Object} * @property {string} ['--audit-level'] - audit level option */ /** * Default options used when they are missing in audit.opts file * or when the audit.opts file is missing. * @type {NpmAuditOptions} */ const defaultNpmAuditOptions = { '--audit-level': auditLevel.low }; /** * @typedef KeyValue * @type {Object} * @property {string} key - key part of the key-value object * @property {string} value - value part of the key-value object */ /** * Extract the key and value of an npm-audit command-line option * @param {string} option - npm audit comman-line option * @returns {KeyValue} */ function getNpmAuditOptionFrom(option) { if (!option) { return { key: undefined, value: undefined }; } if (option.includes('=')) { const parts = option.split('=').map(part => part.trim()); return { key: parts[0], value: parts[1] ? parts[1] : true }; } const parts = option.split(' ').filter(part => part && part.length > 0); return { key: parts[0], value: parts[1] ? parts[1] : true }; } function enforceValidValueForAuditLevelIn(npmAuditOptions) { if (npmAuditOptions && npmAuditOptions['--audit-level']) { const value = npmAuditOptions['--audit-level']; // prettier-ignore switch (value) { case auditLevel.low: case auditLevel.moderate: case auditLevel.high: case auditLevel.critical: return; default: npmAuditOptions['--audit-level'] = auditLevel.low; return; } } } /** * Get Audit levels that should be kept in the response given by npm audit command execution * @param {DefaultOptions} options - default options of this module * @returns {[string]} */ function getLevelsToAudit(options) { const auditOptions = getNpmAuditOptions(options); const auditLevelOption = auditOptions && auditOptions['--audit-level'] ? auditOptions['--audit-level'] : auditLevel.low; const filteredLevels = []; // prettier-ignore switch (auditLevelOption) { case auditLevel.critical: filteredLevels.push(auditLevel.critical); break; case auditLevel.high: filteredLevels.push(auditLevel.high); filteredLevels.push(auditLevel.critical); break; case auditLevel.moderate: filteredLevels.push(auditLevel.moderate); filteredLevels.push(auditLevel.high); filteredLevels.push(auditLevel.critical); break; default: filteredLevels.push(auditLevel.low); filteredLevels.push(auditLevel.moderate); filteredLevels.push(auditLevel.high); filteredLevels.push(auditLevel.critical); } return filteredLevels; } /** * exported for testing purposes */ module.exports.getLevelsToAudit = getLevelsToAudit; /** * Remove, from npm audit command response, the vulnerabilities whose level is below the one defined in audit.opts file * @param {*} response - result of the npm audit command (eventually modified by previous middlewares execution) * @param {DefaultOptions} options - options * @returns {*} returns a new response object that is a deep copy of input response minus ignored levels. * when --audit-level=low, this method does nothing and returns input response. */ function removeIgnoredLevels(response, options) { try { const filteredLevels = getLevelsToAudit(options); if (filteredLevels && filteredLevels.indexOf(auditLevel.low) >= 0) { return response; } const filteredResponse = JSON.parse(JSON.stringify(response, null, 2)); const ignoredVulnerabilities = []; const advisories = filteredResponse.advisories || {}; for (const key in advisories) { const advisory = advisories[key]; if (advisory && advisory.severity && advisory.id && filteredLevels.indexOf(advisory.severity) < 0) { ignoredVulnerabilities.push(advisory.id); } } ignoredVulnerabilities.forEach(ignoredVulnerabilityId => { delete filteredResponse.advisories[`${ignoredVulnerabilityId}`]; }); const severities = {}; for (const key in advisories) { const advisory = advisories[key]; if (advisory && advisory.severity && advisory.id) { severities[`${advisory.id}`] = advisory.severity; } } /* prettier-ignore */ filteredResponse.actions = filteredResponse.actions ? filteredResponse.actions.map(action => { action.resolves = action.resolves.filter(resolve => ignoredVulnerabilities.indexOf(resolve.id) < 0); return action; }).filter(action => action.resolves.length > 0) : []; const vulnerabilitiesMetadata = { info: 0, low: 0, moderate: 0, high: 0, critical: 0 }; filteredResponse.actions.forEach(action => { action.resolves.forEach(resolve => { if (resolve && resolve.id) { vulnerabilitiesMetadata[severities[`${resolve.id}`]] += 1; } }); }); filteredResponse.metadata.vulnerabilities = vulnerabilitiesMetadata; return filteredResponse; } catch (error) { if (response) { response.internalErrors = response.internalErrors || []; response.internalErrors.push(error); return response; } return response; } } /** * exported for testing purposes */ module.exports.removeIgnoredLevels = removeIgnoredLevels;