Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/npm/cli.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorPhilip Harrison <philip@mailharrison.com>2022-07-11 20:49:21 +0300
committerGitHub <noreply@github.com>2022-07-11 20:49:21 +0300
commitf032e1c0ada062e2139c8f057b24abb1ce2e4a33 (patch)
treef2082b192509a9abee0f66bc3b1d80b46a5a1378 /lib
parentef8d2edd7da993f4086c85089952cd45834ac78b (diff)
feat: add npm audit signatures (#4827)
* feat: add npm audit signatures Implements [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
Diffstat (limited to 'lib')
-rw-r--r--lib/commands/audit.js384
1 files changed, 380 insertions, 4 deletions
diff --git a/lib/commands/audit.js b/lib/commands/audit.js
index 08d011d83..779bc22fc 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'
@@ -19,7 +347,7 @@ class Audit extends ArboristWorkspaceCmd {
...super.params,
]
- static usage = ['[fix]']
+ static usage = ['[fix|signatures]']
async completion (opts) {
const argv = opts.conf.argv.remain
@@ -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