xref: /third_party/node/deps/npm/lib/commands/audit.js (revision 1cb0ef41)
1const npmAuditReport = require('npm-audit-report')
2const fetch = require('npm-registry-fetch')
3const localeCompare = require('@isaacs/string-locale-compare')('en')
4const npa = require('npm-package-arg')
5const pacote = require('pacote')
6const pMap = require('p-map')
7const tufClient = require('@sigstore/tuf')
8
9const ArboristWorkspaceCmd = require('../arborist-cmd.js')
10const auditError = require('../utils/audit-error.js')
11const log = require('../utils/log-shim.js')
12const reifyFinish = require('../utils/reify-finish.js')
13
14const sortAlphabetically = (a, b) => localeCompare(a.name, b.name)
15
16class VerifySignatures {
17  constructor (tree, filterSet, npm, opts) {
18    this.tree = tree
19    this.filterSet = filterSet
20    this.npm = npm
21    this.opts = opts
22    this.keys = new Map()
23    this.invalid = []
24    this.missing = []
25    this.checkedPackages = new Set()
26    this.auditedWithKeysCount = 0
27    this.verifiedSignatureCount = 0
28    this.verifiedAttestationCount = 0
29    this.exitCode = 0
30  }
31
32  async run () {
33    const start = process.hrtime.bigint()
34
35    // Find all deps in tree
36    const { edges, registries } = this.getEdgesOut(this.tree.inventory.values(), this.filterSet)
37    if (edges.size === 0) {
38      throw new Error('found no installed dependencies to audit')
39    }
40
41    const tuf = await tufClient.initTUF({
42      cachePath: this.opts.tufCache,
43      retry: this.opts.retry,
44      timeout: this.opts.timeout,
45    })
46    await Promise.all([...registries].map(registry => this.setKeys({ registry, tuf })))
47
48    const progress = log.newItem('verifying registry signatures', edges.size)
49    const mapper = async (edge) => {
50      progress.completeWork(1)
51      await this.getVerifiedInfo(edge)
52    }
53    await pMap(edges, mapper, { concurrency: 20, stopOnError: true })
54
55    // Didn't find any dependencies that could be verified, e.g. only local
56    // deps, missing version, not on a registry etc.
57    if (!this.auditedWithKeysCount) {
58      throw new Error('found no dependencies to audit that were installed from ' +
59                      'a supported registry')
60    }
61
62    const invalid = this.invalid.sort(sortAlphabetically)
63    const missing = this.missing.sort(sortAlphabetically)
64
65    const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0
66
67    if (!hasNoInvalidOrMissing) {
68      process.exitCode = 1
69    }
70
71    if (this.npm.config.get('json')) {
72      this.npm.output(JSON.stringify({
73        invalid,
74        missing,
75      }, null, 2))
76      return
77    }
78    const end = process.hrtime.bigint()
79    const elapsed = end - start
80
81    const auditedPlural = this.auditedWithKeysCount > 1 ? 's' : ''
82    const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` +
83      `${Math.floor(Number(elapsed) / 1e9)}s`
84    this.npm.output(timing)
85    this.npm.output('')
86
87    const verifiedBold = this.npm.chalk.bold('verified')
88    if (this.verifiedSignatureCount) {
89      if (this.verifiedSignatureCount === 1) {
90        /* eslint-disable-next-line max-len */
91        this.npm.output(`${this.verifiedSignatureCount} package has a ${verifiedBold} registry signature`)
92      } else {
93        /* eslint-disable-next-line max-len */
94        this.npm.output(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`)
95      }
96      this.npm.output('')
97    }
98
99    if (this.verifiedAttestationCount) {
100      if (this.verifiedAttestationCount === 1) {
101        /* eslint-disable-next-line max-len */
102        this.npm.output(`${this.verifiedAttestationCount} package has a ${verifiedBold} attestation`)
103      } else {
104        /* eslint-disable-next-line max-len */
105        this.npm.output(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`)
106      }
107      this.npm.output('')
108    }
109
110    if (missing.length) {
111      const missingClr = this.npm.chalk.bold(this.npm.chalk.red('missing'))
112      if (missing.length === 1) {
113        /* eslint-disable-next-line max-len */
114        this.npm.output(`1 package has a ${missingClr} registry signature but the registry is providing signing keys:`)
115      } else {
116        /* eslint-disable-next-line max-len */
117        this.npm.output(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`)
118      }
119      this.npm.output('')
120      missing.map(m =>
121        this.npm.output(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`)
122      )
123    }
124
125    if (invalid.length) {
126      if (missing.length) {
127        this.npm.output('')
128      }
129      const invalidClr = this.npm.chalk.bold(this.npm.chalk.red('invalid'))
130      // We can have either invalid signatures or invalid provenance
131      const invalidSignatures = this.invalid.filter(i => i.code === 'EINTEGRITYSIGNATURE')
132      if (invalidSignatures.length) {
133        if (invalidSignatures.length === 1) {
134          this.npm.output(`1 package has an ${invalidClr} registry signature:`)
135        } else {
136          /* eslint-disable-next-line max-len */
137          this.npm.output(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`)
138        }
139        this.npm.output('')
140        invalidSignatures.map(i =>
141          this.npm.output(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
142        )
143        this.npm.output('')
144      }
145
146      const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY')
147      if (invalidAttestations.length) {
148        if (invalidAttestations.length === 1) {
149          this.npm.output(`1 package has an ${invalidClr} attestation:`)
150        } else {
151          /* eslint-disable-next-line max-len */
152          this.npm.output(`${invalidAttestations.length} packages have ${invalidClr} attestations:`)
153        }
154        this.npm.output('')
155        invalidAttestations.map(i =>
156          this.npm.output(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
157        )
158        this.npm.output('')
159      }
160
161      if (invalid.length === 1) {
162        /* eslint-disable-next-line max-len */
163        this.npm.output(`Someone might have tampered with this package since it was published on the registry!`)
164      } else {
165        /* eslint-disable-next-line max-len */
166        this.npm.output(`Someone might have tampered with these packages since they were published on the registry!`)
167      }
168      this.npm.output('')
169    }
170  }
171
172  getEdgesOut (nodes, filterSet) {
173    const edges = new Set()
174    const registries = new Set()
175    for (const node of nodes) {
176      for (const edge of node.edgesOut.values()) {
177        const filteredOut =
178          edge.from
179            && filterSet
180            && filterSet.size > 0
181            && !filterSet.has(edge.from.target)
182
183        if (!filteredOut) {
184          const spec = this.getEdgeSpec(edge)
185          if (spec) {
186            // Prefetch and cache public keys from used registries
187            registries.add(this.getSpecRegistry(spec))
188          }
189          edges.add(edge)
190        }
191      }
192    }
193    return { edges, registries }
194  }
195
196  async setKeys ({ registry, tuf }) {
197    const { host, pathname } = new URL(registry)
198    // Strip any trailing slashes from pathname
199    const regKey = `${host}${pathname.replace(/\/$/, '')}/keys.json`
200    let keys = await tuf.getTarget(regKey)
201      .then((target) => JSON.parse(target))
202      .then(({ keys: ks }) => ks.map((key) => ({
203        ...key,
204        keyid: key.keyId,
205        pemkey: `-----BEGIN PUBLIC KEY-----\n${key.publicKey.rawBytes}\n-----END PUBLIC KEY-----`,
206        expires: key.publicKey.validFor.end || null,
207      }))).catch(err => {
208        if (err.code === 'TUF_FIND_TARGET_ERROR') {
209          return null
210        } else {
211          throw err
212        }
213      })
214
215    // If keys not found in Sigstore TUF repo, fallback to registry keys API
216    if (!keys) {
217      keys = await fetch.json('/-/npm/v1/keys', {
218        ...this.npm.flatOptions,
219        registry,
220      }).then(({ keys: ks }) => ks.map((key) => ({
221        ...key,
222        pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`,
223      }))).catch(err => {
224        if (err.code === 'E404' || err.code === 'E400') {
225          return null
226        } else {
227          throw err
228        }
229      })
230    }
231
232    if (keys) {
233      this.keys.set(registry, keys)
234    }
235  }
236
237  getEdgeType (edge) {
238    return edge.optional ? 'optionalDependencies'
239      : edge.peer ? 'peerDependencies'
240      : edge.dev ? 'devDependencies'
241      : 'dependencies'
242  }
243
244  getEdgeSpec (edge) {
245    let name = edge.name
246    try {
247      name = npa(edge.spec).subSpec.name
248    } catch {
249      // leave it as edge.name
250    }
251    try {
252      return npa(`${name}@${edge.spec}`)
253    } catch {
254      // Skip packages with invalid spec
255    }
256  }
257
258  buildRegistryConfig (registry) {
259    const keys = this.keys.get(registry) || []
260    const parsedRegistry = new URL(registry)
261    const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
262    return {
263      [`${regKey}:_keys`]: keys,
264    }
265  }
266
267  getSpecRegistry (spec) {
268    return fetch.pickRegistry(spec, this.npm.flatOptions)
269  }
270
271  getValidPackageInfo (edge) {
272    const type = this.getEdgeType(edge)
273    // Skip potentially optional packages that are not on disk, as these could
274    // be omitted during install
275    if (edge.error === 'MISSING' && type !== 'dependencies') {
276      return
277    }
278
279    const spec = this.getEdgeSpec(edge)
280    // Skip invalid version requirements
281    if (!spec) {
282      return
283    }
284    const node = edge.to || edge
285    const { version } = node.package || {}
286
287    if (node.isWorkspace || // Skip local workspaces packages
288        !version || // Skip packages that don't have a installed version, e.g. optonal dependencies
289        !spec.registry) { // Skip if not from registry, e.g. git package
290      return
291    }
292
293    for (const omitType of this.npm.config.get('omit')) {
294      if (node[omitType]) {
295        return
296      }
297    }
298
299    return {
300      name: spec.name,
301      version,
302      type,
303      location: node.location,
304      registry: this.getSpecRegistry(spec),
305    }
306  }
307
308  async verifySignatures (name, version, registry) {
309    const {
310      _integrity: integrity,
311      _signatures,
312      _attestations,
313      _resolved: resolved,
314    } = await pacote.manifest(`${name}@${version}`, {
315      verifySignatures: true,
316      verifyAttestations: true,
317      ...this.buildRegistryConfig(registry),
318      ...this.npm.flatOptions,
319    })
320    const signatures = _signatures || []
321    const result = {
322      integrity,
323      signatures,
324      attestations: _attestations,
325      resolved,
326    }
327    return result
328  }
329
330  async getVerifiedInfo (edge) {
331    const info = this.getValidPackageInfo(edge)
332    if (!info) {
333      return
334    }
335    const { name, version, location, registry, type } = info
336    if (this.checkedPackages.has(location)) {
337      // we already did or are doing this one
338      return
339    }
340    this.checkedPackages.add(location)
341
342    // We only "audit" or verify the signature, or the presence of it, on
343    // packages whose registry returns signing keys
344    const keys = this.keys.get(registry) || []
345    if (keys.length) {
346      this.auditedWithKeysCount += 1
347    }
348
349    try {
350      const { integrity, signatures, attestations, resolved } = await this.verifySignatures(
351        name, version, registry
352      )
353
354      // Currently we only care about missing signatures on registries that provide a public key
355      // We could make this configurable in the future with a strict/paranoid mode
356      if (signatures.length) {
357        this.verifiedSignatureCount += 1
358      } else if (keys.length) {
359        this.missing.push({
360          integrity,
361          location,
362          name,
363          registry,
364          resolved,
365          version,
366        })
367      }
368
369      // Track verified attestations separately to registry signatures, as all
370      // packages on registries with signing keys are expected to have registry
371      // signatures, but not all packages have provenance and publish attestations.
372      if (attestations) {
373        this.verifiedAttestationCount += 1
374      }
375    } catch (e) {
376      if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') {
377        this.invalid.push({
378          code: e.code,
379          message: e.message,
380          integrity: e.integrity,
381          keyid: e.keyid,
382          location,
383          name,
384          registry,
385          resolved: e.resolved,
386          signature: e.signature,
387          predicateType: e.predicateType,
388          type,
389          version,
390        })
391      } else {
392        throw e
393      }
394    }
395  }
396}
397
398class Audit extends ArboristWorkspaceCmd {
399  static description = 'Run a security audit'
400  static name = 'audit'
401  static params = [
402    'audit-level',
403    'dry-run',
404    'force',
405    'json',
406    'package-lock-only',
407    'package-lock',
408    'omit',
409    'include',
410    'foreground-scripts',
411    'ignore-scripts',
412    ...super.params,
413  ]
414
415  static usage = ['[fix|signatures]']
416
417  static async completion (opts) {
418    const argv = opts.conf.argv.remain
419
420    if (argv.length === 2) {
421      return ['fix', 'signatures']
422    }
423
424    switch (argv[2]) {
425      case 'fix':
426      case 'signatures':
427        return []
428      default:
429        throw Object.assign(new Error(argv[2] + ' not recognized'), {
430          code: 'EUSAGE',
431        })
432    }
433  }
434
435  async exec (args) {
436    if (args[0] === 'signatures') {
437      await this.auditSignatures()
438    } else {
439      await this.auditAdvisories(args)
440    }
441  }
442
443  async auditAdvisories (args) {
444    const fix = args[0] === 'fix'
445    if (this.npm.config.get('package-lock') === false && fix) {
446      throw this.usageError('fix can not be used without a package-lock')
447    }
448    const reporter = this.npm.config.get('json') ? 'json' : 'detail'
449    const Arborist = require('@npmcli/arborist')
450    const opts = {
451      ...this.npm.flatOptions,
452      audit: true,
453      path: this.npm.prefix,
454      reporter,
455      workspaces: this.workspaceNames,
456    }
457
458    const arb = new Arborist(opts)
459    await arb.audit({ fix })
460    if (fix) {
461      await reifyFinish(this.npm, arb)
462    } else {
463      // will throw if there's an error, because this is an audit command
464      auditError(this.npm, arb.auditReport)
465      const result = npmAuditReport(arb.auditReport, {
466        ...opts,
467        chalk: this.npm.chalk,
468      })
469      process.exitCode = process.exitCode || result.exitCode
470      this.npm.output(result.report)
471    }
472  }
473
474  async auditSignatures () {
475    if (this.npm.global) {
476      throw Object.assign(
477        new Error('`npm audit signatures` does not support global packages'), {
478          code: 'EAUDITGLOBAL',
479        }
480      )
481    }
482
483    log.verbose('loading installed dependencies')
484    const Arborist = require('@npmcli/arborist')
485    const opts = {
486      ...this.npm.flatOptions,
487      path: this.npm.prefix,
488      workspaces: this.workspaceNames,
489    }
490
491    const arb = new Arborist(opts)
492    const tree = await arb.loadActual()
493    let filterSet = new Set()
494    if (opts.workspaces && opts.workspaces.length) {
495      filterSet =
496        arb.workspaceDependencySet(
497          tree,
498          opts.workspaces,
499          this.npm.flatOptions.includeWorkspaceRoot
500        )
501    } else if (!this.npm.flatOptions.workspacesEnabled) {
502      filterSet =
503        arb.excludeWorkspacesDependencySet(tree)
504    }
505
506    const verify = new VerifySignatures(tree, filterSet, this.npm, { ...opts })
507    await verify.run()
508  }
509}
510
511module.exports = Audit
512