From 13edfd7073ca04833158078cd22e3f230efe9127 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 8 Oct 2026 09:22:49 -0400 Subject: [PATCH 1/9] 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 85af5c1861fc2492b933e1779b586f827cf7d040 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 1 Oct 2026 13:11:31 -0400 Subject: [PATCH 2/9] Remove cve-core dependency and own OpenSearch integration --- README.md | 12 +- api-docs/openapi.json | 17 +- config/custom-environment-variables.jsonc | 15 +- config/default.jsonc | 4 +- config/devel.jsonc | 26 +- config/prod.jsonc | 3 +- controllers/searchController.js | 56 +- controllers/searchController.test.js | 382 ++------------ package-lock.json | 595 +--------------------- package.json | 14 +- routes/index.js | 3 +- routes/search.integration.test.js | 32 +- routes/swagger.js | 13 +- server.js | 4 +- utils/appConfig.js | 8 + utils/appConfig.test.js | 111 ++++ utils/featureFlags.js | 2 +- utils/featureFlags.test.js | 8 +- utils/requestLogging.js | 2 +- utils/requestLogging.test.js | 8 +- utils/searchProvider.js | 127 ++--- utils/searchProvider.test.js | 198 ++++--- utils/webSearchQueryBuilder.js | 142 ++++++ utils/webSearchQueryBuilder.test.js | 113 ++++ 24 files changed, 662 insertions(+), 1233 deletions(-) create mode 100644 utils/appConfig.js create mode 100644 utils/appConfig.test.js create mode 100644 utils/webSearchQueryBuilder.js create mode 100644 utils/webSearchQueryBuilder.test.js diff --git a/README.md b/README.md index fd53f3c..526624e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ The compose file runs only the Search API with `NODE_ENV=production` and `NODE_C Use Node.js 24 for the repository's current runtime and development tooling. CI and the Docker dependency-build stage use Node.js 24.14.1; the final runtime image uses Distroless Node.js 24. -**Upstream compatibility caveat:** The locked `cve-core` package still declares Node.js `>= 16.20.2 < 22`. That metadata conflicts with this repository's Node.js 24 baseline and can cause installation warnings or failures when strict engine checks are enabled. Resolving the declaration requires coordination with the `cve-core` maintainers; it is not a recommendation to use Node.js 16 or 18 with this repository's current development tooling. +The API depends directly on the official OpenSearch JavaScript client. `utils/searchProvider.js` owns its configuration and reuses one client per process for both endpoints and readiness checks. Query construction remains separate: `utils/searchQueryBuilder.js` implements `/search`, and `utils/webSearchQueryBuilder.js` implements `/webSearch`. + +`utils/appConfig.js` loads `.env` before reading `config/` through node-config. Existing process environment variables take precedence over `.env`; the mappings in `config/custom-environment-variables.jsonc` override deployment-file values. `NODE_CONFIG_ENV` selects a deployment configuration when set, otherwise node-config uses `NODE_ENV`. No credentials or index configuration need to change for this dependency migration. 1. Set `OpenSearchDomainEndpoint` in `.env` to an existing OpenSearch deployment: @@ -111,7 +113,7 @@ The Search API uses `PORT`, defaulting to `http://localhost:3000`. The tracked P `GET /api/health-check` is a process liveness check. It returns HTTP 200 when the Node.js process is running and deliberately does not contact OpenSearch. Use it where a dependency outage should not restart otherwise healthy API tasks. -`GET /api/ready-check` first verifies that the configured CVE index exposes compatible field capabilities for each logical `/search` feature, then sends a minimal zero-result search. CNA, ADP, legacy, and CVSS-version fields that implement the same feature are treated as alternatives, while mapped fields must still use compatible searchable types. This checks mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when these checks pass; it does not prove data completeness or capacity for every search. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, missing logical capabilities, incompatible mapped fields, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. `SearchReadinessTimeoutMs` defaults to five seconds for the overall readiness check, covering both field-capability and search operations. Each individual operation also uses that value as its transport timeout, and the overall deadline can abort it earlier. +`GET /api/ready-check` first verifies that the configured CVE index exposes compatible field capabilities for each logical `/search` feature, then sends a minimal zero-result search. CNA, ADP, legacy, and CVSS-version fields that implement the same feature are treated as alternatives, while mapped fields must still use compatible searchable types. This checks mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when these checks pass; it does not prove data completeness or capacity for every search. Configuration errors, connection failures, timeouts, missing logical capabilities, incompatible mapped fields, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. `SearchReadinessTimeoutMs` defaults to five seconds for the overall readiness check, covering both field-capability and search operations. Each individual operation also uses that value as its transport timeout, and the overall deadline can abort it earlier. An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior. @@ -294,7 +296,11 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al - `from` must be an integer greater than or equal to `0` - `size` must be an integer from `1` to `500` -The controller validates successful `BasicSearchManager` responses before returning HTTP 200. A response without the expected status metadata, total-hit count, or hit array is treated as an invalid upstream response and returns a sanitized HTTP 502. +The raw-query builder preserves the original expression and uses `query_string` with an `AND` default operator. Standalone calendar dates such as `2024-01-01` and ranges such as `2024-01-01..2024-01-31` search the supported record/container date fields using inclusive boundaries. Range endpoints can also use year or month precision (`2023..2024`, `2024-01..2024-03`); a standalone year or month remains a text query. Timestamps accept UTC or explicit offsets, and timestamps without a zone are interpreted as UTC. Date-query years are limited to 1900 through 2100. Quoted dates remain literal query expressions. This date syntax is specific to `/webSearch`; `/search` retains its explicit timestamp filters. + +Both endpoints use the shared OpenSearch client and reject incomplete upstream responses. A response with failed shards, a timeout, missing hit data, or an invalid total-hit count cannot produce a successful `/webSearch` response. Backend failures return sanitized HTTP 502 responses; transport timeouts return HTTP 504. + +The `/webSearch` response retains `searchMetadata`, `resultsTotal`, and `data`. Metadata includes `searchStatus`, the response-format `version`, `notes`, `errors`, and `queryTime_ms` when available. The obsolete `cveCore` and `cveMitreCore` package-version fields have been removed. `/search` keeps its existing API-owned response format, warnings, and filter behavior. ## Swagger API docs diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 43f0bec..eeeba13 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -275,7 +275,7 @@ "CVE Record" ], "summary": "Raw query search", - "description": "

Expected Behavior

Retrieves matching CVE records using the raw query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration.

", + "description": "

Expected Behavior

Retrieves matching CVE records using the raw query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration. Standalone dates and date ranges search the supported record/container date fields; other expressions use raw query syntax with an AND default operator.

", "operationId": "webSearch", "responses": { "200": { @@ -380,6 +380,7 @@ "properties": { "query": { "type": "string", + "description": "Raw search expression. Examples: remote code execution, apache*, 2024-01-01, or 2024-01-01..2024-01-31. Date ranges are inclusive and allow year/month endpoints; standalone years/months and quoted dates remain text queries. Date-query years must be between 1900 and 2100.", "minLength": 1 }, "sort": { @@ -598,6 +599,7 @@ "searchMetadata": { "type": "object", "additionalProperties": true, + "description": "API-owned raw-query metadata. Includes the response-format version, not dependency package versions.", "required": [ "searchStatus", "version", @@ -608,7 +610,18 @@ "type": "string" }, "version": { - "type": "string" + "type": "string", + "description": "Version of the raw-query response format." + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "queryTime_ms": { + "type": "number", + "description": "Query processing duration when available." }, "errors": { "type": "array", diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 045c52a..265fc61 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -1,9 +1,6 @@ { - // this file is used by node-config to map a node-config (AppConfig) - // hierarchy of constants to an environment variable - // Note that much of the environment variables mapped here existed for some time - // without AppConfig, this file bridges the historical uses of those with the new - // as we transition to AppConfig + // Map appConfig settings to environment variables. utils/appConfig.js loads .env + // before node-config resolves these overrides; existing process variables take precedence. "appConfig": { // constants for search capability "search": { @@ -21,14 +18,6 @@ }, "features": { "webSearchEnabled": "WebSearchEnabled" - }, - // constants for testing node-config - // these values are only used to test node-config in AppConfig.test.int.ts - // DO NOT USE THIS FOR ANYTHING ELSE - "test": { - "appConfigTest": { - "test": "JEST_env_config_test" - } } } } diff --git a/config/default.jsonc b/config/default.jsonc index ac30f34..cceb5e4 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -3,8 +3,8 @@ // These values are overridable using other *.jsonc (e.g., prod.jsonc) // as well as using environment variables (e.g., in `.env`) // - Each configuration is mapped using custom-environment-variables.jsonc to enable environment variable overrides. - // - For more information, see cve-core/src/adapters/config/AppConfig.ts - // NOTE for consistency, all values need to be strings for proper type when using .env overrides + // - utils/appConfig.js loads .env before reading these files through node-config. + // Values are normalized by their consumers because environment overrides are strings. "appConfig": { // constants for search capability "search": { diff --git a/config/devel.jsonc b/config/devel.jsonc index e718b92..b8c26dd 100644 --- a/config/devel.jsonc +++ b/config/devel.jsonc @@ -1,8 +1,7 @@ { // development configurations // overrides values specified in default.jsonc, read additional comments there - // and in cve-core/src/adapters/config/AppConfig.ts - // NOTE for consistency, all values need to be strings for proper type when using .env overrides + // and in utils/appConfig.js "appConfig": { // constants for search capability "search": { @@ -14,29 +13,6 @@ // TRUE disables verification for isolated local development with self-signed certificates. // DO NOT USE THIS IN ANY PUBLIC OR PRODUCTION ENVIRONMENTS "allowUnknownSslCerts": "TRUE" - }, - // constants for unit, int, e2e testing - "test": { - // constants for testing search capability - "searchTest": { - // many tests for search uses snapshots, which requires CVEs to remain unchanged - // since the live server is updated all the time, a fixture containing fixed CVEs - // is required to keep the test consistent. "fixtures" provides the link - // to the cve-fixtures repository - "fixtures": { - // @todo these constants needs to be in sync in cve-fixtures - // so that testing snapshots are consistent and valid - "name": "fixtures-search-baseline-1086", // release tag - "numCves": "1086" // possible identifier assuming we always add cves to a new release - } - }, - // constants for testing node-config - "appConfigTest": { - // these values are only used to test node-config in AppConfig.test.int.ts - // DO NOT USE THIS FOR ANYTHING ELSE - "two": "2", - "five": "5" - } } } } diff --git a/config/prod.jsonc b/config/prod.jsonc index eb4d49c..175f757 100644 --- a/config/prod.jsonc +++ b/config/prod.jsonc @@ -1,8 +1,7 @@ { // production (example) constants // overrides values specified in default.jsonc, read additional comments there - // and in cve-core/src/adapters/config/AppConfig.ts - // NOTE for consistency, all values need to be strings for proper type when using .env overrides + // and in utils/appConfig.js "appConfig": { "search": { "providerEndpoint": "", // preference is to specify this in a (secret) environment variable on production platforms diff --git a/controllers/searchController.js b/controllers/searchController.js index a8eec9d..244e15c 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -1,4 +1,3 @@ -const PackageInfo = require('cve-core').packageInfo; const constants = require('../utils/constants'); const errors = require('../utils/errors'); const searchDeadline = require('../utils/searchDeadline'); @@ -6,6 +5,7 @@ const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); const searchResponseBuilder = require('../utils/searchResponseBuilder'); +const webSearchQueryBuilder = require('../utils/webSearchQueryBuilder'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); @@ -23,7 +23,6 @@ function getOpenSearchErrorStatusCode(err) { function isOpenSearchUnavailableError(err) { return ( - searchProvider.isSearchProviderContractError?.(err) === true || openSearchUnavailableErrorNames.has(err?.name) || openSearchUnavailableStatusCodes.has(getOpenSearchErrorStatusCode(err)) ); @@ -55,28 +54,6 @@ function buildWebSearchSort(sort) { return undefined; } -function getWebSearchSuccessData(searchResponse) { - if (typeof searchResponse?.isOk !== 'function' || searchResponse.isOk() !== true) return null; - - const hits = searchResponse?.data?.hits; - const totalValue = hits?.total?.value; - const notes = searchResponse.notes === undefined ? [] : searchResponse.notes; - const responseErrors = searchResponse.errors === undefined ? [] : searchResponse.errors; - const hasValidMetadata = - typeof searchResponse.status === 'string' && Array.isArray(notes) && Array.isArray(responseErrors); - - if (!hasValidMetadata || !Array.isArray(hits?.hits) || !Number.isSafeInteger(totalValue) || totalValue < 0) { - return null; - } - - return { - hits: hits.hits, - totalValue, - notes, - errors: responseErrors - }; -} - function getHitCveId(hit) { return hit?._source?.cveMetadata?.cveId ?? hit?._id; } @@ -221,13 +198,10 @@ async function webSearchHandler(req, res, next) { const sort = buildWebSearchSort(req.body.sort); // initialize response body to error condition - let status; - let body = { + const body = { searchMetadata: { searchStatus: 'errors', version: kLambdaTimeVersion, - cveCore: PackageInfo['version'], - cveMitreCore: PackageInfo['version'], notes: [], errors: [] }, @@ -236,29 +210,25 @@ async function webSearchHandler(req, res, next) { }; try { - const searchManager = searchProvider.getBasicSearchManager(); const _startTime = Date.now(); - - const searchResponse = await searchManager.search(`${userProvidedQuery}`, { + const request = webSearchQueryBuilder.buildWebSearchRequest(userProvidedQuery, { sort, from: req.body.from, size: req.body.size }); + const searchData = await searchProvider.searchOpenSearch(request.body); const queryTime = Date.now() - _startTime; body.searchMetadata.queryTime_ms = queryTime; - const successData = getWebSearchSuccessData(searchResponse); - if (successData) { - status = 200; - body.searchMetadata.searchStatus = searchResponse.status; - body.searchMetadata.notes = successData.notes; - body.searchMetadata.errors = successData.errors; - body.resultsTotal = successData.totalValue; - body.data = successData.hits; - } else { - status = 502; - body.searchMetadata.errors = [error.upstreamSearchError()]; + const total = searchData?.hits?.total?.value; + if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(searchData?.hits?.hits)) { + throw new Error('Invalid web search results.'); } + body.searchMetadata.searchStatus = 'ok'; + body.searchMetadata.notes = request.notes; + body.resultsTotal = total; + body.data = searchData.hits.hits; + return res.status(200).json(body); } catch (ex) { const timedOut = searchDeadline.isSearchTimeoutError(ex); const err = timedOut ? error.searchTimeoutError() : error.upstreamSearchError(); @@ -266,8 +236,6 @@ async function webSearchHandler(req, res, next) { return res.status(timedOut ? 504 : 502).json(body); } - - return res.status(status).json(body); } module.exports = { diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8319ec4..f1e07f5 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -1,7 +1,6 @@ const { EventEmitter } = require('events'); const mockApiSearch = jest.fn(); -const mockBasicSearch = jest.fn(); const mockSearchRaw = jest.fn(); const mockSearchOpenSearch = jest.fn((body, options) => { const search = Array.isArray(body?._source) ? mockSearchRaw : mockApiSearch; @@ -9,22 +8,8 @@ const mockSearchOpenSearch = jest.fn((body, options) => { return Promise.resolve(search(body, options)).then((result) => result?.data ?? result); }); const mockGetSearchRequestDeadlineMs = jest.fn(() => 30000); -const mockIsSearchProviderContractError = jest.fn((err) => err?.code === 'SEARCH_PROVIDER_CONTRACT'); -const mockGetBasicSearchManager = jest.fn(() => ({ - search: mockBasicSearch -})); -jest.mock('cve-core', () => { - return { - packageInfo: { - version: 'test-core-version' - } - }; -}); - jest.mock('../utils/searchProvider', () => ({ - getBasicSearchManager: mockGetBasicSearchManager, getSearchRequestDeadlineMs: mockGetSearchRequestDeadlineMs, - isSearchProviderContractError: mockIsSearchProviderContractError, searchOpenSearch: mockSearchOpenSearch })); @@ -81,23 +66,6 @@ function searchSuccessResponse(totalResults, hits = [], options = {}) { }; } -function basicResponse({ ok = true, status = 'ok', total = 0, hits = [], notes, errors } = {}) { - return { - status, - notes, - errors, - data: { - hits: { - total: { - value: total - }, - hits - } - }, - isOk: jest.fn(() => ok) - }; -} - function cvssTokenRegexp(field, token) { return { regexp: { @@ -151,12 +119,9 @@ describe('Search controller SEARCH handler', () => { beforeEach(() => { mockApiSearch.mockReset(); - mockBasicSearch.mockReset(); mockSearchRaw.mockReset(); mockSearchOpenSearch.mockClear(); - mockGetBasicSearchManager.mockClear(); mockGetSearchRequestDeadlineMs.mockClear(); - mockIsSearchProviderContractError.mockClear(); mockGetSearchRequestDeadlineMs.mockReturnValue(30000); res = createResponse(); next = jest.fn(); @@ -1161,7 +1126,6 @@ describe('Search controller SEARCH handler', () => { test.each([ ['ConnectionError', { name: 'ConnectionError' }], ['NoLivingConnectionsError', { name: 'NoLivingConnectionsError' }], - ['cve-core provider contract error', { code: 'SEARCH_PROVIDER_CONTRACT' }], ['OpenSearch 503 response', { name: 'ResponseError', meta: { statusCode: 503 } }] ])('SEARCH returns a retryable 503 when OpenSearch is unavailable through %s', async (_caseName, thrownError) => { mockApiSearch.mockRejectedValue(thrownError); @@ -1305,7 +1269,7 @@ describe('Search controller SEARCH handler', () => { expect(res.json).not.toHaveBeenCalled(); }); - test('SEARCH returns sanitized errors from cve-core', async () => { + test('SEARCH returns sanitized upstream errors', async () => { const err = { name: 'SearchError', message: 'bad query', @@ -1338,53 +1302,32 @@ describe('Search controller WEB_SEARCH handler', () => { beforeEach(() => { mockApiSearch.mockReset(); - mockBasicSearch.mockReset(); mockSearchRaw.mockReset(); mockSearchOpenSearch.mockClear(); - mockGetBasicSearchManager.mockClear(); jest.spyOn(console, 'log').mockImplementation(() => {}); - res = createResponse(); next = jest.fn(); }); afterEach(() => jest.restoreAllMocks()); - test('WEB_SEARCH sends cveId ascending sort, paging, and response metadata', async () => { + test('WEB_SEARCH sends sorting and paging and owns its response metadata', async () => { const hits = [{ _id: 'CVE-2024-0001' }, { _id: 'CVE-2024-0002' }]; - const resp = basicResponse({ - total: 2, - hits, - notes: ['note'] - }); - mockBasicSearch.mockResolvedValue(resp); + mockApiSearch.mockResolvedValue(rawResponse(2, hits)); jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(1042); await controller.WEB_SEARCH( { - body: { - query: 'apache', - sort: { - property: 'cveId', - order: 'asc' - }, - from: 5, - size: 10 - } + body: { query: 'apache', sort: { property: 'cveId', order: 'asc' }, from: 5, size: 10 } }, res, next ); - expect(mockGetBasicSearchManager).toHaveBeenCalledTimes(1); - expect(mockBasicSearch).toHaveBeenCalledWith('apache', { - sort: [ - { - 'cveMetadata.cveId.keyword': { - order: 'asc' - } - } - ], + expect(mockSearchOpenSearch).toHaveBeenCalledWith({ + query: { query_string: { query: 'apache', default_operator: 'AND' } }, + sort: [{ 'cveMetadata.cveId.keyword': { order: 'asc' } }], + track_total_hits: true, from: 5, size: 10 }); @@ -1393,9 +1336,7 @@ describe('Search controller WEB_SEARCH handler', () => { searchMetadata: { searchStatus: 'ok', version: '2025-08-26', - cveCore: 'test-core-version', - cveMitreCore: 'test-core-version', - notes: ['note'], + notes: ['general search text string'], errors: [], queryTime_ms: 42 }, @@ -1405,187 +1346,49 @@ describe('Search controller WEB_SEARCH handler', () => { expect(console.log).not.toHaveBeenCalled(); }); - test('WEB_SEARCH defaults cveId sort to descending when order is not asc', async () => { - mockBasicSearch.mockResolvedValue(basicResponse()); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache', - sort: { - property: 'cveId', - order: 'desc' - } - } - }, - res, - next - ); - - expect(mockBasicSearch.mock.calls[0][1].sort).toStrictEqual([ - { - 'cveMetadata.cveId.keyword': { - order: 'desc' - } - } - ]); - }); - - test('WEB_SEARCH supports relevancy ascending sort', async () => { - mockBasicSearch.mockResolvedValue(basicResponse()); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache', - sort: { - property: 'relevancy', - order: 'asc' - } - } - }, - res, - next - ); - - expect(mockBasicSearch.mock.calls[0][1].sort).toStrictEqual([ - { - _score: { - order: 'asc' - } - } - ]); - }); - - test('WEB_SEARCH supports relevancy descending sort', async () => { - mockBasicSearch.mockResolvedValue(basicResponse()); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache', - sort: { - property: 'relevancy', - order: 'desc' - } - } - }, - res, - next - ); - - expect(mockBasicSearch.mock.calls[0][1].sort).toStrictEqual([ - { - _score: { - order: 'desc' - } - } - ]); + test.each([ + ['cveId', 'asc', 'cveMetadata.cveId.keyword', 'asc'], + ['cveId', 'desc', 'cveMetadata.cveId.keyword', 'desc'], + ['cveId', undefined, 'cveMetadata.cveId.keyword', 'desc'], + ['relevancy', 'asc', '_score', 'asc'], + ['relevancy', 'desc', '_score', 'desc'] + ])('WEB_SEARCH supports %s sorting in %s order', async (property, order, field, expected) => { + mockApiSearch.mockResolvedValue(rawResponse(0)); + await controller.WEB_SEARCH({ body: { query: 'apache', sort: { property, order } } }, res, next); + expect(mockSearchOpenSearch.mock.calls[0][0].sort).toStrictEqual([{ [field]: { order: expected } }]); + expect(res.status).toHaveBeenCalledWith(200); }); - test('WEB_SEARCH omits sort, from, and size when they are not provided', async () => { - mockBasicSearch.mockResolvedValue(basicResponse()); - - await controller.WEB_SEARCH( - { - body: { - query: 'nginx' - } - }, - res, - next - ); - - expect(mockBasicSearch).toHaveBeenCalledWith('nginx', { - sort: undefined, - from: undefined, - size: undefined + test('WEB_SEARCH uses the legacy default sort and paging', async () => { + mockApiSearch.mockResolvedValue(rawResponse(0)); + await controller.WEB_SEARCH({ body: { query: 'nginx' } }, res, next); + expect(mockSearchOpenSearch.mock.calls[0][0]).toMatchObject({ + sort: [{ 'cveMetadata.cveId.keyword': { order: 'desc' } }], + from: 0, + size: 25 }); }); - test('WEB_SEARCH returns sanitized metadata when cve-core response is not ok', async () => { - mockBasicSearch.mockResolvedValue( - basicResponse({ - ok: false, - status: 'errors', - total: 3, - hits: [{ _id: 'CVE-2024-0001' }], - notes: ['partial'], - errors: [ - { - code: 1, - url: 'redacted-sensitive-url', - headers: { - authorization: 'redacted-sensitive-header' - } - } - ] - }) - ); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache' - } - }, - res, - next - ); - - expect(res.status).toHaveBeenCalledWith(502); - expect(res.json.mock.calls[0][0]).toStrictEqual({ - searchMetadata: { - searchStatus: 'errors', - version: '2025-08-26', - cveCore: 'test-core-version', - cveMitreCore: 'test-core-version', - notes: [], - errors: [ - { - error: 'UPSTREAM_SEARCH_ERROR', - message: 'Search backend returned an error. Please try again later.' - } - ], - queryTime_ms: expect.any(Number) - }, - resultsTotal: 0, - data: [] - }); - }); - - test('WEB_SEARCH fails closed when cve-core reports success without a complete hits response', async () => { - mockBasicSearch.mockResolvedValue({ - status: 'ok', - data: {}, - isOk: jest.fn(() => true) - }); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache' - } - }, - res, - next - ); - + test.each([ + {}, + { hits: {} }, + { hits: { total: { value: -1 }, hits: [] } }, + { hits: { total: { value: '1' }, hits: [] } }, + { hits: { total: { value: 1 } } } + ])('WEB_SEARCH rejects malformed totals or hits: %p', async (response) => { + mockApiSearch.mockResolvedValue(response); + await controller.WEB_SEARCH({ body: { query: 'apache' } }, res, next); expect(res.status).toHaveBeenCalledWith(502); - expect(res.json.mock.calls[0][0]).toStrictEqual({ + expect(res.json.mock.calls[0][0]).toMatchObject({ searchMetadata: { searchStatus: 'errors', - version: '2025-08-26', - cveCore: 'test-core-version', - cveMitreCore: 'test-core-version', notes: [], errors: [ { error: 'UPSTREAM_SEARCH_ERROR', message: 'Search backend returned an error. Please try again later.' } - ], - queryTime_ms: expect.any(Number) + ] }, resultsTotal: 0, data: [] @@ -1593,100 +1396,25 @@ describe('Search controller WEB_SEARCH handler', () => { }); test.each([ - ['notes', 'invalid notes'], - ['errors', { message: 'invalid errors' }] - ])('WEB_SEARCH fails closed when successful cve-core metadata has invalid %s', async (field, value) => { - mockBasicSearch.mockResolvedValue({ - ...basicResponse(), - [field]: value - }); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache' - } - }, - res, - next - ); - - expect(res.status).toHaveBeenCalledWith(502); - expect(res.json.mock.calls[0][0].searchMetadata.errors).toStrictEqual([ - { - error: 'UPSTREAM_SEARCH_ERROR', - message: 'Search backend returned an error. Please try again later.' - } - ]); - }); - - test('WEB_SEARCH returns a sanitized error when BasicSearchManager throws', async () => { - mockBasicSearch.mockRejectedValue(new Error('boom')); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache' - } - }, - res, - next - ); - - expect(res.status).toHaveBeenCalledWith(502); - expect(res.json.mock.calls[0][0]).toStrictEqual({ - searchMetadata: { - searchStatus: 'errors', - version: '2025-08-26', - cveCore: 'test-core-version', - cveMitreCore: 'test-core-version', - notes: [], - errors: [ - { - error: 'UPSTREAM_SEARCH_ERROR', - message: 'Search backend returned an error. Please try again later.' - } - ] - }, - resultsTotal: 0, - data: [] - }); + [new Error('sensitive backend details'), 502, 'UPSTREAM_SEARCH_ERROR'], + [{ name: 'TimeoutError', message: 'sensitive timeout details' }, 504, 'SEARCH_TIMEOUT'] + ])('WEB_SEARCH sanitizes transport errors: %p', async (err, status, code) => { + mockApiSearch.mockRejectedValue(err); + await controller.WEB_SEARCH({ body: { query: 'apache' } }, res, next); + expect(res.status).toHaveBeenCalledWith(status); + const body = res.json.mock.calls[0][0]; + expect(body.searchMetadata.searchStatus).toBe('errors'); + expect(body.searchMetadata.errors[0].error).toBe(code); + expect(body.searchMetadata).not.toHaveProperty('cveCore'); + expect(body.searchMetadata).not.toHaveProperty('cveMitreCore'); + expect(JSON.stringify(body)).not.toContain('sensitive'); expect(console.log).not.toHaveBeenCalled(); }); - test('WEB_SEARCH returns a sanitized 504 when OpenSearch times out', async () => { - mockBasicSearch.mockRejectedValue({ - name: 'TimeoutError', - message: 'sensitive upstream timeout details' - }); - - await controller.WEB_SEARCH( - { - body: { - query: 'apache' - } - }, - res, - next - ); - - expect(res.status).toHaveBeenCalledWith(504); - expect(res.json.mock.calls[0][0]).toStrictEqual({ - searchMetadata: { - searchStatus: 'errors', - version: '2025-08-26', - cveCore: 'test-core-version', - cveMitreCore: 'test-core-version', - notes: [], - errors: [ - { - error: 'SEARCH_TIMEOUT', - message: 'Search request timed out. Please try again later.' - } - ] - }, - resultsTotal: 0, - data: [] - }); + test('WEB_SEARCH keeps unsupported expressions sanitized without contacting OpenSearch', async () => { + await controller.WEB_SEARCH({ body: { query: '{invalid}' } }, res, next); + expect(res.status).toHaveBeenCalledWith(502); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + expect(res.json.mock.calls[0][0].searchMetadata.errors[0].error).toBe('UPSTREAM_SEARCH_ERROR'); }); }); diff --git a/package-lock.json b/package-lock.json index 045fcd6..d3f115f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,15 @@ "name": "cve-search-api", "version": "1.0.1-beta", "dependencies": { + "@opensearch-project/opensearch": "2.13.0", + "config": "4.4.1", "cors": "^2.8.5", - "cve-core": "github:CVEProject/cve-core.git#dev-package-test", - "dotenv": "^6.1.0", + "dotenv": "^16.6.1", "express": "^4.22.2", "express-validator": "^7.2.1", "morgan": "~1.12.1", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "validator": "^13.15.26" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -496,15 +498,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -578,15 +571,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@commander-js/extra-typings": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-10.0.3.tgz", - "integrity": "sha512-OIw28QV/GlP8k0B5CJTRsl8IyNvd0R8C8rfo54Yz9P388vCNDgdNrFlKxZTGqps+5j6lSw3Ss9JTQwcur1w1oA==", - "license": "MIT", - "peerDependencies": { - "commander": "10.0.x" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -799,15 +783,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1213,38 +1188,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kwsites/file-exists": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1" - } - }, - "node_modules/@kwsites/file-exists/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "license": "MIT" - }, "node_modules/@opensearch-project/opensearch": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-2.13.0.tgz", @@ -1287,21 +1230,6 @@ "hasInstallScript": true, "license": "Apache-2.0" }, - "node_modules/@simple-git/args-pathspec": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", - "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", - "license": "MIT" - }, - "node_modules/@simple-git/argv-parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", - "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", - "license": "MIT", - "dependencies": { - "@simple-git/args-pathspec": "^1.0.3" - } - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -1509,15 +1437,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/adm-zip": { - "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" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1604,42 +1523,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT" }, - "node_modules/axios": { - "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.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "license": "Apache-2.0", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, "node_modules/babel-jest": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.3.tgz", @@ -1743,6 +1632,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -1855,6 +1745,7 @@ "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2134,27 +2025,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/config": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/config/-/config-4.4.1.tgz", @@ -2231,6 +2101,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2241,82 +2112,6 @@ "node": ">= 8" } }, - "node_modules/cve-core": { - "version": "2.2.0-rc4", - "resolved": "git+ssh://git@github.com/CVEProject/cve-core.git#a1640b2979aebb681f151aa20248f87eb4fd8a81", - "license": "(CC0)", - "dependencies": { - "@commander-js/extra-typings": "^10.0.2", - "@opensearch-project/opensearch": "^2.3.1", - "adm-zip": "^0.5.10", - "axios": "^1.6.0", - "axios-retry": "^4.4.1", - "commander": "^10.0.0", - "config": "^4.0.0", - "date-fns": "^2.29.3", - "date-fns-tz": "^2.0.0", - "dotenv": "^16.0.1", - "fetch-retry": "^6.0.0", - "glob": "^11.0.1", - "json-difference": "^1.16.1", - "lodash.clonedeep": "^4.5.0", - "lodash.isequal": "^4.5.0", - "lodash.isinteger": "^4.0.4", - "lodash.map": "^4.6.0", - "lodash.omit": "^4.5.0", - "lodash.reduce": "^4.6.0", - "lodash.truncate": "^4.4.2", - "lodash.unset": "^4.5.2", - "semver": "^7.7.2", - "simple-git": "^3.19.1", - "ts-pattern": "^4.0.5", - "tslib": "~2.4", - "validator": "^13.15.20" - }, - "bin": { - "cves": "dist/index.js" - }, - "engines": { - "node": ">= 16.20.2 < 22" - } - }, - "node_modules/cve-core/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, - "node_modules/date-fns-tz": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", - "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==", - "license": "MIT", - "peerDependencies": { - "date-fns": "2.x" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -2341,15 +2136,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2390,12 +2176,15 @@ } }, "node_modules/dotenv": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz", - "integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dunder-proto": { @@ -2494,21 +2283,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2987,12 +2761,6 @@ "bser": "2.1.1" } }, - "node_modules/fetch-retry": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", - "integrity": "sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3087,58 +2855,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3268,30 +2984,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -3359,21 +3051,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3402,48 +3079,6 @@ "dev": true, "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3629,18 +3264,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3658,6 +3281,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3759,21 +3383,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest/-/jest-28.1.3.tgz", @@ -4477,15 +4086,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-difference": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/json-difference/-/json-difference-1.16.1.tgz", - "integrity": "sha512-tNVUzBKn2I4BB4Cjn+j+Kq/BfLNwqtEvxhnlyuX5XJmKn9VlfV5MvaTbB/ure/toFgLaD6XglOUt+dd4iMQ3dA==", - "license": "MIT", - "engines": { - "node": ">=18.17.x" - } - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4598,56 +4198,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.map": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", - "integrity": "sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==", - "license": "MIT" - }, - "node_modules/lodash.omit": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", - "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", - "license": "MIT" - }, - "node_modules/lodash.reduce": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", - "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "license": "MIT" - }, - "node_modules/lodash.unset": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.18.0.tgz", - "integrity": "sha512-7B95k5zjcJtuGuLqxOjT91g0Txwv1PWBlBDGzjW4kM8qgw2qfSgBo1QcIWGUpW9uN/1ZSKX9NHJzVJIgbeR5PQ==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4788,6 +4338,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -4799,15 +4350,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/morgan": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", @@ -5116,12 +4658,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -5174,6 +4710,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5186,31 +4723,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -5346,15 +4858,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -5604,6 +5107,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5696,6 +5200,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5708,6 +5213,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5785,52 +5291,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-git": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", - "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", - "license": "MIT", - "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "@simple-git/args-pathspec": "^1.0.3", - "@simple-git/argv-parser": "^1.1.0", - "debug": "^4.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" - } - }, - "node_modules/simple-git/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -6197,18 +5657,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/ts-pattern": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-4.3.0.tgz", - "integrity": "sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6385,6 +5833,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 8507465..b15aafa 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,15 @@ "test": "jest" }, "dependencies": { + "@opensearch-project/opensearch": "2.13.0", + "config": "4.4.1", "cors": "^2.8.5", - "cve-core": "github:CVEProject/cve-core.git#dev-package-test", - "dotenv": "^6.1.0", + "dotenv": "^16.6.1", "express": "^4.22.2", "express-validator": "^7.2.1", "morgan": "~1.12.1", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "validator": "^13.15.26" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -32,15 +34,11 @@ "swagger-autogen": "^2.23.7" }, "overrides": { - "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", "path-to-regexp": "0.1.13", "picomatch": "2.3.2", - "qs": "6.16.0", - "simple-git": "3.36.0" + "qs": "6.16.0" } } diff --git a/routes/index.js b/routes/index.js index 7e5a5a5..0bf142c 100644 --- a/routes/index.js +++ b/routes/index.js @@ -484,7 +484,7 @@ router.post( #swagger.summary = "Raw query search" #swagger.description = "

Expected Behavior

-

Retrieves matching CVE records using the raw query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration.

+

Retrieves matching CVE records using the raw query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration. Standalone dates and date ranges search the supported record/container date fields; other expressions use raw query syntax with an AND default operator.

#swagger.requestBody = { required: true, content: { @@ -496,6 +496,7 @@ router.post( properties: { query: { type: "string", + description: "Raw search expression. Examples: remote code execution, apache*, 2024-01-01, or 2024-01-01..2024-01-31. Date ranges are inclusive and allow year/month endpoints; standalone years/months and quoted dates remain text queries. Date-query years must be between 1900 and 2100.", minLength: 1 }, sort: { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 63c3958..d4082e4 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -2,12 +2,8 @@ const { request } = require('../test/http'); const constants = require('../utils/constants'); const mockSearchOpenSearch = jest.fn(); -const mockBasicSearch = jest.fn(); jest.mock('../utils/searchProvider', () => ({ - getBasicSearchManager: jest.fn(() => ({ - search: mockBasicSearch - })), getSearchRequestDeadlineMs: jest.fn(() => 30000), searchOpenSearch: mockSearchOpenSearch })); @@ -27,25 +23,9 @@ function advancedResponse(totalValue, hits = []) { }; } -function basicResponse({ total = 0, hits = [] } = {}) { - return { - status: 'ok', - data: { - hits: { - total: { - value: total - }, - hits - } - }, - isOk: jest.fn(() => true) - }; -} - describe('search route integration', () => { beforeEach(() => { mockSearchOpenSearch.mockReset(); - mockBasicSearch.mockReset(); }); test('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { @@ -284,8 +264,8 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); - test('POST /webSearch normalizes body fields and formats BasicSearchManager responses', async () => { - mockBasicSearch.mockResolvedValue(basicResponse({ total: 1, hits: [{ _id: 'CVE-2024-0002' }] })); + test('POST /webSearch normalizes fields, builds DSL, and formats OpenSearch responses', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(1, [{ _id: 'CVE-2024-0002' }]).data); const response = await request(createApp(), { method: 'POST', @@ -302,7 +282,9 @@ describe('search route integration', () => { }); expect(response.status).toBe(200); - expect(mockBasicSearch).toHaveBeenCalledWith('apache', { + expect(mockSearchOpenSearch).toHaveBeenCalledWith({ + query: { query_string: { query: 'apache', default_operator: 'AND' } }, + track_total_hits: true, sort: [ { _score: { @@ -313,7 +295,9 @@ describe('search route integration', () => { from: 5, size: 10 }); - expect(response.body.searchMetadata.notes).toStrictEqual([]); + expect(response.body.searchMetadata.notes).toStrictEqual(['general search text string']); + expect(response.body.searchMetadata).not.toHaveProperty('cveCore'); + expect(response.body.searchMetadata).not.toHaveProperty('cveMitreCore'); expect(response.body.searchMetadata.errors).toStrictEqual([]); expect(response.body.resultsTotal).toBe(1); expect(response.body.data).toStrictEqual([{ _id: 'CVE-2024-0002' }]); diff --git a/routes/swagger.js b/routes/swagger.js index 2dcc8c8..1392cd0 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -489,13 +489,24 @@ const doc = { searchMetadata: { type: 'object', additionalProperties: true, + description: + 'API-owned raw-query metadata. Includes the response-format version, not dependency package versions.', required: ['searchStatus', 'version', 'errors'], properties: { searchStatus: { type: 'string' }, version: { - type: 'string' + type: 'string', + description: 'Version of the raw-query response format.' + }, + notes: { + type: 'array', + items: { type: 'string' } + }, + queryTime_ms: { + type: 'number', + description: 'Query processing duration when available.' }, errors: { type: 'array', diff --git a/server.js b/server.js index 02f4eff..6cc0233 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,4 @@ -const dotenv = require('dotenv'); +require('dotenv/config'); const cors = require('cors'); const express = require('express'); const swaggerUi = require('swagger-ui-express'); @@ -9,8 +9,6 @@ const router = require('./routes/index'); const errors = require('./utils/errors'); const requestLogging = require('./utils/requestLogging'); -dotenv.load(); - const error = new errors.errors(); const swaggerSetupOptions = { diff --git a/utils/appConfig.js b/utils/appConfig.js new file mode 100644 index 0000000..6c72cd6 --- /dev/null +++ b/utils/appConfig.js @@ -0,0 +1,8 @@ +// Load local environment overrides before node-config resolves its deployment files. +require('dotenv/config'); +const config = require('config'); + +module.exports = { + get: (path) => config.get(`appConfig.${path}`), + has: (path) => config.has(`appConfig.${path}`) +}; diff --git a/utils/appConfig.test.js b/utils/appConfig.test.js new file mode 100644 index 0000000..4740419 --- /dev/null +++ b/utils/appConfig.test.js @@ -0,0 +1,111 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const projectRoot = path.resolve(__dirname, '..'); +let directory; + +function readConfig(env = {}) { + const script = ` + const config = require(${JSON.stringify(path.join(__dirname, 'appConfig'))}); + const provider = require(${JSON.stringify(path.join(__dirname, 'searchProvider'))}); + const flags = require(${JSON.stringify(path.join(__dirname, 'featureFlags'))}); + const logging = require(${JSON.stringify(path.join(__dirname, 'requestLogging'))}); + console.log(JSON.stringify({ + endpoint: config.has('search.providerEndpoint') ? config.get('search.providerEndpoint') : null, + index: config.has('search.index') ? config.get('search.index') : null, + insecure: provider.getAllowUnknownSslCerts(), + timeout: provider.getOpenSearchRequestTimeoutMs(), + enabled: flags.isWebSearchEnabled(), + sampleRate: logging.getSuccessSampleRate(), + missing: config.has('missing') + })); + `; + return JSON.parse( + execFileSync(process.execPath, ['-e', script], { + cwd: directory, + env: { + NODE_ENV: 'test', + NODE_CONFIG_DIR: path.join(projectRoot, 'config'), + DOTENV_CONFIG_PATH: path.join(directory, '.env'), + ...env + }, + encoding: 'utf8', + timeout: 10000 + }) + ); +} + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'search-config-')); +}); +afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + +test('default settings load without a dotenv file or endpoint', () => { + expect(readConfig()).toStrictEqual({ + endpoint: null, + index: null, + insecure: false, + timeout: 20000, + enabled: true, + sampleRate: 0.1, + missing: false + }); +}); + +test('dotenv is loaded before deployment selection and custom environment mapping', () => { + fs.writeFileSync( + path.join(directory, '.env'), + [ + 'NODE_CONFIG_ENV=prod', + 'OpenSearchDomainEndpoint=https://dotenv.example', + 'OpenSearchCveIndex=dotenv-index', + 'OpenSearchAllowUnknownSslCerts=FALSE', + 'SearchOpenSearchRequestTimeoutMs=25000', + 'WebSearchEnabled=false', + 'RequestLogSuccessSampleRate=0.25' + ].join('\n') + ); + expect(readConfig()).toStrictEqual({ + endpoint: 'https://dotenv.example', + index: 'dotenv-index', + insecure: false, + timeout: 25000, + enabled: false, + sampleRate: 0.25, + missing: false + }); +}); + +test('process environment overrides dotenv and deployment files', () => { + fs.writeFileSync( + path.join(directory, '.env'), + [ + 'NODE_CONFIG_ENV=devel', + 'OpenSearchDomainEndpoint=https://dotenv.example', + 'OpenSearchCveIndex=dotenv-index', + 'OpenSearchAllowUnknownSslCerts=TRUE' + ].join('\n') + ); + expect( + readConfig({ + NODE_CONFIG_ENV: 'prod', + OpenSearchDomainEndpoint: 'https://process.example', + OpenSearchCveIndex: 'process-index', + OpenSearchAllowUnknownSslCerts: 'FALSE' + }) + ).toMatchObject({ endpoint: 'https://process.example', index: 'process-index', insecure: false }); +}); + +test.each([ + ['devel', 'https://localhost:9200', true], + ['prod', '', false] +])('NODE_CONFIG_ENV selects the %s deployment', (deployment, endpoint, insecure) => { + expect(readConfig({ NODE_CONFIG_ENV: deployment })).toMatchObject({ endpoint, insecure }); +}); + +test('a missing config key retains the node-config error behavior', () => { + const config = require('./appConfig'); + expect(() => config.get('nonexistent')).toThrow('appConfig.nonexistent'); +}); diff --git a/utils/featureFlags.js b/utils/featureFlags.js index 61ff67f..8a1168e 100644 --- a/utils/featureFlags.js +++ b/utils/featureFlags.js @@ -1,4 +1,4 @@ -const { AppConfig } = require('cve-core'); +const AppConfig = require('./appConfig'); const DEFAULT_WEB_SEARCH_ENABLED = true; diff --git a/utils/featureFlags.test.js b/utils/featureFlags.test.js index 3d47d75..3fc1c00 100644 --- a/utils/featureFlags.test.js +++ b/utils/featureFlags.test.js @@ -1,11 +1,9 @@ const mockAppConfigHas = jest.fn(); const mockAppConfigGet = jest.fn(); -jest.mock('cve-core', () => ({ - AppConfig: { - get: mockAppConfigGet, - has: mockAppConfigHas - } +jest.mock('./appConfig', () => ({ + get: mockAppConfigGet, + has: mockAppConfigHas })); const featureFlags = require('./featureFlags'); diff --git a/utils/requestLogging.js b/utils/requestLogging.js index 57e7248..a0fd092 100644 --- a/utils/requestLogging.js +++ b/utils/requestLogging.js @@ -1,5 +1,5 @@ const { randomUUID } = require('crypto'); -const { AppConfig } = require('cve-core'); +const AppConfig = require('./appConfig'); const DEFAULT_SUCCESS_SAMPLE_RATE = 0.1; const DEFAULT_LOG_HEALTH_CHECKS = false; diff --git a/utils/requestLogging.test.js b/utils/requestLogging.test.js index 1cae11e..0821e43 100644 --- a/utils/requestLogging.test.js +++ b/utils/requestLogging.test.js @@ -1,11 +1,9 @@ const mockAppConfigHas = jest.fn(); const mockAppConfigGet = jest.fn(); -jest.mock('cve-core', () => ({ - AppConfig: { - get: mockAppConfigGet, - has: mockAppConfigHas - } +jest.mock('./appConfig', () => ({ + get: mockAppConfigGet, + has: mockAppConfigHas })); const requestLogging = require('./requestLogging'); diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 03b9855..d166cf2 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -1,34 +1,12 @@ -const { AppConfig, BasicSearchManager, SearchProviderSpec, SearchReader } = require('cve-core'); +const { Client } = require('@opensearch-project/opensearch'); +const AppConfig = require('./appConfig'); const constants = require('./constants'); const searchDeadline = require('./searchDeadline'); const searchReadiness = require('./searchReadiness'); const searchResponseValidator = require('./searchResponseValidator'); -class SearchProviderContractError extends Error { - constructor() { - super('Installed cve-core SearchReader does not expose the required OpenSearch client contract.'); - this.name = 'SearchProviderContractError'; - this.code = 'SEARCH_PROVIDER_CONTRACT'; - } -} - let searchProviderConfig; -let basicSearchManager; -let searchReader; - -function configureClientRequestTimeout(client) { - if (client?.transport) { - client.transport.requestTimeout = getOpenSearchRequestTimeoutMs(); - client.transport.maxRetries = 0; - } - - return client; -} - -function configureManagerRequestTimeout(manager) { - configureClientRequestTimeout(manager?._searchReader?._client); - return manager; -} +let searchClient; function normalizeBoolean(value, defaultValue = false) { if (value === undefined || value === null || value === '') return defaultValue; @@ -51,73 +29,54 @@ function getIntegerConfig(path, defaultValue, { min = 0, max = Number.MAX_SAFE_I return defaultValue; } -function getAllowUnknownSslCerts(spec) { +function getAllowUnknownSslCerts() { if (AppConfig.has('search.allowUnknownSslCerts')) { return normalizeBoolean(AppConfig.get('search.allowUnknownSslCerts')); } - return normalizeBoolean(spec?.allowUnknownSslCerts); + return false; } -function normalizeSearchProviderConfig(spec) { - const providerEndpoint = spec?.providerEndpoint; - const index = spec?.index; +function readSearchProviderConfig() { + const providerEndpoint = AppConfig.has('search.providerEndpoint') ? AppConfig.get('search.providerEndpoint') : ''; + const index = AppConfig.has('search.index') ? AppConfig.get('search.index') : ''; - if (!providerEndpoint || !index) { + if ( + typeof providerEndpoint !== 'string' || + !providerEndpoint.trim() || + typeof index !== 'string' || + !index.trim() + ) { throw new Error('OpenSearch providerEndpoint and index must be configured.'); } return Object.freeze({ providerEndpoint, index, - allowUnknownSslCerts: getAllowUnknownSslCerts(spec) + allowUnknownSslCerts: getAllowUnknownSslCerts() }); } function getSearchProviderConfig() { if (!searchProviderConfig) { - const defaultSpec = SearchProviderSpec.getDefaultSearchProviderSpec(); - searchProviderConfig = normalizeSearchProviderConfig(defaultSpec); - defaultSpec.allowUnknownSslCerts = searchProviderConfig.allowUnknownSslCerts; - SearchProviderSpec._sDefaultSearchProviderSpec = defaultSpec; + searchProviderConfig = readSearchProviderConfig(); } return searchProviderConfig; } -function getBasicSearchManager() { - if (!basicSearchManager) { - basicSearchManager = configureManagerRequestTimeout(new BasicSearchManager(getSearchProviderConfig())); - } - - return basicSearchManager; -} - -function validateSearchReader(reader) { - const hasIndex = typeof reader?._cveIndex === 'string' && reader._cveIndex.trim() !== ''; - const hasSearchClient = typeof reader?._client?.search === 'function'; - const hasFieldCapsClient = typeof reader?._client?.fieldCaps === 'function'; - - if (!hasIndex || !hasSearchClient || !hasFieldCapsClient) { - throw new SearchProviderContractError(); - } - - return reader; -} - -function getSearchReader() { - if (!searchReader) { +function getSearchClient() { + if (!searchClient) { const config = getSearchProviderConfig(); - const reader = validateSearchReader( - new SearchReader(config.providerEndpoint, config.index, { - ignore_ssl_cert_check_errors: config.allowUnknownSslCerts - }) - ); - configureClientRequestTimeout(reader._client); - searchReader = reader; + searchClient = new Client({ + node: config.providerEndpoint, + ssl: { rejectUnauthorized: !config.allowUnknownSslCerts }, + requestTimeout: getOpenSearchRequestTimeoutMs(), + maxRetries: 0 + }); } - return searchReader; + return searchClient; } function awaitAbortableRequest(request, signal) { @@ -160,12 +119,12 @@ async function executeOpenSearchRequest(createRequest, { signal } = {}) { async function searchOpenSearch(body, { signal } = {}) { searchDeadline.throwIfAborted(signal); - const reader = getSearchReader(); + const client = getSearchClient(); const response = await executeOpenSearchRequest( () => - reader._client.search( + client.search( { - index: reader._cveIndex, + index: getSearchProviderConfig().index, allow_partial_search_results: false, body }, @@ -182,16 +141,16 @@ async function searchOpenSearch(body, { signal } = {}) { async function checkOpenSearchReadiness({ signal } = {}) { searchDeadline.throwIfAborted(signal); - const reader = getSearchReader(); + const client = getSearchClient(); const requestOptions = { maxRetries: 0, requestTimeout: getOpenSearchReadinessTimeoutMs() }; const fieldCapsResponse = await executeOpenSearchRequest( () => - reader._client.fieldCaps( + client.fieldCaps( { - index: reader._cveIndex, + index: getSearchProviderConfig().index, fields: searchReadiness.getRequiredFieldNames().join(','), include_unmapped: true }, @@ -204,9 +163,9 @@ async function checkOpenSearchReadiness({ signal } = {}) { const response = await executeOpenSearchRequest( () => - reader._client.search( + client.search( { - index: reader._cveIndex, + index: getSearchProviderConfig().index, allow_partial_search_results: false, body: { query: { @@ -227,15 +186,6 @@ async function checkOpenSearchReadiness({ signal } = {}) { return true; } -function isSearchProviderContractError(err) { - return ( - err instanceof SearchProviderContractError || - err instanceof searchReadiness.SearchMappingContractError || - err?.code === 'SEARCH_PROVIDER_CONTRACT' || - err?.code === 'SEARCH_MAPPING_CONTRACT' - ); -} - function getOpenSearchReadinessTimeoutMs() { return getIntegerConfig('search.readinessTimeoutMs', constants.SEARCH_READINESS_TIMEOUT_MS, { min: 1, @@ -259,24 +209,19 @@ function getSearchRequestDeadlineMs() { function resetSearchProviderForTests() { searchProviderConfig = undefined; - basicSearchManager = undefined; - searchReader = undefined; + searchClient = undefined; } module.exports = { checkOpenSearchReadiness, - getBasicSearchManager, getAllowUnknownSslCerts, getIntegerConfig, getOpenSearchReadinessTimeoutMs, getOpenSearchRequestTimeoutMs, getSearchRequestDeadlineMs, getSearchProviderConfig, - getSearchReader, - isSearchProviderContractError, + getSearchClient, normalizeBoolean, resetSearchProviderForTests, - searchOpenSearch, - validateSearchReader, - SearchProviderContractError + searchOpenSearch }; diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index 050644b..06b899f 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -1,44 +1,17 @@ -const mockBasicSearchManager = jest.fn(function BasicSearchManager(spec) { - this.spec = spec; - this._searchReader = { - _client: { - transport: { - maxRetries: 3, - requestTimeout: 30000 - } - } - }; -}); const mockClientSearch = jest.fn(); const mockClientFieldCaps = jest.fn(); -const mockSearchReader = jest.fn(function SearchReader(endpoint, index, options) { - this._openSearchDomainEndpoint = endpoint; - this._cveIndex = index; - this._options = options; - this._client = { - fieldCaps: mockClientFieldCaps, - search: mockClientSearch, - transport: { - maxRetries: 3, - requestTimeout: 30000 - } - }; +const mockClient = jest.fn(function Client() { + this.fieldCaps = mockClientFieldCaps; + this.search = mockClientSearch; }); -const mockGetDefaultSearchProviderSpec = jest.fn(); const mockAppConfigHas = jest.fn(); const mockAppConfigGet = jest.fn(); -const mockSearchProviderSpec = { - getDefaultSearchProviderSpec: mockGetDefaultSearchProviderSpec -}; - -jest.mock('cve-core', () => ({ - AppConfig: { - get: mockAppConfigGet, - has: mockAppConfigHas - }, - BasicSearchManager: mockBasicSearchManager, - SearchReader: mockSearchReader, - SearchProviderSpec: mockSearchProviderSpec +let mockConfig; + +jest.mock('@opensearch-project/opensearch', () => ({ Client: mockClient })); +jest.mock('./appConfig', () => ({ + get: mockAppConfigGet, + has: mockAppConfigHas })); const searchProvider = require('./searchProvider'); @@ -93,24 +66,22 @@ function fieldCapsResponse() { describe('searchProvider', () => { beforeEach(() => { searchProvider.resetSearchProviderForTests(); - mockBasicSearchManager.mockClear(); - mockSearchReader.mockClear(); + mockClient.mockClear(); mockClientSearch.mockReset(); mockClientFieldCaps.mockReset(); mockClientFieldCaps.mockResolvedValue(fieldCapsResponse()); - mockGetDefaultSearchProviderSpec.mockReset(); - mockGetDefaultSearchProviderSpec.mockReturnValue({ - providerEndpoint: 'https://search.example', - index: 'test-index', - allowUnknownSslCerts: false - }); + mockConfig = { + 'search.providerEndpoint': 'https://search.example', + 'search.index': 'test-index', + 'search.allowUnknownSslCerts': false + }; mockAppConfigHas.mockReset(); mockAppConfigGet.mockReset(); - mockAppConfigHas.mockReturnValue(false); - delete mockSearchProviderSpec._sDefaultSearchProviderSpec; + mockAppConfigHas.mockImplementation((key) => Object.hasOwn(mockConfig, key)); + mockAppConfigGet.mockImplementation((key) => mockConfig[key]); }); - test('resolves search provider config once through cve-core config', () => { + test('resolves immutable search provider config once through application config', () => { const first = searchProvider.getSearchProviderConfig(); const second = searchProvider.getSearchProviderConfig(); @@ -121,35 +92,28 @@ describe('searchProvider', () => { allowUnknownSslCerts: false }); expect(Object.isFrozen(first)).toBe(true); - expect(mockSearchProviderSpec._sDefaultSearchProviderSpec).toStrictEqual({ - providerEndpoint: 'https://search.example', - index: 'test-index', - allowUnknownSslCerts: false - }); - expect(mockGetDefaultSearchProviderSpec).toHaveBeenCalledTimes(1); + expect(mockAppConfigGet).toHaveBeenCalledTimes(3); }); - test('reuses one basic search manager per process', () => { - const first = searchProvider.getBasicSearchManager(); - const second = searchProvider.getBasicSearchManager(); + test('reuses one OpenSearch client per process', () => { + const first = searchProvider.getSearchClient(); + const second = searchProvider.getSearchClient(); expect(first).toBe(second); - expect(mockBasicSearchManager).toHaveBeenCalledTimes(1); - expect(mockBasicSearchManager).toHaveBeenCalledWith({ - providerEndpoint: 'https://search.example', - index: 'test-index', - allowUnknownSslCerts: false + expect(mockClient).toHaveBeenCalledTimes(1); + expect(mockClient).toHaveBeenCalledWith({ + node: 'https://search.example', + ssl: { rejectUnauthorized: true }, + requestTimeout: 20000, + maxRetries: 0 }); - expect(mockGetDefaultSearchProviderSpec).toHaveBeenCalledTimes(1); - expect(first._searchReader._client.transport.maxRetries).toBe(0); - expect(first._searchReader._client.transport.requestTimeout).toBe(20000); }); - test('reuses one search reader per process for OpenSearch searches', async () => { + test('uses the shared client for validated OpenSearch searches', async () => { mockClientSearch.mockResolvedValue(openSearchResponse()); - const first = searchProvider.getSearchReader(); - const second = searchProvider.getSearchReader(); + const first = searchProvider.getSearchClient(); + const second = searchProvider.getSearchClient(); const body = { query: { match_all: {} @@ -158,10 +122,7 @@ describe('searchProvider', () => { const result = await searchProvider.searchOpenSearch(body); expect(first).toBe(second); - expect(mockSearchReader).toHaveBeenCalledTimes(1); - expect(mockSearchReader).toHaveBeenCalledWith('https://search.example', 'test-index', { - ignore_ssl_cert_check_errors: false - }); + expect(mockClient).toHaveBeenCalledTimes(1); expect(mockClientSearch).toHaveBeenCalledWith( { index: 'test-index', @@ -173,8 +134,6 @@ describe('searchProvider', () => { requestTimeout: 20000 } ); - expect(first._client.transport.maxRetries).toBe(0); - expect(first._client.transport.requestTimeout).toBe(20000); expect(result).toStrictEqual(openSearchResponse().body); }); @@ -267,7 +226,7 @@ describe('searchProvider', () => { expect(request.abort).toHaveBeenCalledTimes(1); }); - test('does not construct a SearchReader for an already-canceled readiness check', async () => { + test('does not construct a client for an already-canceled readiness check', async () => { const controller = new AbortController(); controller.abort(new searchDeadline.SearchClientDisconnectedError()); @@ -278,21 +237,57 @@ describe('searchProvider', () => { ).rejects.toMatchObject({ code: 'SEARCH_CLIENT_DISCONNECTED' }); - expect(mockSearchReader).not.toHaveBeenCalled(); + expect(mockClient).not.toHaveBeenCalled(); expect(mockClientSearch).not.toHaveBeenCalled(); }); - test('rejects and does not cache an incompatible cve-core SearchReader', () => { - mockSearchReader.mockImplementationOnce(() => ({ - _cveIndex: 'test-index', - _client: { - transport: {} + test('does not retain a client after failed initialization', () => { + mockClient.mockImplementationOnce(() => { + throw new Error('Invalid endpoint'); + }); + expect(() => searchProvider.getSearchClient()).toThrow('Invalid endpoint'); + expect(searchProvider.getSearchClient()).toBeDefined(); + expect(mockClient).toHaveBeenCalledTimes(2); + }); + + test.each([ + [ + 'timed-out response', + (body) => { + body.timed_out = true; } - })); + ], + [ + 'partial shards', + (body) => { + body._shards.failed = 1; + } + ], + [ + 'missing hit source', + (body) => { + body.hits.hits = [{ _id: 'CVE-2024-0001' }]; + } + ] + ])('rejects a %s for either search endpoint', async (_case, mutate) => { + const response = openSearchResponse(); + mutate(response.body); + mockClientSearch.mockResolvedValue(response); + await expect(searchProvider.searchOpenSearch({ query: { match_all: {} } })).rejects.toMatchObject({ + code: 'INVALID_OPEN_SEARCH_RESPONSE' + }); + }); - expect(() => searchProvider.getSearchReader()).toThrow(searchProvider.SearchProviderContractError); - expect(searchProvider.getSearchReader()).toBeDefined(); - expect(mockSearchReader).toHaveBeenCalledTimes(2); + test.each([undefined, '', 'invalid'])('certificate verification stays enabled for %p', (value) => { + mockConfig['search.allowUnknownSslCerts'] = value; + searchProvider.getSearchClient(); + expect(mockClient.mock.calls[0][0].ssl.rejectUnauthorized).toBe(true); + }); + + test('certificate verification stays enabled when the setting is absent', () => { + delete mockConfig['search.allowUnknownSslCerts']; + searchProvider.getSearchClient(); + expect(mockClient.mock.calls[0][0].ssl.rejectUnauthorized).toBe(true); }); test('aborts an active OpenSearch request when the lifecycle signal aborts', async () => { @@ -339,43 +334,42 @@ describe('searchProvider', () => { ).rejects.toMatchObject({ code: 'SEARCH_CLIENT_DISCONNECTED' }); - expect(mockSearchReader).not.toHaveBeenCalled(); + expect(mockClient).not.toHaveBeenCalled(); expect(mockClientSearch).not.toHaveBeenCalled(); }); - test('throws when OpenSearch endpoint or index are missing', () => { - mockGetDefaultSearchProviderSpec.mockReturnValue({ - providerEndpoint: '', - index: 'test-index' - }); + test.each([ + ['search.providerEndpoint', ''], + ['search.providerEndpoint', undefined], + ['search.index', ''], + ['search.index', ' '], + ['search.index', 123] + ])('throws when %s is missing or invalid (%s)', (key, value) => { + mockConfig[key] = value; expect(() => searchProvider.getSearchProviderConfig()).toThrow( 'OpenSearch providerEndpoint and index must be configured.' ); }); - test('normalizes string FALSE from AppConfig before constructing managers', () => { - mockGetDefaultSearchProviderSpec.mockReturnValue({ - providerEndpoint: 'https://search.example', - index: 'test-index', - allowUnknownSslCerts: true - }); - mockAppConfigHas.mockReturnValue(true); - mockAppConfigGet.mockReturnValue('FALSE'); + test('normalizes string FALSE before constructing a client', () => { + mockConfig['search.allowUnknownSslCerts'] = 'FALSE'; expect(searchProvider.getSearchProviderConfig()).toStrictEqual({ providerEndpoint: 'https://search.example', index: 'test-index', allowUnknownSslCerts: false }); - expect(mockSearchProviderSpec._sDefaultSearchProviderSpec.allowUnknownSslCerts).toBe(false); + searchProvider.getSearchClient(); + expect(mockClient.mock.calls[0][0].ssl.rejectUnauthorized).toBe(true); }); - test('normalizes string TRUE from AppConfig for local certificate bypasses', () => { - mockAppConfigHas.mockReturnValue(true); - mockAppConfigGet.mockReturnValue('TRUE'); + test('normalizes string TRUE for local certificate bypasses', () => { + mockConfig['search.allowUnknownSslCerts'] = 'TRUE'; expect(searchProvider.getSearchProviderConfig().allowUnknownSslCerts).toBe(true); + searchProvider.getSearchClient(); + expect(mockClient.mock.calls[0][0].ssl.rejectUnauthorized).toBe(false); }); test('reads search timeout runtime settings from config', () => { diff --git a/utils/webSearchQueryBuilder.js b/utils/webSearchQueryBuilder.js new file mode 100644 index 0000000..0040236 --- /dev/null +++ b/utils/webSearchQueryBuilder.js @@ -0,0 +1,142 @@ +const path = require('path'); +const validator = require('validator'); + +const DATE_FIELDS = Object.freeze([ + 'cveMetadata.datePublished', + 'cveMetadata.dateRejected', + 'cveMetadata.dateReserved', + 'cveMetadata.dateUpdated', + 'containers.cna.datePublic', + 'containers.cna.providerMetadata.dateUpdated', + 'containers.cna.timeline.time', + 'containers.adp.metrics.other.content.dateAdded', + 'containers.adp.metrics.other.content.timestamp', + 'containers.adp.providerMetadata.dateUpdated' +]); +const URL_OPTIONS = { protocols: ['http', 'https', 'ftp', 'app', 'wss', 'ws', 'file'] }; + +function parseDate(value, allowPartial = false) { + const text = value.trim(); + const match = + /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?)?$/.exec(text); + if (!match) return null; + const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction, zone] = match; + if ((!allowPartial || hourText !== undefined) && !dayText) return null; + + const year = Number(yearText); + const month = Number(monthText ?? 1); + const day = Number(dayText ?? 1); + const hour = Number(hourText ?? 0); + const minute = Number(minuteText ?? 0); + const second = Number(secondText ?? 0); + if ( + year < 1900 || + year > 2100 || + month < 1 || + month > 12 || + day < 1 || + day > new Date(Date.UTC(year, month, 0)).getUTCDate() || + hour > 23 || + minute > 59 || + second > 59 + ) + return null; + + const milliseconds = Number((fraction ?? '0').padEnd(3, '0').slice(0, 3)); + let timestamp = Date.UTC(year, month - 1, day, hour, minute, second, milliseconds); + if (zone && zone !== 'Z') { + const [hours, minutes] = zone.slice(1).split(':').map(Number); + timestamp -= (zone[0] === '+' ? 1 : -1) * (hours * 60 + minutes) * 60000; + } + const utc = new Date(timestamp).toISOString().replace('.000Z', 'Z'); + const rounding = hourText !== undefined ? '' : !monthText ? 'y' : !dayText ? 'M' : 'd'; + return { timestamp, utc, text, rounding }; +} + +function parseDateRange(text) { + const parts = text.split('..'); + if (parts.length !== 2) return null; + const [start, end] = parts.map((part) => parseDate(part, true)); + if (!start || !end) return null; + if (start.timestamp > end.timestamp) throw new Error('Invalid web search date range.'); + return [start, end]; +} + +function dateExpression(date, rangeEndpoint) { + const value = rangeEndpoint || !date.rounding ? date.utc : date.text; + return date.rounding ? `${value}||/${date.rounding}` : value; +} + +function isUrl(text) { + return !validator.isIP(text) && validator.isURL(text, URL_OPTIONS); +} + +function describeToken(token) { + if (token.includes('{')) return null; + if (/"[^"]*"/.test(token)) return 'search text is a phrase (surrounded by double quotes)'; + if (parseDate(token)) return 'search text is a date (ISO 8601)'; + if (parseDateRange(token)) return 'search text is a date range (ISO 8601)'; + if (token.includes('*')) return "search as wildcard ('*')"; + if (token.includes('?')) return isUrl(token) ? 'search text is a URL' : "search as wildcard ('?')"; + if (token.includes('%')) return "does not support wildcard character '%'"; + if (isUrl(token)) return 'search text is a URL'; + if (token.includes('-')) { + if (/^CVE-[1,2]\d{3}-\d{4,7}$/i.test(token)) return 'search text is a CVE ID'; + if (/^CVE-\d{4}$/i.test(token)) return 'search text is a CVE ID year series'; + if (/^CWE-\d{1,4}$/i.test(token)) return 'search text is a CWE ID'; + if (/^capec-\d{1,4}$/i.test(token)) return 'search text is a CAPEC ID'; + if (path.parse(token).base) return 'search text is a version string'; + } + if (token.includes(':')) return validator.isIP(token, 6) ? 'search text is an IP v6 address' : null; + if (token.includes('.')) { + return validator.isIP(token, 4) ? 'search text is an IP v4 address' : 'search text is a version string'; + } + if (token.includes('&')) return 'search text is a phrase (surrounded by double quotes)'; + return 'general search text string'; +} + +function describeQuery(query) { + const cleaned = query.trim().replace(/([^\p{L}0-9.-])\1{2,}/gu, ''); + const tokens = (cleaned.match(/"[^"]*"|[\S]+/g) ?? []).filter((token) => token !== ','); + let note; + for (let token of tokens) { + if (token.startsWith('"') && token.endsWith('"')) { + token = token.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + // Preserve the legacy final-token note/acceptance behavior; the DSL uses the original query. + note = describeToken(token); + } + if (!note) throw new Error('Unsupported web search expression.'); + return [note]; +} + +function buildWebSearchRequest(query, { sort, from = 0, size = 25 } = {}) { + const notes = describeQuery(query); + const date = parseDate(query); + const range = date ? [date, date] : parseDateRange(query); + const searchQuery = range + ? { + bool: { + should: DATE_FIELDS.map((field) => ({ + range: { [field]: { gte: dateExpression(range[0], !date), lte: dateExpression(range[1], !date) } } + })), + minimum_should_match: 1 + } + } + : { query_string: { query, default_operator: 'AND' } }; + + // Keep legacy offset/size handling separate from /search pagination. + const offset = Math.max(0, from); + return { + body: { + query: searchQuery, + track_total_hits: true, + sort: sort ?? [{ 'cveMetadata.cveId.keyword': { order: 'desc' } }], + from: offset, + size: size < offset ? offset + 1 : size + }, + notes + }; +} + +module.exports = { buildWebSearchRequest }; diff --git a/utils/webSearchQueryBuilder.test.js b/utils/webSearchQueryBuilder.test.js new file mode 100644 index 0000000..5d4b1c3 --- /dev/null +++ b/utils/webSearchQueryBuilder.test.js @@ -0,0 +1,113 @@ +const { buildWebSearchRequest } = require('./webSearchQueryBuilder'); + +const dateFields = [ + 'cveMetadata.datePublished', + 'cveMetadata.dateRejected', + 'cveMetadata.dateReserved', + 'cveMetadata.dateUpdated', + 'containers.cna.datePublic', + 'containers.cna.providerMetadata.dateUpdated', + 'containers.cna.timeline.time', + 'containers.adp.metrics.other.content.dateAdded', + 'containers.adp.metrics.other.content.timestamp', + 'containers.adp.providerMetadata.dateUpdated' +]; + +describe('raw web search compatibility', () => { + test.each([ + 'apache', + 'remote code', + 'remote code execution', + '"remote code execution"', + 'apache*', + 'ap?che', + 'apache AND nginx', + '(apache OR nginx)', + 'cveMetadata.cveId.keyword:CVE-2024-0001', + 'CVE-2024-0001', + 'https://example.com/a?b=1', + '192.0.2.1', + '2001:db8::1', + '2024', + '2024-01', + '2024-02-30', + 'prefix\\:value apache' + ])('preserves the original query expression: %s', (query) => { + expect(buildWebSearchRequest(query).body).toStrictEqual({ + query: { query_string: { query, default_operator: 'AND' } }, + track_total_hits: true, + sort: [{ 'cveMetadata.cveId.keyword': { order: 'desc' } }], + from: 0, + size: 25 + }); + }); + + test.each([ + ['apache', 'general search text string'], + ['"remote code"', 'general search text string'], + ['CVE-2024-0001', 'search text is a CVE ID'], + ['CVE-2024', 'search text is a CVE ID year series'], + ['CWE-79', 'search text is a CWE ID'], + ['CAPEC-1', 'search text is a CAPEC ID'], + ['apache*', "search as wildcard ('*')"], + ['ap?che', "search as wildcard ('?')"], + ['foo%bar', "does not support wildcard character '%'"], + ['https://example.com/a?b=1', 'search text is a URL'], + ['192.0.2.1', 'search text is an IP v4 address'], + ['2001:db8::1', 'search text is an IP v6 address'], + ['1.2.3', 'search text is a version string'], + ['some-file', 'search text is a version string'], + ['file.txt', 'search text is a URL'], + ['a&b', 'search text is a phrase (surrounded by double quotes)'], + ['"a\\\\b"', 'general search text string'], + ['{reserved} apache', 'general search text string'] + ])('preserves the legacy description for %s', (query, note) => { + expect(buildWebSearchRequest(query).notes).toStrictEqual([note]); + }); + + test.each([ + ['2024-02-29', '2024-02-29||/d', '2024-02-29||/d'], + ['2024-01-01T12:30:00Z', '2024-01-01T12:30:00Z', '2024-01-01T12:30:00Z'], + ['2024-01-01T12:30:00.25+02:00', '2024-01-01T10:30:00.250Z', '2024-01-01T10:30:00.250Z'], + ['2024-01-01T12:30:00', '2024-01-01T12:30:00Z', '2024-01-01T12:30:00Z'], + ['2024-01-01..2024-01-31', '2024-01-01T00:00:00Z||/d', '2024-01-31T00:00:00Z||/d'], + ['2023..2024', '2023-01-01T00:00:00Z||/y', '2024-01-01T00:00:00Z||/y'], + ['2024-01..2024-03', '2024-01-01T00:00:00Z||/M', '2024-03-01T00:00:00Z||/M'], + ['2024-01-01 .. 2024-01-02', '2024-01-01T00:00:00Z||/d', '2024-01-02T00:00:00Z||/d'] + ])('expands %s across the legacy date fields with inclusive bounds', (query, gte, lte) => { + expect(buildWebSearchRequest(query).body.query).toStrictEqual({ + bool: { + should: dateFields.map((field) => ({ range: { [field]: { gte, lte } } })), + minimum_should_match: 1 + } + }); + }); + + test.each(['', ' ', ',', '{reserved}', 'field:value', '2024-02-01..2024-01-01'])( + 'rejects unsupported input without sending a query: %s', + (query) => { + expect(() => buildWebSearchRequest(query)).toThrow(); + } + ); + + test('quoted dates remain literal query expressions', () => { + expect(buildWebSearchRequest('"2024-01-01"').body.query).toStrictEqual({ + query_string: { query: '"2024-01-01"', default_operator: 'AND' } + }); + }); + + test.each([ + [0, 25, 25], + [5, 10, 10], + [25, 25, 25], + [50, 25, 51], + [-1, 10, 10] + ])('retains legacy paging for offset %s and size %s', (from, size, effectiveSize) => { + const sort = [{ _score: { order: 'asc' } }]; + expect(buildWebSearchRequest('apache', { from, size, sort }).body).toMatchObject({ + from: Math.max(0, from), + size: effectiveSize, + sort + }); + }); +}); From eabe01526ef938929866c3ae2627a6a6130acdde Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:02:28 -0400 Subject: [PATCH 3/9] Keep web search page size independent of offset --- routes/index.js | 2 +- routes/search.integration.test.js | 41 +++++++++++++++++++++++++++++ utils/constants.js | 2 ++ utils/webSearchQueryBuilder.js | 6 ++--- utils/webSearchQueryBuilder.test.js | 8 ++++-- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/routes/index.js b/routes/index.js index 0bf142c..fc546cf 100644 --- a/routes/index.js +++ b/routes/index.js @@ -71,7 +71,7 @@ function hasBodyField(req, field) { function isWebSearchWindowWithinLimit(_value, { req }) { const from = hasBodyField(req, 'from') ? Number(req.body.from) : 0; - const size = hasBodyField(req, 'size') ? Number(req.body.size) : constants.MAX_RESULTS_LIMIT; + const size = hasBodyField(req, 'size') ? Number(req.body.size) : constants.WEB_SEARCH_RESULTS_PER_PAGE; if (!Number.isFinite(from) || !Number.isFinite(size)) return true; diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index d4082e4..3503bc4 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -264,6 +264,47 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test.each([ + [500, 1, 1], + [4999, 1, 1], + [9500, 500, 500], + [9999, 1, 1], + [9975, undefined, 25] + ])('POST /webSearch preserves page size at offset %s with size %s', async (from, size, expectedSize) => { + mockSearchOpenSearch.mockImplementation( + async (body) => + advancedResponse( + 20000, + Array.from({ length: body.size }, (_, index) => ({ _id: String(index) })) + ).data + ); + + const response = await request(createApp(), { + method: 'POST', + path: '/webSearch', + body: { query: 'apache', from, ...(size === undefined ? {} : { size }) } + }); + + expect(response.status).toBe(200); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + expect(mockSearchOpenSearch).toHaveBeenCalledWith(expect.objectContaining({ from, size: expectedSize })); + expect(response.body.data).toHaveLength(expectedSize); + }); + + test.each([{ from: 9501, size: 500 }, { from: 9976 }, { from: 10000, size: 1 }, { size: 501 }])( + 'POST /webSearch rejects pagination outside its limits: %j', + async (pagination) => { + const response = await request(createApp(), { + method: 'POST', + path: '/webSearch', + body: { query: 'apache', ...pagination } + }); + + expect(response.status).toBe(400); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + test('POST /webSearch normalizes fields, builds DSL, and formats OpenSearch responses', async () => { mockSearchOpenSearch.mockResolvedValue(advancedResponse(1, [{ _id: 'CVE-2024-0002' }]).data); diff --git a/utils/constants.js b/utils/constants.js index 83f7b4d..3d46982 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -1,5 +1,6 @@ const MAX_RESULTS_LIMIT = 500; const RESULTS_PER_PAGE = 20; +const WEB_SEARCH_RESULTS_PER_PAGE = 25; const MAX_SEARCH_WINDOW = 10000; const MAX_CVSS_METRICS_LENGTH = 256; const MAX_CVSS_METRIC_TOKENS = 32; @@ -139,6 +140,7 @@ module.exports = { SEARCH_READINESS_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS, RESULTS_PER_PAGE, + WEB_SEARCH_RESULTS_PER_PAGE, REJECTED_CVE_STATES: rejectedCveStates, CPE_VERSION_RANGE_TYPES: cpeVersionRangeTypes, CPE_MATCH_FIELDS: cpeMatchFields, diff --git a/utils/webSearchQueryBuilder.js b/utils/webSearchQueryBuilder.js index 0040236..ecf8a3b 100644 --- a/utils/webSearchQueryBuilder.js +++ b/utils/webSearchQueryBuilder.js @@ -1,5 +1,6 @@ const path = require('path'); const validator = require('validator'); +const { WEB_SEARCH_RESULTS_PER_PAGE } = require('./constants'); const DATE_FIELDS = Object.freeze([ 'cveMetadata.datePublished', @@ -110,7 +111,7 @@ function describeQuery(query) { return [note]; } -function buildWebSearchRequest(query, { sort, from = 0, size = 25 } = {}) { +function buildWebSearchRequest(query, { sort, from = 0, size = WEB_SEARCH_RESULTS_PER_PAGE } = {}) { const notes = describeQuery(query); const date = parseDate(query); const range = date ? [date, date] : parseDateRange(query); @@ -125,7 +126,6 @@ function buildWebSearchRequest(query, { sort, from = 0, size = 25 } = {}) { } : { query_string: { query, default_operator: 'AND' } }; - // Keep legacy offset/size handling separate from /search pagination. const offset = Math.max(0, from); return { body: { @@ -133,7 +133,7 @@ function buildWebSearchRequest(query, { sort, from = 0, size = 25 } = {}) { track_total_hits: true, sort: sort ?? [{ 'cveMetadata.cveId.keyword': { order: 'desc' } }], from: offset, - size: size < offset ? offset + 1 : size + size }, notes }; diff --git a/utils/webSearchQueryBuilder.test.js b/utils/webSearchQueryBuilder.test.js index 5d4b1c3..5c85777 100644 --- a/utils/webSearchQueryBuilder.test.js +++ b/utils/webSearchQueryBuilder.test.js @@ -100,9 +100,13 @@ describe('raw web search compatibility', () => { [0, 25, 25], [5, 10, 10], [25, 25, 25], - [50, 25, 51], + [50, 25, 25], + [500, 1, 1], + [4999, 1, 1], + [9500, 500, 500], + [9975, undefined, 25], [-1, 10, 10] - ])('retains legacy paging for offset %s and size %s', (from, size, effectiveSize) => { + ])('keeps page size independent of offset %s with size %s', (from, size, effectiveSize) => { const sort = [{ _score: { order: 'asc' } }]; expect(buildWebSearchRequest('apache', { from, size, sort }).body).toMatchObject({ from: Math.max(0, from), From 42e01ac6f939ca452dbef4a87f460ed730465518 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:19:25 -0400 Subject: [PATCH 4/9] 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'] } } From c59d6779ed6cf98b57700d9d78fcbc69263a2211 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:36:15 -0400 Subject: [PATCH 5/9] Omit ingestion metadata from search records --- README.md | 2 ++ api-docs/openapi.json | 2 +- controllers/searchController.test.js | 13 ++++++++----- routes/search.integration.test.js | 5 ++++- routes/swagger.js | 3 ++- utils/searchResponseBuilder.js | 5 ++++- utils/searchResponseBuilder.test.js | 29 ++++++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fd53f3c..f987c4d 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ JSON request bodies for both search endpoints are limited to 100 KiB (102,400 by Successful `/search` responses use an API-owned response structure rather than exposing search-backend response objects. The top-level fields are `apiVersion`, `status`, `warnings`, `pagination`, and `records`, in that order. `records` contains complete CVE Records directly, without search-backend hit wrappers or infrastructure metadata. `pagination` contains `page`, `pageSize`, `resultsReturned`, `totalResults`, `totalPages`, and `totalIsExact`. Non-fatal conditions are reported in `warnings` before the potentially long `records` array. +The API omits the top-level `opensearch_ops` ingestion metadata from each `/search` record, including ingestion timestamps and signatures, while preserving all other record fields and containers. This targeted removal does not validate or repair records against the CVE JSON schema and does not modify the indexed data. + CVE Records do not exist for years before 1999. A syntactically valid pre-1999 value in `cveIds` is therefore a valid search that contributes no matches rather than a request error. The response includes a `CVE_IDS_NOT_RETURNED` warning listing those IDs. When pre-1999 and supported-year IDs are requested together, matching supported-year records are still returned. Use the optional `scope` field to control which parts of each record participate in matching. Values are case-insensitive: `FULL` (the default) searches base record fields, CNA data, and every ADP provider; `CNA_ONLY` searches base record fields and CNA data; and `ADP_ONLY` searches base record fields and every ADP provider. Scope changes matching only, so successful responses always contain the complete CVE record. CPE applicability filters use CNA data and return HTTP 400 when combined with `ADP_ONLY`. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 43f0bec..dee24e4 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -542,7 +542,7 @@ "CveRecord": { "type": "object", "additionalProperties": true, - "description": "A complete CVE Record without search-backend transport metadata." + "description": "A complete CVE Record without search-backend transport metadata. The top-level opensearch_ops ingestion metadata is omitted; all other record fields and containers are preserved. This targeted removal does not validate or repair the record against the CVE JSON schema." }, "SearchResponse": { "type": "object", diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8319ec4..1ca9710 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -660,12 +660,14 @@ describe('Search controller SEARCH handler', () => { } } }; + const publicRecord = { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } + }; + const metadata = { ingest_timestamp: '2026-06-30T17:40:18.109339170Z' }; const fullRecord = { _id: 'CVE-2024-0001', - _source: { - cveMetadata: { cveId: 'CVE-2024-0001' }, - containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } - } + _source: Object.freeze({ ...publicRecord, opensearch_ops: metadata }) }; mockSearchRaw.mockResolvedValue(rawResponse(2, [matching, splitAcrossEntries])); mockApiSearch.mockResolvedValue(advancedResponse(1, [fullRecord])); @@ -691,7 +693,8 @@ describe('Search controller SEARCH handler', () => { ]); expect(res.json.mock.calls[0][0].status).toBe('ok'); expect(res.json.mock.calls[0][0].pagination.totalResults).toBe(1); - expect(res.json.mock.calls[0][0].records).toStrictEqual([fullRecord._source]); + expect(res.json.mock.calls[0][0].records).toStrictEqual([publicRecord]); + expect(fullRecord._source.opensearch_ops).toStrictEqual(metadata); }); test('SEARCH semantically filters date-bounded KEV candidates within the selected scope', async () => { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 63c3958..8d7ad92 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -55,13 +55,15 @@ describe('search route integration', () => { cveId: 'CVE-2024-0001' } }; + const metadata = { _source_version: '0.1', ingest_timestamp: '2026-06-30T17:40:18.109339170Z' }; + const source = Object.freeze({ ...record, opensearch_ops: metadata }); mockSearchOpenSearch.mockResolvedValue( advancedResponse(1, [ { _index: 'private-index-name', _id: 'CVE-2024-0001', _score: null, - _source: record + _source: source } ]).data ); @@ -122,6 +124,7 @@ describe('search route integration', () => { }, records: [record] }); + expect(source.opensearch_ops).toStrictEqual(metadata); }); test('POST /search builds a scope-aware literal reference URL substring filter', async () => { diff --git a/routes/swagger.js b/routes/swagger.js index 2dcc8c8..f7ab7e3 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -447,7 +447,8 @@ const doc = { CveRecord: { type: 'object', additionalProperties: true, - description: 'A complete CVE Record without search-backend transport metadata.' + description: + 'A complete CVE Record without search-backend transport metadata. The top-level opensearch_ops ingestion metadata is omitted; all other record fields and containers are preserved. This targeted removal does not validate or repair the record against the CVE JSON schema.' }, SearchResponse: { type: 'object', diff --git a/utils/searchResponseBuilder.js b/utils/searchResponseBuilder.js index 04920e6..dd08d36 100644 --- a/utils/searchResponseBuilder.js +++ b/utils/searchResponseBuilder.js @@ -75,7 +75,10 @@ function getRequestWarnings(params = {}) { function buildSearchResponse({ totalResults, hits, pagingOptions, totalIsExact = true, warnings = [] }) { const pageSize = pagingOptions.resultsPerPage; - const records = hits.map((hit) => hit._source); + const records = hits.map((hit) => { + const { opensearch_ops: _ingestionMetadata, ...record } = hit._source; + return record; + }); const page = totalResults === 0 ? 0 : Math.floor(pagingOptions.from / pageSize) + 1; const totalPages = totalResults === 0 ? 0 : Math.ceil(totalResults / pageSize); diff --git a/utils/searchResponseBuilder.test.js b/utils/searchResponseBuilder.test.js index 8e51f4e..89c9a09 100644 --- a/utils/searchResponseBuilder.test.js +++ b/utils/searchResponseBuilder.test.js @@ -43,6 +43,35 @@ describe('search response builder', () => { }); }); + test.each(['PUBLISHED', 'REJECTED'])('omits ingestion metadata without mutating a %s record', (state) => { + const record = { + dataType: 'CVE_RECORD', + dataVersion: '5.1', + cveMetadata: { cveId: 'CVE-2024-0001', state }, + containers: { + cna: { descriptions: [{ lang: 'en', value: 'CNA content' }] }, + adp: [{ descriptions: [{ lang: 'en', value: 'ADP content' }] }] + }, + futureRecordField: { value: 'preserved without a schema allowlist' } + }; + const metadata = { + _source_version: '0.1', + ingest_timestamp: '2026-06-30T17:40:18.109339170Z', + ingested_cve_signature: 'CVE-2024-0001|2026-06-30T17:40:18.109Z|signature' + }; + const source = Object.freeze({ ...record, opensearch_ops: metadata }); + const response = searchResponseBuilder.buildSearchResponse({ + totalResults: 1, + hits: [{ _source: source }], + pagingOptions: { from: 0, resultsPerPage: 20 } + }); + + expect(response.records).toStrictEqual([record]); + expect(response.records[0]).not.toHaveProperty('opensearch_ops'); + expect(response.records[0]).not.toBe(source); + expect(source).toStrictEqual({ ...record, opensearch_ops: metadata }); + }); + test('preserves warnings before records and represents an empty result page', () => { const warnings = [searchResponseBuilder.semanticCandidateLimitWarning()]; const response = searchResponseBuilder.buildSearchResponse({ From 270e91b0633505318dcc6418e0a57ed01bfd27f5 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:46:57 -0400 Subject: [PATCH 6/9] Accept NONE severity for CVSS v3 and v4 filters --- README.md | 2 + api-docs/openapi.json | 12 ++++-- controllers/middleware.js | 2 +- controllers/middleware.test.js | 8 ++-- routes/search.integration.test.js | 41 +++++++++++++++++++ routes/swagger.js | 16 ++++---- .../CVE-Search-API.postman_collection.json | 8 ++-- utils/constants.js | 2 +- utils/errors.js | 8 ++-- utils/errors.test.js | 8 ++-- 10 files changed, 77 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index fd53f3c..cfe9192 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ Use the optional `scope` field to control which parts of each record participate Date filters use the API's full UTC timestamp format and inclusive boundaries. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end. +CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`, case-insensitively. They match the stored `baseSeverity` label in scope-selected CNA/ADP metrics; the API does not recalculate the label from scores. CVSS v2 instead maps `LOW`, `MEDIUM`, and `HIGH` to base-score ranges and does not accept `NONE` or `CRITICAL`. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 43f0bec..b6fd69e 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -708,42 +708,46 @@ "cvssV3Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive." }, "cvssV3_0Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 metric. Values are case-insensitive." }, "cvssV3_1Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.1 metric. Values are case-insensitive." }, "cvssV4Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v4.0 metric. Values are case-insensitive." }, "cvssV2Metrics": { "type": "string", diff --git a/controllers/middleware.js b/controllers/middleware.js index bc06ade..0c7f415 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -7,7 +7,7 @@ const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; -const cvssV3_4SeverityEnum = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +const cvssV3_4SeverityEnum = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; const severityValidators = [ { diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 037e98a..ebfc338 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -667,7 +667,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV3_SEVERITY', - 'Test is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); @@ -686,7 +686,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV3_0_SEVERITY', - 'Test is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); @@ -705,7 +705,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV3_1_SEVERITY', - 'Test is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); @@ -724,7 +724,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV4_SEVERITY', - 'Test is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 63c3958..8d8d400 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -124,6 +124,47 @@ describe('search route integration', () => { }); }); + test.each(['cvssV3Severity', 'cvssV3_0Severity', 'cvssV3_1Severity', 'cvssV4Severity'])( + 'POST /search accepts NONE for %s in every scope', + async (param) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + const metricNames = { + cvssV3Severity: ['cvssV3_0', 'cvssV3_1'], + cvssV3_0Severity: ['cvssV3_0'], + cvssV3_1Severity: ['cvssV3_1'], + cvssV4Severity: ['cvssV4_0'] + }; + for (const scope of ['FULL', 'CNA_ONLY', 'ADP_ONLY']) { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { [param]: 'none', scope } + }); + expect(response.status).toBe(200); + const containers = scope === 'FULL' ? ['cna', 'adp'] : [scope === 'CNA_ONLY' ? 'cna' : 'adp']; + const clauses = containers.flatMap((container) => + metricNames[param].map((metric) => ({ + term: { [`containers.${container}.metrics.${metric}.baseSeverity.keyword`]: 'NONE' } + })) + ); + expect(mockSearchOpenSearch.mock.lastCall[0].query.bool.filter).toStrictEqual([ + clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } } + ]); + } + } + ); + + test('POST /search rejects NONE for CVSS v2', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cvssV2Severity: 'NONE' } + }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('INVALID_CVSSV2_SEVERITY'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + 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..ee4778d 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -83,31 +83,31 @@ const searchRequestProperties = { } ), cvssV3Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_0Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_1Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.1 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV4Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v4.0 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV2Metrics: searchRequestProperty( diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index c87b1c5..8feef83 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -5146,7 +5146,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5190,7 +5190,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5234,7 +5234,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5278,7 +5278,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", diff --git a/utils/constants.js b/utils/constants.js index 83f7b4d..5d81011 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -45,7 +45,7 @@ const cpeMatchFields = { * cveIds: comma-separated list of CVE-year-XXXX identifiers * noRejected: no-value flag that excludes rejected CVE records * cweId: CWE-XXX - * cvssV2Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL + * cvssV2Severity: LOW, MEDIUM, HIGH * cvssV3Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL * cvssV3_0Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL * cvssV3_1Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL diff --git a/utils/errors.js b/utils/errors.js index cdbaa2e..ef12f58 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -251,28 +251,28 @@ class errors { cvssV3SeverityError(severity) { return makeError( 'INVALID_CVSSV3_SEVERITY', - `${severity} is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } cvssV3_0SeverityError(severity) { return makeError( 'INVALID_CVSSV3_0_SEVERITY', - `${severity} is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } cvssV3_1SeverityError(severity) { return makeError( 'INVALID_CVSSV3_1_SEVERITY', - `${severity} is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } cvssV4SeverityError(severity) { return makeError( 'INVALID_CVSSV4_SEVERITY', - `${severity} is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } diff --git a/utils/errors.test.js b/utils/errors.test.js index 6c7bfbb..9e1b0c2 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -245,7 +245,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV3_SEVERITY', message: - 'bad is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ @@ -254,7 +254,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV3_0_SEVERITY', message: - 'bad is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ @@ -263,7 +263,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV3_1_SEVERITY', message: - 'bad is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ @@ -272,7 +272,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV4_SEVERITY', message: - 'bad is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ From e420fece037619e142a410fcb46824955903479b Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:54:49 -0400 Subject: [PATCH 7/9] Validate version-specific CVSS metric filters --- README.md | 2 + api-docs/openapi.json | 10 +- controllers/middleware.js | 7 +- routes/search.integration.test.js | 16 ++ routes/swagger.js | 11 +- .../CVE-Search-API.postman_collection.json | 176 ++++++++++++++++++ test/postman/buildCollection.js | 26 +++ utils/cvss.js | 145 ++++++++++++++- utils/cvss.test.js | 68 +++++++ utils/errors.js | 4 + utils/searchQueryBuilder.js | 13 +- utils/searchQueryBuilder.test.js | 17 +- 12 files changed, 462 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index cfe9192..8ccac2a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Date filters use the API's full UTC timestamp format and inclusive boundaries. W CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`, case-insensitively. They match the stored `baseSeverity` label in scope-selected CNA/ADP metrics; the API does not recalculate the label from scores. CVSS v2 instead maps `LOW`, `MEDIUM`, and `HIGH` to base-score ranges and does not accept `NONE` or `CRITICAL`. +CVSS metric filters accept partial vectors, such as `AV:N/AC:L`, with case-insensitive metric names and values from the selected CVSS version, including optional metrics. A `CVSS:2.0`, `CVSS:3.0`, `CVSS:3.1`, or `CVSS:4.0` prefix is optional but must match the requested filter. For `cvssV3Metrics`, omitting the prefix searches both v3.0 and v3.1; providing one restricts matching to that version. Unknown metrics or values, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 `CVSS_METRICS_FORMAT`. Repeated identical tokens are accepted. Every supplied token must occur in the same eligible stored vector; filters do not calculate a CVSS score. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index b6fd69e..741a5df 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -753,31 +753,31 @@ "type": "string", "maxLength": 256, "example": "AV:N/AC:L/Au:N/C:P/I:P/A:P", - "description": "Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v2.0 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Without a prefix, both versions are searched; CVSS:3.0 or CVSS:3.1 restricts matching to that version. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_0Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_1Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV4Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", - "description": "Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cpeName": { "type": "string", diff --git a/controllers/middleware.js b/controllers/middleware.js index 0c7f415..504aac4 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -3,6 +3,7 @@ const constants = require('../utils/constants'); const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); const ssvc = require('../utils/ssvc'); +const cvss = require('../utils/cvss'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -43,7 +44,7 @@ const ssvcEnumValidators = [ { param: 'ssvcTechnicalImpact', allowed: ssvc.TECHNICAL_IMPACT_VALUES } ]; -const cvssMetricsParams = ['cvssV2Metrics', 'cvssV3Metrics', 'cvssV3_0Metrics', 'cvssV3_1Metrics', 'cvssV4Metrics']; +const cvssMetricsParams = cvss.PARAMS; const dateParams = [ 'lastModStartDate', @@ -368,6 +369,10 @@ function validateParams(req, res, next) { if (params?.[param] && hasInvalidCvssMetricsShape(params[param])) { return res.status(400).json(error.cvssMetricsLimitError(param)); } + if (hasParam(params, param)) { + const parsed = cvss.parseMetricFilter(param, params[param]); + if (!parsed.valid) return res.status(400).json(error.cvssMetricsFormatError(param, parsed.reason)); + } } //Ensures date params are valid timestamps matching yyyy-MM-ddTHH:mm:ss.SSS'Z' and real calendar dates. diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 8d8d400..d977715 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,6 +48,22 @@ describe('search route integration', () => { mockBasicSearch.mockReset(); }); + test.each([ + ['cvssV2Metrics', 'AU:Z'], + ['cvssV3Metrics', 'CVSS:4.0/AV:N'], + ['cvssV3_0Metrics', 'CVSS:3.1/AV:N'], + ['cvssV3_1Metrics', 'AV:N/AV:L'], + ['cvssV4Metrics', 'FOO:N'], + ['cvssV4Metrics', 'CVSS:4.0'], + ['cvssV4Metrics', 'AV:N/'], + ['cvssV4Metrics', 'CVSS#4.0/AV:N'] + ])('rejects invalid %s vectors before OpenSearch: %s', async (param, value) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { [param]: value } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('CVSS_METRICS_FORMAT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { const record = { dataType: 'CVE_RECORD', diff --git a/routes/swagger.js b/routes/swagger.js index ee4778d..567d3a8 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -11,6 +11,7 @@ const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); const routes = [path.join(__dirname, 'index.js')]; const maxSearchWindowLabel = constants.MAX_SEARCH_WINDOW.toLocaleString('en-US'); const semanticPostFilterDescription = `CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most ${maxSearchWindowLabel} search candidates, so reported totals and pages include only matches found within that candidate set.`; +const cvssMetricsDescription = `Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`; function searchRequestProperty(description, schema) { return { @@ -111,7 +112,7 @@ const searchRequestProperties = { } ), cvssV2Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v2.0 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -119,7 +120,7 @@ const searchRequestProperties = { } ), cvssV3Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Without a prefix, both versions are searched; CVSS:3.0 or CVSS:3.1 restricts matching to that version. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -127,7 +128,7 @@ const searchRequestProperties = { } ), cvssV3_0Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -135,7 +136,7 @@ const searchRequestProperties = { } ), cvssV3_1Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -143,7 +144,7 @@ const searchRequestProperties = { } ), cvssV4Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 8feef83..52a47b7 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -4816,6 +4816,182 @@ } }, "response": [] + }, + { + "name": "CVSS vector version mismatch", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3Metrics parameter is invalid. The CVSS version prefix must match the requested filter.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3Metrics\": \"CVSS:4.0/AV:N\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector unknown metric", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV4Metrics parameter is invalid. A metric name or value is not supported by the requested CVSS version.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV4Metrics\": \"FOO:N\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector empty segment", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3_1Metrics parameter is invalid. Supply one or more slash-delimited metric:value tokens without empty segments.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_1Metrics\": \"AV:N/\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector conflicting metric values", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV2Metrics parameter is invalid. A metric cannot have conflicting values in the same filter.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV2Metrics\": \"AV:N/AV:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] } ] }, diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 60feeb1..5d6699a 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -953,6 +953,32 @@ const limitFailures = folder( 'CVSS vector token length', { cvssV2Metrics: `AV:${'A'.repeat(constants.MAX_CVSS_METRIC_TOKEN_LENGTH + 1)}` }, error.cvssMetricsLimitError('cvssV2Metrics') + ), + negative( + 'CVSS vector version mismatch', + { cvssV3Metrics: 'CVSS:4.0/AV:N' }, + error.cvssMetricsFormatError('cvssV3Metrics', 'The CVSS version prefix must match the requested filter.') + ), + negative( + 'CVSS vector unknown metric', + { cvssV4Metrics: 'FOO:N' }, + error.cvssMetricsFormatError( + 'cvssV4Metrics', + 'A metric name or value is not supported by the requested CVSS version.' + ) + ), + negative( + 'CVSS vector empty segment', + { cvssV3_1Metrics: 'AV:N/' }, + error.cvssMetricsFormatError( + 'cvssV3_1Metrics', + 'Supply one or more slash-delimited metric:value tokens without empty segments.' + ) + ), + negative( + 'CVSS vector conflicting metric values', + { cvssV2Metrics: 'AV:N/AV:L' }, + error.cvssMetricsFormatError('cvssV2Metrics', 'A metric cannot have conflicting values in the same filter.') ) ] ); diff --git a/utils/cvss.js b/utils/cvss.js index 0641e7d..7aa9cf6 100644 --- a/utils/cvss.js +++ b/utils/cvss.js @@ -10,6 +10,135 @@ const VECTOR_FIELDS = Object.freeze({ }); const PARAMS = Object.freeze(Object.keys(VECTOR_FIELDS)); +// Metric vocabularies from FIRST's CVSS v2.0, v3.x, and v4.0 vector specifications. +// Search filters may supply a subset of metrics, unlike complete scoring vectors. +const V3_METRIC_VALUES = { + AV: ['N', 'A', 'L', 'P'], + AC: ['L', 'H'], + PR: ['N', 'L', 'H'], + UI: ['N', 'R'], + S: ['U', 'C'], + C: ['N', 'L', 'H'], + I: ['N', 'L', 'H'], + A: ['N', 'L', 'H'], + E: ['X', 'U', 'P', 'F', 'H'], + RL: ['X', 'O', 'T', 'W', 'U'], + RC: ['X', 'U', 'R', 'C'], + CR: ['X', 'L', 'M', 'H'], + IR: ['X', 'L', 'M', 'H'], + AR: ['X', 'L', 'M', 'H'], + MAV: ['X', 'N', 'A', 'L', 'P'], + MAC: ['X', 'L', 'H'], + MPR: ['X', 'N', 'L', 'H'], + MUI: ['X', 'N', 'R'], + MS: ['X', 'U', 'C'], + MC: ['X', 'N', 'L', 'H'], + MI: ['X', 'N', 'L', 'H'], + MA: ['X', 'N', 'L', 'H'] +}; +const METRIC_VALUES = { + cvssV2_0: { + AV: ['N', 'A', 'L'], + AC: ['L', 'M', 'H'], + AU: ['M', 'S', 'N'], + C: ['N', 'P', 'C'], + I: ['N', 'P', 'C'], + A: ['N', 'P', 'C'], + E: ['U', 'POC', 'F', 'H', 'ND'], + RL: ['OF', 'TF', 'W', 'U', 'ND'], + RC: ['UC', 'UR', 'C', 'ND'], + CDP: ['N', 'L', 'LM', 'MH', 'H', 'ND'], + TD: ['N', 'L', 'M', 'H', 'ND'], + CR: ['L', 'M', 'H', 'ND'], + IR: ['L', 'M', 'H', 'ND'], + AR: ['L', 'M', 'H', 'ND'] + }, + cvssV3_0: V3_METRIC_VALUES, + cvssV3_1: V3_METRIC_VALUES, + cvssV4_0: { + AV: ['N', 'A', 'L', 'P'], + AC: ['L', 'H'], + AT: ['N', 'P'], + PR: ['N', 'L', 'H'], + UI: ['N', 'P', 'A'], + VC: ['H', 'L', 'N'], + VI: ['H', 'L', 'N'], + VA: ['H', 'L', 'N'], + SC: ['H', 'L', 'N'], + SI: ['H', 'L', 'N'], + SA: ['H', 'L', 'N'], + E: ['X', 'A', 'P', 'U'], + CR: ['X', 'H', 'M', 'L'], + IR: ['X', 'H', 'M', 'L'], + AR: ['X', 'H', 'M', 'L'], + MAV: ['X', 'N', 'A', 'L', 'P'], + MAC: ['X', 'L', 'H'], + MAT: ['X', 'N', 'P'], + MPR: ['X', 'N', 'L', 'H'], + MUI: ['X', 'N', 'P', 'A'], + MVC: ['X', 'N', 'L', 'H'], + MVI: ['X', 'N', 'L', 'H'], + MVA: ['X', 'N', 'L', 'H'], + MSC: ['X', 'N', 'L', 'H'], + MSI: ['X', 'N', 'L', 'H', 'S'], + MSA: ['X', 'N', 'L', 'H', 'S'], + S: ['X', 'N', 'P'], + AU: ['X', 'N', 'Y'], + R: ['X', 'A', 'U', 'I'], + V: ['X', 'D', 'C'], + RE: ['X', 'L', 'M', 'H'], + U: ['X', 'CLEAR', 'GREEN', 'AMBER', 'RED'] + } +}; + +function parseMetricFilter(param, input) { + if (!PARAMS.includes(param) || typeof input !== 'string') { + return { valid: false, reason: 'A supported CVSS field and a string value are required.' }; + } + const tokens = input + .trim() + .toUpperCase() + .split('/') + .map((token) => token.trim()); + let fieldNames = VECTOR_FIELDS[param]; + if (tokens[0].startsWith('CVSS')) { + const prefix = /^CVSS:(2\.0|3\.0|3\.1|4\.0)$/.exec(tokens.shift()); + const field = prefix && `cvssV${prefix[1].replace('.', '_')}`; + if (!field || !fieldNames.includes(field)) { + return { valid: false, reason: 'The CVSS version prefix must match the requested filter.' }; + } + fieldNames = [field]; + } + if (!tokens.length || tokens.some((token) => !/^[A-Z]+:[A-Z]+$/.test(token))) { + return { + valid: false, + reason: 'Supply one or more slash-delimited metric:value tokens without empty segments.' + }; + } + const seen = new Map(); + for (const token of tokens) { + const [name, value] = token.split(':'); + if ( + !fieldNames.every( + (field) => Object.hasOwn(METRIC_VALUES[field], name) && METRIC_VALUES[field][name].includes(value) + ) + ) { + return { valid: false, reason: 'A metric name or value is not supported by the requested CVSS version.' }; + } + if (seen.has(name) && seen.get(name) !== value) { + return { valid: false, reason: 'A metric cannot have conflicting values in the same filter.' }; + } + seen.set(name, value); + } + return { valid: true, fieldNames, tokens: [...new Set(tokens)] }; +} + +function requireMetricFilter(param, input) { + const parsed = parseMetricFilter(param, input); + if (!parsed.valid) throw new TypeError(parsed.reason); + return parsed; +} + function parseMetricTokens(inputVectorString) { return String(inputVectorString ?? '') .split('/') @@ -20,7 +149,10 @@ function parseMetricTokens(inputVectorString) { function buildPostFilter(params) { const vectors = Object.fromEntries( - PARAMS.filter((param) => params?.[param] !== undefined).map((param) => [param, params[param]]) + PARAMS.filter((param) => params?.[param] !== undefined).map((param) => [ + param, + requireMetricFilter(param, params[param]) + ]) ); if (!Object.keys(vectors).length) return null; @@ -44,14 +176,13 @@ function metricMatches(metric, fieldNames, tokens) { function hitMatches(hit, filter) { const metrics = searchContainerMetrics.getMetrics(hit?._source, filter.scope); - return Object.entries(filter.vectors).every(([param, inputVectorString]) => { - const tokens = parseMetricTokens(inputVectorString); - return tokens.length > 0 && metrics.some((metric) => metricMatches(metric, VECTOR_FIELDS[param], tokens)); - }); + return Object.values(filter.vectors).every(({ fieldNames, tokens }) => + metrics.some((metric) => metricMatches(metric, fieldNames, tokens)) + ); } function getCandidateSourceFields(filter) { - const fieldNames = new Set(Object.keys(filter.vectors).flatMap((param) => VECTOR_FIELDS[param])); + const fieldNames = new Set(Object.values(filter.vectors).flatMap((vector) => vector.fieldNames)); return [...fieldNames].flatMap((fieldName) => searchScope.resolveContainerPaths(`metrics.${fieldName}.vectorString`, filter.scope) @@ -65,5 +196,7 @@ module.exports = { getCandidateSourceFields, hitMatches, parseMetricTokens, + parseMetricFilter, + requireMetricFilter, vectorContainsEveryToken }; diff --git a/utils/cvss.test.js b/utils/cvss.test.js index 98b6a6f..5c20608 100644 --- a/utils/cvss.test.js +++ b/utils/cvss.test.js @@ -1,5 +1,60 @@ const cvss = require('./cvss'); +describe('CVSS filter validation', () => { + test.each([ + ['cvssV2Metrics', ' av:n / au:s / E:POC / RL:OF / CDP:LM / TD:ND '], + ['cvssV2Metrics', 'CVSS:2.0/AV:N'], + ['cvssV3Metrics', 'AV:N/AC:L'], + ['cvssV3_0Metrics', 'cvss:3.0/av:n/mav:x/rc:r'], + ['cvssV3_1Metrics', 'CVSS:3.1/AV:N/MUI:R/CR:H'], + ['cvssV4Metrics', 'CVSS:4.0/AT:P/UI:A/MSI:S/S:P/U:Green/RE:M/AU:Y'] + ])('accepts valid partial vectors and optional metrics: %s %s', (param, value) => { + expect(cvss.parseMetricFilter(param, value).valid).toBe(true); + }); + + test.each([ + ['unknown', 'AV:N'], + ['cvssV2Metrics', null], + ['cvssV2Metrics', 'CVSS:3.1/AV:N'], + ['cvssV3_0Metrics', 'CVSS:3.1/AV:N'], + ['cvssV3_1Metrics', 'CVSS:3.0/AV:N'], + ['cvssV3Metrics', 'CVSS:4.0/AV:N'], + ['cvssV4Metrics', 'CVSS:5.0/AV:N'], + ['cvssV4Metrics', 'CVSS#4.0/AV:N'], + ['cvssV4Metrics', 'CVSS:4.0'], + ['cvssV4Metrics', ''], + ['cvssV4Metrics', 'AV:N/'], + ['cvssV4Metrics', '/AV:N'], + ['cvssV4Metrics', 'AV:N//AC:L'], + ['cvssV4Metrics', 'AV:N/AV:L'], + ['cvssV4Metrics', 'AV:N/CVSS:4.0'], + ['cvssV4Metrics', 'FOO:N'], + ['cvssV4Metrics', 'AV:Z'], + ['cvssV4Metrics', 'AC:*?'], + ['cvssV4Metrics', 'C:H'], + ['cvssV3_1Metrics', 'AT:P'], + ['cvssV2Metrics', 'PR:N'], + ['cvssV2Metrics', 'AV:P'], + ['cvssV2Metrics', 'C:H'], + ['cvssV3Metrics', 'UI:P'], + ['cvssV4Metrics', 'UI:R'], + ['cvssV4Metrics', 'MSC:S'], + ['cvssV3Metrics', 'AV'], + ['cvssV3Metrics', 'AV:N:L'] + ])('rejects malformed or version-incompatible filters: %s %s', (param, value) => { + expect(cvss.parseMetricFilter(param, value)).toMatchObject({ valid: false, reason: expect.any(String) }); + expect(() => cvss.requireMetricFilter(param, value)).toThrow(TypeError); + }); + + test('normalizes case and harmless repeated identical tokens', () => { + expect(cvss.parseMetricFilter('cvssV3Metrics', 'cvss:3.1/av:n/AV:N')).toStrictEqual({ + valid: true, + fieldNames: ['cvssV3_1'], + tokens: ['AV:N'] + }); + }); +}); + function hit(metrics) { return { _source: { @@ -11,6 +66,19 @@ function hit(metrics) { } describe('CVSS semantic vector matching', () => { + test.each(['3.0', '3.1'])( + 'an explicit %s prefix restricts semantic matching and candidate source fields', + (version) => { + const field = `cvssV${version.replace('.', '_')}`; + const other = version === '3.0' ? 'cvssV3_1' : 'cvssV3_0'; + const filter = cvss.buildPostFilter({ scope: 'ADP_ONLY', cvssV3Metrics: `CVSS:${version}/AV:N` }); + expect(cvss.hitMatches(hit([{ [field]: { vectorString: `CVSS:${version}/AV:N` } }]), filter)).toBe(true); + expect(cvss.hitMatches(hit([{ [other]: { vectorString: 'AV:N' } }]), filter)).toBe(false); + expect(cvss.getCandidateSourceFields(filter)).toStrictEqual([ + `containers.adp.metrics.${field}.vectorString` + ]); + } + ); test('requires every token to occur in one eligible vector', () => { const filter = cvss.buildPostFilter({ scope: 'ADP_ONLY', diff --git a/utils/errors.js b/utils/errors.js index ef12f58..a8b6272 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -301,6 +301,10 @@ class errors { ); } + cvssMetricsFormatError(param, reason) { + return makeError('CVSS_METRICS_FORMAT', `The ${param} parameter is invalid. ${reason}`); + } + dateTimeError(dateTime) { return makeError( 'DATE_TIME_FORMAT', diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 1a95324..0866048 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -340,8 +340,11 @@ function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { } if (cvssVectorParams.has(param)) { - const filter = buildCvssWildcardFilter(params[param], fieldSpec); - if (filter) fieldsToReturn.push(filter); + const { tokens, fieldNames } = cvss.requireMetricFilter(param, params[param]); + const fields = fieldNames.flatMap((name) => + searchScope.resolveContainerPaths(`metrics.${name}.vectorString`, scope) + ); + fieldsToReturn.push(buildCvssWildcardFilter(tokens, fields)); return; } @@ -622,11 +625,7 @@ function buildHasAnyRangeFieldFilter(rangeFields) { }; } -function buildCvssWildcardFilter(inputVectorString, fieldSpec) { - const tokens = cvss.parseMetricTokens(inputVectorString); - - if (!tokens.length) return null; - +function buildCvssWildcardFilter(tokens, fieldSpec) { const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; const perFieldFilter = (fieldPath) => { diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index fd628fc..a45bbfd 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -674,7 +674,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { test('getFilterFields() builds token-boundary filters for combined cvssV3 metrics.', () => { const output = queryBuilder.getFilterFields({ - cvssV3Metrics: 'CVSS:3.1/AV:N/AC:L' + cvssV3Metrics: 'AV:N/AC:L' }); expect(output).toStrictEqual([ @@ -704,18 +704,17 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() escapes regexp metacharacters in CVSS metric filters.', () => { - const output = queryBuilder.getFilterFields({ - cvssV4Metrics: 'CVSS#4.0/AV:N/AC:*?/UI:P' - }); + test('getFilterFields() rejects regexp metacharacters in CVSS metric filters.', () => { + expect(() => queryBuilder.getFilterFields({ cvssV4Metrics: 'CVSS:4.0/AC:*?' })).toThrow(TypeError); + }); + test.each(['3.0', '3.1'])('combined v3 candidate queries honor the explicit %s prefix', (version) => { + const output = queryBuilder.getFilterFields({ cvssV3Metrics: `CVSS:${version}/AV:N` }); expect(output).toStrictEqual([ { bool: { filter: [ - cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AV:N'), - cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AC:\\*\\?'), - cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'UI:P') + cvssTokenRegexp(`containers.cna.metrics.cvssV${version.replace('.', '_')}.vectorString`, 'AV:N') ] } } @@ -777,7 +776,7 @@ describe('scope-aware search filters', () => { expect(output.postFilters.cvss).toStrictEqual({ scope: 'ADP_ONLY', vectors: { - cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L' + cvssV3_1Metrics: { valid: true, fieldNames: ['cvssV3_1'], tokens: ['AV:N', 'AC:L'] } } }); expect(body._source).toStrictEqual(['cveMetadata.cveId', 'containers.adp.metrics.cvssV3_1.vectorString']); From 54e816729ba6f82d355e197c3a7c10c046f9ae7d Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:56:12 -0400 Subject: [PATCH 8/9] Validate and normalize single CWE identifiers --- README.md | 2 + api-docs/openapi.json | 2 +- controllers/middleware.js | 4 + controllers/middleware.test.js | 2 +- routes/index.js | 5 +- routes/search.integration.test.js | 28 +++ routes/swagger.js | 2 +- .../CVE-Search-API.postman_collection.json | 220 ++++++++++++++++++ test/postman/buildCollection.js | 3 + utils/errors.js | 7 + 10 files changed, 270 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8ccac2a..8e8e4b4 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MED CVSS metric filters accept partial vectors, such as `AV:N/AC:L`, with case-insensitive metric names and values from the selected CVSS version, including optional metrics. A `CVSS:2.0`, `CVSS:3.0`, `CVSS:3.1`, or `CVSS:4.0` prefix is optional but must match the requested filter. For `cvssV3Metrics`, omitting the prefix searches both v3.0 and v3.1; providing one restricts matching to that version. Unknown metrics or values, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 `CVSS_METRICS_FORMAT`. Repeated identical tokens are accepted. Every supplied token must occur in the same eligible stored vector; filters do not calculate a CVSS score. +`cweId` accepts one ID in the form `CWE-`, without leading zeros, such as `CWE-79`. It is case-insensitive and normalized to uppercase. Bare numbers, comma-separated lists, wildcard patterns, and other malformed IDs return HTTP 400 `CWE_ID_FORMAT`; arrays are also unsupported. The API validates the format, not membership in the CWE catalog. A valid ID with no matching indexed records returns HTTP 200 with zero results. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 741a5df..8caf19a 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -694,7 +694,7 @@ "type": "string", "maxLength": 32, "example": "CWE-79", - "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID." + "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog." }, "cvssV2Severity": { "type": "string", diff --git a/controllers/middleware.js b/controllers/middleware.js index 504aac4..97254f9 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -198,6 +198,10 @@ function validateParams(req, res, next) { } } + if (hasParam(params, 'cweId') && !/^CWE-[1-9][0-9]*$/i.test(params.cweId.trim())) { + return res.status(400).json(error.cweIdFormatError()); + } + const parsedCpeName = params.cpeName ? cpe.parseCpeName(params.cpeName) : null; if (parsedCpeName && !parsedCpeName.valid) { return res.status(400).json(error.invalidCpeNameError(parsedCpeName.reason)); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index ebfc338..f135fd1 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -536,7 +536,7 @@ describe('middleware validation function', () => { test.each([ ['keywordSearch', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch)], - ['cweId', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId)] + ['cweId', `CWE-${'1'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId - 4)}`] ])('ValidateParams accepts %s at its maximum length.', (param, value) => { validate({ [param]: value diff --git a/routes/index.js b/routes/index.js index 7e5a5a5..27cf895 100644 --- a/routes/index.js +++ b/routes/index.js @@ -157,8 +157,9 @@ function requireWebSearchEnabled(req, res, next) { function normalizeSearchParams(req, res, next) { const searchParams = { ...(req.body ?? {}) }; - const upperCaseEnums = [ + const upperCaseFields = [ 'scope', + 'cweId', 'cvssV2Severity', 'cvssV3Severity', 'cvssV3_0Severity', @@ -175,7 +176,7 @@ function normalizeSearchParams(req, res, next) { } searchParams.scope = searchScope.normalizeScope(searchParams.scope); - upperCaseEnums.forEach((param) => { + upperCaseFields.forEach((param) => { if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toUpperCase(); }); lowerCaseEnums.forEach((param) => { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index d977715..d0378ed 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,6 +48,34 @@ describe('search route integration', () => { mockBasicSearch.mockReset(); }); + test.each(['79', 'CWE-0', 'CWE-079', 'CWE--1', 'CWE-79.1', 'CWE-79,CWE-89', 'CWE-*', 'CWE-79 extra'])( + 'rejects malformed CWE IDs before searching: %s', + async (cweId) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { cweId } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('CWE_ID_FORMAT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + + test.each(['FULL', 'CNA_ONLY', 'ADP_ONLY'])( + 'normalizes CWE IDs in %s without requiring catalog membership', + async (scope) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { scope, cweId: ' cwe-99999999 ' } + }); + expect(response.status).toBe(200); + expect(response.body.pagination.totalResults).toBe(0); + const query = JSON.stringify(mockSearchOpenSearch.mock.calls[0][0].query); + expect(query).toContain('CWE-99999999'); + expect(query.includes('containers.cna.')).toBe(scope !== 'ADP_ONLY'); + expect(query.includes('containers.adp.')).toBe(scope !== 'CNA_ONLY'); + } + ); + test.each([ ['cvssV2Metrics', 'AU:Z'], ['cvssV3Metrics', 'CVSS:4.0/AV:N'], diff --git a/routes/swagger.js b/routes/swagger.js index 567d3a8..f4061a3 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -69,7 +69,7 @@ const searchRequestProperties = { coercibleBooleanSchema() ), cweId: searchRequestProperty( - 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID.', + 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 52a47b7..5646043 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -4421,6 +4421,226 @@ }, "response": [] }, + { + "name": "Invalid CWE ID 79", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweId\": \"79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweId\": \"CWE-0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-079", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweId\": \"CWE-079\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-79,CWE-89", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweId\": \"CWE-79,CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-*", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweId\": \"CWE-*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "CVE ID length", "event": [ diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 5d6699a..4455443 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -915,6 +915,9 @@ const limitFailures = folder( error.searchFieldMinimumLengthError('referenceURL', 3) ), negative('CVE ID format', { cveIds: 'not-a-cve' }, error.cveIdsFormatError()), + ...['79', 'CWE-0', 'CWE-079', 'CWE-79,CWE-89', 'CWE-*'].map((cweId) => + negative(`Invalid CWE ID ${cweId}`, { cweId }, error.cweIdFormatError()) + ), negative('CVE ID length', { cveIds: `CVE-2024-${'1'.repeat(56)}` }, error.cveIdLengthError()), negative( 'CVE ID count limit', diff --git a/utils/errors.js b/utils/errors.js index a8b6272..b0ab053 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -241,6 +241,13 @@ class errors { ); } + cweIdFormatError() { + return makeError( + 'CWE_ID_FORMAT', + 'The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).' + ); + } + cvssV2SeverityError(severity) { return makeError( 'INVALID_CVSSV2_SEVERITY', From a2a78751580ce77ec937d38b2f338aab64c07c75 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 14 Oct 2026 13:59:38 -0400 Subject: [PATCH 9/9] Support exact scoped searches for multiple CWE IDs --- README.md | 6 +- api-docs/openapi.json | 10 +- api-docs/openapi.test.js | 6 + controllers/middleware.js | 25 +- controllers/middleware.test.js | 52 ++- controllers/searchController.test.js | 6 +- routes/index.js | 8 +- routes/search.integration.test.js | 91 +++- routes/swagger.js | 8 +- .../CVE-Search-API.postman_collection.json | 418 +++++++++++++++++- test/postman/buildCollection.js | 39 +- utils/constants.js | 10 +- utils/errors.js | 20 +- utils/searchQueryBuilder.js | 20 +- utils/searchQueryBuilder.test.js | 52 ++- utils/searchReadiness.js | 8 +- utils/searchReadiness.test.js | 20 + utils/searchScope.js | 2 +- 18 files changed, 703 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 8e8e4b4..cf89c6b 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MED CVSS metric filters accept partial vectors, such as `AV:N/AC:L`, with case-insensitive metric names and values from the selected CVSS version, including optional metrics. A `CVSS:2.0`, `CVSS:3.0`, `CVSS:3.1`, or `CVSS:4.0` prefix is optional but must match the requested filter. For `cvssV3Metrics`, omitting the prefix searches both v3.0 and v3.1; providing one restricts matching to that version. Unknown metrics or values, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 `CVSS_METRICS_FORMAT`. Repeated identical tokens are accepted. Every supplied token must occur in the same eligible stored vector; filters do not calculate a CVSS score. -`cweId` accepts one ID in the form `CWE-`, without leading zeros, such as `CWE-79`. It is case-insensitive and normalized to uppercase. Bare numbers, comma-separated lists, wildcard patterns, and other malformed IDs return HTTP 400 `CWE_ID_FORMAT`; arrays are also unsupported. The API validates the format, not membership in the CWE catalog. A valid ID with no matching indexed records returns HTTP 200 with zero results. +`cweIds` accepts one or more comma-separated IDs, such as `"CWE-79, cwe-89"`, following the same input pattern as `cveIds`. Each ID must use `CWE-`, without leading zeros. Values are case-insensitive, whitespace around each ID is trimmed, and duplicate IDs do not change results. A record matches when any requested ID occurs in the scope-selected CNA or ADP containers; the CWE filter is combined with other filters using AND. Matching uses complete identifiers rather than analyzed text. Arrays, bare numbers, empty entries, wildcard patterns, and other malformed IDs return HTTP 400 `CWE_IDS_FORMAT`; any invalid entry rejects the whole request. Limits apply before deduplication: at most 100 IDs, 32 characters per ID, and 4,096 characters overall. The API validates format, not membership in the CWE catalog. Valid IDs without indexed matches contribute no results and do not prevent other IDs from matching. The former `cweId` request field is no longer supported. Scope applies to filter matching as follows: @@ -144,7 +144,7 @@ Scope applies to filter matching as follows: | `cveIds`, `pubStartDate`, `pubEndDate`, `lastModStartDate`, `lastModEndDate` | Base record metadata in every scope | | `noRejected` | Base record state in every scope, plus the legacy CNA state when CNA data is selected | | `keywordSearch`, `keywordExactMatch` | Description text in the selected CNA and/or ADP containers | -| `cweId`, all CVSS severity and metric filters | Problem types or metrics in the selected CNA and/or ADP containers | +| `cweIds`, all CVSS severity and metric filters | Problem types or metrics in the selected CNA and/or ADP containers | | All SSVC filters, `knownExploited`, KEV date filters, `referenceURL`, `disputed` | Structured metrics, references, or tags in the selected CNA and/or ADP containers | | `cpeName`, `isVulnerable`, `virtualMatchString`, and version range filters | CNA CPE applicability data; incompatible with `ADP_ONLY` | | `resultsPerPage`, `pageNumber` | Response controls; they do not select record data | @@ -167,7 +167,7 @@ String inputs are bounded before query construction: | ---------------------------- | ---------------------------------------------------------------------: | | `keywordSearch` | 512 characters and 64 whitespace-delimited terms | | `cveIds` | 4,096 characters overall, 100 IDs, and 64 characters per ID | -| `cweId` | 32 characters | +| `cweIds` | 4,096 characters overall, 100 IDs, and 32 characters per ID | | `cpeName` | 2,048 characters | | `virtualMatchString` | 2,048 characters | | `versionStart`, `versionEnd` | 256 characters each | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 8caf19a..ba475c9 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -137,7 +137,7 @@ "keywordExactMatch": true, "cveIds": "CVE-2024-1234,CVE-2024-5678", "noRejected": true, - "cweId": "CWE-79", + "cweIds": "CWE-79,CWE-89", "cvssV2Severity": "HIGH", "cvssV3Severity": "CRITICAL", "cvssV3_0Severity": "CRITICAL", @@ -690,11 +690,11 @@ "default": false, "description": "When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, - "cweId": { + "cweIds": { "type": "string", - "maxLength": 32, - "example": "CWE-79", - "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog." + "maxLength": 4096, + "example": "CWE-79,CWE-89", + "description": "Returns CVE records whose scope-selected containers include any ID in the comma-separated list of CWE IDs. Each ID must use CWE-, without leading zeros. Input is case-insensitive, surrounding whitespace is trimmed per ID, and duplicate IDs do not change results. Matching uses complete identifiers, with OR between IDs and AND with other filters. Limited to 100 CWE IDs, 32 characters per ID, and 4096 characters overall. Arrays, empty entries, and malformed IDs return HTTP 400 CWE_IDS_FORMAT; one invalid entry rejects the whole request. The API validates format, not membership in the CWE catalog. The former cweId field is unsupported." }, "cvssV2Severity": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index be8da04..a88fbf6 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -74,6 +74,12 @@ describe('OpenAPI contract', () => { expect(schema.properties.cveIds.description).toContain(`${constants.MAX_CVE_ID_LENGTH} characters per ID`); expect(schema.properties.cveIds.description).toContain(`before ${constants.FIRST_CVE_RECORD_YEAR}`); expect(schema.properties.cveIds.description).toContain('non-fatal response warning'); + expect(schema.properties.cweIds.type).toBe('string'); + expect(schema.properties.cweIds.description).toContain('comma-separated list of CWE IDs'); + expect(schema.properties.cweIds.description).toContain(`${constants.MAX_CWE_IDS} CWE IDs`); + expect(schema.properties.cweIds.description).toContain(`${constants.MAX_CWE_ID_LENGTH} characters per ID`); + expect(schema.properties.cweIds.description).toContain('OR between IDs and AND with other filters'); + expect(schema.properties.cweId).toBeUndefined(); expect(schema.properties.keywordSearch.description).toContain( `${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms` ); diff --git a/controllers/middleware.js b/controllers/middleware.js index 97254f9..6b11084 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -68,6 +68,7 @@ const versionRangeTypeParams = ['versionStartType', 'versionEndType']; const nonStringSearchParams = [ 'keywordExactMatch', 'cveIds', + 'cweIds', 'isVulnerable', 'noRejected', 'knownExploited', @@ -80,6 +81,7 @@ const nonEmptyStringParams = constants.SEARCH_QUERY_PARAMS.filter((param) => !no const dateTimeFormat = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/; const cveIdFormat = /^CVE-\d{4}-\d{4,}$/i; +const cweIdFormat = /^CWE-[1-9][0-9]*$/i; function isTrueParam(value) { return value === true || String(value).toLowerCase() === 'true'; @@ -129,13 +131,13 @@ function countWhitespaceDelimitedTokens(value) { return value.trim().split(/\s+/).length; } -function parseCveIds(value) { +function parseIds(value, format) { if (typeof value !== 'string') return null; - const cveIds = value.split(',').map((id) => id.trim()); - if (!cveIds.length || cveIds.some((id) => !id || !cveIdFormat.test(id))) return null; + const ids = value.split(',').map((id) => id.trim()); + if (!ids.length || ids.some((id) => !id || !format.test(id))) return null; - return cveIds; + return ids; } function getRequestedResultsPerPage(params) { @@ -198,10 +200,6 @@ function validateParams(req, res, next) { } } - if (hasParam(params, 'cweId') && !/^CWE-[1-9][0-9]*$/i.test(params.cweId.trim())) { - return res.status(400).json(error.cweIdFormatError()); - } - const parsedCpeName = params.cpeName ? cpe.parseCpeName(params.cpeName) : null; if (parsedCpeName && !parsedCpeName.valid) { return res.status(400).json(error.invalidCpeNameError(parsedCpeName.reason)); @@ -233,7 +231,7 @@ function validateParams(req, res, next) { } if (hasParam(params, 'cveIds')) { - const cveIds = parseCveIds(params.cveIds); + const cveIds = parseIds(params.cveIds, cveIdFormat); if (!cveIds) { return res.status(400).json(error.cveIdsFormatError()); } @@ -245,6 +243,15 @@ function validateParams(req, res, next) { } } + if (hasParam(params, 'cweIds')) { + const cweIds = parseIds(params.cweIds, cweIdFormat); + if (!cweIds) return res.status(400).json(error.cweIdsFormatError()); + if (cweIds.some((id) => id.length > constants.MAX_CWE_ID_LENGTH)) { + return res.status(400).json(error.cweIdLengthError()); + } + if (cweIds.length > constants.MAX_CWE_IDS) return res.status(400).json(error.cweIdsLimitError()); + } + if (hasParam(params, 'noRejected') && ![true, false, ''].includes(params.noRejected)) { return res.status(400).json(error.noRejectedValueError()); } diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index f135fd1..27ea2a5 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -450,7 +450,7 @@ describe('middleware validation function', () => { ['keywordExactMatch', 'false'], ['cveIds', 'CVE-2024-0001,CVE-2024-0002'], ['noRejected', true], - ['cweId', 'CWE-79'], + ['cweIds', 'CWE-79, cwe-89'], ['cvssV2Severity', 'LOW'], ['cvssV2Metrics', 'AV:N/AC:L/Au:N/C:P/I:P/A:C'], ['cvssV3Severity', 'MEDIUM'], @@ -481,6 +481,7 @@ describe('middleware validation function', () => { test.each([ ['cveId', 'CVE-2024-0001'], + ['cweId', 'CWE-79'], ['sourceIdentifier', 'CISA'] ])('ValidateParams returns an error when unsupported %s is provided.', (param, value) => { validate({ @@ -534,16 +535,16 @@ describe('middleware validation function', () => { } ); - test.each([ - ['keywordSearch', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch)], - ['cweId', `CWE-${'1'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId - 4)}`] - ])('ValidateParams accepts %s at its maximum length.', (param, value) => { - validate({ - [param]: value - }); + test.each([['keywordSearch', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch)]])( + 'ValidateParams accepts %s at its maximum length.', + (param, value) => { + validate({ + [param]: value + }); - expectValidationSuccess(); - }); + expectValidationSuccess(); + } + ); test('ValidateParams accepts cveIds and each CVE ID at their maximum lengths.', () => { const idPrefix = 'CVE-2024-'; @@ -558,6 +559,37 @@ describe('middleware validation function', () => { expectValidationSuccess(); }); + test('ValidateParams accepts cweIds at the total length, item length, and item count limits', () => { + const id = `CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 4)}`; + const cweIds = Array(constants.MAX_CWE_IDS) + .fill(id) + .join(',') + .padEnd(constants.SEARCH_STRING_LENGTH_LIMITS.cweIds, ' '); + expect(cweIds).toHaveLength(constants.SEARCH_STRING_LENGTH_LIMITS.cweIds); + validate({ cweIds }); + expectValidationSuccess(); + }); + + test('ValidateParams rejects a CWE ID that exceeds the item length limit', () => { + validate({ cweIds: `CWE-79,CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 3)}` }); + expectValidationError( + 'CWE_ID_LENGTH', + `Each CWE ID in the cweIds field must be ${constants.MAX_CWE_ID_LENGTH} characters or fewer.` + ); + }); + + test('ValidateParams counts all supplied CWE IDs before deduplication', () => { + validate({ + cweIds: Array(constants.MAX_CWE_IDS + 1) + .fill('CWE-79') + .join(',') + }); + expectValidationError( + 'CWE_IDS_LIMIT', + `The cweIds parameter cannot contain more than ${constants.MAX_CWE_IDS} CWE IDs.` + ); + }); + test('ValidateParams returns an error when an individual CVE ID exceeds its maximum length.', () => { const idPrefix = 'CVE-2024-'; diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8319ec4..01123ae 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -466,7 +466,7 @@ describe('Search controller SEARCH handler', () => { { query: { scope: 'CNA_ONLY', - cweId: 'CWE-79', + cweIds: 'CWE-79', cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', isVulnerable: 'true' } @@ -480,8 +480,8 @@ describe('Search controller SEARCH handler', () => { expect(filters).toHaveLength(3); expect(filters[0]).toStrictEqual({ - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { value: 'CWE-79', case_insensitive: true } } }); expect(filters[1].bool.minimum_should_match).toBe(1); diff --git a/routes/index.js b/routes/index.js index 27cf895..5ee3cd7 100644 --- a/routes/index.js +++ b/routes/index.js @@ -16,6 +16,7 @@ const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( ![ 'keywordExactMatch', 'cveIds', + 'cweIds', 'isVulnerable', 'noRejected', 'knownExploited', @@ -157,9 +158,8 @@ function requireWebSearchEnabled(req, res, next) { function normalizeSearchParams(req, res, next) { const searchParams = { ...(req.body ?? {}) }; - const upperCaseFields = [ + const upperCaseEnums = [ 'scope', - 'cweId', 'cvssV2Severity', 'cvssV3Severity', 'cvssV3_0Severity', @@ -176,7 +176,7 @@ function normalizeSearchParams(req, res, next) { } searchParams.scope = searchScope.normalizeScope(searchParams.scope); - upperCaseFields.forEach((param) => { + upperCaseEnums.forEach((param) => { if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toUpperCase(); }); lowerCaseEnums.forEach((param) => { @@ -216,7 +216,7 @@ router.post( keywordExactMatch: true, cveIds: "CVE-2024-1234,CVE-2024-5678", noRejected: true, - cweId: "CWE-79", + cweIds: "CWE-79,CWE-89", cvssV2Severity: "HIGH", cvssV3Severity: "CRITICAL", cvssV3_0Severity: "CRITICAL", diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index d0378ed..0ba7ee9 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,16 +48,95 @@ describe('search route integration', () => { mockBasicSearch.mockReset(); }); - test.each(['79', 'CWE-0', 'CWE-079', 'CWE--1', 'CWE-79.1', 'CWE-79,CWE-89', 'CWE-*', 'CWE-79 extra'])( - 'rejects malformed CWE IDs before searching: %s', - async (cweId) => { - const response = await request(createApp(), { method: 'POST', path: '/search', body: { cweId } }); + test.each( + [ + '79', + 'CWE-0', + 'CWE-079', + 'CWE--1', + 'CWE-79.1', + 'CWE-79,,CWE-89', + 'CWE-*', + 'CWE-79 extra', + 'CWE-79,invalid', + ',CWE-79', + 'CWE-79,', + '', + ' ', + ['CWE-79', 'CWE-89'], + null, + 79, + true, + {} + ].map((value) => [value]) + )('rejects malformed CWE IDs before searching: %s', async (cweIds) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { cweIds } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('CWE_IDS_FORMAT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + [ + { + cweIds: Array(constants.MAX_CWE_IDS + 1) + .fill('CWE-79') + .join(',') + }, + 'CWE_IDS_LIMIT' + ], + [{ cweIds: `CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 3)}` }, 'CWE_ID_LENGTH'], + [{ cweIds: 'CWE-79'.padEnd(constants.SEARCH_STRING_LENGTH_LIMITS.cweIds + 1, ' ') }, 'SEARCH_FIELD_LENGTH'] + ])('rejects oversized CWE input with %s', async (body, code) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body }); + expect(response.status).toBe(400); + expect(response.body.error).toBe(code); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([{ cweId: 'CWE-79' }, { cweId: 'CWE-79', cweIds: 'CWE-89' }])( + 'rejects the removed cweId parameter: %j', + async (body) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body }); expect(response.status).toBe(400); - expect(response.body.error).toBe('CWE_ID_FORMAT'); + expect(response.body[0].error).toBe('INVALID_PARAM'); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); } ); + test.each(['FULL', 'CNA_ONLY', 'ADP_ONLY'])( + 'matches any requested CWE ID within %s and combines other filters with AND', + async (scope) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { scope, cweIds: ' cwe-79, CWE-89, CWE-79 ', cveIds: 'CVE-2024-0001', noRejected: true } + }); + expect(response.status).toBe(200); + const filters = mockSearchOpenSearch.mock.calls[0][0].query.bool.filter; + expect(filters).toContainEqual({ terms: { 'cveMetadata.cveId.keyword': ['CVE-2024-0001'] } }); + const cweFilter = filters.find((filter) => filter.bool?.minimum_should_match === 1); + const containers = scope === 'FULL' ? ['cna', 'adp'] : [scope === 'CNA_ONLY' ? 'cna' : 'adp']; + expect(cweFilter).toStrictEqual({ + bool: { + minimum_should_match: 1, + should: containers.flatMap((container) => + ['CWE-79', 'CWE-89'].map((value) => ({ + term: { + [`containers.${container}.problemTypes.descriptions.cweId.keyword`]: { + value, + case_insensitive: true + } + } + })) + ) + } + }); + expect(filters).toHaveLength(3); + } + ); + test.each(['FULL', 'CNA_ONLY', 'ADP_ONLY'])( 'normalizes CWE IDs in %s without requiring catalog membership', async (scope) => { @@ -65,7 +144,7 @@ describe('search route integration', () => { const response = await request(createApp(), { method: 'POST', path: '/search', - body: { scope, cweId: ' cwe-99999999 ' } + body: { scope, cweIds: ' cwe-99999999 ' } }); expect(response.status).toBe(200); expect(response.body.pagination.totalResults).toBe(0); diff --git a/routes/swagger.js b/routes/swagger.js index f4061a3..9efb150 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -68,12 +68,12 @@ const searchRequestProperties = { 'When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings "true" and "false" are accepted.', coercibleBooleanSchema() ), - cweId: searchRequestProperty( - 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog.', + cweIds: searchRequestProperty( + `Returns CVE records whose scope-selected containers include any ID in the comma-separated list of CWE IDs. Each ID must use CWE-, without leading zeros. Input is case-insensitive, surrounding whitespace is trimmed per ID, and duplicate IDs do not change results. Matching uses complete identifiers, with OR between IDs and AND with other filters. Limited to ${constants.MAX_CWE_IDS} CWE IDs, ${constants.MAX_CWE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cweIds} characters overall. Arrays, empty entries, and malformed IDs return HTTP 400 CWE_IDS_FORMAT; one invalid entry rejects the whole request. The API validates format, not membership in the CWE catalog. The former cweId field is unsupported.`, { type: 'string', - maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, - example: 'CWE-79' + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweIds, + example: 'CWE-79,CWE-89' } ), cvssV2Severity: searchRequestProperty( diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 5646043..3000add 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -816,7 +816,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -872,7 +872,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"CWE-367\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -1165,7 +1165,7 @@ "response": [] }, { - "name": "cweId", + "name": "cweIds", "event": [ { "listen": "test", @@ -1214,7 +1214,119 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Multiple CWE IDs use OR and accept case and whitespace", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \" CWE-79, cwe-89, CWE-89 \"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "An unmatched CWE ID does not discard another match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-99999999,CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -3779,7 +3891,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweId\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweIds\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", "options": { "raw": { "language": "json" } } } }, @@ -3835,7 +3947,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -4422,7 +4534,271 @@ "response": [] }, { - "name": "Invalid CWE ID 79", + "name": "Invalid CWE IDs 79", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweIds\": \"79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweIds\": \"CWE-0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-079", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweIds\": \"CWE-079\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,,CWE-89", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweIds\": \"CWE-79,,CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-*", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweIds\": \"CWE-*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,invalid", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cweIds\": \"CWE-79,invalid\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79", "event": [ { "listen": "test", @@ -4436,7 +4812,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4459,14 +4835,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"79\"\n}", + "raw": "{\n \"cweIds\": [\n \"CWE-79\"\n ]\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-0", + "name": "Invalid CWE IDs CWE-79,", "event": [ { "listen": "test", @@ -4480,7 +4856,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4503,14 +4879,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-0\"\n}", + "raw": "{\n \"cweIds\": \"CWE-79,\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-079", + "name": "Former cweId field is unsupported", "event": [ { "listen": "test", @@ -4524,7 +4900,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"cweId is not supported by the Search API.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4547,14 +4923,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-079\"\n}", + "raw": "{\n \"cweId\": \"CWE-79\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-79,CWE-89", + "name": "CWE ID item length", "event": [ { "listen": "test", @@ -4568,7 +4944,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_ID_LENGTH\",\"message\":\"Each CWE ID in the cweIds field must be 32 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4591,14 +4967,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-79,CWE-89\"\n}", + "raw": "{\n \"cweIds\": \"CWE-11111111111111111111111111111\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-*", + "name": "CWE ID count limit", "event": [ { "listen": "test", @@ -4612,7 +4988,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_IDS_LIMIT\",\"message\":\"The cweIds parameter cannot contain more than 100 CWE IDs.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4635,7 +5011,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-*\"\n}", + "raw": "{\n \"cweIds\": \"CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79\"\n}", "options": { "raw": { "language": "json" } } } }, diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 4455443..2a7e0ad 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -346,7 +346,7 @@ const scopeCoverage = folder('02 - Scope coverage', 'Base, CNA, and ADP matching ), positive( 'CNA_ONLY CWE match', - { scope: 'CNA_ONLY', cveIds: ids.primary, cweId: 'CWE-89' }, + { scope: 'CNA_ONLY', cveIds: ids.primary, cweIds: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] @@ -354,7 +354,7 @@ const scopeCoverage = folder('02 - Scope coverage', 'Base, CNA, and ADP matching ), positive( 'ADP_ONLY CWE match', - { scope: 'ADP_ONLY', cveIds: ids.adp, cweId: 'CWE-367' }, + { scope: 'ADP_ONLY', cveIds: ids.adp, cweIds: 'CWE-367' }, { exactTotal: 1, expectedIds: [ids.adp] @@ -400,7 +400,17 @@ const keywordAndRecordFilters = folder( { cveIds: `${ids.primary},${ids.rejected}`, noRejected: false, resultsPerPage: 2 }, { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } ), - positive('cweId', { cveIds: ids.primary, cweId: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] }) + positive('cweIds', { cveIds: ids.primary, cweIds: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] }), + positive( + 'Multiple CWE IDs use OR and accept case and whitespace', + { scope: 'CNA_ONLY', cveIds: ids.primary, cweIds: ' CWE-79, cwe-89, CWE-89 ' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'An unmatched CWE ID does not discard another match', + { scope: 'CNA_ONLY', cveIds: ids.primary, cweIds: 'CWE-99999999,CWE-89' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ) ] ); @@ -819,7 +829,7 @@ const intentionalCombinations = folder( noRejected: true, keywordSearch: 'sql injection', keywordExactMatch: true, - cweId: 'CWE-89', + cweIds: 'CWE-89', cvssV3_1Severity: 'MEDIUM', cvssV3_1Metrics: 'AV:N/AC:L/PR:L', pubStartDate: '2024-01-01T00:00:00.000Z', @@ -836,7 +846,7 @@ const intentionalCombinations = folder( { scope: 'ADP_ONLY', cveIds: ids.adp, - cweId: 'CWE-367', + cweIds: 'CWE-367', cvssV3_1Severity: 'HIGH', cvssV3_1Metrics: 'AV:L/AC:H/PR:L', ssvcExploitation: 'NONE', @@ -915,8 +925,23 @@ const limitFailures = folder( error.searchFieldMinimumLengthError('referenceURL', 3) ), negative('CVE ID format', { cveIds: 'not-a-cve' }, error.cveIdsFormatError()), - ...['79', 'CWE-0', 'CWE-079', 'CWE-79,CWE-89', 'CWE-*'].map((cweId) => - negative(`Invalid CWE ID ${cweId}`, { cweId }, error.cweIdFormatError()) + ...['79', 'CWE-0', 'CWE-079', 'CWE-79,,CWE-89', 'CWE-*', 'CWE-79,invalid', ['CWE-79'], 'CWE-79,'].map( + (cweIds) => negative(`Invalid CWE IDs ${cweIds}`, { cweIds }, error.cweIdsFormatError()) + ), + negative('Former cweId field is unsupported', { cweId: 'CWE-79' }, error.invalidParamError('cweId')), + negative( + 'CWE ID item length', + { cweIds: `CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 3)}` }, + error.cweIdLengthError() + ), + negative( + 'CWE ID count limit', + { + cweIds: Array(constants.MAX_CWE_IDS + 1) + .fill('CWE-79') + .join(',') + }, + error.cweIdsLimitError() ), negative('CVE ID length', { cveIds: `CVE-2024-${'1'.repeat(56)}` }, error.cveIdLengthError()), negative( diff --git a/utils/constants.js b/utils/constants.js index 5d81011..fcca22e 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -6,6 +6,8 @@ const MAX_CVSS_METRIC_TOKENS = 32; const MAX_CVSS_METRIC_TOKEN_LENGTH = 32; const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; +const MAX_CWE_IDS = 100; +const MAX_CWE_ID_LENGTH = 32; const FIRST_CVE_RECORD_YEAR = 1999; const MAX_KEYWORD_SEARCH_TOKENS = 64; const MIN_REFERENCE_URL_LENGTH = 3; @@ -13,7 +15,7 @@ const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, cveIds: 4096, - cweId: 32, + cweIds: 4096, cpeName: 2048, versionStart: 256, versionEnd: 256, @@ -44,7 +46,7 @@ const cpeMatchFields = { * keywordExactMatch: list of terms, must be an exact match to terms in a description * cveIds: comma-separated list of CVE-year-XXXX identifiers * noRejected: no-value flag that excludes rejected CVE records - * cweId: CWE-XXX + * cweIds: comma-separated list of CWE-XXX identifiers * cvssV2Severity: LOW, MEDIUM, HIGH * cvssV3Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL * cvssV3_0Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL @@ -85,7 +87,7 @@ const searchQueryParams = [ 'keywordExactMatch', 'cveIds', 'noRejected', - 'cweId', + 'cweIds', 'cvssV2Severity', 'cvssV3Severity', 'cvssV3_0Severity', @@ -130,6 +132,8 @@ module.exports = { MAX_CVSS_METRIC_TOKEN_LENGTH, MAX_CVE_IDS, MAX_CVE_ID_LENGTH, + MAX_CWE_IDS, + MAX_CWE_ID_LENGTH, FIRST_CVE_RECORD_YEAR, MAX_KEYWORD_SEARCH_TOKENS, MIN_REFERENCE_URL_LENGTH, diff --git a/utils/errors.js b/utils/errors.js index b0ab053..6710d26 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -241,10 +241,24 @@ class errors { ); } - cweIdFormatError() { + cweIdsFormatError() { return makeError( - 'CWE_ID_FORMAT', - 'The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).' + 'CWE_IDS_FORMAT', + 'The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).' + ); + } + + cweIdLengthError() { + return makeError( + 'CWE_ID_LENGTH', + `Each CWE ID in the cweIds field must be ${constants.MAX_CWE_ID_LENGTH} characters or fewer.` + ); + } + + cweIdsLimitError() { + return makeError( + 'CWE_IDS_LIMIT', + `The cweIds parameter cannot contain more than ${constants.MAX_CWE_IDS} CWE IDs.` ); } diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 0866048..f1c035d 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -298,6 +298,11 @@ function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { return; } + if (param === 'cweIds') { + fieldsToReturn.push(buildCweIdsFilter(params[param], fieldSpec)); + return; + } + if (param === 'cpeName') { const filter = buildCpeNameFilter(params[param]); if (filter) fieldsToReturn.push(filter); @@ -367,7 +372,7 @@ function escapeForRegexp(s) { return String(s).replace(/([\\.?+*|{}[\]()"#@&<>~])/g, '\\$1'); } -function parseCveIds(value) { +function parseIds(value) { return String(value ?? '') .split(',') .map((id) => id.trim().toUpperCase()) @@ -375,7 +380,7 @@ function parseCveIds(value) { } function buildCveIdsFilter(value, field) { - const cveIds = [...new Set(parseCveIds(value))]; + const cveIds = [...new Set(parseIds(value))]; if (!cveIds.length) return null; return { @@ -385,6 +390,17 @@ function buildCveIdsFilter(value, field) { }; } +function buildCweIdsFilter(value, fields) { + const ids = [...new Set(parseIds(value))]; + const clauses = fields.flatMap((field) => + ids.map((id) => ({ + term: { [`${field}.keyword`]: { value: id, case_insensitive: true } } + })) + ); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + function buildNoRejectedFilter(fieldSpec) { const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index a45bbfd..e578b07 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -286,7 +286,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { const output = queryBuilder.buildSearchRequest({ scope: 'CNA_ONLY', keywordSearch: 'remote code', - cweId: 'CWE-79', + cweIds: 'CWE-79', resultsPerPage: 10, pageNumber: 3 }); @@ -305,8 +305,11 @@ describe('searchQueryBuilder buildSearchRequest()', () => { ], filter: [ { - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { + value: 'CWE-79', + case_insensitive: true + } } } ] @@ -375,15 +378,41 @@ describe('searchQueryBuilder buildQueryStrings() function', () => { }); describe('searchQueryBuilder getFilterFields() function', () => { - test('getFilterFields() function correctly builds array of one matchPhrase object when one parameter is passed.', () => { + test.each([ + ['FULL', ['cna', 'adp']], + ['CNA_ONLY', ['cna']], + ['ADP_ONLY', ['adp']] + ])('builds exact OR clauses for deduplicated CWE IDs in %s', (scope, containers) => { + expect(queryBuilder.getFilterFields({ cweIds: ' cwe-79, CWE-89, CWE-79 ' }, scope)).toStrictEqual([ + { + bool: { + minimum_should_match: 1, + should: containers.flatMap((container) => + ['CWE-79', 'CWE-89'].map((value) => ({ + term: { + [`containers.${container}.problemTypes.descriptions.cweId.keyword`]: { + value, + case_insensitive: true + } + } + })) + ) + } + } + ]); + }); + test('getFilterFields() builds one case-insensitive exact CWE identifier query', () => { const testParams = { - cweId: 'CWE-79' + cweIds: 'CWE-79' }; const testMatchPhrase = [ { - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { + value: 'CWE-79', + case_insensitive: true + } } } ]; @@ -395,14 +424,17 @@ describe('searchQueryBuilder getFilterFields() function', () => { test('getFilterFields() function correctly builds default and severity filter objects when multiple parameters are passed.', () => { const testParams = { - cweId: 'CWE-79', + cweIds: 'CWE-79', cvssV4Severity: 'HIGH' }; const testMatchPhrase = [ { - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { + value: 'CWE-79', + case_insensitive: true + } } }, { diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js index d65e456..fb28d56 100644 --- a/utils/searchReadiness.js +++ b/utils/searchReadiness.js @@ -71,13 +71,7 @@ const FIELD_CAPABILITY_GROUPS = Object.freeze([ types: ['text', 'keyword'] })) ), - capabilityGroup( - 'cweId', - unique(searchScope.resolveFields('cweId')).map((field) => ({ - field, - types: ['text', 'keyword'] - })) - ), + capabilityGroup('cweIds', keywordRequirements(['cweIds'])), capabilityGroup( 'cvssV2Severity', unique(searchScope.resolveFields('cvssV2Severity')).map((field) => ({ diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js index 3cc3944..63b8891 100644 --- a/utils/searchReadiness.test.js +++ b/utils/searchReadiness.test.js @@ -17,6 +17,26 @@ function validCapabilities() { } describe('search readiness mapping contract', () => { + test('requires searchable keyword CWE fields instead of analyzed text fields', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS.find(({ name }) => name === 'cweIds'); + group.requirements.forEach(({ field }) => { + delete body.fields[field]; + body.fields[field.replace(/\.keyword$/, '')] = { text: { searchable: true, aggregatable: false } }; + }); + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test.each(['cna', 'adp'])( + 'accepts a missing optional %s CWE field when the other container provides keyword support', + (container) => { + const body = validCapabilities(); + delete body.fields[`containers.${container}.problemTypes.descriptions.cweId.keyword`]; + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + } + ); test('accepts searchable fields with the required types', () => { expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); }); diff --git a/utils/searchScope.js b/utils/searchScope.js index c25d1d4..0dfe919 100644 --- a/utils/searchScope.js +++ b/utils/searchScope.js @@ -18,7 +18,7 @@ const FIELD_PATHS = Object.freeze({ base: ['cveMetadata.state'], cna: ['containers.cna.x_legacyV4Record.CVE_data_meta.STATE'] }, - cweId: { + cweIds: { cna: ['containers.cna.problemTypes.descriptions.cweId'], adp: ['containers.adp.problemTypes.descriptions.cweId'] },