diff --git a/Dockerfile b/Dockerfile index 8914334..e132286 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ # syntax=docker/dockerfile:1 ARG NODE_VERSION=24.14.1 -ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot +ARG NODE_IMAGE_DIGEST=sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec +ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot@sha256:9eeb7f5887d0e239e78264b06f7f11d2e14be534050481803a9e4728fcdd278e -FROM node:${NODE_VERSION} AS deps +FROM node:${NODE_VERSION}@${NODE_IMAGE_DIGEST} AS deps ENV NODE_ENV=production diff --git a/Dockerfile.local b/Dockerfile.local index 65345fb..68cacc3 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,9 +1,10 @@ # syntax=docker/dockerfile:1 ARG NODE_VERSION=24.14.1 -ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot +ARG NODE_IMAGE_DIGEST=sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec +ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot@sha256:9eeb7f5887d0e239e78264b06f7f11d2e14be534050481803a9e4728fcdd278e -FROM node:${NODE_VERSION} AS deps +FROM node:${NODE_VERSION}@${NODE_IMAGE_DIGEST} AS deps ENV NODE_ENV=production diff --git a/README.md b/README.md index fd53f3c..559ee2f 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 @@ -376,3 +382,26 @@ docker build -f Dockerfile.local --secret id=npm_ca,src=/path/to/ca.crt -t cve-s ``` The final runtime image uses Google Distroless Node, so it does not include a shell, package manager, or command lookup tools. + +### Base Image Updates + +Both Dockerfiles pin the dependency-build and runtime images to multi-platform manifest digests. A digest makes the base image reproducible; it does not fix vulnerabilities or automatically receive security updates. The dependency-build image is not shipped in the final runtime image, but still needs maintenance because it installs application dependencies. + +To refresh the pins, inspect the intended supported image tags: + +```bash +docker buildx imagetools inspect node:24.14.1 +docker buildx imagetools inspect gcr.io/distroless/nodejs24-debian13:nonroot +``` + +Use the top-level manifest `Digest`, not an individual architecture's digest. Update `NODE_IMAGE_DIGEST` and `DISTROLESS_NODE_IMAGE` in both Dockerfiles together. If changing the dependency-build Node version, also update `NODE_VERSION` and the documented/CI baseline as appropriate. Overriding `NODE_VERSION` alone does not select a different image: the digest determines the image contents. + +Build and scan the resulting runtime image, review changes in native package versions, and run the runtime checks and application quality checks before merging a refresh. Keep both Dockerfiles' pins synchronized. For example, if Docker Scout is available: + +```bash +docker scout sbom local://cve-search-api:runtime-smoke --format list --only-package-type deb +docker scout cves local://cve-search-api:runtime-smoke +npm run quality +``` + +A clean scanner result is not proof that every upstream advisory is resolved. Compare findings with the distribution's security tracker and deployed image digest. When no supported package fix exists, track the unresolved advisory and its applicability rather than replacing system libraries with packages from a different distribution release. 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/config/runtime-config.test.js b/config/runtime-config.test.js index c94173e..d93a6b3 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -9,8 +9,11 @@ describe('runtime container and compose configuration', () => { test('Dockerfile keeps AWS-compatible production runtime hardening invariants', () => { const dockerfile = readProjectFile('Dockerfile'); - expect(dockerfile).toContain('ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot'); - expect(dockerfile).toContain('FROM node:${NODE_VERSION} AS deps'); + expect(dockerfile).toMatch(/^ARG NODE_IMAGE_DIGEST=sha256:[a-f0-9]{64}$/m); + expect(dockerfile).toMatch( + /^ARG DISTROLESS_NODE_IMAGE=gcr\.io\/distroless\/nodejs24-debian13:nonroot@sha256:[a-f0-9]{64}$/m + ); + expect(dockerfile).toContain('FROM node:${NODE_VERSION}@${NODE_IMAGE_DIGEST} AS deps'); expect(dockerfile).toContain('FROM ${DISTROLESS_NODE_IMAGE} AS runtime'); expect(dockerfile).toMatch(/^RUN npm ci --omit=dev \\/m); expect(dockerfile).toMatch(/^COPY --chown=65532:65532 config \.\/config\//m); @@ -33,8 +36,11 @@ describe('runtime container and compose configuration', () => { test('local Dockerfile keeps npm CA secret mount for local builds', () => { const dockerfile = readProjectFile('Dockerfile.local'); - expect(dockerfile).toContain('ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot'); - expect(dockerfile).toContain('FROM node:${NODE_VERSION} AS deps'); + expect(dockerfile).toMatch(/^ARG NODE_IMAGE_DIGEST=sha256:[a-f0-9]{64}$/m); + expect(dockerfile).toMatch( + /^ARG DISTROLESS_NODE_IMAGE=gcr\.io\/distroless\/nodejs24-debian13:nonroot@sha256:[a-f0-9]{64}$/m + ); + expect(dockerfile).toContain('FROM node:${NODE_VERSION}@${NODE_IMAGE_DIGEST} AS deps'); expect(dockerfile).toContain('FROM ${DISTROLESS_NODE_IMAGE} AS runtime'); expect(dockerfile).toMatch(/^RUN --mount=type=secret,id=npm_ca,required=false/m); expect(dockerfile).toContain('if [ -s /run/secrets/npm_ca ]; then'); 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 70c8592..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.0", - "swagger-ui-express": "^5.0.1" + "morgan": "~1.12.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.0", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", - "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", - "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.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "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" @@ -1852,9 +1742,10 @@ "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==", + "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,19 +4350,10 @@ "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.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", @@ -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", @@ -5330,9 +4842,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,15 +4852,10 @@ }, "engines": { "node": ">= 0.10" - } - }, - "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pstree.remy": { @@ -5600,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" @@ -5692,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" @@ -5704,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" @@ -5781,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", @@ -6193,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", @@ -6381,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 abbe9f6..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.0", - "swagger-ui-express": "^5.0.1" + "morgan": "~1.12.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.0", - "axios": "1.18.0", - "brace-expansion": "5.0.9", + "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..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; @@ -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..3503bc4 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,49 @@ 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.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); const response = await request(createApp(), { method: 'POST', @@ -302,7 +323,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 +336,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/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/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..ecf8a3b --- /dev/null +++ b/utils/webSearchQueryBuilder.js @@ -0,0 +1,142 @@ +const path = require('path'); +const validator = require('validator'); +const { WEB_SEARCH_RESULTS_PER_PAGE } = require('./constants'); + +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 = WEB_SEARCH_RESULTS_PER_PAGE } = {}) { + 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' } }; + + const offset = Math.max(0, from); + return { + body: { + query: searchQuery, + track_total_hits: true, + sort: sort ?? [{ 'cveMetadata.cveId.keyword': { order: 'desc' } }], + from: offset, + size + }, + notes + }; +} + +module.exports = { buildWebSearchRequest }; diff --git a/utils/webSearchQueryBuilder.test.js b/utils/webSearchQueryBuilder.test.js new file mode 100644 index 0000000..5c85777 --- /dev/null +++ b/utils/webSearchQueryBuilder.test.js @@ -0,0 +1,117 @@ +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, 25], + [500, 1, 1], + [4999, 1, 1], + [9500, 500, 500], + [9975, undefined, 25], + [-1, 10, 10] + ])('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), + size: effectiveSize, + sort + }); + }); +});