Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adding :vuln and selectors to npm query #7218

Merged
merged 3 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/lib/content/using-npm/dependency-selectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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

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

Expand Down Expand Up @@ -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 `[email protected]` 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.
Expand Down
42 changes: 42 additions & 0 deletions node_modules/@npmcli/query/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,46 @@ const fixupOutdated = astNode => {
}
}

const fixupVuln = astNode => {
const vulns = []
if (astNode.nodes.length) {
for (const selector of astNode.nodes) {
const vuln = {}
for (const node of selector.nodes) {
if (node.type !== 'attribute') {
throw Object.assign(
new Error(':vuln pseudo-class only accepts attribute matchers or "cwe" tag'),
{ code: 'EQUERYATTR' }
)
}
if (!['severity', 'cwe'].includes(node._attribute)) {
throw Object.assign(
new Error(':vuln pseudo-class only matches "severity" and "cwe" attributes'),
{ code: 'EQUERYATTR' }
)
}
if (!node.operator) {
node.operator = '='
node.value = '*'
}
if (node.operator !== '=') {
throw Object.assign(
new Error(':vuln pseudo-class attribute selector only accepts "=" operator', node),
{ code: 'EQUERYATTR' }
)
}
if (!vuln[node._attribute]) {
vuln[node._attribute] = []
}
vuln[node._attribute].push(node._value)
}
vulns.push(vuln)
}
astNode.vulns = vulns
astNode.nodes.length = 0
}
}

// a few of the supported ast nodes need to be tweaked in order to properly be
// interpreted as proper arborist query selectors, namely semver ranges from
// both ids and :semver pseudo-class selectors need to be translated from what
Expand All @@ -192,6 +232,8 @@ const transformAst = selector => {
return fixupTypes(nextAstNode)
case ':outdated':
return fixupOutdated(nextAstNode)
case ':vuln':
return fixupVuln(nextAstNode)
}
})
}
Expand Down
8 changes: 4 additions & 4 deletions node_modules/@npmcli/query/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"name": "@npmcli/query",
"version": "3.0.1",
"version": "3.1.0",
"description": "npm query parser and tools",
"main": "lib/index.js",
"scripts": {
"test": "tap",
"lint": "eslint \"**/*.js\"",
"lint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"",
"postlint": "template-oss-check",
"template-oss-apply": "template-oss-apply --force",
"lintfix": "npm run lint -- --fix",
Expand Down Expand Up @@ -39,12 +39,12 @@
},
"templateOSS": {
"//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
"version": "4.18.0",
"version": "4.21.3",
"publish": true
},
"devDependencies": {
"@npmcli/eslint-config": "^4.0.0",
"@npmcli/template-oss": "4.18.0",
"@npmcli/template-oss": "4.21.3",
"tap": "^16.2.0"
},
"dependencies": {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1893,9 +1893,9 @@
}
},
"node_modules/@npmcli/query": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@npmcli/query/-/query-3.0.1.tgz",
"integrity": "sha512-0jE8iHBogf/+bFDj+ju6/UMLbJ39c8h6nSe6qile+dB7PJ0iV3gNqcb2vtt6WWCBrxv9uAjzUT/8vroluulidA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@npmcli/query/-/query-3.1.0.tgz",
"integrity": "sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ==",
"dependencies": {
"postcss-selector-parser": "^6.0.10"
},
Expand Down Expand Up @@ -16035,7 +16035,7 @@
"@npmcli/name-from-folder": "^2.0.0",
"@npmcli/node-gyp": "^3.0.0",
"@npmcli/package-json": "^5.0.0",
"@npmcli/query": "^3.0.1",
"@npmcli/query": "^3.1.0",
"@npmcli/run-script": "^7.0.2",
"bin-links": "^4.0.1",
"cacache": "^18.0.0",
Expand Down
80 changes: 80 additions & 0 deletions workspaces/arborist/lib/query-selector-all.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ class Results {
#initialItems
#inventory
#outdatedCache = new Map()
#vulnCache
#pendingCombinator
#results = new Map()
#targetNode
Expand All @@ -26,6 +28,7 @@ class Results {
this.#currentAstSelector = opts.rootAstNode.nodes[0]
this.#inventory = opts.inventory
this.#initialItems = opts.initialItems
this.#vulnCache = opts.vulnCache
this.#targetNode = opts.targetNode

this.currentResults = this.#initialItems
Expand Down Expand Up @@ -211,6 +214,7 @@ class Results {
inventory: this.#inventory,
rootAstNode: this.currentAstNode.nestedNode,
targetNode: item,
vulnCache: this.#vulnCache,
})
if (res.size > 0) {
found.push(item)
Expand Down Expand Up @@ -239,6 +243,7 @@ class Results {
inventory: this.#inventory,
rootAstNode: this.currentAstNode.nestedNode,
targetNode: this.currentAstNode,
vulnCache: this.#vulnCache,
})
return [...res]
}
Expand Down Expand Up @@ -266,6 +271,7 @@ class Results {
inventory: this.#inventory,
rootAstNode: this.currentAstNode.nestedNode,
targetNode: this.currentAstNode,
vulnCache: this.#vulnCache,
})
const internalSelector = new Set(res)
return this.initialItems.filter(node =>
Expand Down Expand Up @@ -432,6 +438,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

Expand All @@ -445,6 +520,11 @@ class Results {
return false
}

// private packages can't be published, skip them
if (node.package.private) {
return false
}

// we cache the promise representing the full versions list, this helps reduce the
// number of requests we send by keeping population of the cache in a single tick
// making it less likely that multiple requests for the same package will be inflight
Expand Down
2 changes: 1 addition & 1 deletion workspaces/arborist/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"@npmcli/name-from-folder": "^2.0.0",
"@npmcli/node-gyp": "^3.0.0",
"@npmcli/package-json": "^5.0.0",
"@npmcli/query": "^3.0.1",
"@npmcli/query": "^3.1.0",
"@npmcli/run-script": "^7.0.2",
"bin-links": "^4.0.1",
"cacache": "^18.0.0",
Expand Down
17 changes: 17 additions & 0 deletions workspaces/arborist/test/query-selector-all.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -842,6 +850,15 @@ t.test('query-selector-all', async t => {
], { before: yesterday }],
[':outdated(nonsense)', [], { before: yesterday }], // again, no results here ever

// vuln pseudo
[':vuln', ['[email protected]', '[email protected]']],
[':vuln([severity=high])', ['[email protected]']],
[':vuln:not(:vuln([cwe=123]))', ['[email protected]']],
[':vuln([cwe])', ['[email protected]']],
[':vuln([cwe=123])', ['[email protected]']],
[':vuln([severity=critical])', []],
['#nomatch:vuln', []], // no network requests are made if the result set is empty

// attr pseudo
[':attr([name=dasher])', ['[email protected]']],
[':attr(dependencies, [bar="^1.0.0"])', ['[email protected]']],
Expand Down
Loading