Skip to content

Commit d83266b

Browse files
committed
feat(query): add :vuln pseudo selector
1 parent 2bc722f commit d83266b

File tree

3 files changed

+104
-1
lines changed

3 files changed

+104
-1
lines changed

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

Lines changed: 17 additions & 1 deletion
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 edges in
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 `[email protected]` 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

Lines changed: 71 additions & 0 deletions
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
@@ -432,6 +434,75 @@ class Results {
432434
return this.initialItems.filter(node => node.target.edgesIn.size > 1)
433435
}
434436

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

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

Lines changed: 16 additions & 0 deletions
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,14 @@ 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+
855+
[':vuln([severity=high])', ['[email protected]']],
856+
[':vuln([cwe])', ['[email protected]']],
857+
[':vuln([cwe=123])', ['[email protected]']],
858+
[':vuln([severity=critical])', []],
859+
['#nomatch:vuln', []], // no network requests are made if the result set is empty
860+
845861
// attr pseudo
846862
[':attr([name=dasher])', ['[email protected]']],
847863
[':attr(dependencies, [bar="^1.0.0"])', ['[email protected]']],

0 commit comments

Comments
 (0)