diff --git a/tools/npm_audit/BUILD b/tools/npm_audit/BUILD new file mode 100644 index 0000000..5fccbe1 --- /dev/null +++ b/tools/npm_audit/BUILD @@ -0,0 +1,19 @@ +subinclude("@third_party/subrepos/pleasings//docker") + +filegroup( + name = "npm_audit", + srcs = [ + "index.js", + "package.json", + "package-lock.json", + ], +) + +docker_image( + name = "npm-audit", + srcs = [ + ":npm_audit", + ], + dockerfile = "Dockerfile-tool-npm-audit", + image = "dracon-tool-npm-audit", +) diff --git a/tools/npm_audit/Dockerfile-tool-npm-audit b/tools/npm_audit/Dockerfile-tool-npm-audit new file mode 100644 index 0000000..2534e3d --- /dev/null +++ b/tools/npm_audit/Dockerfile-tool-npm-audit @@ -0,0 +1,15 @@ +FROM node:15-alpine3.12 as node + +RUN mkdir -p /npm-audit + +COPY /index.js /npm-audit/ +COPY /package.json /npm-audit/ +COPY /package-lock.json /npm-audit/ + +RUN apk add -U --no-cache ca-certificates \ + && cd /npm-audit \ + && npm install --production \ + && rm -rf /tmp/v8-compile-cache-* + +WORKDIR / +ENTRYPOINT ["/usr/local/bin/node", "/npm-audit/index.js"] diff --git a/tools/npm_audit/index.js b/tools/npm_audit/index.js new file mode 100644 index 0000000..f51ade7 --- /dev/null +++ b/tools/npm_audit/index.js @@ -0,0 +1,313 @@ +const childProc = require('child_process'); +const filehound = require('filehound'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const spawn = require('await-spawn'); +const util = require('util'); + +const asyncMkdir = util.promisify(fs.mkdir); +const asyncReadFile = util.promisify(fs.readFile); +const asyncWriteFile = util.promisify(fs.writeFile); + +if (process.argv.length != 4) { + console.error('Usage: %s %s [SRC_DIR] [DEST_DIR]', process.argv[0], process.argv[1]); + process.exit(1); +} + +const srcDir = process.argv[2]; +const destDir = process.argv[3]; + +const metadataFiles = [ + 'npm-shrinkwrap.json', + 'package.json', + 'package-lock.json', + 'yarn.lock' +]; + +const npmEnvironment = { + 'npm_config_audit': 'false', + 'npm_config_fund': 'false', + 'npm_config_loglevel': 'warn', + 'npm_config_update_notifier': 'false', +}; + +async function yarnAudit(packageDir) { + // A bug in yarn currently makes `yarn audit --json` unusable + // (https://github.com/yarnpkg/yarn/issues/7404), so we need to extract the + // registry's audit report from `yarn audit --verbose` manually instead + const yarn = childProc.spawn( + 'yarn', + ['audit', '--verbose', '--groups', 'dependencies,optionalDependencies,peerDependencies'], + { + cwd: packageDir, + stdio: ['ignore', 'pipe', process.stderr] + } + ); + + const yarnReader = require('readline').createInterface({ + input: yarn.stdout, + terminal: false + }); + + var reading = false; + var report; + + for await (const line of yarnReader) { + var match = line.match(/^verbose [\d.]+ Audit Response: (?.*)/); + if (match) { + report = match.groups.start; + reading = true; + } else if (reading) { + report += line; + + if (line == '}') { + break; + } + } + } + + return report ? JSON.parse(report) : null; +} + +async function npmAudit(packageDir) { + // `npm audit` exits with a non-zero return code if vulnerabilities are + // reported - although the minimum vulnerability severity required to + // trigger this behaviour is configurable, it can't be disabled altogether, + // preventing us from checking the return code to see whether the audit + // succeeded + const npm = childProc.spawn( + 'npm', + ['audit', '--json', '--only=prod'], + { + cwd: packageDir, + env: npmEnvironment, + stdio: ['ignore', 'pipe', process.stderr] + } + ); + + const npmReader = require('readline').createInterface({ + input: npm.stdout, + terminal: false + }); + + var report = ''; + + for await (const line of npmReader) { + report += line; + } + + return report ? JSON.parse(report) : null; +} + +async function npmInstall(packageDir) { + try { + var packageJSON = JSON.parse( + await asyncReadFile(path.join(packageDir, 'package.json'), { + encoding: 'utf8' + }) + ); + } catch (err) { + console.error(`${packageDir}: failed to read package.json: ${err}`); + return 0; + } + + var packageModified = false; + var installed = 0; + + // `npm audit` will fail if package.json defines any devDependencies that + // aren't listed in package-lock.json, even if we tell it to only audit the + // production dependencies - since we're not going to install the + // devDependencies, remove that section from package.json altogether + if ('devDependencies' in packageJSON) { + delete packageJSON.devDependencies; + packageModified = true; + } + + for (const list of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + if (!(list in packageJSON)) { + continue; + } + + console.log(`${packageDir}: installing packages in category '${list}'`); + + for (const [name, version] of Object.entries(packageJSON[list])) { + console.log(`${packageDir}: installing package: ${name}@${version}`); + + try { + await spawn( + 'npm', + [ + 'install', '--package-lock-only', '--only=prod', '--legacy-peer-deps', + name + '@' + version + ], + { + cwd: packageDir, + env: npmEnvironment, + stdio: ['ignore', 'ignore', process.stderr], + } + ); + + installed++; + } catch (err) { + // If the package installation failed, the package needs to be + // removed from the dependencies list, otherwise `npm audit` + // will later fail on the basis that not all dependencies in + // package.json are installed + console.error(`${packageDir}: failed to install package ${name}@${version}; removing from ${list}`); + delete packageJSON[list][name]; + packageModified = true; + } + } + } + + if (packageModified) { + try { + await asyncWriteFile( + path.join(packageDir, 'package.json'), + JSON.stringify(packageJSON, null, 2), + { encoding: 'utf8' } + ); + } catch (err) { + console.error(`${packageDir}: failed to write package.json: ${err}`); + return 0; + } + } + + return installed; +} + +function auditSummary(report, summaryIntro) { + const levels = ['critical', 'high', 'moderate', 'low', 'info']; + + const detail = levels.reduce((acc, level) => { + acc.output.push(report.metadata.vulnerabilities[level] + ' ' + level); + acc.total += report.metadata.vulnerabilities[level]; + + return acc; + }, { output: [], total: 0 }); + + console.log(`${summaryIntro}: ${detail.total} (` + detail.output.join(', ') + ')'); + + return detail.total; +} + +(async function () { + try { + asyncMkdir(destDir, { recursive: true }); + } catch (err) { + console.error(`Could not create output directory ${destDir}: ${err}`); + process.exit(1); + } + + const packageDirs = filehound.create() + .paths(srcDir) + .match(['package.json']) + .discard('node_modules') + .findSync() + .map(file => { + console.info(`Found package file: ${file}`); + + var relDir = path.dirname(path.relative(srcDir, file)); + var relDestDir = path.join(destDir, relDir); + + // Mirror package metadata files into an equivalent directory structure + // beneath DEST_DIR + fs.mkdirSync(relDestDir, { recursive: true }); + metadataFiles.forEach(meta => { + const metaSrc = path.join(srcDir, relDir, meta); + const metaDest = path.join(relDestDir, meta); + + if (fs.existsSync(metaSrc)) { + fs.copyFileSync(metaSrc, metaDest); + } + }); + + return relDestDir; + }); + + console.log('Auditing found packages for vulnerable dependencies'); + + for (const dir of packageDirs) { + // If both npm and yarn lock files exist for this package, audit both, + // since either could be used during deployment of the package + var audited = false; + + if (fs.existsSync(path.join(dir, 'yarn.lock'))) { + console.info(`${dir}: auditing yarn-based dependencies`); + + const report = await yarnAudit(dir); + if (report) { + const total = auditSummary(report, `${dir}: advisories for yarn-based dependencies`); + if (total > 0) { + try { + await asyncWriteFile( + path.join(dir, 'package.yarn-audit'), + JSON.stringify(report), + { encoding: 'utf8' } + ); + } catch (err) { + console.error(`${packageDir}: failed to write package.yarn-audit: ${err}`); + } + } + } + + audited = true; + } + + if ( + fs.existsSync(path.join(dir, 'npm-shrinkwrap.json')) + || fs.existsSync(path.join(dir, 'package-lock.json')) + ) { + console.info(`${dir}: auditing npm-based dependencies`); + + const report = await npmAudit(dir); + if (report) { + const total = auditSummary(report, `${dir}: advisories for npm-based dependencies`); + if (total > 0) { + try { + await asyncWriteFile( + path.join(dir, 'package.npm-audit'), + JSON.stringify(report), + { encoding: 'utf8' } + ); + } catch (err) { + console.error(`${packageDir}: failed to write package.npm-audit: ${err}`); + } + } + } + + audited = true; + } + + // If we haven't audited the package at all yet, install dependency + // metadata with npm using the information in package.json and audit + // that - installation isn't guaranteed to succeed (e.g. when the + // package is OS/architecture-specific and doesn't support the OS/ + // architecture we're currently running on), so install them + // individually and we'll audit whatever we can + if (!audited) { + console.info(`${dir}: installing dependencies`); + if (await npmInstall(dir) > 0) { + console.info(`${dir}: auditing dependencies`); + const report = await npmAudit(dir); + if (report) { + const total = auditSummary(report, `${dir}: advisories for dependencies`); + if (total > 0) { + try { + await asyncWriteFile( + path.join(dir, 'package.npm-audit'), + JSON.stringify(report), + { encoding: 'utf8' } + ); + } catch (err) { + console.error(`${packageDir}: failed to write package.npm-audit: ${err}`); + } + } + } + } else { + console.info(`${dir}: no dependencies installed; skipping audit`); + } + } + } +})(); diff --git a/tools/npm_audit/package-lock.json b/tools/npm_audit/package-lock.json new file mode 100644 index 0000000..d4120d3 --- /dev/null +++ b/tools/npm_audit/package-lock.json @@ -0,0 +1,182 @@ +{ + "name": "npm_audit", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "await-spawn": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/await-spawn/-/await-spawn-4.0.1.tgz", + "integrity": "sha512-cQSpdH79ktTdsMjUuUvyhdIYbXArynlV5jvHY8FPWXdwF5UGyrVaHCQxo/Iw5DbSQx2Ha3EOS+cy41sup+AkiQ==", + "requires": { + "bl": "^4.0.3" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bl": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", + "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "err-code": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-1.1.2.tgz", + "integrity": "sha1-BuARbTAo9q70gGhJ6w6mp0iuaWA=" + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "file-js": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/file-js/-/file-js-0.3.0.tgz", + "integrity": "sha1-+rRr94I0bJKUSZ8fDSrQfYOPJdE=", + "requires": { + "bluebird": "^3.4.7", + "minimatch": "^3.0.3", + "proper-lockfile": "^1.2.0" + } + }, + "filehound": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/filehound/-/filehound-1.17.4.tgz", + "integrity": "sha512-A74hiTADH20bpFbXBNyKtpqN4Guffa+ROmdGJWNnuCRhaD45UVSVoI6McLcpHYmuaOERrzD3gMV3v9VZq/SHeA==", + "requires": { + "bluebird": "^3.5.1", + "file-js": "0.3.0", + "lodash": "^4.17.10", + "minimatch": "^3.0.4", + "moment": "^2.22.1", + "unit-compare": "^1.0.1" + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, + "proper-lockfile": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-1.2.0.tgz", + "integrity": "sha1-zv9d2J0+XxD7deHo52vHWAGlnDQ=", + "requires": { + "err-code": "^1.0.0", + "extend": "^3.0.0", + "graceful-fs": "^4.1.2", + "retry": "^0.10.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "retry": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", + "integrity": "sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q=" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "unit-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unit-compare/-/unit-compare-1.0.1.tgz", + "integrity": "sha1-DHRZ8OW/U2N+qHPKPO4Y3i7so4Y=", + "requires": { + "moment": "^2.14.1" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + } + } +} diff --git a/tools/npm_audit/package.json b/tools/npm_audit/package.json new file mode 100644 index 0000000..93f4de9 --- /dev/null +++ b/tools/npm_audit/package.json @@ -0,0 +1,13 @@ +{ + "name": "npm_audit", + "version": "0.1.0", + "description": "A Dracon tool container for 'npm audit' and 'yarn audit'", + "main": "index.js", + "scripts": {}, + "author": "Chris Novakovic", + "license": "ISC", + "dependencies": { + "await-spawn": "^4.0.1", + "filehound": "^1.17.4" + } +}