This commit is contained in:
2025-08-18 23:06:34 +08:00
parent 0bc04fb659
commit ed18af0cad
1926 changed files with 275098 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
The ISC License
Copyright (c) npm, Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@@ -0,0 +1,435 @@
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

View File

@@ -0,0 +1,15 @@
module.exports = (mani, name) => {
// skip dev because that only matters at the root,
// where we aren't fetching a manifest from the registry
// with multiple versions anyway.
const {
dependencies: deps = {},
optionalDependencies: optDeps = {},
peerDependencies: peerDeps = {},
} = mani
return deps && typeof deps[name] === 'string' ? deps[name]
: optDeps && typeof optDeps[name] === 'string' ? optDeps[name]
: peerDeps && typeof peerDeps[name] === 'string' ? peerDeps[name]
: null
}

View File

@@ -0,0 +1,5 @@
const { createHash } = require('crypto')
module.exports = ({ name, source }) => createHash('sha512')
.update(JSON.stringify([name, source]))
.digest('base64')

View File

@@ -0,0 +1,128 @@
// this is the public class that is used by consumers.
// the Advisory class handles all the calculation, and this
// class handles all the IO with the registry and cache.
const pacote = require('pacote')
const cacache = require('cacache')
const { time } = require('proc-log')
const Advisory = require('./advisory.js')
const { homedir } = require('os')
const jsonParse = require('json-parse-even-better-errors')
const _packument = Symbol('packument')
const _cachePut = Symbol('cachePut')
const _cacheGet = Symbol('cacheGet')
const _cacheData = Symbol('cacheData')
const _packuments = Symbol('packuments')
const _cache = Symbol('cache')
const _options = Symbol('options')
const _advisories = Symbol('advisories')
const _calculate = Symbol('calculate')
class Calculator {
constructor (options = {}) {
this[_options] = { ...options }
this[_cache] = this[_options].cache || (homedir() + '/.npm/_cacache')
this[_options].cache = this[_cache]
this[_packuments] = new Map()
this[_cacheData] = new Map()
this[_advisories] = new Map()
}
get cache () {
return this[_cache]
}
get options () {
return { ...this[_options] }
}
async calculate (name, source) {
const k = `security-advisory:${name}:${source.id}`
if (this[_advisories].has(k)) {
return this[_advisories].get(k)
}
const p = this[_calculate](name, source)
this[_advisories].set(k, p)
return p
}
async [_calculate] (name, source) {
const k = `security-advisory:${name}:${source.id}`
const timeEnd = time.start(`metavuln:calculate:${k}`)
const advisory = new Advisory(name, source, this[_options])
// load packument and cached advisory
const [cached, packument] = await Promise.all([
this[_cacheGet](advisory),
this[_packument](name),
])
const timeEndLoad = time.start(`metavuln:load:${k}`)
advisory.load(cached, packument)
timeEndLoad()
if (advisory.updated) {
await this[_cachePut](advisory)
}
this[_advisories].set(k, advisory)
timeEnd()
return advisory
}
async [_cachePut] (advisory) {
const { name, id } = advisory
const key = `security-advisory:${name}:${id}`
const timeEnd = time.start(`metavuln:cache:put:${key}`)
const data = JSON.stringify(advisory)
const options = { ...this[_options] }
this[_cacheData].set(key, jsonParse(data))
await cacache.put(this[_cache], key, data, options).catch(() => {})
timeEnd()
}
async [_cacheGet] (advisory) {
const { name, id } = advisory
const key = `security-advisory:${name}:${id}`
/* istanbul ignore if - should be impossible, since we memoize the
* advisory object itself using the same key, just being cautious */
if (this[_cacheData].has(key)) {
return this[_cacheData].get(key)
}
const timeEnd = time.start(`metavuln:cache:get:${key}`)
const p = cacache.get(this[_cache], key, { ...this[_options] })
.catch(() => ({ data: '{}' }))
.then(({ data }) => {
data = jsonParse(data)
timeEnd()
this[_cacheData].set(key, data)
return data
})
this[_cacheData].set(key, p)
return p
}
async [_packument] (name) {
if (this[_packuments].has(name)) {
return this[_packuments].get(name)
}
const timeEnd = time.start(`metavuln:packument:${name}`)
const p = pacote.packument(name, { ...this[_options] })
.catch(() => {
// presumably not something from the registry.
// an empty packument will have an effective range of *
return {
name,
versions: {},
}
})
.then(paku => {
timeEnd()
this[_packuments].set(name, paku)
return paku
})
this[_packuments].set(name, p)
return p
}
}
module.exports = Calculator

View File

@@ -0,0 +1,62 @@
{
"name": "@npmcli/metavuln-calculator",
"version": "7.1.1",
"main": "lib/index.js",
"files": [
"bin/",
"lib/"
],
"description": "Calculate meta-vulnerabilities from package security advisories",
"repository": {
"type": "git",
"url": "git+https://github.com/npm/metavuln-calculator.git"
},
"author": "GitHub Inc.",
"license": "ISC",
"scripts": {
"test": "tap",
"posttest": "npm run lint",
"snap": "tap",
"postsnap": "npm run lint",
"eslint": "eslint",
"lint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"",
"lintfix": "npm run lint -- --fix",
"postlint": "template-oss-check",
"template-oss-apply": "template-oss-apply --force"
},
"tap": {
"check-coverage": true,
"coverage-map": "map.js",
"nyc-arg": [
"--exclude",
"tap-snapshots/**"
]
},
"devDependencies": {
"@npmcli/eslint-config": "^4.0.0",
"@npmcli/template-oss": "4.22.0",
"require-inject": "^1.4.4",
"tap": "^16.0.1"
},
"dependencies": {
"cacache": "^18.0.0",
"json-parse-even-better-errors": "^3.0.0",
"pacote": "^18.0.0",
"proc-log": "^4.1.0",
"semver": "^7.3.5"
},
"engines": {
"node": "^16.14.0 || >=18.0.0"
},
"templateOSS": {
"//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
"version": "4.22.0",
"publish": "true",
"ciVersions": [
"16.14.0",
"16.x",
"18.0.0",
"18.x"
]
}
}