diff --git a/docs/lib/content/using-npm/dependency-selectors.md b/docs/lib/content/using-npm/dependency-selectors.md index 87a722748c261..0d05fa3426ac3 100644 --- a/docs/lib/content/using-npm/dependency-selectors.md +++ b/docs/lib/content/using-npm/dependency-selectors.md @@ -13,7 +13,7 @@ The [`npm query`](/commands/npm-query) command exposes a new dependency selector - Unlocks the ability to answer complex, multi-faceted questions about dependencies, their relationships & associative metadata - Consolidates redundant logic of similar query commands in `npm` (ex. `npm fund`, `npm ls`, `npm outdated`, `npm audit` ...) -### Dependency Selector Syntax `v1.0.0` +### Dependency Selector Syntax #### Overview: @@ -62,6 +62,7 @@ The [`npm query`](/commands/npm-query) command exposes a new dependency selector - `:path()` [glob](https://www.npmjs.com/package/glob) matching based on dependencies path relative to the project - `:type()` [based on currently recognized types](https://github.com/npm/npm-package-arg#result-object) - `:outdated()` when a dependency is outdated +- `:vuln()` when a dependency has a known vulnerability ##### `:semver(, [selector], [function])` @@ -101,6 +102,21 @@ Some examples: - `:root > :outdated(major)` returns every direct dependency that has a new semver major release - `.prod:outdated(in-range)` returns production dependencies that have a new release that satisfies at least one of its edges in +##### `:vuln` + +The `:vuln` pseudo selector retrieves data from the registry and returns information about which if your dependencies has a known vulnerability. Only dependencies whose current version matches a vulnerability will be returned. For example if you have `semver@7.6.0` in your tree, a vulnerability for `semver` which affects versions `<=6.3.1` will not match. + +You can also filter results by certain attributes in advisories. Currently that includes `severity` and `cwe`. Note that severity filtering is done per severity, it does not include severities "higher" or "lower" than the one specified. + +In addition to the filtering performed by the pseudo selector, info about each relevant advisory will be added to the `queryContext` attribute of each node under the `advisories` attribute. + +Some examples: + +- `:root > .prod:vuln` returns direct production dependencies with any known vulnerability +- `:vuln([severity=high])` returns only dependencies with a vulnerability with a `high` severity. +- `:vuln([severity=high],[severity=moderate])` returns only dependencies with a vulnerability with a `high` or `moderate` severity. +- `:vuln([cwe=1333])` returns only dependencies with a vulnerability that includes CWE-1333 (ReDoS) + #### [Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) The attribute selector evaluates the key/value pairs in `package.json` if they are `String`s. diff --git a/node_modules/@npmcli/query/lib/index.js b/node_modules/@npmcli/query/lib/index.js index c7888d5bb5de6..db7dc345a8c75 100644 --- a/node_modules/@npmcli/query/lib/index.js +++ b/node_modules/@npmcli/query/lib/index.js @@ -166,6 +166,46 @@ const fixupOutdated = astNode => { } } +const fixupVuln = astNode => { + const vulns = [] + if (astNode.nodes.length) { + for (const selector of astNode.nodes) { + const vuln = {} + for (const node of selector.nodes) { + if (node.type !== 'attribute') { + throw Object.assign( + new Error(':vuln pseudo-class only accepts attribute matchers or "cwe" tag'), + { code: 'EQUERYATTR' } + ) + } + if (!['severity', 'cwe'].includes(node._attribute)) { + throw Object.assign( + new Error(':vuln pseudo-class only matches "severity" and "cwe" attributes'), + { code: 'EQUERYATTR' } + ) + } + if (!node.operator) { + node.operator = '=' + node.value = '*' + } + if (node.operator !== '=') { + throw Object.assign( + new Error(':vuln pseudo-class attribute selector only accepts "=" operator', node), + { code: 'EQUERYATTR' } + ) + } + if (!vuln[node._attribute]) { + vuln[node._attribute] = [] + } + vuln[node._attribute].push(node._value) + } + vulns.push(vuln) + } + astNode.vulns = vulns + astNode.nodes.length = 0 + } +} + // a few of the supported ast nodes need to be tweaked in order to properly be // interpreted as proper arborist query selectors, namely semver ranges from // both ids and :semver pseudo-class selectors need to be translated from what @@ -192,6 +232,8 @@ const transformAst = selector => { return fixupTypes(nextAstNode) case ':outdated': return fixupOutdated(nextAstNode) + case ':vuln': + return fixupVuln(nextAstNode) } }) } diff --git a/node_modules/@npmcli/query/package.json b/node_modules/@npmcli/query/package.json index 5f9fb2744538a..ad45c18c44cd6 100644 --- a/node_modules/@npmcli/query/package.json +++ b/node_modules/@npmcli/query/package.json @@ -1,11 +1,11 @@ { "name": "@npmcli/query", - "version": "3.0.1", + "version": "3.1.0", "description": "npm query parser and tools", "main": "lib/index.js", "scripts": { "test": "tap", - "lint": "eslint \"**/*.js\"", + "lint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"", "postlint": "template-oss-check", "template-oss-apply": "template-oss-apply --force", "lintfix": "npm run lint -- --fix", @@ -39,12 +39,12 @@ }, "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", - "version": "4.18.0", + "version": "4.21.3", "publish": true }, "devDependencies": { "@npmcli/eslint-config": "^4.0.0", - "@npmcli/template-oss": "4.18.0", + "@npmcli/template-oss": "4.21.3", "tap": "^16.2.0" }, "dependencies": { diff --git a/package-lock.json b/package-lock.json index 1b7e1aa128c15..05406c6f7fe86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1893,9 +1893,9 @@ } }, "node_modules/@npmcli/query": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-3.0.1.tgz", - "integrity": "sha512-0jE8iHBogf/+bFDj+ju6/UMLbJ39c8h6nSe6qile+dB7PJ0iV3gNqcb2vtt6WWCBrxv9uAjzUT/8vroluulidA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-3.1.0.tgz", + "integrity": "sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ==", "dependencies": { "postcss-selector-parser": "^6.0.10" }, @@ -16035,7 +16035,7 @@ "@npmcli/name-from-folder": "^2.0.0", "@npmcli/node-gyp": "^3.0.0", "@npmcli/package-json": "^5.0.0", - "@npmcli/query": "^3.0.1", + "@npmcli/query": "^3.1.0", "@npmcli/run-script": "^7.0.2", "bin-links": "^4.0.1", "cacache": "^18.0.0", diff --git a/workspaces/arborist/lib/query-selector-all.js b/workspaces/arborist/lib/query-selector-all.js index 96c52144060b8..0c37ebd46be76 100644 --- a/workspaces/arborist/lib/query-selector-all.js +++ b/workspaces/arborist/lib/query-selector-all.js @@ -8,6 +8,7 @@ const { minimatch } = require('minimatch') const npa = require('npm-package-arg') const pacote = require('pacote') const semver = require('semver') +const fetch = require('npm-registry-fetch') // handle results for parsed query asts, results are stored in a map that has a // key that points to each ast selector node and stores the resulting array of @@ -18,6 +19,7 @@ class Results { #initialItems #inventory #outdatedCache = new Map() + #vulnCache #pendingCombinator #results = new Map() #targetNode @@ -26,6 +28,7 @@ class Results { this.#currentAstSelector = opts.rootAstNode.nodes[0] this.#inventory = opts.inventory this.#initialItems = opts.initialItems + this.#vulnCache = opts.vulnCache this.#targetNode = opts.targetNode this.currentResults = this.#initialItems @@ -211,6 +214,7 @@ class Results { inventory: this.#inventory, rootAstNode: this.currentAstNode.nestedNode, targetNode: item, + vulnCache: this.#vulnCache, }) if (res.size > 0) { found.push(item) @@ -239,6 +243,7 @@ class Results { inventory: this.#inventory, rootAstNode: this.currentAstNode.nestedNode, targetNode: this.currentAstNode, + vulnCache: this.#vulnCache, }) return [...res] } @@ -266,6 +271,7 @@ class Results { inventory: this.#inventory, rootAstNode: this.currentAstNode.nestedNode, targetNode: this.currentAstNode, + vulnCache: this.#vulnCache, }) const internalSelector = new Set(res) return this.initialItems.filter(node => @@ -432,6 +438,75 @@ class Results { return this.initialItems.filter(node => node.target.edgesIn.size > 1) } + async vulnPseudo () { + if (!this.initialItems.length) { + return this.initialItems + } + if (!this.#vulnCache) { + const packages = {} + // We have to map the items twice, once to get the request, and a second time to filter out the results of that request + this.initialItems.map((node) => { + if (node.isProjectRoot || node.package.private) { + return + } + if (!packages[node.name]) { + packages[node.name] = [] + } + if (!packages[node.name].includes(node.version)) { + packages[node.name].push(node.version) + } + }) + const res = await fetch('/-/npm/v1/security/advisories/bulk', { + ...this.flatOptions, + registry: this.flatOptions.auditRegistry || this.flatOptions.registry, + method: 'POST', + gzip: true, + body: packages, + }) + this.#vulnCache = await res.json() + } + const advisories = this.#vulnCache + const { vulns } = this.currentAstNode + return this.initialItems.filter(item => { + const vulnerable = advisories[item.name]?.filter(advisory => { + // This could be for another version of this package elsewhere in the tree + if (!semver.intersects(advisory.vulnerable_versions, item.version)) { + return false + } + if (!vulns) { + return true + } + // vulns are OR with each other, if any one matches we're done + for (const vuln of vulns) { + if (vuln.severity && !vuln.severity.includes('*')) { + if (!vuln.severity.includes(advisory.severity)) { + continue + } + } + + if (vuln?.cwe) { + // * is special, it means "has a cwe" + if (vuln.cwe.includes('*')) { + if (!advisory.cwe.length) { + continue + } + } else if (!vuln.cwe.every(cwe => advisory.cwe.includes(`CWE-${cwe}`))) { + continue + } + } + return true + } + }) + if (vulnerable?.length) { + item.queryContext = { + advisories: vulnerable, + } + return true + } + return false + }) + } + async outdatedPseudo () { const { outdatedKind = 'any' } = this.currentAstNode @@ -445,6 +520,11 @@ class Results { return false } + // private packages can't be published, skip them + if (node.package.private) { + return false + } + // we cache the promise representing the full versions list, this helps reduce the // number of requests we send by keeping population of the cache in a single tick // making it less likely that multiple requests for the same package will be inflight diff --git a/workspaces/arborist/package.json b/workspaces/arborist/package.json index d1d145af06c6a..6e6d52024b50c 100644 --- a/workspaces/arborist/package.json +++ b/workspaces/arborist/package.json @@ -11,7 +11,7 @@ "@npmcli/name-from-folder": "^2.0.0", "@npmcli/node-gyp": "^3.0.0", "@npmcli/package-json": "^5.0.0", - "@npmcli/query": "^3.0.1", + "@npmcli/query": "^3.1.0", "@npmcli/run-script": "^7.0.2", "bin-links": "^4.0.1", "cacache": "^18.0.0", diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index 08d62034479ea..d1b9b9acb4674 100644 --- a/workspaces/arborist/test/query-selector-all.js +++ b/workspaces/arborist/test/query-selector-all.js @@ -99,6 +99,14 @@ t.test('query-selector-all', async t => { nock.enableNetConnect() }) + nock('https://registry.npmjs.org') + .persist() + .post('/-/npm/v1/security/advisories/bulk') + .reply(200, { + foo: [{ id: 'test-vuln', vulnerable_versions: '*', severity: 'high', cwe: [] }], + sive: [{ id: 'test-vuln', vulnerable_versions: '*', severity: 'low', cwe: ['CWE-123'] }], + moo: [{ id: 'test-vuln', vulnerable_versions: '<1.0.0' }], + }) for (const [pkg, versions] of Object.entries(packumentStubs)) { nock('https://registry.npmjs.org') .persist() @@ -842,6 +850,15 @@ t.test('query-selector-all', async t => { ], { before: yesterday }], [':outdated(nonsense)', [], { before: yesterday }], // again, no results here ever + // vuln pseudo + [':vuln', ['foo@2.2.2', 'sive@1.0.0']], + [':vuln([severity=high])', ['foo@2.2.2']], + [':vuln:not(:vuln([cwe=123]))', ['foo@2.2.2']], + [':vuln([cwe])', ['sive@1.0.0']], + [':vuln([cwe=123])', ['sive@1.0.0']], + [':vuln([severity=critical])', []], + ['#nomatch:vuln', []], // no network requests are made if the result set is empty + // attr pseudo [':attr([name=dasher])', ['dasher@2.0.0']], [':attr(dependencies, [bar="^1.0.0"])', ['foo@2.2.2']],