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