• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 { sigstore } = require('sigstore')
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 sigstore.tuf.client({
42      tufCachePath: 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 where 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    'omit',
408    'foreground-scripts',
409    'ignore-scripts',
410    ...super.params,
411  ]
412
413  static usage = ['[fix|signatures]']
414
415  static async completion (opts) {
416    const argv = opts.conf.argv.remain
417
418    if (argv.length === 2) {
419      return ['fix', 'signatures']
420    }
421
422    switch (argv[2]) {
423      case 'fix':
424      case 'signatures':
425        return []
426      default:
427        throw Object.assign(new Error(argv[2] + ' not recognized'), {
428          code: 'EUSAGE',
429        })
430    }
431  }
432
433  async exec (args) {
434    if (args[0] === 'signatures') {
435      await this.auditSignatures()
436    } else {
437      await this.auditAdvisories(args)
438    }
439  }
440
441  async auditAdvisories (args) {
442    const reporter = this.npm.config.get('json') ? 'json' : 'detail'
443    const Arborist = require('@npmcli/arborist')
444    const opts = {
445      ...this.npm.flatOptions,
446      audit: true,
447      path: this.npm.prefix,
448      reporter,
449      workspaces: this.workspaceNames,
450    }
451
452    const arb = new Arborist(opts)
453    const fix = args[0] === 'fix'
454    await arb.audit({ fix })
455    if (fix) {
456      await reifyFinish(this.npm, arb)
457    } else {
458      // will throw if there's an error, because this is an audit command
459      auditError(this.npm, arb.auditReport)
460      const result = npmAuditReport(arb.auditReport, {
461        ...opts,
462        chalk: this.npm.chalk,
463      })
464      process.exitCode = process.exitCode || result.exitCode
465      this.npm.output(result.report)
466    }
467  }
468
469  async auditSignatures () {
470    if (this.npm.global) {
471      throw Object.assign(
472        new Error('`npm audit signatures` does not support global packages'), {
473          code: 'EAUDITGLOBAL',
474        }
475      )
476    }
477
478    log.verbose('loading installed dependencies')
479    const Arborist = require('@npmcli/arborist')
480    const opts = {
481      ...this.npm.flatOptions,
482      path: this.npm.prefix,
483      workspaces: this.workspaceNames,
484    }
485
486    const arb = new Arborist(opts)
487    const tree = await arb.loadActual()
488    let filterSet = new Set()
489    if (opts.workspaces && opts.workspaces.length) {
490      filterSet =
491        arb.workspaceDependencySet(
492          tree,
493          opts.workspaces,
494          this.npm.flatOptions.includeWorkspaceRoot
495        )
496    } else if (!this.npm.flatOptions.workspacesEnabled) {
497      filterSet =
498        arb.excludeWorkspacesDependencySet(tree)
499    }
500
501    const verify = new VerifySignatures(tree, filterSet, this.npm, { ...opts })
502    await verify.run()
503  }
504}
505
506module.exports = Audit
507