This repository has been archived by the owner on Jan 20, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 64
/
audit-report.js
416 lines (367 loc) · 11.8 KB
/
audit-report.js
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
// an object representing the set of vulnerabilities in a tree
/* eslint camelcase: "off" */
const localeCompare = require('@isaacs/string-locale-compare')('en')
const npa = require('npm-package-arg')
const pickManifest = require('npm-pick-manifest')
const Vuln = require('./vuln.js')
const Calculator = require('@npmcli/metavuln-calculator')
const _getReport = Symbol('getReport')
const _fixAvailable = Symbol('fixAvailable')
const _checkTopNode = Symbol('checkTopNode')
const _init = Symbol('init')
const _omit = Symbol('omit')
const procLog = require('proc-log')
const fetch = require('npm-registry-fetch')
class AuditReport extends Map {
static load (tree, opts) {
return new AuditReport(tree, opts).run()
}
get auditReportVersion () {
return 2
}
toJSON () {
const obj = {
auditReportVersion: this.auditReportVersion,
vulnerabilities: {},
metadata: {
vulnerabilities: {
info: 0,
low: 0,
moderate: 0,
high: 0,
critical: 0,
total: this.size,
},
dependencies: {
prod: 0,
dev: 0,
optional: 0,
peer: 0,
peerOptional: 0,
total: this.tree.inventory.size - 1,
},
},
}
for (const node of this.tree.inventory.values()) {
const { dependencies } = obj.metadata
let prod = true
for (const type of [
'dev',
'optional',
'peer',
'peerOptional',
]) {
if (node[type]) {
dependencies[type]++
prod = false
}
}
if (prod) {
dependencies.prod++
}
}
// if it doesn't have any topVulns, then it's fixable with audit fix
// for each topVuln, figure out if it's fixable with audit fix --force,
// or if we have to just delete the thing, and if the fix --force will
// require a semver major update.
const vulnerabilities = []
for (const [name, vuln] of this.entries()) {
vulnerabilities.push([name, vuln.toJSON()])
obj.metadata.vulnerabilities[vuln.severity]++
}
obj.vulnerabilities = vulnerabilities
.sort(([a], [b]) => localeCompare(a, b))
.reduce((set, [name, vuln]) => {
set[name] = vuln
return set
}, {})
return obj
}
constructor (tree, opts = {}) {
super()
const { omit } = opts
this[_omit] = new Set(omit || [])
this.topVulns = new Map()
this.calculator = new Calculator(opts)
this.error = null
this.options = opts
this.log = opts.log || procLog
this.tree = tree
this.filterSet = opts.filterSet
}
async run () {
this.report = await this[_getReport]()
this.log.silly('audit report', this.report)
if (this.report) {
await this[_init]()
}
return this
}
isVulnerable (node) {
const vuln = this.get(node.packageName)
return !!(vuln && vuln.isVulnerable(node))
}
async [_init] () {
process.emit('time', 'auditReport:init')
const promises = []
for (const [name, advisories] of Object.entries(this.report)) {
for (const advisory of advisories) {
promises.push(this.calculator.calculate(name, advisory))
}
}
// now the advisories are calculated with a set of versions
// and the packument. turn them into our style of vuln objects
// which also have the affected nodes, and also create entries
// for all the metavulns that we find from dependents.
const advisories = new Set(await Promise.all(promises))
const seen = new Set()
for (const advisory of advisories) {
const { name, range } = advisory
// don't flag the exact same name/range more than once
// adding multiple advisories with the same range is fine, but no
// need to search for nodes we already would have added.
const k = `${name}@${range}`
if (seen.has(k)) {
continue
}
seen.add(k)
const vuln = this.get(name) || new Vuln({ name, advisory })
if (this.has(name)) {
vuln.addAdvisory(advisory)
}
super.set(name, vuln)
const p = []
for (const node of this.tree.inventory.query('packageName', name)) {
if (!shouldAudit(node, this[_omit], this.filterSet)) {
continue
}
// if not vulnerable by this advisory, keep searching
if (!advisory.testVersion(node.version)) {
continue
}
// we will have loaded the source already if this is a metavuln
if (advisory.type === 'metavuln') {
vuln.addVia(this.get(advisory.dependency))
}
// already marked this one, no need to do it again
if (vuln.nodes.has(node)) {
continue
}
// haven't marked this one yet. get its dependents.
vuln.nodes.add(node)
for (const { from: dep, spec } of node.edgesIn) {
if (dep.isTop && !vuln.topNodes.has(dep)) {
this[_checkTopNode](dep, vuln, spec)
} else {
// calculate a metavuln, if necessary
const calc = this.calculator.calculate(dep.packageName, advisory)
p.push(calc.then(meta => {
if (meta.testVersion(dep.version, spec)) {
advisories.add(meta)
}
}))
}
}
}
await Promise.all(p)
// make sure we actually got something. if not, remove it
// this can happen if you are loading from a lockfile created by
// npm v5, since it lists the current version of all deps,
// rather than the range that is actually depended upon,
// or if using --omit with the older audit endpoint.
if (this.get(name).nodes.size === 0) {
this.delete(name)
continue
}
// if the vuln is valid, but THIS advisory doesn't apply to any of
// the nodes it references, then remove it from the advisory list.
// happens when using omit with old audit endpoint.
for (const advisory of vuln.advisories) {
const relevant = [...vuln.nodes]
.some(n => advisory.testVersion(n.version))
if (!relevant) {
vuln.deleteAdvisory(advisory)
}
}
}
process.emit('timeEnd', 'auditReport:init')
}
[_checkTopNode] (topNode, vuln, spec) {
vuln.fixAvailable = this[_fixAvailable](topNode, vuln, spec)
if (vuln.fixAvailable !== true) {
// now we know the top node is vulnerable, and cannot be
// upgraded out of the bad place without --force. But, there's
// no need to add it to the actual vulns list, because nothing
// depends on root.
this.topVulns.set(vuln.name, vuln)
vuln.topNodes.add(topNode)
}
}
// check whether the top node is vulnerable.
// check whether we can get out of the bad place with --force, and if
// so, whether that update is SemVer Major
[_fixAvailable] (topNode, vuln, spec) {
// this will always be set to at least {name, versions:{}}
const paku = vuln.packument
if (!vuln.testSpec(spec)) {
return true
}
// similarly, even if we HAVE a packument, but we're looking for it
// somewhere other than the registry, and we got something vulnerable,
// then we're stuck with it.
const specObj = npa(spec)
if (!specObj.registry) {
return false
}
if (specObj.subSpec) {
spec = specObj.subSpec.rawSpec
}
// We don't provide fixes for top nodes other than root, but we
// still check to see if the node is fixable with a different version,
// and if that is a semver major bump.
try {
const {
_isSemVerMajor: isSemVerMajor,
version,
name,
} = pickManifest(paku, spec, {
...this.options,
before: null,
avoid: vuln.range,
avoidStrict: true,
})
return { name, version, isSemVerMajor }
} catch (er) {
return false
}
}
set () {
throw new Error('do not call AuditReport.set() directly')
}
// convert a quick-audit into a bulk advisory listing
static auditToBulk (report) {
if (!report.advisories) {
// tack on the report json where the response body would go
throw Object.assign(new Error('Invalid advisory report'), {
body: JSON.stringify(report),
})
}
const bulk = {}
const { advisories } = report
for (const advisory of Object.values(advisories)) {
const {
id,
url,
title,
severity = 'high',
vulnerable_versions = '*',
module_name: name,
} = advisory
bulk[name] = bulk[name] || []
bulk[name].push({ id, url, title, severity, vulnerable_versions })
}
return bulk
}
async [_getReport] () {
// if we're not auditing, just return false
if (this.options.audit === false || this.tree.inventory.size === 1) {
return null
}
process.emit('time', 'auditReport:getReport')
try {
try {
// first try the super fast bulk advisory listing
const body = prepareBulkData(this.tree, this[_omit], this.filterSet)
this.log.silly('audit', 'bulk request', body)
// no sense asking if we don't have anything to audit,
// we know it'll be empty
if (!Object.keys(body).length) {
return null
}
const res = await fetch('/-/npm/v1/security/advisories/bulk', {
...this.options,
registry: this.options.auditRegistry || this.options.registry,
method: 'POST',
gzip: true,
body,
})
return await res.json()
} catch (er) {
this.log.silly('audit', 'bulk request failed', String(er.body))
// that failed, try the quick audit endpoint
const body = prepareData(this.tree, this.options)
const res = await fetch('/-/npm/v1/security/audits/quick', {
...this.options,
registry: this.options.auditRegistry || this.options.registry,
method: 'POST',
gzip: true,
body,
})
return AuditReport.auditToBulk(await res.json())
}
} catch (er) {
this.log.verbose('audit error', er)
this.log.silly('audit error', String(er.body))
this.error = er
return null
} finally {
process.emit('timeEnd', 'auditReport:getReport')
}
}
}
// return true if we should audit this one
const shouldAudit = (node, omit, filterSet) =>
!node.version ? false
: node.isRoot ? false
: filterSet && filterSet.size !== 0 && !filterSet.has(node) ? false
: omit.size === 0 ? true
: !( // otherwise, just ensure we're not omitting this one
node.dev && omit.has('dev') ||
node.optional && omit.has('optional') ||
node.devOptional && omit.has('dev') && omit.has('optional') ||
node.peer && omit.has('peer')
)
const prepareBulkData = (tree, omit, filterSet) => {
const payload = {}
for (const name of tree.inventory.query('packageName')) {
const set = new Set()
for (const node of tree.inventory.query('packageName', name)) {
if (!shouldAudit(node, omit, filterSet)) {
continue
}
set.add(node.version)
}
if (set.size) {
payload[name] = [...set]
}
}
return payload
}
const prepareData = (tree, opts) => {
const { npmVersion: npm_version } = opts
const node_version = process.version
const { platform, arch } = process
const { NODE_ENV: node_env } = process.env
const data = tree.meta.commit()
// the legacy audit endpoint doesn't support any kind of pre-filtering
// we just have to get the advisories and skip over them in the report
return {
name: data.name,
version: data.version,
requires: {
...(tree.package.devDependencies || {}),
...(tree.package.peerDependencies || {}),
...(tree.package.optionalDependencies || {}),
...(tree.package.dependencies || {}),
},
dependencies: data.dependencies,
metadata: {
node_version,
npm_version,
platform,
arch,
node_env,
},
}
}
module.exports = AuditReport