Skip to content

Commit 0cd2149

Browse files
committed
feat: add selectors to :vuln
This will require npm/query#65 before it will work
1 parent d6ae11d commit 0cd2149

File tree

3 files changed

+73
-27
lines changed

3 files changed

+73
-27
lines changed

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +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` when a dependency has a known vulnerability
65+
- `:vuln(<selector>)` when a dependency has a known vulnerability
6666

6767
##### `:semver(<spec>, [selector], [function])`
6868

@@ -106,8 +106,17 @@ Some examples:
106106

107107
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.
108108

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+
109111
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.
110112

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+
111120
#### [Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors)
112121

113122
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: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Results {
1919
#initialItems
2020
#inventory
2121
#outdatedCache = new Map()
22+
#vulnCache
2223
#pendingCombinator
2324
#results = new Map()
2425
#targetNode
@@ -437,31 +438,61 @@ class Results {
437438
if (!this.initialItems.length) {
438439
return this.initialItems
439440
}
440-
const packages = {}
441-
// We have to map the items twice, once to get the request, and a second time to filter off the results of that request
442-
this.initialItems.map((node) => {
443-
if (node.isProjectRoot || node.package.private) {
444-
return
445-
}
446-
if (!packages[node.name]) {
447-
packages[node.name] = []
448-
}
449-
if (!packages[node.name].includes(node.version)) {
450-
packages[node.name].push(node.version)
451-
}
452-
})
453-
const res = await fetch('/-/npm/v1/security/advisories/bulk', {
454-
...this.flatOptions,
455-
registry: this.flatOptions.auditRegistry || this.flatOptions.registry,
456-
method: 'POST',
457-
gzip: true,
458-
body: packages,
459-
})
460-
const advisories = await res.json()
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
461466
return this.initialItems.filter(item => {
462-
const vulnerable = advisories[item.name]?.filter(advisory =>
463-
semver.intersects(advisory.vulnerable_versions, item.version)
464-
)
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+
})
465496
if (vulnerable?.length) {
466497
item.queryContext = {
467498
advisories: vulnerable,

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,11 @@ t.test('query-selector-all', async t => {
100100
})
101101

102102
nock('https://registry.npmjs.org')
103+
.persist()
103104
.post('/-/npm/v1/security/advisories/bulk')
104105
.reply(200, {
105-
foo: [{ id: 'test-vuln', vulnerable_versions: '*' }],
106+
foo: [{ id: 'test-vuln', vulnerable_versions: '*', severity: 'high', cwe: [] }],
107+
sive: [{ id: 'test-vuln', vulnerable_versions: '*', severity: 'low', cwe: ['CWE-123'] }],
106108
moo: [{ id: 'test-vuln', vulnerable_versions: '<1.0.0' }],
107109
})
108110
for (const [pkg, versions] of Object.entries(packumentStubs)) {
@@ -849,7 +851,11 @@ t.test('query-selector-all', async t => {
849851
[':outdated(nonsense)', [], { before: yesterday }], // again, no results here ever
850852

851853
// vuln pseudo
852-
[':vuln', ['[email protected]']],
854+
855+
[':vuln([severity=high])', ['[email protected]']],
856+
[':vuln([cwe])', ['[email protected]']],
857+
[':vuln([cwe=123])', ['[email protected]']],
858+
[':vuln([severity=critical])', []],
853859
['#nomatch:vuln', []], // no network requests are made if the result set is empty
854860

855861
// attr pseudo

0 commit comments

Comments
 (0)