관리-도구
편집 파일: advisory.js
const hash = require('./hash.js') const semver = require('semver') const semverOpt = { includePrerelease: true, loose: true } const getDepSpec = require('./get-dep-spec.js') // any fields that we don't want in the cache need to be hidden const _source = Symbol('source') const _packument = Symbol('packument') const _versionVulnMemo = Symbol('versionVulnMemo') const _updated = Symbol('updated') const _options = Symbol('options') const _specVulnMemo = Symbol('specVulnMemo') const _testVersion = Symbol('testVersion') const _testVersions = Symbol('testVersions') const _calculateRange = Symbol('calculateRange') const _markVulnerable = Symbol('markVulnerable') const _testSpec = Symbol('testSpec') class Advisory { constructor (name, source, options = {}) { this.source = source.id this[_source] = source this[_options] = options this.name = name if (!source.name) { source.name = name } this.dependency = source.name if (this.type === 'advisory') { this.title = source.title this.url = source.url } else { this.title = `Depends on vulnerable versions of ${source.name}` this.url = null } this.severity = source.severity || 'high' this.versions = [] this.vulnerableVersions = [] this.cwe = source.cwe this.cvss = source.cvss // advisories have the range, metavulns do not // if an advisory doesn't specify range, assume all are vulnerable this.range = this.type === 'advisory' ? source.vulnerable_versions || '*' : null this.id = hash(this) this[_packument] = null // memoized list of which versions are vulnerable this[_versionVulnMemo] = new Map() // memoized list of which dependency specs are vulnerable this[_specVulnMemo] = new Map() this[_updated] = false } // true if we updated from what we had in cache get updated () { return this[_updated] } get type () { return this.dependency === this.name ? 'advisory' : 'metavuln' } get packument () { return this[_packument] } // load up the data from a cache entry and a fetched packument load (cached, packument) { // basic data integrity gutcheck if (!cached || typeof cached !== 'object') { throw new TypeError('invalid cached data, expected object') } if (!packument || typeof packument !== 'object') { throw new TypeError('invalid packument data, expected object') } if (cached.id && cached.id !== this.id) { throw Object.assign(new Error('loading from incorrect cache entry'), { expected: this.id, actual: cached.id, }) } if (packument.name !== this.name) { throw Object.assign(new Error('loading from incorrect packument'), { expected: this.name, actual: packument.name, }) } if (this[_packument]) { throw new Error('advisory object already loaded') } // if we have a range from the initialization, and the cached // data has a *different* range, then we know we have to recalc. // just don't use the cached data, so we will definitely not match later if (!this.range || cached.range && cached.range === this.range) { Object.assign(this, cached) } this[_packument] = packument const pakuVersions = Object.keys(packument.versions || {}) const allVersions = new Set([...pakuVersions, ...this.versions]) const versionsAdded = [] const versionsRemoved = [] for (const v of allVersions) { if (!this.versions.includes(v)) { versionsAdded.push(v) this.versions.push(v) } else if (!pakuVersions.includes(v)) { versionsRemoved.push(v) } } // strip out any removed versions from our lists, and sort by semver this.versions = semver.sort(this.versions.filter(v => !versionsRemoved.includes(v)), semverOpt) // if no changes, then just return what we got from cache // versions added or removed always means we changed // otherwise, advisories change if the range changes, and // metavulns change if the source was updated const unchanged = this.type === 'advisory' ? this.range && this.range === cached.range : !this[_source].updated // if the underlying source changed, by an advisory updating the // range, or a source advisory being updated, then we have to re-check // otherwise, only recheck the new ones. this.vulnerableVersions = !unchanged ? [] : semver.sort(this.vulnerableVersions.filter(v => !versionsRemoved.includes(v)), semverOpt) if (unchanged && !versionsAdded.length && !versionsRemoved.length) { // nothing added or removed, nothing to do here. use the cached copy. return this } this[_updated] = true // test any versions newly added if (!unchanged || versionsAdded.length) { this[_testVersions](unchanged ? versionsAdded : this.versions) } this.vulnerableVersions = semver.sort(this.vulnerableVersions, semverOpt) // metavulns have to calculate their range, since cache is invalidated // advisories just get their range from the advisory above if (this.type === 'metavuln') { this[_calculateRange]() } return this } [_calculateRange] () { // calling semver.simplifyRange with a massive list of versions, and those // versions all concatenated with `||` is a geometric CPU explosion! // we can try to be a *little* smarter up front by doing x-y for all // contiguous version sets in the list const ranges = [] this.versions = semver.sort(this.versions, semverOpt) this.vulnerableVersions = semver.sort(this.vulnerableVersions, semverOpt) for (let v = 0, vulnVer = 0; v < this.versions.length; v++) { // figure out the vulnerable subrange const vr = [this.versions[v]] while (v < this.versions.length) { if (this.versions[v] !== this.vulnerableVersions[vulnVer]) { // we don't test prerelease versions, so just skip past it if (/-/.test(this.versions[v])) { v++ continue } break } if (vr.length > 1) { vr[1] = this.versions[v] } else { vr.push(this.versions[v]) } v++ vulnVer++ } // it'll either be just the first version, which means no overlap, // or the start and end versions, which might be the same version if (vr.length > 1) { const tail = this.versions[this.versions.length - 1] ranges.push(vr[1] === tail ? `>=${vr[0]}` : vr[0] === vr[1] ? vr[0] : vr.join(' - ')) } } const metavuln = ranges.join(' || ').trim() this.range = !metavuln ? '<0.0.0-0' : semver.simplifyRange(this.versions, metavuln, semverOpt) } // returns true if marked as vulnerable, false if ok // spec is a dependency specifier, for metavuln cases // where the version might not be in the packument. if // we have the packument and spec is not provided, then // we use the dependency version from the manifest. testVersion (version, spec = null) { const sv = String(version) if (this[_versionVulnMemo].has(sv)) { return this[_versionVulnMemo].get(sv) } const result = this[_testVersion](version, spec) if (result) { this[_markVulnerable](version) } this[_versionVulnMemo].set(sv, !!result) return result } [_markVulnerable] (version) { const sv = String(version) if (!this.vulnerableVersions.includes(sv)) { this.vulnerableVersions.push(sv) } } [_testVersion] (version, spec) { const sv = String(version) if (this.vulnerableVersions.includes(sv)) { return true } if (this.type === 'advisory') { // advisory, just test range return semver.satisfies(version, this.range, semverOpt) } // check the dependency of this version on the vulnerable dep // if we got a version that's not in the packument, fall back on // the spec provided, if possible. const mani = this[_packument]?.versions?.[version] || { dependencies: { [this.dependency]: spec, }, } if (!spec) { spec = getDepSpec(mani, this.dependency) } // no dep, no vuln if (spec === null) { return false } if (!semver.validRange(spec, semverOpt)) { // not a semver range, nothing we can hope to do about it return true } const bd = mani.bundleDependencies const bundled = bd && bd.includes(this[_source].name) // XXX if bundled, then semver.intersects() means vulnerable // else, pick a manifest and see if it can't be avoided // try to pick a version of the dep that isn't vulnerable const avoid = this[_source].range if (bundled) { return semver.intersects(spec, avoid, semverOpt) } return this[_source].testSpec(spec) } testSpec (spec) { // testing all the versions is a bit costly, and the spec tends to stay // consistent across multiple versions, so memoize this as well, in case // we're testing lots of versions. const memo = this[_specVulnMemo] if (memo.has(spec)) { return memo.get(spec) } const res = this[_testSpec](spec) memo.set(spec, res) return res } [_testSpec] (spec) { for (const v of this.versions) { const satisfies = semver.satisfies(v, spec) if (!satisfies) { continue } if (!this.testVersion(v)) { return false } } // either vulnerable, or not installable because nothing satisfied // either way, best avoided. return true } [_testVersions] (versions) { if (!versions.length) { return } // set of lists of versions const versionSets = new Set() versions = semver.sort(versions.map(v => semver.parse(v, semverOpt))) // start out with the versions grouped by major and minor let last = versions[0].major + '.' + versions[0].minor let list = [] versionSets.add(list) for (const v of versions) { const k = v.major + '.' + v.minor if (k !== last) { last = k list = [] versionSets.add(list) } list.push(v) } for (const set of versionSets) { // it's common to have version lists like: // 1.0.0 // 1.0.1-alpha.0 // 1.0.1-alpha.1 // ... // 1.0.1-alpha.999 // 1.0.1 // 1.0.2-alpha.0 // ... // 1.0.2-alpha.99 // 1.0.2 // with a huge number of prerelease versions that are not installable // anyway. // If mid has a prerelease tag, and set[0] does not, then walk it // back until we hit a non-prerelease version // If mid has a prerelease tag, and set[set.length-1] does not, // then walk it forward until we hit a version without a prerelease tag // Similarly, if the head/tail is a prerelease, but there is a non-pr // version in the set, then start there instead. let h = 0 const origHeadVuln = this.testVersion(set[h]) while (h < set.length && /-/.test(String(set[h]))) { h++ } // don't filter out the whole list! they might all be pr's if (h === set.length) { h = 0 } else if (origHeadVuln) { // if the original was vulnerable, assume so are all of these for (let hh = 0; hh < h; hh++) { this[_markVulnerable](set[hh]) } } let t = set.length - 1 const origTailVuln = this.testVersion(set[t]) while (t > h && /-/.test(String(set[t]))) { t-- } // don't filter out the whole list! might all be pr's if (t === h) { t = set.length - 1 } else if (origTailVuln) { // if original tail was vulnerable, assume these are as well for (let tt = set.length - 1; tt > t; tt--) { this[_markVulnerable](set[tt]) } } const headVuln = h === 0 ? origHeadVuln : this.testVersion(set[h]) const tailVuln = t === set.length - 1 ? origTailVuln : this.testVersion(set[t]) // if head and tail both vulnerable, whole list is thrown out if (headVuln && tailVuln) { for (let v = h; v < t; v++) { this[_markVulnerable](set[v]) } continue } // if length is 2 or 1, then we marked them all already if (t < h + 2) { continue } const mid = Math.floor(set.length / 2) const pre = set.slice(0, mid) const post = set.slice(mid) // if the parent list wasn't prereleases, then drop pr tags // from end of the pre list, and beginning of the post list, // marking as vulnerable if the midpoint item we picked is. if (!/-/.test(String(pre[0]))) { const midVuln = this.testVersion(pre[pre.length - 1]) while (/-/.test(String(pre[pre.length - 1]))) { const v = pre.pop() if (midVuln) { this[_markVulnerable](v) } } } if (!/-/.test(String(post[post.length - 1]))) { const midVuln = this.testVersion(post[0]) while (/-/.test(String(post[0]))) { const v = post.shift() if (midVuln) { this[_markVulnerable](v) } } } versionSets.add(pre) versionSets.add(post) } } } module.exports = Advisory