From 3ae53e4623e9670d828e97c38648b5865254b842 Mon Sep 17 00:00:00 2001 From: Philip Harrison Date: Fri, 29 Apr 2022 14:35:57 +0100 Subject: [PATCH] feat: add npm audit signatures Implemenents [RFC: Improve signature verification](https://github.com/npm/rfcs/pull/550/) Adds a new sub-command to `audit`: `npm audit signatures` (following [`npm audit licenses`](https://github.com/npm/cli/pull/3452)) This command will verify registry signatures stored in the packument against a public key on the registry. Supporting: - Any registry that implements `host/-/npm/v1/keys` endpoint and provides `signatures` in the packument `dist` object - Validates public keys are not expired - Errors when encountering packages with missing signatures when the registry returns keys at `host/-/npm/v1/keys` - Errors when encountering invalid signatures - Output: json/human formats Co-authored-by: Michael Garvin --- lib/commands/audit.js | 382 ++++- package-lock.json | 5 +- package.json | 2 + .../test/lib/commands/audit.js.test.cjs | 227 +++ test/lib/commands/audit.js | 1464 ++++++++++++++++- 5 files changed, 2071 insertions(+), 9 deletions(-) diff --git a/lib/commands/audit.js b/lib/commands/audit.js index 08d011d831875..352c0e2974466 100644 --- a/lib/commands/audit.js +++ b/lib/commands/audit.js @@ -1,8 +1,336 @@ const Arborist = require('@npmcli/arborist') const auditReport = require('npm-audit-report') -const reifyFinish = require('../utils/reify-finish.js') -const auditError = require('../utils/audit-error.js') +const fetch = require('npm-registry-fetch') +const localeCompare = require('@isaacs/string-locale-compare')('en') +const npa = require('npm-package-arg') +const pacote = require('pacote') +const pMap = require('p-map') + const ArboristWorkspaceCmd = require('../arborist-cmd.js') +const auditError = require('../utils/audit-error.js') +const log = require('../utils/log-shim.js') +const reifyFinish = require('../utils/reify-finish.js') + +const sortAlphabetically = (a, b) => localeCompare(a.name, b.name) + +class VerifySignatures { + constructor (tree, filterSet, npm, opts) { + this.tree = tree + this.filterSet = filterSet + this.npm = npm + this.opts = opts + this.keys = new Map() + this.invalid = [] + this.missing = [] + this.checkedPackages = new Set() + this.auditedWithKeysCount = 0 + this.verifiedCount = 0 + this.output = [] + this.exitCode = 0 + } + + async run () { + const start = process.hrtime.bigint() + + // Find all deps in tree + const { edges, registries } = this.getEdgesOut(this.tree.inventory.values(), this.filterSet) + if (edges.size === 0) { + throw new Error('found no installed dependencies to audit') + } + + await Promise.all([...registries].map(registry => this.setKeys({ registry }))) + + const progress = log.newItem('verifying registry signatures', edges.size) + const mapper = async (edge) => { + progress.completeWork(1) + await this.getVerifiedInfo(edge) + } + await pMap(edges, mapper, { concurrency: 20, stopOnError: true }) + + // Didn't find any dependencies that could be verified, e.g. only local + // deps, missing version, not on a registry etc. + if (!this.auditedWithKeysCount) { + throw new Error('found no dependencies to audit that where installed from ' + + 'a supported registry') + } + + const invalid = this.invalid.sort(sortAlphabetically) + const missing = this.missing.sort(sortAlphabetically) + + const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0 + + if (!hasNoInvalidOrMissing) { + this.exitCode = 1 + } + + if (this.npm.config.get('json')) { + this.appendOutput(JSON.stringify({ + invalid: this.makeJSON(invalid), + missing: this.makeJSON(missing), + }, null, 2)) + return + } + const end = process.hrtime.bigint() + const elapsed = end - start + + const auditedPlural = this.auditedWithKeysCount > 1 ? 's' : '' + const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` + + `${Math.floor(Number(elapsed) / 1e9)}s` + this.appendOutput(`${timing}\n`) + + if (this.verifiedCount) { + const verifiedBold = this.npm.chalk.bold('verified') + const msg = this.verifiedCount === 1 ? + `${this.verifiedCount} package has a ${verifiedBold} registry signature\n` : + `${this.verifiedCount} packages have ${verifiedBold} registry signatures\n` + this.appendOutput(msg) + } + + if (missing.length) { + const missingClr = this.npm.chalk.bold(this.npm.chalk.red('missing')) + const msg = missing.length === 1 ? + `package has a ${missingClr} registry signature` : + `packages have ${missingClr} registry signatures` + this.appendOutput( + `${missing.length} ${msg} but the registry is ` + + `providing signing keys:\n` + ) + this.appendOutput(this.humanOutput(missing)) + } + + if (invalid.length) { + const invalidClr = this.npm.chalk.bold(this.npm.chalk.red('invalid')) + const msg = invalid.length === 1 ? + `${invalid.length} package has an ${invalidClr} registry signature:\n` : + `${invalid.length} packages have ${invalidClr} registry signatures:\n` + this.appendOutput( + `${missing.length ? '\n' : ''}${msg}` + ) + this.appendOutput(this.humanOutput(invalid)) + const tamperMsg = invalid.length === 1 ? + `\nSomeone might have tampered with this package since it was ` + + `published on the registry!\n` : + `\nSomeone might have tampered with these packages since they where ` + + `published on the registry!\n` + this.appendOutput(tamperMsg) + } + } + + appendOutput (...args) { + this.output.push(...args.flat()) + } + + report () { + return { report: this.output.join('\n'), exitCode: this.exitCode } + } + + getEdgesOut (nodes, filterSet) { + const edges = new Set() + const registries = new Set() + for (const node of nodes) { + for (const edge of node.edgesOut.values()) { + const filteredOut = + edge.from + && filterSet + && filterSet.size > 0 + && !filterSet.has(edge.from.target) + + if (!filteredOut) { + const spec = this.getEdgeSpec(edge) + if (spec) { + // Prefetch and cache public keys from used registries + registries.add(this.getSpecRegistry(spec)) + } + edges.add(edge) + } + } + } + return { edges, registries } + } + + async setKeys ({ registry }) { + const keys = await fetch.json('/-/npm/v1/keys', { + ...this.npm.flatOptions, + registry, + }).then(({ keys }) => keys.map((key) => ({ + ...key, + pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`, + }))).catch(err => { + if (err.code === 'E404') { + return null + } else { + throw err + } + }) + if (keys) { + this.keys.set(registry, keys) + } + } + + getEdgeType (edge) { + return edge.optional ? 'optionalDependencies' + : edge.peer ? 'peerDependencies' + : edge.dev ? 'devDependencies' + : 'dependencies' + } + + getEdgeSpec (edge) { + let name = edge.name + try { + name = npa(edge.spec).subSpec.name + } catch (_) { + } + try { + return npa(`${name}@${edge.spec}`) + } catch (_) { + // Skip packages with invalid spec + } + } + + buildRegistryConfig (registry) { + const keys = this.keys.get(registry) || [] + const parsedRegistry = new URL(registry) + const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}` + return { + [`${regKey}:_keys`]: keys, + } + } + + getSpecRegistry (spec) { + return fetch.pickRegistry(spec, this.npm.flatOptions) + } + + getValidPackageInfo (edge) { + const type = this.getEdgeType(edge) + // Skip potentially optional packages that are not on disk, as these could + // be omitted during install + if (edge.error === 'MISSING' && type !== 'dependencies') { + return + } + + const spec = this.getEdgeSpec(edge) + // Skip invalid version requirements + if (!spec) { + return + } + const node = edge.to || edge + const { version } = node.package || {} + + if (node.isWorkspace || // Skip local workspaces packages + !version || // Skip packages that don't have a installed version, e.g. optonal dependencies + !spec.registry) { // Skip if not from registry, e.g. git package + return + } + + for (const omitType of this.npm.config.get('omit')) { + if (node[omitType]) { + return + } + } + + return { + name: spec.name, + version, + type, + location: node.location, + registry: this.getSpecRegistry(spec), + } + } + + async verifySignatures (name, version, registry) { + const { + _integrity: integrity, + _signatures, + _resolved: resolved, + } = await pacote.manifest(`${name}@${version}`, { + verifySignatures: true, + ...this.buildRegistryConfig(registry), + ...this.npm.flatOptions, + }) + const signatures = _signatures || [] + return { + integrity, + signatures, + resolved, + } + } + + async getVerifiedInfo (edge) { + const info = this.getValidPackageInfo(edge) + if (!info) { + return + } + const { name, version, location, registry, type } = info + if (this.checkedPackages.has(location)) { + // we already did or are doing this one + return + } + this.checkedPackages.add(location) + + // We only "audit" or verify the signature, or the presence of it, on + // packages whose registry returns signing keys + const keys = this.keys.get(registry) || [] + if (keys.length) { + this.auditedWithKeysCount += 1 + } + + try { + const { integrity, signatures, resolved } = await this.verifySignatures( + name, version, registry + ) + + // Currently we only care about missing signatures on registries that provide a public key + // We could make this configurable in the future with a strict/paranoid mode + if (signatures.length) { + this.verifiedCount += 1 + } else if (keys.length) { + this.missing.push({ + name, + version, + location, + resolved, + integrity, + registry, + }) + } + } catch (e) { + if (e.code === 'EINTEGRITYSIGNATURE') { + const { signature, keyid, integrity, resolved } = e + this.invalid.push({ + name, + type, + version, + resolved, + location, + integrity, + registry, + signature, + keyid, + }) + } else { + throw e + } + } + } + + humanOutput (list) { + return list.map(v => + `${this.npm.chalk.red(`${v.name}@${v.version}`)} (${v.registry})` + ).join('\n') + } + + makeJSON (deps) { + return deps.map(d => ({ + name: d.name, + version: d.version, + location: d.location, + resolved: d.resolved, + integrity: d.integrity, + signature: d.signature, + keyid: d.keyid, + })) + } +} class Audit extends ArboristWorkspaceCmd { static description = 'Run a security audit' @@ -32,11 +360,21 @@ class Audit extends ArboristWorkspaceCmd { case 'fix': return [] default: - throw new Error(argv[2] + ' not recognized') + throw Object.assign(new Error(argv[2] + ' not recognized'), { + code: 'EUSAGE', + }) } } async exec (args) { + if (args[0] === 'signatures') { + await this.auditSignatures() + } else { + await this.auditAdvisories(args) + } + } + + async auditAdvisories (args) { const reporter = this.npm.config.get('json') ? 'json' : 'detail' const opts = { ...this.npm.flatOptions, @@ -59,6 +397,44 @@ class Audit extends ArboristWorkspaceCmd { this.npm.output(result.report) } } + + async auditSignatures () { + if (this.npm.global) { + throw Object.assign( + new Error('`npm audit signatures` does not support global packages'), { + code: 'EAUDITGLOBAL', + } + ) + } + + log.verbose('loading installed dependencies') + const opts = { + ...this.npm.flatOptions, + path: this.npm.prefix, + workspaces: this.workspaceNames, + } + + const arb = new Arborist(opts) + const tree = await arb.loadActual() + let filterSet = new Set() + if (opts.workspaces && opts.workspaces.length) { + filterSet = + arb.workspaceDependencySet( + tree, + opts.workspaces, + this.npm.flatOptions.includeWorkspaceRoot + ) + } else if (!this.npm.flatOptions.workspacesEnabled) { + filterSet = + arb.excludeWorkspacesDependencySet(tree) + } + + const verify = new VerifySignatures(tree, filterSet, this.npm, { ...opts }) + await verify.run() + const result = verify.report() + process.exitCode = process.exitCode || result.exitCode + this.npm.output(result.report) + } } module.exports = Audit diff --git a/package-lock.json b/package-lock.json index 54416be8b80a3..dcd0dbb6a5b31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "npm-user-validate", "npmlog", "opener", + "p-map", "pacote", "parse-conflict-json", "proc-log", @@ -138,6 +139,7 @@ "npm-user-validate": "^1.0.1", "npmlog": "^6.0.2", "opener": "^1.5.2", + "p-map": "^4.0.0", "pacote": "^13.6.1", "parse-conflict-json": "^2.0.2", "proc-log": "^2.0.1", @@ -5526,8 +5528,9 @@ }, "node_modules/p-map": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "inBundle": true, - "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, diff --git a/package.json b/package.json index a9d84ab62ce15..50818981c16dc 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "npm-user-validate": "^1.0.1", "npmlog": "^6.0.2", "opener": "^1.5.2", + "p-map": "^4.0.0", "pacote": "^13.6.1", "parse-conflict-json": "^2.0.2", "proc-log": "^2.0.1", @@ -179,6 +180,7 @@ "npm-user-validate", "npmlog", "opener", + "p-map", "pacote", "parse-conflict-json", "proc-log", diff --git a/tap-snapshots/test/lib/commands/audit.js.test.cjs b/tap-snapshots/test/lib/commands/audit.js.test.cjs index c3680933e6a79..3e7658c14bb19 100644 --- a/tap-snapshots/test/lib/commands/audit.js.test.cjs +++ b/tap-snapshots/test/lib/commands/audit.js.test.cjs @@ -41,6 +41,233 @@ added 1 package, and audited 2 packages in xxx found 0 vulnerabilities ` +exports[`test/lib/commands/audit.js TAP audit signatures ignores optional dependencies > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a verified registry signature + +` + +exports[`test/lib/commands/audit.js TAP audit signatures json output with invalid and missing signatures > must match snapshot 1`] = ` +{ + "invalid": [ + { + "name": "kms-demo", + "version": "1.0.0", + "location": "node_modules/kms-demo", + "resolved": "https://registry.npmjs.org/kms-demo/-/kms-demo-1.0.0.tgz", + "integrity": "sha512-QqZ7VJ/8xPkS9s2IWB7Shj3qTJdcRyeXKbPQnsZjsPEwvutGv0EGeVchPcauoiDFJlGbZMFq5GDCurAGNSghJQ==", + "signature": "bogus", + "keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA" + } + ], + "missing": [ + { + "name": "async", + "version": "1.1.1", + "location": "node_modules/async", + "resolved": "https://registry.npmjs.org/async/-/async-1.1.1.tgz" + } + ] +} +` + +exports[`test/lib/commands/audit.js TAP audit signatures json output with invalid signatures > must match snapshot 1`] = ` +{ + "invalid": [ + { + "name": "kms-demo", + "version": "1.0.0", + "location": "node_modules/kms-demo", + "resolved": "https://registry.npmjs.org/kms-demo/-/kms-demo-1.0.0.tgz", + "integrity": "sha512-QqZ7VJ/8xPkS9s2IWB7Shj3qTJdcRyeXKbPQnsZjsPEwvutGv0EGeVchPcauoiDFJlGbZMFq5GDCurAGNSghJQ==", + "signature": "bogus", + "keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA" + } + ], + "missing": [] +} +` + +exports[`test/lib/commands/audit.js TAP audit signatures json output with valid signatures > must match snapshot 1`] = ` +{ + "invalid": [], + "missing": [] +} +` + +exports[`test/lib/commands/audit.js TAP audit signatures multiple registries with keys and signatures > must match snapshot 1`] = ` +audited 2 packages in xxx + +2 packages have verified registry signatures + +` + +exports[`test/lib/commands/audit.js TAP audit signatures omit dev dependencies with missing signature > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a verified registry signature + +` + +exports[`test/lib/commands/audit.js TAP audit signatures output details about missing signatures > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a missing registry signature but the registry is providing signing keys: + +kms-demo@1.0.0 (https://registry.npmjs.org/) +` + +exports[`test/lib/commands/audit.js TAP audit signatures third-party registry with invalid signatures errors > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has an invalid registry signature: + +@npmcli/arborist@1.0.14 (https://verdaccio-clone.org) + +Someone might have tampered with this package since it was published on the registry! + +` + +exports[`test/lib/commands/audit.js TAP audit signatures third-party registry with keys and missing signatures errors > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a missing registry signature but the registry is providing signing keys: + +@npmcli/arborist@1.0.14 (https://verdaccio-clone.org) +` + +exports[`test/lib/commands/audit.js TAP audit signatures third-party registry with keys and signatures > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a verified registry signature + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with both invalid and missing signatures > must match snapshot 1`] = ` +audited 2 packages in xxx + +1 package has a missing registry signature but the registry is providing signing keys: + +async@1.1.1 (https://registry.npmjs.org/) + +1 package has an invalid registry signature: + +kms-demo@1.0.0 (https://registry.npmjs.org/) + +Someone might have tampered with this package since it was published on the registry! + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with bundled and peer deps and no signatures > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a verified registry signature + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with invalid signatures > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has an invalid registry signature: + +kms-demo@1.0.0 (https://registry.npmjs.org/) + +Someone might have tampered with this package since it was published on the registry! + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with invalid signtaures and color output enabled > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has an invalid registry signature: + +kms-demo@1.0.0 (https://registry.npmjs.org/) + +Someone might have tampered with this package since it was published on the registry! + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with keys but missing signature > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a missing registry signature but the registry is providing signing keys: + +kms-demo@1.0.0 (https://registry.npmjs.org/) +` + +exports[`test/lib/commands/audit.js TAP audit signatures with multiple invalid signatures > must match snapshot 1`] = ` +audited 2 packages in xxx + +2 packages have invalid registry signatures: + +async@1.1.1 (https://registry.npmjs.org/) +kms-demo@1.0.0 (https://registry.npmjs.org/) + +Someone might have tampered with these packages since they where published on the registry! + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with multiple missing signatures > must match snapshot 1`] = ` +audited 2 packages in xxx + +2 packages have missing registry signatures but the registry is providing signing keys: + +async@1.1.1 (https://registry.npmjs.org/) +kms-demo@1.0.0 (https://registry.npmjs.org/) +` + +exports[`test/lib/commands/audit.js TAP audit signatures with multiple valid signatures and one invalid > must match snapshot 1`] = ` +audited 3 packages in xxx + +2 packages have verified registry signatures + +1 package has an invalid registry signature: + +node-fetch@1.6.0 (https://registry.npmjs.org/) + +Someone might have tampered with this package since it was published on the registry! + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with valid and missing signatures > must match snapshot 1`] = ` +audited 2 packages in xxx + +1 package has a verified registry signature + +1 package has a missing registry signature but the registry is providing signing keys: + +async@1.1.1 (https://registry.npmjs.org/) +` + +exports[`test/lib/commands/audit.js TAP audit signatures with valid signatures > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a verified registry signature + +` + +exports[`test/lib/commands/audit.js TAP audit signatures with valid signatures using alias > must match snapshot 1`] = ` +audited 1 package in xxx + +1 package has a verified registry signature + +` + +exports[`test/lib/commands/audit.js TAP audit signatures workspaces verifies registry deps and ignores local workspace deps > must match snapshot 1`] = ` +audited 3 packages in xxx + +3 packages have verified registry signatures + +` + +exports[`test/lib/commands/audit.js TAP audit signatures workspaces verifies registry deps when filtering by workspace name > must match snapshot 1`] = ` +audited 2 packages in xxx + +2 packages have verified registry signatures + +` + exports[`test/lib/commands/audit.js TAP fallback audit > must match snapshot 1`] = ` # npm audit report diff --git a/test/lib/commands/audit.js b/test/lib/commands/audit.js index da6de4774e6b8..b6c6c77a2b40a 100644 --- a/test/lib/commands/audit.js +++ b/test/lib/commands/audit.js @@ -1,14 +1,15 @@ +const fs = require('fs') +const zlib = require('zlib') +const path = require('path') const t = require('tap') const { load: loadMockNpm } = require('../../fixtures/mock-npm') const MockRegistry = require('../../fixtures/mock-registry.js') -const zlib = require('zlib') -const gzip = zlib.gzipSync + const gunzip = zlib.gunzipSync -const path = require('path') -const fs = require('fs') +const gzip = zlib.gzipSync -t.cleanSnapshot = str => str.replace(/packages in [0-9]+[a-z]+/g, 'packages in xxx') +t.cleanSnapshot = str => str.replace(/package(s)? in [0-9]+[a-z]+/g, 'package$1 in xxx') const tree = { 'package.json': JSON.stringify({ @@ -236,3 +237,1456 @@ t.test('completion', async t => { }) }) }) + +t.test('audit signatures', async t => { + const VALID_REGISTRY_KEYS = { + keys: [{ + expires: null, + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + } + + const MISMATCHING_REGISTRY_KEYS = { + keys: [{ + expires: null, + keyid: 'SHA256:2l3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + } + + const EXPIRED_REGISTRY_KEYS = { + keys: [{ + expires: '2021-01-11T15:45:42.144Z', + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + } + + const installWithValidSigs = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + 'kms-demo': '1.0.0', + }, + }), + node_modules: { + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'scratch', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + }, + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + }, + dependencies: { + 'kms-demo': { + version: '1.0.0', + }, + }, + }), + } + + const installWithAlias = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + get: 'npm:node-fetch@^1.0.0', + }, + }), + node_modules: { + get: { + 'package.json': JSON.stringify({ + name: 'node-fetch', + version: '1.7.1', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'test-dep', + version: '1.0.0', + dependencies: { + get: 'npm:node-fetch@^1.0.0', + }, + }, + 'node_modules/demo': { + name: 'node-fetch', + version: '1.7.1', + }, + }, + dependencies: { + get: { + version: 'npm:node-fetch@1.7.1', + }, + }, + }), + } + + const noInstall = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + 'kms-demo': '1.0.0', + }, + }), + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'scratch', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + }, + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + }, + dependencies: { + 'kms-demo': { + version: '1.0.0', + }, + }, + }), + } + + const workspaceInstall = { + 'package.json': JSON.stringify({ + name: 'workspaces-project', + version: '1.0.0', + workspaces: ['packages/*'], + dependencies: { + 'kms-demo': '^1.0.0', + }, + }), + node_modules: { + a: t.fixture('symlink', '../packages/a'), + b: t.fixture('symlink', '../packages/b'), + c: t.fixture('symlink', '../packages/c'), + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + async: { + 'package.json': JSON.stringify({ + name: 'async', + version: '2.5.0', + }), + }, + 'light-cycle': { + 'package.json': JSON.stringify({ + name: 'light-cycle', + version: '1.4.2', + }), + }, + }, + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + dependencies: { + b: '^1.0.0', + async: '^2.0.0', + }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.0.0', + dependencies: { + 'light-cycle': '^1.0.0', + }, + }), + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '1.0.0', + }), + }, + }, + } + + const installWithMultipleDeps = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + }, + devDependencies: { + async: '~1.1.0', + }, + }), + node_modules: { + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + async: { + 'package.json': JSON.stringify({ + name: 'async', + version: '1.1.1', + dependencies: { + 'kms-demo': '^1.0.0', + }, + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'scratch', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + }, + devDependencies: { + async: '~1.0.0', + }, + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + 'node_modules/async': { + version: '1.1.1', + }, + }, + dependencies: { + 'kms-demo': { + version: '1.0.0', + }, + async: { + version: '1.1.1', + dependencies: { + 'kms-demo': '^1.0.0', + }, + }, + }, + }), + } + + const installWithPeerDeps = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + peerDependencies: { + 'kms-demo': '^1.0.0', + }, + }), + node_modules: { + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'scratch', + version: '1.0.0', + peerDependencies: { + 'kms-demo': '^1.0.0', + }, + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + }, + dependencies: { + 'kms-demo': { + version: '1.0.0', + }, + }, + }), + } + + const installWithOptionalDeps = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + }, + optionalDependencies: { + lorem: '^1.0.0', + }, + }, null, 2), + node_modules: { + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'scratch', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + }, + optionalDependencies: { + lorem: '^1.0.0', + }, + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + }, + dependencies: { + 'kms-demo': { + version: '1.0.0', + }, + }, + }), + } + + const installWithMultipleRegistries = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + '@npmcli/arborist': '^1.0.0', + 'kms-demo': '^1.0.0', + }, + }), + node_modules: { + '@npmcli/arborist': { + 'package.json': JSON.stringify({ + name: '@npmcli/arborist', + version: '1.0.14', + }), + }, + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'test-dep', + version: '1.0.0', + dependencies: { + '@npmcli/arborist': '^1.0.0', + 'kms-demo': '^1.0.0', + }, + }, + 'node_modules/@npmcli/arborist': { + version: '1.0.14', + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + }, + dependencies: { + '@npmcli/arborist': { + version: '1.0.14', + }, + 'kms-demo': { + version: '1.0.0', + }, + }, + }), + } + + const installWithThirdPartyRegistry = { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + '@npmcli/arborist': '^1.0.0', + }, + }), + node_modules: { + '@npmcli/arborist': { + 'package.json': JSON.stringify({ + name: '@npmcli/arborist', + version: '1.0.14', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'test-dep', + version: '1.0.0', + dependencies: { + '@npmcli/arborist': '^1.0.0', + }, + }, + 'node_modules/@npmcli/arborist': { + version: '1.0.14', + }, + }, + dependencies: { + '@npmcli/arborist': { + version: '1.0.14', + }, + }, + }), + } + + async function manifestWithValidSigs ({ registry }) { + const manifest = registry.manifest({ + name: 'kms-demo', + packuments: [{ + version: '1.0.0', + dist: { + tarball: 'https://registry.npmjs.org/kms-demo/-/kms-demo-1.0.0.tgz', + integrity: 'sha512-QqZ7VJ/8xPkS9s2IWB7Shj3qTJdcRyeXKbPQnsZjsPEwvutGv0EGeVchPca' + + 'uoiDFJlGbZMFq5GDCurAGNSghJQ==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIDrLNspFeU5NZ6d55ycVBZIMXnPJi/XnI1Y2dlJvK8P1AiEAnXjn1IOMUd+U7YfPH' + + '+FNjwfLq+jCwfH8uaxocq+mpPk=', + }, + ], + }, + }], + }) + await registry.package({ manifest }) + } + + async function manifestWithInvalidSigs ({ registry, name = 'kms-demo', version = '1.0.0' }) { + const manifest = registry.manifest({ + name, + packuments: [{ + version, + dist: { + tarball: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, + integrity: 'sha512-QqZ7VJ/8xPkS9s2IWB7Shj3qTJdcRyeXKbPQnsZjsPEwvutGv0EGeVchPca' + + 'uoiDFJlGbZMFq5GDCurAGNSghJQ==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'bogus', + }, + ], + }, + }], + }) + await registry.package({ manifest }) + } + + async function manifestWithoutSigs ({ registry, name = 'kms-demo', version = '1.0.0' }) { + const manifest = registry.manifest({ + name, + packuments: [{ + version, + }], + }) + await registry.package({ manifest }) + } + + t.test('with valid signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 1 package/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with valid signatures using alias', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithAlias, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + const manifest = registry.manifest({ + name: 'node-fetch', + packuments: [{ + version: '1.7.1', + dist: { + tarball: 'https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.1.tgz', + integrity: 'sha512-j8XsFGCLw79vWXkZtMSmmLaOk9z5SQ9bV/tkbZVCqvgwzrjAGq6' + + '6igobLofHtF63NvMTp2WjytpsNTGKa+XRIQ==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEYCIQDEn2XrrMXlRm+wh2tOIUyb0Km3ZujfT+6Mf61OXGK9zQIhANnPauUwx3' + + 'N9RcQYQakDpOmLvYzNkySh7fmzmvyhk21j', + }, + ], + }, + }], + }) + await registry.package({ manifest }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 1 package/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with multiple valid signatures and one invalid', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + 'node-fetch': '^1.6.0', + }, + devDependencies: { + async: '~2.1.0', + }, + }), + node_modules: { + 'kms-demo': { + 'package.json': JSON.stringify({ + name: 'kms-demo', + version: '1.0.0', + }), + }, + async: { + 'package.json': JSON.stringify({ + name: 'async', + version: '2.5.0', + }), + }, + 'node-fetch': { + 'package.json': JSON.stringify({ + name: 'node-fetch', + version: '1.6.0', + }), + }, + }, + 'package-lock.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + lockfileVersion: 2, + requires: true, + packages: { + '': { + name: 'test-dep', + version: '1.0.0', + dependencies: { + 'kms-demo': '^1.0.0', + 'node-fetch': '^1.6.0', + }, + devDependencies: { + async: '~2.1.0', + }, + }, + 'node_modules/kms-demo': { + version: '1.0.0', + }, + 'node_modules/async': { + version: '2.5.0', + }, + 'node_modules/node-fetch': { + version: '1.6.0', + }, + }, + dependencies: { + 'kms-demo': { + version: '1.0.0', + }, + 'node-fetch': { + version: '1.6.0', + }, + async: { + version: '2.5.0', + }, + }, + }), + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + const asyncManifest = registry.manifest({ + name: 'async', + packuments: [{ + version: '2.5.0', + dist: { + tarball: 'https://registry.npmjs.org/async/-/async-2.5.0.tgz', + integrity: 'sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFT' + + 'KE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIQCM8cX2U3IVZKKhzQx1w5AlNSDUI+fVf4857K1qT0NTNgIgdT4qwEl' + + '/kg2vU1uIWUI0bGikRvVHCHlRs1rgjPMpRFA=', + }, + ], + }, + }], + }) + await registry.package({ manifest: asyncManifest }) + await manifestWithInvalidSigs({ registry, name: 'node-fetch', version: '1.6.0' }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match(joinedOutput(), /audited 3 packages/) + t.match(joinedOutput(), /2 packages have verified registry signatures/) + t.match(joinedOutput(), /1 package has an invalid registry signature/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with bundled and peer deps and no signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithPeerDeps, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 1 package/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with invalid signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithInvalidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match(joinedOutput(), /invalid registry signature/) + t.match(joinedOutput(), /kms-demo@1.0.0/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with valid and missing signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + await manifestWithoutSigs({ registry, name: 'async', version: '1.1.1' }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match(joinedOutput(), /audited 2 packages/) + t.match(joinedOutput(), /verified registry signature/) + t.match(joinedOutput(), /missing registry signature/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with both invalid and missing signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithInvalidSigs({ registry }) + await manifestWithoutSigs({ registry, name: 'async', version: '1.1.1' }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match(joinedOutput(), /audited 2 packages/) + t.match(joinedOutput(), /invalid/) + t.match(joinedOutput(), /missing/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('with multiple invalid signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithInvalidSigs({ registry, name: 'kms-demo', version: '1.0.0' }) + await manifestWithInvalidSigs({ registry, name: 'async', version: '1.1.1' }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.matchSnapshot(joinedOutput()) + }) + + t.test('with multiple missing signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithoutSigs({ registry, name: 'kms-demo', version: '1.0.0' }) + await manifestWithoutSigs({ registry, name: 'async', version: '1.1.1' }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.matchSnapshot(joinedOutput()) + }) + + t.test('with signatures but no public keys', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(404) + + await t.rejects( + npm.exec('audit', ['signatures']), + /no corresponding public key can be found/, + 'should throw with error' + ) + }) + + t.test('with signatures but the public keys are expired', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, EXPIRED_REGISTRY_KEYS) + + await t.rejects( + npm.exec('audit', ['signatures']), + /the corresponding public key has expired/, + 'should throw with error' + ) + }) + + t.test('with signatures but the public keyid does not match', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, MISMATCHING_REGISTRY_KEYS) + + await t.rejects( + npm.exec('audit', ['signatures']), + /no corresponding public key can be found/, + 'should throw with error' + ) + }) + + t.test('with keys but missing signature', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithoutSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match( + joinedOutput(), + /registry is providing signing keys/ + ) + t.matchSnapshot(joinedOutput()) + }) + + t.test('output details about missing signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithoutSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match( + joinedOutput(), + /kms-demo/ + ) + t.matchSnapshot(joinedOutput()) + }) + + t.test('json output with valid signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + config: { + json: true, + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), JSON.stringify({ invalid: [], missing: [] }, null, 2)) + t.matchSnapshot(joinedOutput()) + }) + + t.test('json output with invalid signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + config: { + json: true, + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithInvalidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.matchSnapshot(joinedOutput()) + }) + + t.test('json output with invalid and missing signatures', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + config: { + json: true, + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithInvalidSigs({ registry }) + await manifestWithoutSigs({ registry, name: 'async', version: '1.1.1' }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.matchSnapshot(joinedOutput()) + }) + + t.test('omit dev dependencies with missing signature', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + config: { + omit: ['dev'], + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 1 package/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('third-party registry without keys does not verify', async t => { + const registryUrl = 'https://verdaccio-clone2.org' + const { npm } = await loadMockNpm(t, { + prefixDir: installWithThirdPartyRegistry, + config: { + '@npmcli:registry': registryUrl, + }, + }) + const registry = new MockRegistry({ tap: t, registry: registryUrl }) + const manifest = registry.manifest({ + name: '@npmcli/arborist', + packuments: [{ + version: '1.0.14', + dist: { + tarball: 'https://registry.npmjs.org/@npmcli/arborist/-/@npmcli/arborist-1.0.14.tgz', + integrity: 'sha512-caa8hv5rW9VpQKk6tyNRvSaVDySVjo9GkI7Wj/wcsFyxPm3tYrE' + + 'sFyTjSnJH8HCIfEGVQNjqqKXaXLFVp7UBag==', + }, + }], + }) + await registry.package({ manifest }) + registry.nock.get('/-/npm/v1/keys').reply(404) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no dependencies to audit that where installed from a supported registry/ + ) + }) + + t.test('third-party registry with keys and signatures', async t => { + const registryUrl = 'https://verdaccio-clone.org' + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithThirdPartyRegistry, + config: { + '@npmcli:registry': registryUrl, + }, + }) + const registry = new MockRegistry({ tap: t, registry: registryUrl }) + + const manifest = registry.manifest({ + name: '@npmcli/arborist', + packuments: [{ + version: '1.0.14', + dist: { + tarball: 'https://registry.npmjs.org/@npmcli/arborist/-/@npmcli/arborist-1.0.14.tgz', + integrity: 'sha512-caa8hv5rW9VpQKk6tyNRvSaVDySVjo9GkI7Wj/wcsFyxPm3tYrE' + + 'sFyTjSnJH8HCIfEGVQNjqqKXaXLFVp7UBag==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIAvNpR3G0j7WOPUuVMhE0ZdM8PnDNcsoeFD8Iwz9YWIMAiEAn8cicDC2' + + 'Sf9MFQydqTv6S5XYsAh9Af1sig1nApNI11M=', + }, + ], + }, + }], + }) + await registry.package({ manifest }) + registry.nock.get('/-/npm/v1/keys') + .reply(200, { + keys: [{ + expires: null, + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + }) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 1 package/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('third-party registry with invalid signatures errors', async t => { + const registryUrl = 'https://verdaccio-clone.org' + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithThirdPartyRegistry, + config: { + '@npmcli:registry': registryUrl, + }, + }) + const registry = new MockRegistry({ tap: t, registry: registryUrl }) + + const manifest = registry.manifest({ + name: '@npmcli/arborist', + packuments: [{ + version: '1.0.14', + dist: { + tarball: 'https://registry.npmjs.org/@npmcli/arborist/-/@npmcli/arborist-1.0.14.tgz', + integrity: 'sha512-caa8hv5rW9VpQKk6tyNRvSaVDySVjo9GkI7Wj/wcsFyxPm3tYrE' + + 'sFyTjSnJH8HCIfEGVQNjqqKXaXLFVp7UBag==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'bogus', + }, + ], + }, + }], + }) + await registry.package({ manifest }) + registry.nock.get('/-/npm/v1/keys') + .reply(200, { + keys: [{ + expires: null, + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + }) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match(joinedOutput(), /https:\/\/verdaccio-clone.org/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('third-party registry with keys and missing signatures errors', async t => { + const registryUrl = 'https://verdaccio-clone.org' + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithThirdPartyRegistry, + config: { + '@npmcli:registry': registryUrl, + }, + }) + const registry = new MockRegistry({ tap: t, registry: registryUrl }) + + const manifest = registry.manifest({ + name: '@npmcli/arborist', + packuments: [{ + version: '1.0.14', + dist: { + tarball: 'https://registry.npmjs.org/@npmcli/arborist/-/@npmcli/arborist-1.0.14.tgz', + integrity: 'sha512-caa8hv5rW9VpQKk6tyNRvSaVDySVjo9GkI7Wj/wcsFyxPm3tYrE' + + 'sFyTjSnJH8HCIfEGVQNjqqKXaXLFVp7UBag==', + }, + }], + }) + await registry.package({ manifest }) + registry.nock.get('/-/npm/v1/keys') + .reply(200, { + keys: [{ + expires: null, + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + }) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match(joinedOutput(), /1 package has a missing registry signature/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('multiple registries with keys and signatures', async t => { + const registryUrl = 'https://verdaccio-clone.org' + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithMultipleRegistries, + config: { + '@npmcli:registry': registryUrl, + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + const thirdPartyRegistry = new MockRegistry({ + tap: t, + registry: registryUrl, + }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + const manifest = thirdPartyRegistry.manifest({ + name: '@npmcli/arborist', + packuments: [{ + version: '1.0.14', + dist: { + tarball: 'https://registry.npmjs.org/@npmcli/arborist/-/@npmcli/arborist-1.0.14.tgz', + integrity: 'sha512-caa8hv5rW9VpQKk6tyNRvSaVDySVjo9GkI7Wj/wcsFyxPm3tYrE' + + 'sFyTjSnJH8HCIfEGVQNjqqKXaXLFVp7UBag==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIAvNpR3G0j7WOPUuVMhE0ZdM8PnDNcsoeFD8Iwz9YWIMAiEAn8cicDC2' + + 'Sf9MFQydqTv6S5XYsAh9Af1sig1nApNI11M=', + }, + ], + }, + }], + }) + await thirdPartyRegistry.package({ manifest }) + thirdPartyRegistry.nock.get('/-/npm/v1/keys') + .reply(200, { + keys: [{ + expires: null, + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + keytype: 'ecdsa-sha2-nistp256', + scheme: 'ecdsa-sha2-nistp256', + key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+' + + 'IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==', + }], + }) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 2 packages/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('errors with an empty install', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-dep', + version: '1.0.0', + }), + }, + }) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no installed dependencies to audit/ + ) + }) + + t.test('errors when the keys endpoint errors', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: installWithMultipleDeps, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + registry.nock.get('/-/npm/v1/keys') + .reply(500, { error: 'keys broke' }) + + await t.rejects( + npm.exec('audit', ['signatures']), + /keys broke/ + ) + }) + + t.test('ignores optional dependencies', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithOptionalDeps, + }) + + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 1 package/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('errors when no installed dependencies', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: noInstall, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no dependencies to audit that where installed from a supported registry/ + ) + }) + + t.test('should skip missing non-prod deps', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'delta', + version: '1.0.0', + devDependencies: { + chai: '^1.0.0', + }, + }, null, 2), + node_modules: {}, + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no dependencies to audit that where installed from a supported registry/ + ) + }) + + t.test('should skip invalid pkg ranges', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'delta', + version: '1.0.0', + dependencies: { + cat: '>=^2', + }, + }, null, 2), + node_modules: { + cat: { + 'package.json': JSON.stringify({ + name: 'cat', + version: '1.0.0', + }, null, 2), + }, + }, + }, + }) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no dependencies to audit that where installed from a supported registry/ + ) + }) + + t.test('should skip git specs', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: 'delta', + version: '1.0.0', + dependencies: { + cat: 'github:username/foo', + }, + }, null, 2), + node_modules: { + cat: { + 'package.json': JSON.stringify({ + name: 'cat', + version: '1.0.0', + }, null, 2), + }, + }, + }, + }) + + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no dependencies to audit that where installed from a supported registry/ + ) + }) + + t.test('errors for global packages', async t => { + const { npm } = await loadMockNpm(t, { + config: { global: true }, + }) + + await t.rejects( + npm.exec('audit', ['signatures']), + /`npm audit signatures` does not support global packages/, + { code: 'ECIGLOBAL' } + ) + }) + + t.test('with invalid signtaures and color output enabled', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidSigs, + config: { color: 'always' }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithInvalidSigs({ registry }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 1, 'should exit with error') + process.exitCode = 0 + t.match( + joinedOutput(), + // eslint-disable-next-line no-control-regex + /\u001b\[1m\u001b\[31minvalid\u001b\[39m\u001b\[22m registry signature/ + ) + t.matchSnapshot(joinedOutput()) + }) + + t.test('workspaces', async t => { + t.test('verifies registry deps and ignores local workspace deps', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: workspaceInstall, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidSigs({ registry }) + const asyncManifest = registry.manifest({ + name: 'async', + packuments: [{ + version: '2.5.0', + dist: { + tarball: 'https://registry.npmjs.org/async/-/async-2.5.0.tgz', + integrity: 'sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFT' + + 'KE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIQCM8cX2U3IVZKKhzQx1w5AlNSDUI+fVf4857K1qT0NTNgIgdT4qwEl' + + '/kg2vU1uIWUI0bGikRvVHCHlRs1rgjPMpRFA=', + }, + ], + }, + }], + }) + const lightCycleManifest = registry.manifest({ + name: 'light-cycle', + packuments: [{ + version: '1.4.2', + dist: { + tarball: 'https://registry.npmjs.org/light-cycle/-/light-cycle-1.4.2.tgz', + integrity: 'sha512-badZ3KMUaGwQfVcHjXTXSecYSXxT6f99bT+kVzBqmO10U1UNlE' + + 'thJ1XAok97E4gfDRTA2JJ3r0IeMPtKf0EJMw==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIQDXjoxQz4MzPqaIuy2RJmBlcFp0UD3h9EhKZxxEz9IYZAIgLO0znG5' + + 'aGciTAg4u8fE0/UXBU4gU7JcvTZGxW2BmKGw=', + }, + ], + }, + }], + }) + await registry.package({ manifest: asyncManifest }) + await registry.package({ manifest: lightCycleManifest }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 3 packages/) + t.matchSnapshot(joinedOutput()) + }) + + t.test('verifies registry deps when filtering by workspace name', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: workspaceInstall, + config: { workspace: ['./packages/a'] }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + const asyncManifest = registry.manifest({ + name: 'async', + packuments: [{ + version: '2.5.0', + dist: { + tarball: 'https://registry.npmjs.org/async/-/async-2.5.0.tgz', + integrity: 'sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFT' + + 'KE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIQCM8cX2U3IVZKKhzQx1w5AlNSDUI+fVf4857K1qT0NTNgIgdT4qwEl' + + '/kg2vU1uIWUI0bGikRvVHCHlRs1rgjPMpRFA=', + }, + ], + }, + }], + }) + const lightCycleManifest = registry.manifest({ + name: 'light-cycle', + packuments: [{ + version: '1.4.2', + dist: { + tarball: 'https://registry.npmjs.org/light-cycle/-/light-cycle-1.4.2.tgz', + integrity: 'sha512-badZ3KMUaGwQfVcHjXTXSecYSXxT6f99bT+kVzBqmO10U1UNlE' + + 'thJ1XAok97E4gfDRTA2JJ3r0IeMPtKf0EJMw==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIQDXjoxQz4MzPqaIuy2RJmBlcFp0UD3h9EhKZxxEz9IYZAIgLO0znG5' + + 'aGciTAg4u8fE0/UXBU4gU7JcvTZGxW2BmKGw=', + }, + ], + }, + }], + }) + await registry.package({ manifest: asyncManifest }) + await registry.package({ manifest: lightCycleManifest }) + registry.nock.get('/-/npm/v1/keys').reply(200, VALID_REGISTRY_KEYS) + + await npm.exec('audit', ['signatures']) + + t.equal(process.exitCode, 0, 'should exit successfully') + process.exitCode = 0 + t.match(joinedOutput(), /audited 2 packages/) + t.matchSnapshot(joinedOutput()) + }) + + // TODO: This should verify kms-demo, but doesn't because arborist filters + // workspace deps even if they're also root deps + t.test('verifies registry dep if workspaces is disabled', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: workspaceInstall, + config: { workspaces: false }, + }) + + await t.rejects( + npm.exec('audit', ['signatures']), + /found no installed dependencies to audit/ + ) + }) + }) +})