Skip to content

Commit 2366edc

Browse files
committed
feat(query): add :vuln pseudo selector
1 parent 16d4c9f commit 2366edc

File tree

3 files changed

+109
-1
lines changed

3 files changed

+109
-1
lines changed

docs/lib/content/using-npm/dependency-selectors.md

+17-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The [`npm query`](/commands/npm-query) command exposes a new dependency selector
1313
- Unlocks the ability to answer complex, multi-faceted questions about dependencies, their relationships & associative metadata
1414
- Consolidates redundant logic of similar query commands in `npm` (ex. `npm fund`, `npm ls`, `npm outdated`, `npm audit` ...)
1515

16-
### Dependency Selector Syntax `v1.0.0`
16+
### Dependency Selector Syntax
1717

1818
#### Overview:
1919

@@ -62,6 +62,7 @@ The [`npm query`](/commands/npm-query) command exposes a new dependency selector
6262
- `:path(<path>)` [glob](https://www.npmjs.com/package/glob) matching based on dependencies path relative to the project
6363
- `:type(<type>)` [based on currently recognized types](https://github.com/npm/npm-package-arg#result-object)
6464
- `:outdated(<type>)` when a dependency is outdated
65+
- `:vuln(<selector>)` when a dependency has a known vulnerability
6566

6667
##### `:semver(<spec>, [selector], [function])`
6768

@@ -101,6 +102,21 @@ Some examples:
101102
- `:root > :outdated(major)` returns every direct dependency that has a new semver major release
102103
- `.prod:outdated(in-range)` returns production dependencies that have a new release that satisfies at least one of its parent's dependencies
103104

105+
##### `:vuln`
106+
107+
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.
108+
109+
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.
110+
111+
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.
112+
113+
Some examples:
114+
115+
- `:root > .prod:vuln` returns direct production dependencies with any known vulnerability
116+
- `:vuln([severity=high])` returns only dependencies with a vulnerability with a `high` severity.
117+
- `:vuln([severity=high],[severity=moderate])` returns only dependencies with a vulnerability with a `high` or `moderate` severity.
118+
- `:vuln([cwe=1333])` returns only dependencies with a vulnerability that includes CWE-1333 (ReDoS)
119+
104120
#### [Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors)
105121

106122
The attribute selector evaluates the key/value pairs in `package.json` if they are `String`s.

workspaces/arborist/lib/query-selector-all.js

+75
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { minimatch } = require('minimatch')
88
const npa = require('npm-package-arg')
99
const pacote = require('pacote')
1010
const semver = require('semver')
11+
const fetch = require('npm-registry-fetch')
1112

1213
// handle results for parsed query asts, results are stored in a map that has a
1314
// key that points to each ast selector node and stores the resulting array of
@@ -18,6 +19,7 @@ class Results {
1819
#initialItems
1920
#inventory
2021
#outdatedCache = new Map()
22+
#vulnCache
2123
#pendingCombinator
2224
#results = new Map()
2325
#targetNode
@@ -26,6 +28,7 @@ class Results {
2628
this.#currentAstSelector = opts.rootAstNode.nodes[0]
2729
this.#inventory = opts.inventory
2830
this.#initialItems = opts.initialItems
31+
this.#vulnCache = opts.vulnCache
2932
this.#targetNode = opts.targetNode
3033

3134
this.currentResults = this.#initialItems
@@ -211,6 +214,7 @@ class Results {
211214
inventory: this.#inventory,
212215
rootAstNode: this.currentAstNode.nestedNode,
213216
targetNode: item,
217+
vulnCache: this.#vulnCache,
214218
})
215219
if (res.size > 0) {
216220
found.push(item)
@@ -239,6 +243,7 @@ class Results {
239243
inventory: this.#inventory,
240244
rootAstNode: this.currentAstNode.nestedNode,
241245
targetNode: this.currentAstNode,
246+
vulnCache: this.#vulnCache,
242247
})
243248
return [...res]
244249
}
@@ -266,6 +271,7 @@ class Results {
266271
inventory: this.#inventory,
267272
rootAstNode: this.currentAstNode.nestedNode,
268273
targetNode: this.currentAstNode,
274+
vulnCache: this.#vulnCache,
269275
})
270276
const internalSelector = new Set(res)
271277
return this.initialItems.filter(node =>
@@ -432,6 +438,75 @@ class Results {
432438
return this.initialItems.filter(node => node.target.edgesIn.size > 1)
433439
}
434440

441+
async vulnPseudo () {
442+
if (!this.initialItems.length) {
443+
return this.initialItems
444+
}
445+
if (!this.#vulnCache) {
446+
const packages = {}
447+
// We have to map the items twice, once to get the request, and a second time to filter out the results of that request
448+
this.initialItems.map((node) => {
449+
if (node.isProjectRoot || node.package.private) {
450+
return
451+
}
452+
if (!packages[node.name]) {
453+
packages[node.name] = []
454+
}
455+
if (!packages[node.name].includes(node.version)) {
456+
packages[node.name].push(node.version)
457+
}
458+
})
459+
const res = await fetch('/-/npm/v1/security/advisories/bulk', {
460+
...this.flatOptions,
461+
registry: this.flatOptions.auditRegistry || this.flatOptions.registry,
462+
method: 'POST',
463+
gzip: true,
464+
body: packages,
465+
})
466+
this.#vulnCache = await res.json()
467+
}
468+
const advisories = this.#vulnCache
469+
const { vulns } = this.currentAstNode
470+
return this.initialItems.filter(item => {
471+
const vulnerable = advisories[item.name]?.filter(advisory => {
472+
// This could be for another version of this package elsewhere in the tree
473+
if (!semver.intersects(advisory.vulnerable_versions, item.version)) {
474+
return false
475+
}
476+
if (!vulns) {
477+
return true
478+
}
479+
// vulns are OR with each other, if any one matches we're done
480+
for (const vuln of vulns) {
481+
if (vuln.severity && !vuln.severity.includes('*')) {
482+
if (!vuln.severity.includes(advisory.severity)) {
483+
continue
484+
}
485+
}
486+
487+
if (vuln?.cwe) {
488+
// * is special, it means "has a cwe"
489+
if (vuln.cwe.includes('*')) {
490+
if (!advisory.cwe.length) {
491+
continue
492+
}
493+
} else if (!vuln.cwe.every(cwe => advisory.cwe.includes(`CWE-${cwe}`))) {
494+
continue
495+
}
496+
}
497+
return true
498+
}
499+
})
500+
if (vulnerable?.length) {
501+
item.queryContext = {
502+
advisories: vulnerable,
503+
}
504+
return true
505+
}
506+
return false
507+
})
508+
}
509+
435510
async outdatedPseudo () {
436511
const { outdatedKind = 'any' } = this.currentAstNode
437512

workspaces/arborist/test/query-selector-all.js

+17
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ t.test('query-selector-all', async t => {
9999
nock.enableNetConnect()
100100
})
101101

102+
nock('https://registry.npmjs.org')
103+
.persist()
104+
.post('/-/npm/v1/security/advisories/bulk')
105+
.reply(200, {
106+
foo: [{ id: 'test-vuln', vulnerable_versions: '*', severity: 'high', cwe: [] }],
107+
sive: [{ id: 'test-vuln', vulnerable_versions: '*', severity: 'low', cwe: ['CWE-123'] }],
108+
moo: [{ id: 'test-vuln', vulnerable_versions: '<1.0.0' }],
109+
})
102110
for (const [pkg, versions] of Object.entries(packumentStubs)) {
103111
nock('https://registry.npmjs.org')
104112
.persist()
@@ -842,6 +850,15 @@ t.test('query-selector-all', async t => {
842850
], { before: yesterday }],
843851
[':outdated(nonsense)', [], { before: yesterday }], // again, no results here ever
844852

853+
// vuln pseudo
854+
[':vuln', ['foo@2.2.2', 'sive@1.0.0']],
855+
[':vuln([severity=high])', ['foo@2.2.2']],
856+
[':vuln:not(:vuln([cwe=123]))', ['foo@2.2.2']],
857+
[':vuln([cwe])', ['sive@1.0.0']],
858+
[':vuln([cwe=123])', ['sive@1.0.0']],
859+
[':vuln([severity=critical])', []],
860+
['#nomatch:vuln', []], // no network requests are made if the result set is empty
861+
845862
// attr pseudo
846863
[':attr([name=dasher])', ['dasher@2.0.0']],
847864
[':attr(dependencies, [bar="^1.0.0"])', ['foo@2.2.2']],

0 commit comments

Comments
 (0)