From 13edfd7073ca04833158078cd22e3f230efe9127 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 8 Oct 2026 09:22:49 -0400 Subject: [PATCH 1/2] Patch actionable dependency vulnerabilities --- package-lock.json | 38 +++++++++++++++++++++----------------- package.json | 8 ++++---- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70c8592..045fcd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "morgan": "~1.12.0", + "morgan": "~1.12.1", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -1510,9 +1510,9 @@ } }, "node_modules/adm-zip": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", - "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", "license": "MIT", "engines": { "node": ">=14.0" @@ -1617,13 +1617,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -1852,9 +1852,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4809,9 +4809,9 @@ } }, "node_modules/morgan": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", - "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -5330,9 +5330,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -5340,6 +5340,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/package.json b/package.json index abbe9f6..8507465 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "morgan": "~1.12.0", + "morgan": "~1.12.1", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -32,9 +32,9 @@ "swagger-autogen": "^2.23.7" }, "overrides": { - "adm-zip": "0.6.0", - "axios": "1.18.0", - "brace-expansion": "5.0.9", + "adm-zip": "0.6.1", + "axios": "1.20.0", + "brace-expansion": "5.0.12", "js-yaml": "4.3.2", "lodash": "4.18.1", "lodash.unset": "4.18.0", From 42e01ac6f939ca452dbef4a87f460ed730465518 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:19:25 -0400 Subject: [PATCH 2/2] Analyze keyword prefixes for hyphenated search terms --- README.md | 2 +- api-docs/openapi.json | 2 +- controllers/searchController.test.js | 3 ++ routes/search.integration.test.js | 41 ++++++++++++++++++++++++++++ routes/swagger.js | 2 +- utils/searchQueryBuilder.js | 1 + utils/searchQueryBuilder.test.js | 28 +++++++++++++++++++ 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fd53f3c..4db9a8a 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Scope applies to filter matching as follows: Missing data in selected containers does not make an otherwise compatible request invalid. A positive filter cannot match a record that lacks the requested data. Exclusion filters behave differently: `knownExploited: false` and `disputed: false` can match records with no selected KEV metric or disputed tag, including records without a selected container. Other supplied filters must still match. The API does not remove unselected containers from a returned record. -`keywordSearch` searches only description text selected by `scope`. By default, the API escapes caller-supplied query syntax, adds a trailing prefix wildcard to each whitespace-delimited term, and combines terms with `AND` using `simple_query_string`. For example, `remote code` searches for `remote* AND code*`. With `keywordExactMatch: true`, it instead uses an analyzed phrase query in the selected description fields. Phrase matching follows the index's text analyzer; it is not a case-sensitive, character-for-character substring comparison. +`keywordSearch` searches only description text selected by `scope`. By default, the API escapes caller-supplied query syntax, adds a trailing prefix wildcard to each whitespace-delimited term, and combines terms with `AND` using `simple_query_string` with wildcard analysis enabled. For example, `remote code` searches for `remote* AND code*`. Each prefix term is processed by the selected field's text analyzer. With the standard analyzer, `use-after-free` becomes required `use` and `after` tokens plus a `free*` prefix; those tokens need not be adjacent or in order. This also allows hyphenated CVE and CWE IDs to match when present in description text; use `cveIds` or `cweId` to search their dedicated fields. With `keywordExactMatch: true`, the API instead uses an analyzed phrase query in the selected description fields. Phrase matching follows the index's text analyzer; it is not a case-sensitive, character-for-character substring comparison. SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, timestamps use the API's full UTC timestamp format with inclusive boundaries, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 43f0bec..ead4dde 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -650,7 +650,7 @@ "type": "string", "maxLength": 512, "example": "remote code execution", - "description": "Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string. For example, remote code searches for remote* AND code*. Matching follows the index text analyzer. Limited to 512 characters and 64 whitespace-delimited terms." + "description": "Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string with wildcard analysis enabled. For example, remote code searches for remote* AND code*. With the standard analyzer, use-after-free requires use and after tokens plus a free* prefix, without requiring adjacency or order. Hyphenated CVE and CWE IDs can match description text; use cveIds or cweId for their dedicated fields. Matching follows the selected field's text analyzer. Limited to 512 characters and 64 whitespace-delimited terms." }, "keywordExactMatch": { "oneOf": [ diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8319ec4..fe0a9a7 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -203,6 +203,7 @@ describe('Search controller SEARCH handler', () => { simple_query_string: { query: 'remote* code*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } } @@ -1026,6 +1027,7 @@ describe('Search controller SEARCH handler', () => { simple_query_string: { query: 'c\\+\\+* path\\:\\/tmp* \\(test\\)*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } } @@ -1051,6 +1053,7 @@ describe('Search controller SEARCH handler', () => { simple_query_string: { query: '', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } } diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 63c3958..7a32ba0 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -86,6 +86,7 @@ describe('search route integration', () => { simple_query_string: { query: 'remote* code*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value', 'containers.adp.descriptions.value'] } } @@ -124,6 +125,46 @@ describe('search route integration', () => { }); }); + test.each([ + ['FULL', ['containers.cna.descriptions.value', 'containers.adp.descriptions.value']], + ['CNA_ONLY', ['containers.cna.descriptions.value']], + ['ADP_ONLY', ['containers.adp.descriptions.value']] + ])('POST /search keeps prefix analysis and phrase matching scoped to %s', async (scope, fields) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + + const prefixResponse = await request(createApp(), { + method: 'POST', + path: '/search', + body: { keywordSearch: 'use-after-free', scope } + }); + + expect(prefixResponse.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.must).toStrictEqual([ + { + simple_query_string: { + query: 'use\\-after\\-free*', + default_operator: 'AND', + analyze_wildcard: true, + fields + } + } + ]); + + const phraseResponse = await request(createApp(), { + method: 'POST', + path: '/search', + body: { keywordSearch: 'use-after-free', keywordExactMatch: true, scope } + }); + + expect(phraseResponse.status).toBe(200); + const phraseBody = mockSearchOpenSearch.mock.calls[1][0]; + expect(phraseBody.query.bool.must).toStrictEqual([]); + const clauses = fields.map((field) => ({ match_phrase: { [field]: 'use-after-free' } })); + expect(phraseBody.query.bool.filter).toStrictEqual([ + clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } } + ]); + }); + test('POST /search builds a scope-aware literal reference URL substring filter', async () => { mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); diff --git a/routes/swagger.js b/routes/swagger.js index 2dcc8c8..08e5e52 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -44,7 +44,7 @@ const searchRequestProperties = { } ), keywordSearch: searchRequestProperty( - `Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string. For example, remote code searches for remote* AND code*. Matching follows the index text analyzer. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, + `Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string with wildcard analysis enabled. For example, remote code searches for remote* AND code*. With the standard analyzer, use-after-free requires use and after tokens plus a free* prefix, without requiring adjacency or order. Hyphenated CVE and CWE IDs can match description text; use cveIds or cweId for their dedicated fields. Matching follows the selected field's text analyzer. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch, diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 1a95324..0de35f6 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -259,6 +259,7 @@ function buildQueryStrings(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { simple_query_string: { query: params.keywordSearch, default_operator: 'AND', + analyze_wildcard: true, fields: fieldList } } diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index fd628fc..70df908 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -80,6 +80,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { simple_query_string: { query: 'remote* code*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } } @@ -253,6 +254,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { simple_query_string: { query: 'kernel*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } } @@ -299,6 +301,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { simple_query_string: { query: 'remote* code*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } } @@ -345,6 +348,30 @@ describe('searchQueryBuilder buildSearchRequest()', () => { }); describe('searchQueryBuilder buildQueryStrings() function', () => { + test.each([ + ['use-after-free', 'use\\-after\\-free*'], + ['cross-site', 'cross\\-site*'], + ['CVE-2021-44228', 'CVE\\-2021\\-44228*'], + ['CWE-79', 'CWE\\-79*'], + ['log4j-core', 'log4j\\-core*'], + ['node.js', 'node.js*'], + ['x86_64', 'x86_64*'], + ['remote code', 'remote* code*'] + ])('analyzes escaped prefix terms for %s', (keywordSearch, query) => { + const output = queryBuilder.buildSearchRequest({ keywordSearch }); + + expect(output.queryStringObjects).toStrictEqual([ + { + simple_query_string: { + query, + default_operator: 'AND', + analyze_wildcard: true, + fields: ['containers.cna.descriptions.value', 'containers.adp.descriptions.value'] + } + } + ]); + }); + test('buildQueryStrings() function correctly builds array of one simple_query_string object when keywordSearch is passed.', () => { const testParams = { keywordSearch: 'test* params*' @@ -355,6 +382,7 @@ describe('searchQueryBuilder buildQueryStrings() function', () => { simple_query_string: { query: 'test* params*', default_operator: 'AND', + analyze_wildcard: true, fields: ['containers.cna.descriptions.value'] } }