UNPKG

@testcafe/publish-please

Version:

Safe and highly functional replacement for `npm publish`.

383 lines (362 loc) 13.1 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 = 'ENOLOCK'; /** * 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 */ const vulnerabilities = filteredResponse.vulnerabilities ? filterIgnoredVulnerabilities(filteredResponse.vulnerabilities, ignoredVulnerabilities) : {}; vulnerabilities.forEach(vulnerability => { delete filteredResponse.vulnerabilities[vulnerability.name]; }); return filteredResponse; } catch (error) { if (response) { response.internalErrors = response.internalErrors || []; response.internalErrors.push(error); return response; } return response; } } function filterIgnoredVulnerabilities(vulnerabilities, ignoredVulnerabilities) { const isIgnoredVulnerability = ({ url }) => { return !ignoredVulnerabilities.some(ignoredVulnerability => { return url ? url.indexOf(ignoredVulnerability) > 0 : false; }); }; return Object.keys(vulnerabilities).map(vulnerability => { vulnerabilities[vulnerability].via = vulnerabilities[vulnerability].via.filter(isIgnoredVulnerability); return vulnerabilities[vulnerability]; }).filter(vulnerability => vulnerability.via.length === 0); } /** * 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)); /* prettier-ignore */ const vulnerabilities = filteredResponse.vulnerabilities ? filterIgnoredLevels(filteredResponse.vulnerabilities, filteredLevels) : []; vulnerabilities.forEach(vulnerability => { delete filteredResponse.vulnerabilities[vulnerability]; }); return filteredResponse; } catch (error) { if (response) { response.internalErrors = response.internalErrors || []; response.internalErrors.push(error); return response; } return response; } } function filterIgnoredLevels(vulnerabilities, filteredLevels) { return Object.keys(vulnerabilities).filter(vulnerability => { return filteredLevels.indexOf(vulnerabilities[vulnerability].severity) < 0; }); } /** * exported for testing purposes */ module.exports.removeIgnoredLevels = removeIgnoredLevels;