From d83266b6bf93d2e8b65b9615abda040327cf94c8 Mon Sep 17 00:00:00 2001 From: Gar Date: Tue, 6 Feb 2024 13:54:29 -0800 Subject: [PATCH] feat(query): add :vuln pseudo selector --- .../content/using-npm/dependency-selectors.md | 18 ++++- workspaces/arborist/lib/query-selector-all.js | 71 +++++++++++++++++++ .../arborist/test/query-selector-all.js | 16 +++++ 3 files changed, 104 insertions(+), 1 deletion(-) 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/workspaces/arborist/lib/query-selector-all.js b/workspaces/arborist/lib/query-selector-all.js index 5eb3bcc741de3..149b92fe60555 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 @@ -432,6 +434,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 diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index 08d62034479ea..9640f88b262c9 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,14 @@ 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([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']],