summaryrefslogtreecommitdiff
path: root/deps/npm/lib/commands/audit.js
blob: dab871669eba5953d577dbab09e0cc3d929cf097 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
const Arborist = require('@npmcli/arborist')
const auditReport = require('npm-audit-report')
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.verifiedSignatureCount = 0
    this.verifiedAttestationCount = 0
    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) {
      process.exitCode = 1
    }

    if (this.npm.config.get('json')) {
      this.npm.output(JSON.stringify({
        invalid,
        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.npm.output(timing)
    this.npm.output('')

    const verifiedBold = this.npm.chalk.bold('verified')
    if (this.verifiedSignatureCount) {
      if (this.verifiedSignatureCount === 1) {
        /* eslint-disable-next-line max-len */
        this.npm.output(`${this.verifiedSignatureCount} package has a ${verifiedBold} registry signature`)
      } else {
        /* eslint-disable-next-line max-len */
        this.npm.output(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`)
      }
      this.npm.output('')
    }

    if (this.verifiedAttestationCount) {
      if (this.verifiedAttestationCount === 1) {
        /* eslint-disable-next-line max-len */
        this.npm.output(`${this.verifiedAttestationCount} package has a ${verifiedBold} attestation`)
      } else {
        /* eslint-disable-next-line max-len */
        this.npm.output(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`)
      }
      this.npm.output('')
    }

    if (missing.length) {
      const missingClr = this.npm.chalk.bold(this.npm.chalk.red('missing'))
      if (missing.length === 1) {
        /* eslint-disable-next-line max-len */
        this.npm.output(`1 package has a ${missingClr} registry signature but the registry is providing signing keys:`)
      } else {
        /* eslint-disable-next-line max-len */
        this.npm.output(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`)
      }
      this.npm.output('')
      missing.map(m =>
        this.npm.output(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`)
      )
    }

    if (invalid.length) {
      if (missing.length) {
        this.npm.output('')
      }
      const invalidClr = this.npm.chalk.bold(this.npm.chalk.red('invalid'))
      // We can have either invalid signatures or invalid provenance
      const invalidSignatures = this.invalid.filter(i => i.code === 'EINTEGRITYSIGNATURE')
      if (invalidSignatures.length) {
        if (invalidSignatures.length === 1) {
          this.npm.output(`1 package has an ${invalidClr} registry signature:`)
        } else {
          /* eslint-disable-next-line max-len */
          this.npm.output(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`)
        }
        this.npm.output('')
        invalidSignatures.map(i =>
          this.npm.output(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
        )
        this.npm.output('')
      }

      const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY')
      if (invalidAttestations.length) {
        if (invalidAttestations.length === 1) {
          this.npm.output(`1 package has an ${invalidClr} attestation:`)
        } else {
          /* eslint-disable-next-line max-len */
          this.npm.output(`${invalidAttestations.length} packages have ${invalidClr} attestations:`)
        }
        this.npm.output('')
        invalidAttestations.map(i =>
          this.npm.output(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
        )
        this.npm.output('')
      }

      if (invalid.length === 1) {
        /* eslint-disable-next-line max-len */
        this.npm.output(`Someone might have tampered with this package since it was published on the registry!`)
      } else {
        /* eslint-disable-next-line max-len */
        this.npm.output(`Someone might have tampered with these packages since they were published on the registry!`)
      }
      this.npm.output('')
    }
  }

  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: ks }) => ks.map((key) => ({
      ...key,
      pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`,
    }))).catch(err => {
      if (err.code === 'E404' || err.code === 'E400') {
        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 {
      // leave it as edge.name
    }
    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,
      _attestations,
      _resolved: resolved,
    } = await pacote.manifest(`${name}@${version}`, {
      verifySignatures: true,
      verifyAttestations: true,
      ...this.buildRegistryConfig(registry),
      ...this.npm.flatOptions,
    })
    const signatures = _signatures || []
    const result = {
      integrity,
      signatures,
      attestations: _attestations,
      resolved,
    }
    return result
  }

  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, attestations, 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.verifiedSignatureCount += 1
      } else if (keys.length) {
        this.missing.push({
          integrity,
          location,
          name,
          registry,
          resolved,
          version,
        })
      }

      // Track verified attestations separately to registry signatures, as all
      // packages on registries with signing keys are expected to have registry
      // signatures, but not all packages have provenance and publish attestations.
      if (attestations) {
        this.verifiedAttestationCount += 1
      }
    } catch (e) {
      if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') {
        this.invalid.push({
          code: e.code,
          message: e.message,
          integrity: e.integrity,
          keyid: e.keyid,
          location,
          name,
          registry,
          resolved: e.resolved,
          signature: e.signature,
          predicateType: e.predicateType,
          type,
          version,
        })
      } else {
        throw e
      }
    }
  }
}

class Audit extends ArboristWorkspaceCmd {
  static description = 'Run a security audit'
  static name = 'audit'
  static params = [
    'audit-level',
    'dry-run',
    'force',
    'json',
    'package-lock-only',
    'omit',
    'foreground-scripts',
    'ignore-scripts',
    ...super.params,
  ]

  static usage = ['[fix|signatures]']

  async completion (opts) {
    const argv = opts.conf.argv.remain

    if (argv.length === 2) {
      return ['fix', 'signatures']
    }

    switch (argv[2]) {
      case 'fix':
      case 'signatures':
        return []
      default:
        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,
      audit: true,
      path: this.npm.prefix,
      reporter,
      workspaces: this.workspaceNames,
    }

    const arb = new Arborist(opts)
    const fix = args[0] === 'fix'
    await arb.audit({ fix })
    if (fix) {
      await reifyFinish(this.npm, arb)
    } else {
      // will throw if there's an error, because this is an audit command
      auditError(this.npm, arb.auditReport)
      const result = auditReport(arb.auditReport, opts)
      process.exitCode = process.exitCode || result.exitCode
      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()
  }
}

module.exports = Audit