codekingpro/portable-devtools
114k
1// an object representing the set of vulnerabilities in a tree
2
3const localeCompare = require('@isaacs/string-locale-compare')('en')
4const npa = require('npm-package-arg')
5const pickManifest = require('npm-pick-manifest')
6
7const Vuln = require('./vuln.js')
8const Calculator = require('@npmcli/metavuln-calculator')
9
10const { log, time } = require('proc-log')
11
12const npmFetch = require('npm-registry-fetch')
13
14class AuditReport extends Map {
15 #omit
16 error = null
17 topVulns = new Map()
18
19 static load (tree, opts) {
20 return new AuditReport(tree, opts).run()
21 }
22
23 get auditReportVersion () {
24 return 2
25 }
26
27 toJSON () {
28 const obj = {
29 auditReportVersion: this.auditReportVersion,
30 vulnerabilities: {},
31 metadata: {
32 vulnerabilities: {
33 info: 0,
34 low: 0,
35 moderate: 0,
36 high: 0,
37 critical: 0,
38 total: this.size,
39 },
40 dependencies: {
41 prod: 0,
42 dev: 0,
43 optional: 0,
44 peer: 0,
45 peerOptional: 0,
46 total: this.tree.inventory.size - 1,
47 },
48 },
49 }
50
51 for (const node of this.tree.inventory.values()) {
52 const { dependencies } = obj.metadata
53 let prod = true
54 for (const type of [
55 'dev',
56 'optional',
57 'peer',
58 'peerOptional',
59 ]) {
60 if (node[type]) {
61 dependencies[type]++
62 prod = false
63 }
64 }
65 if (prod) {
66 dependencies.prod++
67 }
68 }
69
70 // if it doesn't have any topVulns, then it's fixable with audit fix
71 // for each topVuln, figure out if it's fixable with audit fix --force,
72 // or if we have to just delete the thing, and if the fix --force will
73 // require a semver major update.
74 const vulnerabilities = []
75 for (const [name, vuln] of this.entries()) {
76 vulnerabilities.push([name, vuln.toJSON()])
77 obj.metadata.vulnerabilities[vuln.severity]++
78 }
79
80 obj.vulnerabilities = vulnerabilities
81 .sort(([a], [b]) => localeCompare(a, b))
82 .reduce((set, [name, vuln]) => {
83 set[name] = vuln
84 return set
85 }, {})
86
87 return obj
88 }
89
90 constructor (tree, opts = {}) {
91 super()
92 this.#omit = new Set(opts.omit || [])
93 this.calculator = new Calculator(opts)
94 this.options = opts
95 this.tree = tree
96 this.filterSet = opts.filterSet
97 }
98
99 async run () {
100 this.report = await this.#getReport()
101 log.silly('audit report', this.report)
102 if (this.report) {
103 await this.#init()
104 }
105 return this
106 }
107
108 isVulnerable (node) {
109 const vuln = this.get(node.packageName)
110 return !!(vuln && vuln.isVulnerable(node))
111 }
112
113 async #init () {
114 const timeEnd = time.start('auditReport:init')
115
116 const promises = []
117 for (const [name, advisories] of Object.entries(this.report)) {
118 for (const advisory of advisories) {
119 promises.push(this.calculator.calculate(name, advisory))
120 }
121 }
122
123 // now the advisories are calculated with a set of versions
124 // and the packument. turn them into our style of vuln objects
125 // which also have the affected nodes, and also create entries
126 // for all the metavulns that we find from dependents.
127 const advisories = new Set(await Promise.all(promises))
128 const seen = new Set()
129 for (const advisory of advisories) {
130 const { name, range } = advisory
131 const k = `${name}@${range}`
132
133 const vuln = this.get(name) || new Vuln({ name, advisory })
134 if (this.has(name)) {
135 vuln.addAdvisory(advisory)
136 }
137 super.set(name, vuln)
138
139 // don't flag the exact same name/range more than once
140 // adding multiple advisories with the same range is fine, but no
141 // need to search for nodes we already would have added.
142 if (!seen.has(k)) {
143 const p = []
144 for (const node of this.tree.inventory.query('packageName', name)) {
145 if (!this.shouldAudit(node)) {
146 continue
147 }
148
149 // if not vulnerable by this advisory, keep searching
150 if (!advisory.testVersion(node.version)) {
151 continue
152 }
153
154 // we will have loaded the source already if this is a metavuln
155 if (advisory.type === 'metavuln') {
156 vuln.addVia(this.get(advisory.dependency))
157 }
158
159 // already marked this one, no need to do it again
160 if (vuln.nodes.has(node)) {
161 continue
162 }
163
164 // haven't marked this one yet. get its dependents.
165 vuln.nodes.add(node)
166 for (const { from: dep, spec } of node.edgesIn) {
167 if (dep.isTop && !vuln.topNodes.has(dep)) {
168 vuln.fixAvailable = this.#fixAvailable(vuln, spec)
169 if (vuln.fixAvailable !== true) {
170 // now we know the top node is vulnerable, and cannot be
171 // upgraded out of the bad place without --force. But, there's
172 // no need to add it to the actual vulns list, because nothing
173 // depends on root.
174 this.topVulns.set(vuln.name, vuln)
175 vuln.topNodes.add(dep)
176 }
177 } else {
178 // calculate a metavuln, if necessary
179 const calc = this.calculator.calculate(dep.packageName, advisory)
180 // eslint-disable-next-line promise/always-return
181 p.push(calc.then(meta => {
182 // eslint-disable-next-line promise/always-return
183 if (meta.testVersion(dep.version, spec)) {
184 advisories.add(meta)
185 }
186 }))
187 }
188 }
189 }
190 await Promise.all(p)
191 seen.add(k)
192 }
193
194 // make sure we actually got something. if not, remove it
195 // this can happen if you are loading from a lockfile created by
196 // npm v5, since it lists the current version of all deps,
197 // rather than the range that is actually depended upon,
198 // or if using --omit with the older audit endpoint.
199 if (this.get(name).nodes.size === 0) {
200 this.delete(name)
201 continue
202 }
203
204 // if the vuln is valid, but THIS advisory doesn't apply to any of
205 // the nodes it references, then remove it from the advisory list.
206 // happens when using omit with old audit endpoint.
207 for (const advisory of vuln.advisories) {
208 const relevant = [...vuln.nodes]
209 .some(n => advisory.testVersion(n.version))
210 if (!relevant) {
211 vuln.deleteAdvisory(advisory)
212 }
213 }
214 }
215
216 timeEnd()
217 }
218
219 // given the spec, see if there is a fix available at all, and note whether or not it's a semver major fix or not (i.e. will need --force)
220 #fixAvailable (vuln, spec) {
221 // TODO we return true, false, OR an object here. this is probably a bad pattern.
222 if (!vuln.testSpec(spec)) {
223 return true
224 }
225
226 // even if we HAVE a packument, if we're looking for it somewhere other than the registry and we have something vulnerable then we're stuck with it.
227 const specObj = npa(spec)
228 if (!specObj.registry) {
229 return false
230 }
231
232 if (specObj.subSpec) {
233 spec = specObj.subSpec.rawSpec
234 }
235
236 // 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 note if that is a semver major bump.
237 try {
238 const {
239 _isSemVerMajor: isSemVerMajor,
240 version,
241 name,
242 } = pickManifest(vuln.packument, spec, {
243 ...this.options,
244 before: null,
245 avoid: vuln.range,
246 avoidStrict: true,
247 })
248 return { name, version, isSemVerMajor }
249 } catch (er) {
250 return false
251 }
252 }
253
254 set () {
255 throw new Error('do not call AuditReport.set() directly')
256 }
257
258 async #getReport () {
259 // if we're not auditing, just return false
260 if (this.options.audit === false || this.options.offline === true || this.tree.inventory.size === 1) {
261 return null
262 }
263
264 const timeEnd = time.start('auditReport:getReport')
265 try {
266 const body = this.prepareBulkData()
267 log.silly('audit', 'bulk request', body)
268
269 // no sense asking if we don't have anything to audit,
270 // we know it'll be empty
271 if (!Object.keys(body).length) {
272 return null
273 }
274
275 const res = await npmFetch('/-/npm/v1/security/advisories/bulk', {
276 ...this.options,
277 registry: this.options.auditRegistry || this.options.registry,
278 method: 'POST',
279 gzip: true,
280 body,
281 })
282
283 return await res.json()
284 } catch (er) {
285 log.verbose('audit error', er)
286 log.silly('audit error', String(er.body))
287 this.error = er
288 return null
289 } finally {
290 timeEnd()
291 }
292 }
293
294 // return true if we should audit this one
295 shouldAudit (node) {
296 if (
297 !node.version ||
298 node.isRoot ||
299 node.isLink ||
300 node.linksIn?.size > 0 ||
301 (this.filterSet && this.filterSet?.size !== 0 && !this.filterSet?.has(node))
302 ) {
303 return false
304 }
305 if (this.#omit.size === 0) {
306 return true
307 }
308 return !node.shouldOmit(this.#omit)
309 }
310
311 prepareBulkData () {
312 const payload = {}
313 for (const name of this.tree.inventory.query('packageName')) {
314 const set = new Set()
315 for (const node of this.tree.inventory.query('packageName', name)) {
316 if (!this.shouldAudit(node)) {
317 continue
318 }
319
320 set.add(node.version)
321 }
322 if (set.size) {
323 payload[name] = [...set]
324 }
325 }
326 return payload
327 }
328}
329
330module.exports = AuditReport
331 