// An object representing a vulnerability either as the result of an // advisory or due to the package in question depending exclusively on // vulnerable versions of a dep. // // - name: package name // - range: Set of vulnerable versions // - nodes: Set of nodes affected // - effects: Set of vulns triggered by this one // - advisories: Set of advisories (including metavulns) causing this vuln. // All of the entries in via are vulnerability objects returned by // @npmcli/metavuln-calculator // - via: dependency vulns which cause this one const { satisfies, simplifyRange } = require('semver') const semverOpt = { loose: true, includePrerelease: true } const localeCompare = require('@isaacs/string-locale-compare')('en') const npa = require('npm-package-arg') const _range = Symbol('_range') const _simpleRange = Symbol('_simpleRange') const _fixAvailable = Symbol('_fixAvailable') const severities = new Map([ ['info', 0], ['low', 1], ['moderate', 2], ['high', 3], ['critical', 4], [null, -1], ]) for (const [name, val] of severities.entries()) { severities.set(val, name) } class Vuln { constructor ({ name, advisory }) { this.name = name this.via = new Set() this.advisories = new Set() this.severity = null this.effects = new Set() this.topNodes = new Set() this[_range] = null this[_simpleRange] = null this.nodes = new Set() // assume a fix is available unless it hits a top node // that locks it in place, setting this false or {isSemVerMajor, version}. this[_fixAvailable] = true this.addAdvisory(advisory) this.packument = advisory.packument this.versions = advisory.versions } get fixAvailable () { return this[_fixAvailable] } set fixAvailable (f) { this[_fixAvailable] = f // if there's a fix available for this at the top level, it means that // it will also fix the vulns that led to it being there. to get there, // we set the vias to the most "strict" of fix availables. // - false: no fix is available // - {name, version, isSemVerMajor} fix requires -f, is semver major // - {name, version} fix requires -f, not semver major // - true: fix does not require -f for (const v of this.via) { // don't blow up on loops if (v.fixAvailable === f) { continue } if (f === false) { v.fixAvailable = f } else if (v.fixAvailable === true) { v.fixAvailable = f } else if (typeof f === 'object' && ( typeof v.fixAvailable !== 'object' || !v.fixAvailable.isSemVerMajor)) { v.fixAvailable = f } } } get isDirect () { for (const node of this.nodes.values()) { for (const edge of node.edgesIn) { if (edge.from.isProjectRoot || edge.from.isWorkspace) { return true } } } return false } testSpec (spec) { const specObj = npa(spec) if (!specObj.registry) { return true } if (specObj.subSpec) { spec = specObj.subSpec.rawSpec } for (const v of this.versions) { if (satisfies(v, spec) && !satisfies(v, this.range, semverOpt)) { return false } } return true } toJSON () { return { name: this.name, severity: this.severity, isDirect: this.isDirect, // just loop over the advisories, since via is only Vuln objects, // and calculated advisories have all the info we need via: [...this.advisories].map(v => v.type === 'metavuln' ? v.dependency : { ...v, versions: undefined, vulnerableVersions: undefined, id: undefined, }).sort((a, b) => localeCompare(String(a.source || a), String(b.source || b))), effects: [...this.effects].map(v => v.name).sort(localeCompare), range: this.simpleRange, nodes: [...this.nodes].map(n => n.location).sort(localeCompare), fixAvailable: this[_fixAvailable], } } addVia (v) { this.via.add(v) v.effects.add(this) // call the setter since we might add vias _after_ setting fixAvailable this.fixAvailable = this.fixAvailable } deleteVia (v) { this.via.delete(v) v.effects.delete(this) } deleteAdvisory (advisory) { this.advisories.delete(advisory) // make sure we have the max severity of all the vulns causing this one this.severity = null this[_range] = null this[_simpleRange] = null // refresh severity for (const advisory of this.advisories) { this.addAdvisory(advisory) } // remove any effects that are no longer relevant const vias = new Set([...this.advisories].map(a => a.dependency)) for (const via of this.via) { if (!vias.has(via.name)) { this.deleteVia(via) } } } addAdvisory (advisory) { this.advisories.add(advisory) const sev = severities.get(advisory.severity) this[_range] = null this[_simpleRange] = null if (sev > severities.get(this.severity)) { this.severity = advisory.severity } } get range () { return this[_range] || (this[_range] = [...this.advisories].map(v => v.range).join(' || ')) } get simpleRange () { if (this[_simpleRange] && this[_simpleRange] === this[_range]) { return this[_simpleRange] } const versions = [...this.advisories][0].versions const range = this.range const simple = simplifyRange(versions, range, semverOpt) return this[_simpleRange] = this[_range] = simple } isVulnerable (node) { if (this.nodes.has(node)) { return true } const { version } = node.package if (!version) { return false } for (const v of this.advisories) { if (v.testVersion(version)) { this.nodes.add(node) return true } } return false } } module.exports = Vuln