• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// An object representing a vulnerability either as the result of an
2// advisory or due to the package in question depending exclusively on
3// vulnerable versions of a dep.
4//
5// - name: package name
6// - range: Set of vulnerable versions
7// - nodes: Set of nodes affected
8// - effects: Set of vulns triggered by this one
9// - advisories: Set of advisories (including metavulns) causing this vuln.
10//   All of the entries in via are vulnerability objects returned by
11//   @npmcli/metavuln-calculator
12// - via: dependency vulns which cause this one
13
14const { satisfies, simplifyRange } = require('semver')
15const semverOpt = { loose: true, includePrerelease: true }
16
17const localeCompare = require('@isaacs/string-locale-compare')('en')
18const npa = require('npm-package-arg')
19
20const severities = new Map([
21  ['info', 0], [0, 'info'],
22  ['low', 1], [1, 'low'],
23  ['moderate', 2], [2, 'moderate'],
24  ['high', 3], [3, 'high'],
25  ['critical', 4], [4, 'critical'],
26  [null, -1], [-1, null],
27])
28
29class Vuln {
30  #range = null
31  #simpleRange = null
32  // assume a fix is available unless it hits a top node
33  // that locks it in place, setting this false or {isSemVerMajor, version}.
34  #fixAvailable = true
35
36  constructor ({ name, advisory }) {
37    this.name = name
38    this.via = new Set()
39    this.advisories = new Set()
40    this.severity = null
41    this.effects = new Set()
42    this.topNodes = new Set()
43    this.nodes = new Set()
44    this.addAdvisory(advisory)
45    this.packument = advisory.packument
46    this.versions = advisory.versions
47  }
48
49  get fixAvailable () {
50    return this.#fixAvailable
51  }
52
53  set fixAvailable (f) {
54    this.#fixAvailable = f
55    // if there's a fix available for this at the top level, it means that
56    // it will also fix the vulns that led to it being there.  to get there,
57    // we set the vias to the most "strict" of fix availables.
58    // - false: no fix is available
59    // - {name, version, isSemVerMajor} fix requires -f, is semver major
60    // - {name, version} fix requires -f, not semver major
61    // - true: fix does not require -f
62    // TODO: duped entries may require different fixes but the current
63    // structure does not support this, so the case were a top level fix
64    // corrects a duped entry may mean you have to run fix more than once
65    for (const v of this.via) {
66      // don't blow up on loops
67      if (v.fixAvailable === f) {
68        continue
69      }
70
71      if (f === false) {
72        v.fixAvailable = f
73      } else if (v.fixAvailable === true) {
74        v.fixAvailable = f
75      } else if (typeof f === 'object' && (
76        typeof v.fixAvailable !== 'object' || !v.fixAvailable.isSemVerMajor)) {
77        v.fixAvailable = f
78      }
79    }
80  }
81
82  get isDirect () {
83    for (const node of this.nodes.values()) {
84      for (const edge of node.edgesIn) {
85        if (edge.from.isProjectRoot || edge.from.isWorkspace) {
86          return true
87        }
88      }
89    }
90    return false
91  }
92
93  testSpec (spec) {
94    const specObj = npa(spec)
95    if (!specObj.registry) {
96      return true
97    }
98
99    if (specObj.subSpec) {
100      spec = specObj.subSpec.rawSpec
101    }
102
103    for (const v of this.versions) {
104      if (satisfies(v, spec) && !satisfies(v, this.range, semverOpt)) {
105        return false
106      }
107    }
108    return true
109  }
110
111  toJSON () {
112    return {
113      name: this.name,
114      severity: this.severity,
115      isDirect: this.isDirect,
116      // just loop over the advisories, since via is only Vuln objects,
117      // and calculated advisories have all the info we need
118      via: [...this.advisories].map(v => v.type === 'metavuln' ? v.dependency : {
119        ...v,
120        versions: undefined,
121        vulnerableVersions: undefined,
122        id: undefined,
123      }).sort((a, b) =>
124        localeCompare(String(a.source || a), String(b.source || b))),
125      effects: [...this.effects].map(v => v.name).sort(localeCompare),
126      range: this.simpleRange,
127      nodes: [...this.nodes].map(n => n.location).sort(localeCompare),
128      fixAvailable: this.#fixAvailable,
129    }
130  }
131
132  addVia (v) {
133    this.via.add(v)
134    v.effects.add(this)
135    // call the setter since we might add vias _after_ setting fixAvailable
136    this.fixAvailable = this.fixAvailable
137  }
138
139  deleteVia (v) {
140    this.via.delete(v)
141    v.effects.delete(this)
142  }
143
144  deleteAdvisory (advisory) {
145    this.advisories.delete(advisory)
146    // make sure we have the max severity of all the vulns causing this one
147    this.severity = null
148    this.#range = null
149    this.#simpleRange = null
150    // refresh severity
151    for (const advisory of this.advisories) {
152      this.addAdvisory(advisory)
153    }
154
155    // remove any effects that are no longer relevant
156    const vias = new Set([...this.advisories].map(a => a.dependency))
157    for (const via of this.via) {
158      if (!vias.has(via.name)) {
159        this.deleteVia(via)
160      }
161    }
162  }
163
164  addAdvisory (advisory) {
165    this.advisories.add(advisory)
166    const sev = severities.get(advisory.severity)
167    this.#range = null
168    this.#simpleRange = null
169    if (sev > severities.get(this.severity)) {
170      this.severity = advisory.severity
171    }
172  }
173
174  get range () {
175    if (!this.#range) {
176      this.#range = [...this.advisories].map(v => v.range).join(' || ')
177    }
178    return this.#range
179  }
180
181  get simpleRange () {
182    if (this.#simpleRange && this.#simpleRange === this.#range) {
183      return this.#simpleRange
184    }
185
186    const versions = [...this.advisories][0].versions
187    const range = this.range
188    this.#simpleRange = simplifyRange(versions, range, semverOpt)
189    this.#range = this.#simpleRange
190    return this.#simpleRange
191  }
192
193  isVulnerable (node) {
194    if (this.nodes.has(node)) {
195      return true
196    }
197
198    const { version } = node.package
199    if (!version) {
200      return false
201    }
202
203    for (const v of this.advisories) {
204      if (v.testVersion(version)) {
205        this.nodes.add(node)
206        return true
207      }
208    }
209
210    return false
211  }
212}
213
214module.exports = Vuln
215