From 0cd2149af3529a2eeb19ec2e62ec83d604c37d6e Mon Sep 17 00:00:00 2001 From: Gar Date: Fri, 9 Feb 2024 16:51:20 -0800 Subject: [PATCH] feat: add selectors to :vuln This will require https://github.com/npm/query/pull/65 before it will work --- .../content/using-npm/dependency-selectors.md | 11 ++- workspaces/arborist/lib/query-selector-all.js | 79 +++++++++++++------ .../arborist/test/query-selector-all.js | 10 ++- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/docs/lib/content/using-npm/dependency-selectors.md b/docs/lib/content/using-npm/dependency-selectors.md index fe0ec38cc7fd9..0d05fa3426ac3 100644 --- a/docs/lib/content/using-npm/dependency-selectors.md +++ b/docs/lib/content/using-npm/dependency-selectors.md @@ -62,7 +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 +- `:vuln()` when a dependency has a known vulnerability ##### `:semver(, [selector], [function])` @@ -106,8 +106,17 @@ Some examples: 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 82f5bd9c56de9..149b92fe60555 100644 --- a/workspaces/arborist/lib/query-selector-all.js +++ b/workspaces/arborist/lib/query-selector-all.js @@ -19,6 +19,7 @@ class Results { #initialItems #inventory #outdatedCache = new Map() + #vulnCache #pendingCombinator #results = new Map() #targetNode @@ -437,31 +438,61 @@ class Results { if (!this.initialItems.length) { return this.initialItems } - const packages = {} - // We have to map the items twice, once to get the request, and a second time to filter off 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, - }) - const advisories = await res.json() + 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 => - semver.intersects(advisory.vulnerable_versions, item.version) - ) + 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, diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index d8a854f429dae..9640f88b262c9 100644 --- a/workspaces/arborist/test/query-selector-all.js +++ b/workspaces/arborist/test/query-selector-all.js @@ -100,9 +100,11 @@ t.test('query-selector-all', async t => { }) nock('https://registry.npmjs.org') + .persist() .post('/-/npm/v1/security/advisories/bulk') .reply(200, { - foo: [{ id: 'test-vuln', vulnerable_versions: '*' }], + 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)) { @@ -849,7 +851,11 @@ t.test('query-selector-all', async t => { [':outdated(nonsense)', [], { before: yesterday }], // again, no results here ever // vuln pseudo - [':vuln', ['foo@2.2.2']], + [':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