diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 083cace..b1b27aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,18 +2,21 @@ version: 2 updates: - package-ecosystem: npm directory: / + target-branch: dev schedule: interval: weekly open-pull-requests-limit: 5 - package-ecosystem: docker directory: / + target-branch: dev schedule: interval: weekly open-pull-requests-limit: 5 - package-ecosystem: github-actions directory: / + target-branch: dev schedule: interval: weekly open-pull-requests-limit: 5 diff --git a/README.md b/README.md index 146efb0..1b3dac7 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,13 @@ npm start The Search API will be served at `localhost:3000`. +Successful port binding emits a sanitized `runtime.lifecycle` event with the `started` outcome. Synchronous configuration failures and asynchronous listener failures such as an unavailable or unauthorized port emit `startup_failed`, set a nonzero process exit code, and do not emit `started`. Startup failure logs contain only a stable error code and, for configuration errors, the relevant configuration path; exception messages, stacks, addresses, and port details are omitted. + ## Health Checks `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` sends a minimal zero-result search to the configured CVE index. This verifies connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 only when search requests can still be served after the dependency check completes. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, a missing index, and an in-progress process shutdown return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. The OpenSearch request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. If shutdown is already active, readiness fails immediately without contacting OpenSearch. If shutdown begins while the dependency check is pending, the completed check is not reported as ready. +`GET /api/ready-check` sends a minimal zero-result search to the configured CVE index. This verifies connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 only when search requests can still be served after the dependency check completes and OpenSearch explicitly reports that the request did not time out and no shards failed. Configuration errors, connection failures, timeouts, incomplete response metadata, failed shards, incompatible `cve-core` SearchReader contracts, a missing index, and an in-progress process shutdown return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. The OpenSearch request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. If shutdown is already active, readiness fails immediately without contacting OpenSearch. If shutdown begins while the dependency check is pending, the completed check is not reported as ready. 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. @@ -339,7 +341,7 @@ The validation tiers have separate purposes: - The optional OpenSearch contract check verifies that the installed client can communicate with a non-empty externally managed index whose representative searchable fields satisfy the application contract. - Load and performance testing remain in the separate automation performance-testing repository. -Dependabot checks npm packages, Docker base-image digests, and GitHub Actions weekly. Both Dockerfiles pin the Node build image and Distroless runtime image to immutable multi-architecture digests; Dependabot updates those references through reviewable pull requests. +Dependabot checks npm packages, Docker base-image digests, and GitHub Actions weekly. Version-update pull requests target the `dev` integration branch; GitHub security updates continue to follow the repository default-branch behavior. Both Dockerfiles pin the Node build image and Distroless runtime image to immutable multi-architecture digests; Dependabot updates those references through reviewable pull requests. ### Operational Logging @@ -349,7 +351,9 @@ Access logs omit query strings and request bodies. By default, the API retains 1 The API always emits a sanitized JSON `search.performance` event for `/search` queue saturation, shared-operation waiter saturation, queue wait timeout, search timeout, client disconnect, OpenSearch connection errors, and upstream search errors. These events include the request ID, outcome, HTTP status, strategy, cache state, total elapsed time, cancellation reason, and bounded queue or waiter state when applicable. Search terms, CPE values, request bodies, credentials, and upstream error details are never included. -Set `SearchLogPerformanceTimings=true` on the API deployment when diagnosing slow `/search` requests. Successful requests then emit the same structured event with additional redacted timing details, including candidate counts, filtered counts, returned count, OpenSearch operation types, maximum observed active OpenSearch requests in the process, shared waiter counts, OpenSearch elapsed time, OpenSearch `took`, post-filter elapsed time, cold-search queue wait time, page fetch elapsed or wait time, response bytes, JSON serialization time, process heap usage and per-request heap delta, cache entry and cached CVE ID counts, process-wide event-loop delay, and total handler elapsed time. Leave it disabled during normal operation unless the deployment logging volume is acceptable. +Set `SearchLogPerformanceTimings=true` on the API deployment when diagnosing slow `/search` requests. Successful requests then emit the same structured event with additional redacted timing details, including candidate counts, filtered counts, returned count, OpenSearch operation types, maximum observed active OpenSearch requests in the process, shared waiter counts, OpenSearch elapsed time, OpenSearch `took`, post-filter elapsed time, cold-search queue wait time, page fetch elapsed or wait time, response bytes, JSON serialization time, process heap usage and per-request heap delta, cache entry and cached CVE ID counts, recent process-wide event-loop delay, and total handler elapsed time. Leave it disabled during normal operation unless the deployment logging volume is acceptable. + +Event-loop delay uses one low-overhead process monitor sampled in rolling 10-second windows. Request events report the most recently completed window through `processEventLoopDelayMeanMs`, `processEventLoopDelayP99Ms`, `processEventLoopDelayMaxMs`, `processEventLoopDelayWindowMs`, and `processEventLoopDelaySampleAgeMs`. These values describe process-wide event-loop saturation near the request, not delay caused by that individual request. They are omitted until the first complete window is available, and concurrent requests read the same completed snapshot without resetting one another's measurements. Heap deltas and event-loop delay are process-level diagnostics rather than isolated per-request measurements because concurrent requests share the same Node.js process. Use them to correlate latency with memory pressure or event-loop stalls across a test window, not to attribute resource use precisely to one request. @@ -369,7 +373,7 @@ Searches that use `cpeName`, `virtualMatchString`, or `virtualMatchString` with The post-filter cache stores only the filtered CVE ID list and candidate count for that expensive candidate query. It does not cache full CVE records. On a cache hit, the API skips the 10,000-candidate OpenSearch query and semantic post-filtering, slices the cached CVE IDs for the requested page, and fetches that page of full CVE records by ID. -Candidate retrieval asks OpenSearch for exact-version or wildcard-version criteria and leaves range applicability decisions to the semantic post-filter. The full-record page fetch uses an exact CVE ID set without total-hit counting or OpenSearch sorting; the API restores the requested CVE ID order locally. Standard searches retain exact total counting and deterministic sorting. +Candidate retrieval asks OpenSearch for exact-version or wildcard-version criteria and leaves range applicability decisions to the semantic post-filter. The full-record page fetch uses an exact CVE ID set without total-hit counting or OpenSearch sorting; the API requires one unique full record for every requested CVE ID and restores the requested CVE ID order locally. Missing, duplicate, malformed, or unexpected page records fail closed with a sanitized HTTP 502 response and evict the associated semantic candidate cache entry so the next request must repeat candidate discovery. Standard searches retain exact total counting and deterministic sorting. The cache is intentionally small and short-lived: @@ -382,7 +386,7 @@ The cache is intentionally small and short-lived: - Cold-search queue depth: up to 500 unique cold searches can wait per process behind the concurrency limit - Cold-search queue wait: up to 10 seconds before a waiting search returns HTTP 429 - Shared operation waiters: up to 500 callers can wait on one candidate or page-fetch operation per process -- Post-filter evaluation chunk: 250 candidates between cancellation checks and event-loop yields +- Post-filter evaluation chunk: 250 CPE Match Criteria entries between cancellation checks and event-loop yields - OpenSearch request timeout: 20 seconds for each operation - Overall `/search` deadline: 30 seconds - Eviction: oldest entries are removed when the cache exceeds its max size @@ -391,7 +395,7 @@ The admission queue applies only to cold CPE candidate searches. Standard `/sear When a cold search waits in the queue for more than 10 seconds, it fails closed with HTTP 429 and a `SEARCH_QUEUE_TIMEOUT` response. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed 20 seconds. Standard searches use the shorter of that timeout and the request's remaining overall deadline. Shared candidate and page-fetch operations retain the independent OpenSearch timeout so one caller's remaining time does not shorten work still needed by other callers. Every caller retains its own 30-second overall deadline. -Semantic filtering runs in bounded chunks and yields to the Node.js event loop between chunks. This lets disconnect and deadline cancellation signals interrupt a large candidate evaluation instead of waiting for the full set to finish synchronously. +Semantic filtering validates and evaluates each direct CPE Match Criteria entry in one pass. Once a candidate has satisfied every requested post-filter, later entries are still validated before that candidate can be returned or cached, but redundant match comparisons are skipped. Work is bounded by CPE Match Criteria entry count rather than CVE record count, and filtering yields to the Node.js event loop between chunks. This lets disconnect and deadline cancellation signals interrupt a single record containing many entries instead of waiting for the full record to finish synchronously. Healthy dev timing logs showed the impact for repeated identical `virtualMatchString` range searches: diff --git a/config/default.jsonc b/config/default.jsonc index 0c4c0b8..b04bc01 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -29,7 +29,7 @@ "postFilterColdSearchMaxWaitMs": "10000", // Limits callers sharing one candidate or page-fetch operation per Node.js process. "inFlightMaxWaitersPerOperation": "500", - // Allows cancellation checks between batches of semantic CPE candidate evaluations. + // Allows cancellation checks between batches of semantic CPE Match Criteria entry evaluations. "postFilterEvaluationChunkSize": "250", // Limits each request from the API process to OpenSearch. "openSearchRequestTimeoutMs": "20000", diff --git a/controllers/searchController.js b/controllers/searchController.js index 64152cc..8853867 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -8,6 +8,7 @@ const searchInFlightRegistry = require('../utils/searchInFlightRegistry'); const searchPostFilterWorkQueue = require('../utils/searchPostFilterWorkQueue'); const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); +const { OPEN_SEARCH_RESPONSE_FAILURE_TYPES, OpenSearchResponseError } = require('../utils/searchResponseValidator'); const constants = require('../utils/constants'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); @@ -135,10 +136,24 @@ function getHitCveIds(hits) { return hits.map(getHitCveId).filter(Boolean); } -function orderHitsByCveIds(hits, cveIds) { - const hitsByCveId = new Map(hits.map((hit) => [getHitCveId(hit), hit])); +function validateAndOrderPageHits(hits, cveIds) { + const requestedCveIds = new Set(cveIds); + const hitsByCveId = new Map(); - return cveIds.map((cveId) => hitsByCveId.get(cveId)).filter(Boolean); + if (requestedCveIds.size !== cveIds.length || hits.length !== cveIds.length) { + throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.INCOMPLETE_PAGE_FETCH); + } + + for (const hit of hits) { + const hitCveId = hit?._source?.cveMetadata?.cveId; + if (typeof hitCveId !== 'string' || !requestedCveIds.has(hitCveId) || hitsByCveId.has(hitCveId)) { + throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.INCOMPLETE_PAGE_FETCH); + } + + hitsByCveId.set(hitCveId, hit); + } + + return cveIds.map((cveId) => hitsByCveId.get(cveId)); } function combineTook(...values) { @@ -193,7 +208,7 @@ async function fetchFullHitsByCveIds(cveIds, metrics, signal) { metrics.pageFetchElapsedMs = searchPerformance.elapsedMs(pageFetchStart); metrics.pageFetchTookMs = pageData?.took; - return orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds); + return validateAndOrderPageHits(pageData.hits.hits, cveIds); }, { signal, @@ -303,7 +318,15 @@ async function executeOptimizedPostFilterSearch( metrics.filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; metrics.returnedCount = pageCveIds.length; - const fullPageHits = await fetchFullHitsByCveIds(pageCveIds, metrics, signal); + let fullPageHits; + try { + fullPageHits = await fetchFullHitsByCveIds(pageCveIds, metrics, signal); + } catch (err) { + if (err?.failureType === OPEN_SEARCH_RESPONSE_FAILURE_TYPES.INCOMPLETE_PAGE_FETCH) { + searchPostFilterCache.remove(cacheKey); + } + throw err; + } const response = { status: 'ok', data: { diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index cd3be86..b5c4ec7 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -176,6 +176,17 @@ function cpeHit(id, cpeMatch) { }; } +function fullCveHit(id) { + return { + _id: id, + _source: { + cveMetadata: { + cveId: id + } + } + }; +} + describe('Search controller SEARCH handler', () => { let res, next; @@ -920,6 +931,108 @@ describe('Search controller SEARCH handler', () => { }); }); + test.each([ + ['missing', ['CVE-2024-0001', 'CVE-2024-0002'], [fullCveHit('CVE-2024-0001')]], + ['duplicate', ['CVE-2024-0001', 'CVE-2024-0002'], [fullCveHit('CVE-2024-0001'), fullCveHit('CVE-2024-0001')]], + ['malformed', ['CVE-2024-0001'], [{ _id: 'CVE-2024-0001', _source: { cveMetadata: {} } }]], + ['unexpected', ['CVE-2024-0001'], [fullCveHit('CVE-2024-9999')]] + ])('SEARCH fails closed for a %s full-record page response', async (_caseName, candidateIds, pageHits) => { + const candidates = candidateIds.map((id) => + cpeHit(id, { + criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' + }) + ); + mockSearchRaw.mockResolvedValue(rawResponse(candidates.length, candidates)); + mockApiSearch.mockResolvedValue(advancedResponse(pageHits.length, pageHits)); + + await controller.SEARCH( + { + requestId: `${_caseName}-page-fetch-request`, + searchParams: { + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + resultsPerPage: candidateIds.length, + pageNumber: 1 + } + }, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith({ + error: 'UPSTREAM_SEARCH_ERROR', + message: 'Search backend returned an error. Please try again later.' + }); + expect(searchPostFilterCache.getState()).toStrictEqual({ + entryCount: 0, + cachedCveIdCount: 0 + }); + expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ + event: 'search.performance', + requestId: `${_caseName}-page-fetch-request`, + outcome: 'upstream_invalid_response', + statusCode: 502, + upstreamFailureType: 'incomplete_page_fetch' + }); + expect(next).not.toHaveBeenCalled(); + }); + + test.each([ + ['cpeName', { cpeName: 'not-a-cpe-name' }], + ['virtualMatchString', { virtualMatchString: 'not-a-cpe-match-string' }], + ['virtualMatchString range', { versionStart: '1.0', versionStartType: 'including' }] + ])( + 'SEARCH does not contact OpenSearch when invalid %s input bypasses middleware', + async (_caseName, searchParams) => { + await controller.SEARCH( + { + searchParams + }, + res, + next + ); + + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(TypeError)); + } + ); + + test('SEARCH re-runs candidate discovery after an incomplete page fetch evicts the cache entry', async () => { + const candidate = cpeHit('CVE-2024-0001', { + criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' + }); + const fullHit = fullCveHit('CVE-2024-0001'); + const req = { + searchParams: { + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + resultsPerPage: 1, + pageNumber: 1 + } + }; + mockSearchRaw.mockResolvedValue(rawResponse(1, [candidate])); + mockApiSearch + .mockResolvedValueOnce(advancedResponse(0, [])) + .mockResolvedValueOnce(advancedResponse(1, [fullHit])); + + await controller.SEARCH(req, res, next); + const secondRes = createResponse(); + await controller.SEARCH(req, secondRes, next); + + expect(mockSearchRaw).toHaveBeenCalledTimes(2); + expect(mockApiSearch).toHaveBeenCalledTimes(2); + expect(res.status).toHaveBeenCalledWith(502); + expect(secondRes.status).toHaveBeenCalledWith(200); + expect(secondRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + pageSize: 1, + status: 'ok' + }) + ); + }); + test('SEARCH coalesces concurrent identical cold semantic post-filter searches', async () => { const candidateSearch = deferred(); const applicable = cpeHit('CVE-2024-0001', { diff --git a/server.js b/server.js index 820510a..6dd8bd4 100644 --- a/server.js +++ b/server.js @@ -150,15 +150,35 @@ function resolveListenPort(port, defaultPort) { return parsed; } -function startServer(port) { +function startServer( + port, + { + log = console.log, + startupErrorLog = console.error, + setExitCode = (code) => { + process.exitCode = code; + } + } = {} +) { const config = runtimeConfig.validateRuntimeConfig(); const effectivePort = resolveListenPort(port, config.server.port); const app = createApp(); const server = configureHttpServer(http.createServer(app), config.runtime.http); + let startupFailed = false; + const handleServerStartupError = (err) => { + startupFailed = true; + handleStartupError(err, startupErrorLog); + setExitCode(1); + }; + + server.once('error', handleServerStartupError); return server.listen(effectivePort, () => { + server.removeListener('error', handleServerStartupError); + if (startupFailed) return; + const sanitizedConfig = runtimeConfig.sanitizeRuntimeConfig(config); - logRuntimeEvent(console.log, { + logRuntimeEvent(log, { event: 'runtime.lifecycle', outcome: 'started', ...sanitizedConfig, @@ -321,7 +341,7 @@ function installShutdownHandlers(server, { processRef = process, shutdownOptions if (require.main === module) { try { const server = startServer(); - installShutdownHandlers(server); + server.once('listening', () => installShutdownHandlers(server)); } catch (err) { handleStartupError(err); process.exitCode = 1; diff --git a/server.test.js b/server.test.js index ba27bfe..debf205 100644 --- a/server.test.js +++ b/server.test.js @@ -61,6 +61,12 @@ function restoreEnv(name, value) { process.env[name] = value; } +function createFakeServer() { + const server = new EventEmitter(); + server.listen = jest.fn(() => server); + return server; +} + describe('server application bootstrap', () => { const originalBaseUrl = process.env.BASE_URL; const originalNodeEnv = process.env.NODE_ENV; @@ -346,8 +352,7 @@ describe('server application bootstrap', () => { test('startServer listens on the default port when PORT is not set', () => { delete process.env.PORT; - const fakeServer = {}; - fakeServer.listen = jest.fn(() => fakeServer); + const fakeServer = createFakeServer(); const createServer = jest.spyOn(http, 'createServer').mockReturnValue(fakeServer); expect(startServer()).toBe(fakeServer); @@ -378,14 +383,77 @@ describe('server application bootstrap', () => { }); test('startServer passes through an explicit port', () => { - const fakeServer = {}; - fakeServer.listen = jest.fn(() => fakeServer); + const fakeServer = createFakeServer(); jest.spyOn(http, 'createServer').mockReturnValue(fakeServer); expect(startServer('4567')).toBe(fakeServer); expect(fakeServer.listen).toHaveBeenCalledWith(4567, expect.any(Function)); }); + test.each(['EADDRINUSE', 'EACCES'])( + 'startServer handles an asynchronous %s failure through sanitized startup logging', + (code) => { + const fakeServer = createFakeServer(); + let listeningCallback; + fakeServer.listen.mockImplementation((_port, callback) => { + listeningCallback = callback; + return fakeServer; + }); + jest.spyOn(http, 'createServer').mockReturnValue(fakeServer); + const log = jest.fn(); + const startupErrorLog = jest.fn(); + const setExitCode = jest.fn(); + const thrownError = Object.assign(new Error('sensitive startup details'), { + code, + address: '0.0.0.0', + port: 3000 + }); + + expect(startServer(undefined, { log, setExitCode, startupErrorLog })).toBe(fakeServer); + expect(fakeServer.listenerCount('error')).toBe(1); + + fakeServer.emit('error', thrownError); + + expect(setExitCode).toHaveBeenCalledWith(1); + expect(startupErrorLog).toHaveBeenCalledWith( + JSON.stringify({ + event: 'runtime.lifecycle', + outcome: 'startup_failed', + errorCode: 'STARTUP_ERROR' + }) + ); + expect(JSON.stringify(startupErrorLog.mock.calls)).not.toContain('sensitive startup details'); + expect(JSON.stringify(startupErrorLog.mock.calls)).not.toContain('0.0.0.0'); + expect(fakeServer.listenerCount('error')).toBe(0); + + listeningCallback(); + expect(log).not.toHaveBeenCalled(); + } + ); + + test('startServer removes its startup error listener after listening succeeds', () => { + const fakeServer = createFakeServer(); + let listeningCallback; + fakeServer.listen.mockImplementation((_port, callback) => { + listeningCallback = callback; + return fakeServer; + }); + jest.spyOn(http, 'createServer').mockReturnValue(fakeServer); + const log = jest.fn(); + const startupErrorLog = jest.fn(); + const setExitCode = jest.fn(); + + startServer(undefined, { log, setExitCode, startupErrorLog }); + expect(fakeServer.listenerCount('error')).toBe(1); + + listeningCallback(); + + expect(fakeServer.listenerCount('error')).toBe(0); + expect(startupErrorLog).not.toHaveBeenCalled(); + expect(setExitCode).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"outcome":"started"')); + }); + test('startServer rejects invalid configuration before creating an HTTP server', () => { process.env.PORT = 'invalid'; runtimeConfig.resetForTests(); diff --git a/test/delivery-policy.test.js b/test/delivery-policy.test.js index 7b4d88c..65af699 100644 --- a/test/delivery-policy.test.js +++ b/test/delivery-policy.test.js @@ -13,6 +13,17 @@ function readWorkflow(file) { } describe('delivery security policy', () => { + test('routes Dependabot version updates through the dev integration branch', () => { + const dependabot = readWorkflow('.github/dependabot.yml'); + + expect(dependabot.updates.map((update) => update['package-ecosystem'])).toStrictEqual([ + 'npm', + 'docker', + 'github-actions' + ]); + expect(dependabot.updates.every((update) => update['target-branch'] === 'dev')).toBe(true); + }); + test('exposes OpenSearch secrets only to the trusted contract execution step', () => { const workflow = readWorkflow('.github/workflows/opensearch-contract.yml'); const job = workflow.jobs.contract; diff --git a/utils/cpe.js b/utils/cpe.js index 58e6741..dd75191 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -535,39 +535,6 @@ function cpeMatchMatchesVirtualMatchString(cpeMatch, virtualMatchString, context return true; } -function getCpeMatchesFromHit(hit) { - const cpeApplicability = hit?._source?.containers?.cna?.cpeApplicability; - const applicabilityItems = Array.isArray(cpeApplicability) ? cpeApplicability : [cpeApplicability]; - const matches = []; - - applicabilityItems.forEach((applicability) => { - const rootNodes = Array.isArray(applicability?.nodes) ? applicability.nodes : []; - rootNodes.forEach((node) => { - if (Array.isArray(node?.cpeMatch)) { - matches.push(...node.cpeMatch); - } - }); - }); - - return matches; -} - -function hitAppliesToCpeName(hit, cpeName, context, options) { - return getCpeMatchesFromHit(hit).some((match) => cpeMatchAppliesToCpeName(match, cpeName, context, options)); -} - -function hitOverlapsVirtualMatchStringRange(hit, virtualMatchStringRange, context) { - return getCpeMatchesFromHit(hit).some((match) => - cpeMatchOverlapsVirtualMatchStringRange(match, virtualMatchStringRange, context) - ); -} - -function hitMatchesVirtualMatchString(hit, virtualMatchString, context) { - return getCpeMatchesFromHit(hit).some((match) => - cpeMatchMatchesVirtualMatchString(match, virtualMatchString, context) - ); -} - module.exports = { buildCpeCriteriaRegexp, buildCompatibleCriteriaPrefixes, @@ -578,10 +545,6 @@ module.exports = { cpeMatchMatchesVirtualMatchString, cpeMatchOverlapsVirtualMatchStringRange, escapeLuceneRegexpLiteral, - getCpeMatchesFromHit, - hitAppliesToCpeName, - hitMatchesVirtualMatchString, - hitOverlapsVirtualMatchStringRange, hasConcreteComponentsAfter, hasConcreteLeadingComponents, isValidCpeMatch, diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 6068cac..b8317a2 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -633,125 +633,4 @@ describe('CPE helpers', () => { }) ).toBe(true); }); - - test('hitAppliesToCpeName evaluates direct entries independently of node operator and negate values', () => { - const hit = { - _source: { - containers: { - cna: { - cpeApplicability: { - nodes: [ - { - operator: 'AND', - negate: true, - cpeMatch: [ - { - criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '2.9', - versionEndIncluding: '2.10' - }, - { - criteria: 'cpe:2.3:a:other:component:*:*:*:*:*:*:*:*', - vulnerable: true - } - ] - } - ] - } - } - } - } - }; - - expect( - cpe.hitAppliesToCpeName(hit, 'cpe:2.3:a:vendor:product:2.10:*:*:*:*:*:*:*', undefined, { - requireVulnerable: true - }) - ).toBe(true); - expect(cpe.hitAppliesToCpeName(hit, 'cpe:2.3:a:vendor:product:2.11:*:*:*:*:*:*:*')).toBe(false); - }); - - test('hitOverlapsVirtualMatchStringRange evaluates direct entries independently of node metadata', () => { - const hit = { - _source: { - containers: { - cna: { - cpeApplicability: { - nodes: [ - { - operator: 'OR', - negate: true, - cpeMatch: [ - { - criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '2.9', - versionEndIncluding: '2.10' - }, - { - criteria: 'cpe:2.3:a:other:component:*:*:*:*:*:*:*:*', - vulnerable: true - } - ] - } - ] - } - } - } - } - }; - - expect( - cpe.hitOverlapsVirtualMatchStringRange(hit, { - matchString: cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'), - versionStart: '2.10', - versionStartType: 'including', - versionEnd: '2.11', - versionEndType: 'excluding' - }) - ).toBe(true); - expect( - cpe.hitOverlapsVirtualMatchStringRange(hit, { - matchString: cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'), - versionStart: '2.11', - versionStartType: 'including', - versionEnd: '2.12', - versionEndType: 'excluding' - }) - ).toBe(false); - }); - - test('getCpeMatchesFromHit ignores nested-only applicability entries', () => { - const hit = { - _source: { - containers: { - cna: { - cpeApplicability: { - nodes: [ - { - operator: 'OR', - negate: false, - nodes: [ - { - cpeMatch: [ - { - criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', - vulnerable: true - } - ] - } - ] - } - ] - } - } - } - } - }; - - expect(cpe.getCpeMatchesFromHit(hit)).toStrictEqual([]); - expect(cpe.hitAppliesToCpeName(hit, 'cpe:2.3:a:vendor:product:2.10:*:*:*:*:*:*:*')).toBe(false); - expect(cpe.hitMatchesVirtualMatchString(hit, 'cpe:2.3:a:vendor:product')).toBe(false); - }); }); diff --git a/utils/searchCandidateProcessor.js b/utils/searchCandidateProcessor.js index 5e408c1..e48a53a 100644 --- a/utils/searchCandidateProcessor.js +++ b/utils/searchCandidateProcessor.js @@ -8,55 +8,53 @@ function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -function isValidCpeCandidateHit(hit, cpeEvaluationContext) { - if (!isObject(hit) || !isObject(hit._source)) return false; +function getCandidateApplicabilityItems(hit) { + if (!isObject(hit) || !isObject(hit._source)) return null; const candidateCveId = hit._source.cveMetadata?.cveId; - if (!cveId.isValidCveId(candidateCveId)) return false; + if (!cveId.isValidCveId(candidateCveId)) return null; const cpeApplicability = hit._source.containers?.cna?.cpeApplicability; const applicabilityItems = Array.isArray(cpeApplicability) ? cpeApplicability : [cpeApplicability]; - if (!applicabilityItems.length || applicabilityItems.some((item) => !isObject(item))) return false; + if (!applicabilityItems.length || applicabilityItems.some((item) => !isObject(item))) return null; - let matchCount = 0; - for (const applicability of applicabilityItems) { - if (!Array.isArray(applicability.nodes)) return false; + return applicabilityItems; +} - for (const node of applicability.nodes) { - if (!isObject(node) || !Array.isArray(node.cpeMatch)) return false; - if (!node.cpeMatch.every((match) => cpe.isValidCpeMatch(match, cpeEvaluationContext))) return false; - matchCount += node.cpeMatch.length; - } - } +function createPostFilterMatchState(postFilters) { + return { + cpeName: !postFilters?.cpeName, + virtualMatchString: !postFilters?.virtualMatchString, + virtualMatchStringRange: !postFilters?.virtualMatchStringRange + }; +} - return matchCount > 0; +function allPostFiltersMatch(matchState) { + return matchState.cpeName && matchState.virtualMatchString && matchState.virtualMatchStringRange; } -function hitMatchesPostFilters(hit, postFilters, cpeEvaluationContext) { - if ( - postFilters?.cpeName && - !cpe.hitAppliesToCpeName(hit, postFilters.cpeName, cpeEvaluationContext, { +function updatePostFilterMatchState(cpeMatch, postFilters, cpeEvaluationContext, matchState) { + if (!matchState.cpeName) { + matchState.cpeName = cpe.cpeMatchAppliesToCpeName(cpeMatch, postFilters.cpeName, cpeEvaluationContext, { requireVulnerable: postFilters.isVulnerable === true - }) - ) { - return false; + }); } - if ( - postFilters?.virtualMatchString && - !cpe.hitMatchesVirtualMatchString(hit, postFilters.virtualMatchString, cpeEvaluationContext) - ) { - return false; + if (!matchState.virtualMatchString) { + matchState.virtualMatchString = cpe.cpeMatchMatchesVirtualMatchString( + cpeMatch, + postFilters.virtualMatchString, + cpeEvaluationContext + ); } - if ( - postFilters?.virtualMatchStringRange && - !cpe.hitOverlapsVirtualMatchStringRange(hit, postFilters.virtualMatchStringRange, cpeEvaluationContext) - ) { - return false; + if (!matchState.virtualMatchStringRange) { + matchState.virtualMatchStringRange = cpe.cpeMatchOverlapsVirtualMatchStringRange( + cpeMatch, + postFilters.virtualMatchStringRange, + cpeEvaluationContext + ); } - - return true; } function yieldToEventLoop() { @@ -72,23 +70,54 @@ async function filterSearchHitsInChunks(resp, postFilters, signal, chunkSize) { const effectiveChunkSize = Math.max(1, chunkSize ?? constants.SEARCH_POST_FILTER_EVALUATION_CHUNK_SIZE); const cpeEvaluationContext = cpe.createEvaluationContext(); const filteredHits = []; + let matchesSinceYield = 0; + + searchDeadline.throwIfAborted(signal); + for (const hit of hitsContainer.hits) { + const applicabilityItems = getCandidateApplicabilityItems(hit); + if (!applicabilityItems) { + throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_CANDIDATE); + } - for (let start = 0; start < hitsContainer.hits.length; start += effectiveChunkSize) { - searchDeadline.throwIfAborted(signal); - const end = Math.min(start + effectiveChunkSize, hitsContainer.hits.length); + const matchState = createPostFilterMatchState(postFilters); + let matchCount = 0; - for (let index = start; index < end; index += 1) { - const hit = hitsContainer.hits[index]; - if (!isValidCpeCandidateHit(hit, cpeEvaluationContext)) { + for (const applicability of applicabilityItems) { + if (!Array.isArray(applicability.nodes)) { throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_CANDIDATE); } - if (hitMatchesPostFilters(hit, postFilters, cpeEvaluationContext)) { - filteredHits.push(hit); + + for (const node of applicability.nodes) { + if (!isObject(node) || !Array.isArray(node.cpeMatch)) { + throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_CANDIDATE); + } + + for (const cpeMatch of node.cpeMatch) { + if (matchesSinceYield >= effectiveChunkSize) { + searchDeadline.throwIfAborted(signal); + await yieldToEventLoop(); + searchDeadline.throwIfAborted(signal); + matchesSinceYield = 0; + } + + if (!cpe.isValidCpeMatch(cpeMatch, cpeEvaluationContext)) { + throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_CANDIDATE); + } + + matchCount += 1; + matchesSinceYield += 1; + if (!allPostFiltersMatch(matchState)) { + updatePostFilterMatchState(cpeMatch, postFilters, cpeEvaluationContext, matchState); + } + } } } - if (end < hitsContainer.hits.length) { - await yieldToEventLoop(); + if (matchCount === 0) { + throw new OpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_CANDIDATE); + } + if (allPostFiltersMatch(matchState)) { + filteredHits.push(hit); } } diff --git a/utils/searchCandidateProcessor.test.js b/utils/searchCandidateProcessor.test.js index 6048b35..9e277f5 100644 --- a/utils/searchCandidateProcessor.test.js +++ b/utils/searchCandidateProcessor.test.js @@ -1,4 +1,5 @@ const constants = require('./constants'); +const cpe = require('./cpe'); const searchCandidateProcessor = require('./searchCandidateProcessor'); const searchDeadline = require('./searchDeadline'); @@ -64,6 +65,10 @@ function nestedOnlyCpeCandidateHit() { } describe('searchCandidateProcessor', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + test('validates and filters candidates in the same processing pass', async () => { const matching = cpeCandidateHit({ versionStartIncluding: '2.9', @@ -194,6 +199,102 @@ describe('searchCandidateProcessor', () => { }); }); + test('continues validating CPE entries after the post-filter matches', async () => { + const candidate = cpeCandidateHit(); + candidate._source.containers.cna.cpeApplicability[0].nodes[0].cpeMatch.push({ + criteria: 'not-a-cpe-name', + vulnerable: true + }); + const matcher = jest.spyOn(cpe, 'cpeMatchAppliesToCpeName'); + + await expect( + searchCandidateProcessor.filterSearchHitsInChunks( + candidateResponse([candidate]), + { + cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', + isVulnerable: true + }, + undefined, + 10 + ) + ).rejects.toMatchObject({ + code: 'OPEN_SEARCH_RESPONSE_INVALID', + failureType: 'malformed_candidate' + }); + expect(matcher).toHaveBeenCalledTimes(1); + }); + + test('preserves candidate order while filtering individual direct CPE entries', async () => { + const first = cpeCandidateHit(); + const excluded = cpeCandidateHit({ + criteria: 'cpe:2.3:a:other:product:*:*:*:*:*:*:*:*' + }); + const third = cpeCandidateHit(); + excluded._source.cveMetadata.cveId = 'CVE-2024-0002'; + third._source.cveMetadata.cveId = 'CVE-2024-0003'; + first._source.containers.cna.cpeApplicability[0].nodes[0].operator = 'AND'; + first._source.containers.cna.cpeApplicability[0].nodes[0].negate = true; + + await expect( + searchCandidateProcessor.filterSearchHitsInChunks( + candidateResponse([first, excluded, third]), + { + virtualMatchString: 'cpe:2.3:a:vendor:product' + }, + undefined, + 1 + ) + ).resolves.toMatchObject({ + candidateCount: 3, + filteredHits: [first, third] + }); + }); + + test('preserves independent entry matching when multiple post-filters are supplied', async () => { + const candidate = cpeCandidateHit(); + candidate._source.containers.cna.cpeApplicability[0].nodes[0].cpeMatch.push({ + criteria: 'cpe:2.3:a:other:component:*:*:*:*:*:*:*:*', + vulnerable: true + }); + + await expect( + searchCandidateProcessor.filterSearchHitsInChunks( + candidateResponse([candidate]), + { + cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', + virtualMatchString: 'cpe:2.3:a:other:component' + }, + undefined, + 10 + ) + ).resolves.toMatchObject({ + candidateCount: 1, + filteredHits: [candidate] + }); + }); + + test('honors cancellation between CPE entries within one candidate', async () => { + const candidate = cpeCandidateHit(); + const matches = candidate._source.containers.cna.cpeApplicability[0].nodes[0].cpeMatch; + matches.push(...Array.from({ length: 10 }, () => ({ ...matches[0] }))); + const abortController = new AbortController(); + const matcher = jest.spyOn(cpe, 'cpeMatchMatchesVirtualMatchString'); + const filtering = searchCandidateProcessor.filterSearchHitsInChunks( + candidateResponse([candidate]), + { + virtualMatchString: 'cpe:2.3:a:other:product' + }, + abortController.signal, + 1 + ); + + setImmediate(() => abortController.abort(new Error('cancelled'))); + + await expect(filtering).rejects.toThrow('cancelled'); + expect(matcher.mock.calls.length).toBeGreaterThan(0); + expect(matcher.mock.calls.length).toBeLessThan(matches.length); + }); + test('honors cancellation before candidate validation begins', async () => { const abortController = new AbortController(); abortController.abort(new searchDeadline.SearchClientDisconnectedError()); diff --git a/utils/searchPerformance.js b/utils/searchPerformance.js index 95caf0b..3e365ae 100644 --- a/utils/searchPerformance.js +++ b/utils/searchPerformance.js @@ -2,8 +2,9 @@ const { monitorEventLoopDelay } = require('perf_hooks'); const searchProvider = require('./searchProvider'); const EVENT_LOOP_DELAY_RESOLUTION_MS = 20; +const EVENT_LOOP_DELAY_WINDOW_MS = 10000; const RESPONSE_JSON_INSTRUMENTED = Symbol('responseJsonInstrumented'); -let eventLoopDelayMonitor; +let eventLoopDelaySampler; function markTime() { return process.hrtime.bigint(); @@ -29,15 +30,69 @@ function nanosecondsToMs(value) { return Number.isFinite(value) ? value / 1e6 : undefined; } -function getEventLoopDelayMonitor() { - if (!eventLoopDelayMonitor) { - eventLoopDelayMonitor = monitorEventLoopDelay({ - resolution: EVENT_LOOP_DELAY_RESOLUTION_MS - }); - eventLoopDelayMonitor.enable(); +function createEventLoopDelaySampler({ + cancel = clearInterval, + createMonitor = () => monitorEventLoopDelay({ resolution: EVENT_LOOP_DELAY_RESOLUTION_MS }), + now = Date.now, + schedule = setInterval, + windowMs = EVENT_LOOP_DELAY_WINDOW_MS +} = {}) { + let latestSnapshot; + let monitor; + let timer; + let windowStartedAtMs; + + function captureWindow() { + if (!monitor) return; + + const sampledAtMs = now(); + latestSnapshot = { + meanMs: nanosecondsToMs(monitor.mean), + p99Ms: nanosecondsToMs(monitor.percentile(99)), + maxMs: nanosecondsToMs(monitor.max), + windowMs: Math.max(0, sampledAtMs - windowStartedAtMs), + sampledAtMs + }; + monitor.reset(); + windowStartedAtMs = sampledAtMs; + } + + return { + getSnapshot() { + if (!latestSnapshot) return undefined; + + return { + ...latestSnapshot, + sampleAgeMs: Math.max(0, now() - latestSnapshot.sampledAtMs) + }; + }, + start() { + if (monitor) return; + + monitor = createMonitor(); + monitor.enable(); + windowStartedAtMs = now(); + timer = schedule(captureWindow, windowMs); + timer?.unref?.(); + }, + stop() { + if (timer) cancel(timer); + monitor?.disable(); + latestSnapshot = undefined; + monitor = undefined; + timer = undefined; + windowStartedAtMs = undefined; + } + }; +} + +function getEventLoopDelaySampler() { + if (!eventLoopDelaySampler) { + eventLoopDelaySampler = createEventLoopDelaySampler(); + eventLoopDelaySampler.start(); } - return eventLoopDelayMonitor; + return eventLoopDelaySampler; } function instrumentJsonResponse(res, metrics) { @@ -63,7 +118,7 @@ function beginSearchRequest(metrics, res) { if (!searchProvider.shouldLogSearchPerformance()) return false; metrics.heapUsedStartBytes = process.memoryUsage().heapUsed; - getEventLoopDelayMonitor(); + getEventLoopDelaySampler(); instrumentJsonResponse(res, metrics); return true; } @@ -72,12 +127,16 @@ function completeSearchRequest(metrics) { if (metrics.heapUsedStartBytes === undefined) return; const heapUsedBytes = process.memoryUsage().heapUsed; - const monitor = getEventLoopDelayMonitor(); + const eventLoopDelaySnapshot = getEventLoopDelaySampler().getSnapshot(); metrics.heapUsedBytes = heapUsedBytes; metrics.heapUsedDeltaBytes = heapUsedBytes - metrics.heapUsedStartBytes; - metrics.eventLoopDelayMeanMs = nanosecondsToMs(monitor.mean); - metrics.eventLoopDelayP99Ms = nanosecondsToMs(monitor.percentile(99)); - metrics.eventLoopDelayMaxMs = nanosecondsToMs(monitor.max); + if (eventLoopDelaySnapshot) { + metrics.processEventLoopDelayMeanMs = eventLoopDelaySnapshot.meanMs; + metrics.processEventLoopDelayP99Ms = eventLoopDelaySnapshot.p99Ms; + metrics.processEventLoopDelayMaxMs = eventLoopDelaySnapshot.maxMs; + metrics.processEventLoopDelayWindowMs = eventLoopDelaySnapshot.windowMs; + metrics.processEventLoopDelaySampleAgeMs = eventLoopDelaySnapshot.sampleAgeMs; + } } function logSearchPerformance(metrics, searchOptions, responseOptions, postFilters) { @@ -123,9 +182,11 @@ function logSearchPerformance(metrics, searchOptions, responseOptions, postFilte responseSerializationElapsedMs: roundMs(metrics.responseSerializationElapsedMs), heapUsedBytes: metrics.heapUsedBytes, heapUsedDeltaBytes: metrics.heapUsedDeltaBytes, - eventLoopDelayMeanMs: roundMs(metrics.eventLoopDelayMeanMs), - eventLoopDelayP99Ms: roundMs(metrics.eventLoopDelayP99Ms), - eventLoopDelayMaxMs: roundMs(metrics.eventLoopDelayMaxMs), + processEventLoopDelayMeanMs: roundMs(metrics.processEventLoopDelayMeanMs), + processEventLoopDelayP99Ms: roundMs(metrics.processEventLoopDelayP99Ms), + processEventLoopDelayMaxMs: roundMs(metrics.processEventLoopDelayMaxMs), + processEventLoopDelayWindowMs: roundMs(metrics.processEventLoopDelayWindowMs), + processEventLoopDelaySampleAgeMs: roundMs(metrics.processEventLoopDelaySampleAgeMs), openSearchTookMs: metrics.openSearchTookMs, pageFetchTookMs: metrics.pageFetchTookMs, openSearchElapsedMs: roundMs(metrics.openSearchElapsedMs), @@ -142,13 +203,14 @@ function logSearchPerformance(metrics, searchOptions, responseOptions, postFilte } function resetForTests() { - eventLoopDelayMonitor?.disable(); - eventLoopDelayMonitor = undefined; + eventLoopDelaySampler?.stop(); + eventLoopDelaySampler = undefined; } module.exports = { beginSearchRequest, completeSearchRequest, + createEventLoopDelaySampler, elapsedMs, getCacheStatus, logSearchPerformance, diff --git a/utils/searchPerformance.test.js b/utils/searchPerformance.test.js index 52a7036..8ea90c3 100644 --- a/utils/searchPerformance.test.js +++ b/utils/searchPerformance.test.js @@ -17,6 +17,122 @@ describe('searchPerformance', () => { jest.restoreAllMocks(); }); + test('samples event-loop delay in completed rolling windows', () => { + let captureWindow; + let nowMs = 1000; + const timer = { + unref: jest.fn() + }; + const monitor = { + disable: jest.fn(), + enable: jest.fn(), + max: 12_345_000, + mean: 2_345_000, + percentile: jest.fn(() => 8_765_000), + reset: jest.fn() + }; + const cancel = jest.fn(); + const createMonitor = jest.fn(() => monitor); + const schedule = jest.fn((callback) => { + captureWindow = callback; + return timer; + }); + const sampler = searchPerformance.createEventLoopDelaySampler({ + cancel, + createMonitor, + now: () => nowMs, + schedule, + windowMs: 10000 + }); + + sampler.start(); + + expect(createMonitor).toHaveBeenCalledTimes(1); + expect(monitor.enable).toHaveBeenCalledTimes(1); + expect(schedule).toHaveBeenCalledWith(expect.any(Function), 10000); + expect(timer.unref).toHaveBeenCalledTimes(1); + expect(sampler.getSnapshot()).toBeUndefined(); + + nowMs = 11100; + captureWindow(); + + expect(monitor.percentile).toHaveBeenCalledWith(99); + expect(monitor.reset).toHaveBeenCalledTimes(1); + expect(sampler.getSnapshot()).toStrictEqual({ + meanMs: 2.345, + p99Ms: 8.765, + maxMs: 12.345, + windowMs: 10100, + sampledAtMs: 11100, + sampleAgeMs: 0 + }); + + nowMs = 11350; + expect(sampler.getSnapshot()).toMatchObject({ + meanMs: 2.345, + sampleAgeMs: 250 + }); + + sampler.start(); + expect(createMonitor).toHaveBeenCalledTimes(1); + expect(schedule).toHaveBeenCalledTimes(1); + + sampler.stop(); + expect(cancel).toHaveBeenCalledWith(timer); + expect(monitor.disable).toHaveBeenCalledTimes(1); + expect(sampler.getSnapshot()).toBeUndefined(); + }); + + test('publishes one immutable snapshot to concurrent readers until the next window completes', () => { + let captureWindow; + let nowMs = 0; + const monitor = { + disable: jest.fn(), + enable: jest.fn(), + max: 3_000_000, + mean: 1_000_000, + percentile: jest.fn(() => 2_000_000), + reset: jest.fn() + }; + const sampler = searchPerformance.createEventLoopDelaySampler({ + cancel: jest.fn(), + createMonitor: () => monitor, + now: () => nowMs, + schedule: (callback) => { + captureWindow = callback; + return { unref: jest.fn() }; + }, + windowMs: 10000 + }); + + sampler.start(); + nowMs = 10000; + captureWindow(); + const firstReader = sampler.getSnapshot(); + const secondReader = sampler.getSnapshot(); + + expect(firstReader).toStrictEqual(secondReader); + expect(firstReader).not.toBe(secondReader); + expect(monitor.reset).toHaveBeenCalledTimes(1); + + monitor.mean = 4_000_000; + monitor.max = 6_000_000; + monitor.percentile.mockReturnValue(5_000_000); + nowMs = 20500; + captureWindow(); + + expect(sampler.getSnapshot()).toMatchObject({ + meanMs: 4, + p99Ms: 5, + maxMs: 6, + windowMs: 10500, + sampleAgeMs: 0 + }); + expect(monitor.reset).toHaveBeenCalledTimes(2); + + sampler.stop(); + }); + test('logSearchPerformance emits redacted search timing metadata when enabled', () => { mockShouldLogSearchPerformance.mockReturnValue(true); @@ -43,9 +159,11 @@ describe('searchPerformance', () => { responseSerializationElapsedMs: 1.234, heapUsedBytes: 2000000, heapUsedDeltaBytes: 50000, - eventLoopDelayMeanMs: 2.345, - eventLoopDelayP99Ms: 8.765, - eventLoopDelayMaxMs: 12.345, + processEventLoopDelayMeanMs: 2.345, + processEventLoopDelayP99Ms: 8.765, + processEventLoopDelayMaxMs: 12.345, + processEventLoopDelayWindowMs: 10000.123, + processEventLoopDelaySampleAgeMs: 250.456, openSearchTookMs: 97, pageFetchTookMs: 8, openSearchElapsedMs: 123.456, @@ -106,9 +224,11 @@ describe('searchPerformance', () => { responseSerializationElapsedMs: 1.23, heapUsedBytes: 2000000, heapUsedDeltaBytes: 50000, - eventLoopDelayMeanMs: 2.35, - eventLoopDelayP99Ms: 8.77, - eventLoopDelayMaxMs: 12.35, + processEventLoopDelayMeanMs: 2.35, + processEventLoopDelayP99Ms: 8.77, + processEventLoopDelayMaxMs: 12.35, + processEventLoopDelayWindowMs: 10000.12, + processEventLoopDelaySampleAgeMs: 250.46, openSearchTookMs: 97, pageFetchTookMs: 8, openSearchElapsedMs: 123.46, @@ -175,6 +295,8 @@ describe('searchPerformance', () => { heapUsedDeltaBytes: expect.any(Number) }); expect(metrics.responseSerializationElapsedMs).toBeGreaterThanOrEqual(0); + expect(metrics.processEventLoopDelayMeanMs).toBeUndefined(); + expect(metrics.processEventLoopDelayWindowMs).toBeUndefined(); }); test('logSearchPerformance always emits sanitized failure outcomes', () => { diff --git a/utils/searchPostFilterCache.js b/utils/searchPostFilterCache.js index ee90e58..19e6c55 100644 --- a/utils/searchPostFilterCache.js +++ b/utils/searchPostFilterCache.js @@ -77,6 +77,10 @@ function set(cacheKey, value, now = Date.now()) { } } +function remove(cacheKey) { + return entries.delete(cacheKey); +} + function getState(now = Date.now()) { for (const [key, entry] of entries) { if (entry.expiresAt <= now) entries.delete(key); @@ -101,5 +105,6 @@ module.exports = { get, getState, normalizeForKey, + remove, set }; diff --git a/utils/searchPostFilterCache.test.js b/utils/searchPostFilterCache.test.js index 7d2f41d..bc5173b 100644 --- a/utils/searchPostFilterCache.test.js +++ b/utils/searchPostFilterCache.test.js @@ -144,6 +144,18 @@ describe('searchPostFilterCache', () => { expect(searchPostFilterCache.get(key, 61001)).toBeNull(); }); + test('remove evicts only the selected cache entry', () => { + searchPostFilterCache.set('first', { filteredCveIds: ['CVE-2024-0001'] }, 1000); + searchPostFilterCache.set('second', { filteredCveIds: ['CVE-2024-0002'] }, 1000); + + expect(searchPostFilterCache.remove('first')).toBe(true); + expect(searchPostFilterCache.get('first', 1001)).toBeNull(); + expect(searchPostFilterCache.get('second', 1001)).toStrictEqual({ + filteredCveIds: ['CVE-2024-0002'] + }); + expect(searchPostFilterCache.remove('missing')).toBe(false); + }); + test('reports entry and cached CVE ID counts while pruning expired entries', () => { searchPostFilterCache.set( 'first', diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index 84f4cc0..78616c5 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -86,6 +86,22 @@ function cpeCandidateHit(cpeMatch = {}) { }; } +function completeSearchResponse({ hits = [], total } = {}) { + const body = { + timed_out: false, + _shards: { + failed: 0 + }, + hits: { + hits + } + }; + + if (total !== undefined) body.hits.total = total; + + return body; +} + describe('searchProvider', () => { beforeEach(() => { searchProvider.resetSearchProviderForTests(); @@ -138,16 +154,14 @@ describe('searchProvider', () => { }); test('reuses one search reader per process for OpenSearch searches', async () => { - mockClientSearch.mockResolvedValue({ - body: { - hits: { - total: { - value: 0 - }, - hits: [] - } + const responseBody = completeSearchResponse({ + total: { + value: 0 } }); + mockClientSearch.mockResolvedValue({ + body: responseBody + }); const first = searchProvider.getSearchReader(); const second = searchProvider.getSearchReader(); @@ -180,14 +194,7 @@ describe('searchProvider', () => { ); expect(first._client.transport.maxRetries).toBe(0); expect(first._client.transport.requestTimeout).toBe(20000); - expect(result).toStrictEqual({ - hits: { - total: { - value: 0 - }, - hits: [] - } - }); + expect(result).toBe(responseBody); expect(onStart).toHaveBeenCalledWith({ activeCount: 1, activeByOperation: { @@ -283,11 +290,7 @@ describe('searchProvider', () => { test('caps an OpenSearch request timeout to the remaining request budget', async () => { mockClientSearch.mockResolvedValue({ - body: { - hits: { - hits: [] - } - } + body: completeSearchResponse() }); await searchProvider.searchOpenSearch( @@ -309,11 +312,7 @@ describe('searchProvider', () => { test('does not extend an OpenSearch request beyond the configured timeout', async () => { mockClientSearch.mockResolvedValue({ - body: { - hits: { - hits: [] - } - } + body: completeSearchResponse() }); await searchProvider.searchOpenSearch( @@ -347,6 +346,7 @@ describe('searchProvider', () => { [ 'partial-shard', { + timed_out: false, _shards: { failed: 1 }, @@ -359,6 +359,10 @@ describe('searchProvider', () => { [ 'malformed', { + timed_out: false, + _shards: { + failed: 0 + }, hits: {} }, 'malformed_response' @@ -372,6 +376,22 @@ describe('searchProvider', () => { }); }); + test.each([ + ['missing timed_out', { _shards: { failed: 0 }, hits: { hits: [] } }], + ['non-boolean timed_out', { timed_out: 'false', _shards: { failed: 0 }, hits: { hits: [] } }], + ['missing _shards', { timed_out: false, hits: { hits: [] } }], + ['non-object _shards', { timed_out: false, _shards: 0, hits: { hits: [] } }], + ['missing failed shard count', { timed_out: false, _shards: {}, hits: { hits: [] } }], + ['non-integer failed shard count', { timed_out: false, _shards: { failed: '0' }, hits: { hits: [] } }] + ])('rejects an OpenSearch response with %s', async (_caseName, body) => { + mockClientSearch.mockResolvedValue({ body }); + + await expect(searchProvider.searchOpenSearch({ query: { match_all: {} } })).rejects.toMatchObject({ + code: 'OPEN_SEARCH_RESPONSE_INVALID', + failureType: 'malformed_response' + }); + }); + test.each([ ['missing', undefined], ['non-object', 1], @@ -381,12 +401,7 @@ describe('searchProvider', () => { ['string', { value: '1' }] ])('rejects a standard OpenSearch response with a %s total', async (_caseName, total) => { mockClientSearch.mockResolvedValue({ - body: { - hits: { - total, - hits: [] - } - } + body: completeSearchResponse({ total }) }); await expect( @@ -407,11 +422,7 @@ describe('searchProvider', () => { }); test('accepts schema-shaped CPE candidate hits for candidate operations', async () => { - const body = { - hits: { - hits: [cpeCandidateHit()] - } - }; + const body = completeSearchResponse({ hits: [cpeCandidateHit()] }); mockClientSearch.mockResolvedValue({ body }); await expect( @@ -429,11 +440,7 @@ describe('searchProvider', () => { }); test('accepts an empty CPE candidate result', async () => { - const body = { - hits: { - hits: [] - } - }; + const body = completeSearchResponse(); mockClientSearch.mockResolvedValue({ body }); await expect( @@ -453,11 +460,7 @@ describe('searchProvider', () => { test('does not synchronously traverse CPE candidate elements during response validation', async () => { const malformedHit = cpeCandidateHit({ criteria: undefined }); mockClientSearch.mockResolvedValue({ - body: { - hits: { - hits: [malformedHit] - } - } + body: completeSearchResponse({ hits: [malformedHit] }) }); await expect( @@ -471,20 +474,12 @@ describe('searchProvider', () => { operation: 'cpe-candidate' } ) - ).resolves.toStrictEqual({ - hits: { - hits: [malformedHit] - } - }); + ).resolves.toStrictEqual(completeSearchResponse({ hits: [malformedHit] })); }); test('checks OpenSearch readiness through the configured CVE index', async () => { mockClientSearch.mockResolvedValue({ - body: { - hits: { - hits: [] - } - } + body: completeSearchResponse() }); await expect(searchProvider.checkOpenSearchReadiness()).resolves.toBe(true); @@ -516,6 +511,7 @@ describe('searchProvider', () => { test('fails readiness when OpenSearch reports a partial response', async () => { mockClientSearch.mockResolvedValue({ body: { + timed_out: false, _shards: { failed: 1 }, @@ -656,8 +652,8 @@ describe('searchProvider', () => { } }); - candidate.resolve({ body: { hits: { hits: [] } } }); - pageFetch.resolve({ body: { hits: { hits: [] } } }); + candidate.resolve({ body: completeSearchResponse() }); + pageFetch.resolve({ body: completeSearchResponse() }); await Promise.all([candidateSearch, pageSearch]); expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 4f86dd9..2a1f62b 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -23,6 +23,8 @@ const cveIdAscendingSort = { order: 'asc' } }; +const versionRangeParamNames = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; +const versionRangeTypes = new Set(['including', 'excluding']); function buildDeterministicSort(queryStringObjects) { const sort = []; @@ -156,35 +158,70 @@ function getFrom(params, resultsPerPage) { } function getCpeNamePostFilter(params) { - if (!params?.cpeName) return null; + if (!hasParam(params, 'cpeName')) return null; const parsed = cpe.parseCpeName(params.cpeName); - return parsed.valid ? parsed : null; + if (!parsed.valid) throwInvalidQueryValue('cpeName'); + + return parsed; } function hasVirtualMatchStringRangeParams(params) { - return Boolean(params?.versionStart || params?.versionStartType || params?.versionEnd || params?.versionEndType); + return versionRangeParamNames.some((param) => hasParam(params, param)); } function getVirtualMatchStringPostFilter(params) { - if (!params?.virtualMatchString || hasVirtualMatchStringRangeParams(params)) return null; + if (!hasParam(params, 'virtualMatchString') || hasVirtualMatchStringRangeParams(params)) return null; const parsed = cpe.parseVirtualMatchString(params.virtualMatchString); - return parsed.valid ? parsed : null; + if (!parsed.valid) throwInvalidQueryValue('virtualMatchString'); + + return parsed; } function getVirtualMatchStringRangePostFilter(params) { - if (!params?.virtualMatchString || !hasVirtualMatchStringRangeParams(params)) return null; + if (!hasVirtualMatchStringRangeParams(params)) return null; + if (!hasParam(params, 'virtualMatchString')) throwInvalidVirtualMatchStringRange(); const matchString = cpe.parseVirtualMatchString(params.virtualMatchString); - if (!matchString.valid || matchString.hasVersionComponent) return null; + if (!matchString.valid || matchString.hasVersionComponent) throwInvalidVirtualMatchStringRange(); + + const versionStart = normalizeVersionRangeBound(params, 'versionStart', 'versionStartType'); + const versionEnd = normalizeVersionRangeBound(params, 'versionEnd', 'versionEndType'); + if (versionStart.value !== undefined && versionEnd.value !== undefined) { + const comparison = cpe.compareCpeVersions(versionStart.value, versionEnd.value); + const isEmptyEqualRange = + comparison === 0 && (versionStart.type !== 'including' || versionEnd.type !== 'including'); + if (comparison > 0 || isEmptyEqualRange) throwInvalidVirtualMatchStringRange(); + } return { matchString, - versionStart: params.versionStart, - versionStartType: params.versionStartType?.toLowerCase(), - versionEnd: params.versionEnd, - versionEndType: params.versionEndType?.toLowerCase() + versionStart: versionStart.value, + versionStartType: versionStart.type, + versionEnd: versionEnd.value, + versionEndType: versionEnd.type + }; +} + +function normalizeVersionRangeBound(params, valueParam, typeParam) { + const hasValue = hasParam(params, valueParam); + const hasType = hasParam(params, typeParam); + if (hasValue !== hasType) throwInvalidVirtualMatchStringRange(); + if (!hasValue) return {}; + + const value = params[valueParam]; + const type = params[typeParam]; + if (typeof value !== 'string' || !value.trim() || typeof type !== 'string') { + throwInvalidVirtualMatchStringRange(); + } + + const normalizedType = type.toLowerCase(); + if (!versionRangeTypes.has(normalizedType)) throwInvalidVirtualMatchStringRange(); + + return { + type: normalizedType, + value }; } @@ -248,6 +285,18 @@ function isTrueValue(value) { return value === true || String(value).toLowerCase() === 'true'; } +function hasParam(params, param) { + return Object.prototype.hasOwnProperty.call(params ?? {}, param) && params[param] !== undefined; +} + +function throwInvalidQueryValue(param) { + throw new TypeError(`Invalid ${param} value reached query construction.`); +} + +function throwInvalidVirtualMatchStringRange() { + throw new TypeError('Invalid virtualMatchString version range reached query construction.'); +} + function addWildCard(searchString) { const terms = String(searchString ?? '') .trim() @@ -403,7 +452,7 @@ function buildNoRejectedFilter(fieldSpec) { function buildCpeNameFilter(cpeName) { const parsedCpe = cpe.parseCpeName(cpeName); - if (!parsedCpe.valid) return null; + if (!parsedCpe.valid) throwInvalidQueryValue('cpeName'); const fields = constants.CPE_MATCH_FIELDS; const criteriaKeywordField = `${fields.criteria}.keyword`; @@ -422,7 +471,7 @@ function buildCpeNameFilter(cpeName) { function buildVirtualMatchStringFilter(virtualMatchString) { const parsedCpe = cpe.parseVirtualMatchString(virtualMatchString); - if (!parsedCpe.valid) return null; + if (!parsedCpe.valid) throwInvalidQueryValue('virtualMatchString'); const criteriaKeywordField = `${constants.CPE_MATCH_FIELDS.criteria}.keyword`; if (cpe.hasConcreteLeadingComponents(parsedCpe, 3) && !cpe.hasConcreteComponentsAfter(parsedCpe, 2)) { @@ -482,9 +531,7 @@ function buildCpeCriteriaRegexpFilter(field, value) { function buildCvssRegexpFilter(inputVectorString, param, fieldSpec) { const parsed = cvss.parseCvssMetrics(inputVectorString, param); - if (!parsed.valid) { - throw new TypeError(`Invalid ${param} value reached query construction.`); - } + if (!parsed.valid) throwInvalidQueryValue(param); let fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; if (parsed.version) { diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index ff7cfa1..71e580b 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -228,6 +228,98 @@ describe('searchQueryBuilder buildSearchRequest()', () => { expect(output.queryStringObjects).toStrictEqual([]); }); + test.each([ + ['cpeName', { cpeName: 'not-a-cpe-name' }, 'Invalid cpeName value reached query construction.'], + [ + 'virtualMatchString', + { virtualMatchString: 'not-a-cpe-match-string' }, + 'Invalid virtualMatchString value reached query construction.' + ] + ])('buildSearchRequest fails closed when invalid %s bypasses middleware validation', (_param, params, message) => { + expect(() => queryBuilder.buildSearchRequest(params)).toThrow(message); + }); + + test.each([ + ['missing virtualMatchString', { versionStart: '1.0', versionStartType: 'including' }], + [ + 'invalid virtualMatchString', + { + virtualMatchString: 'not-a-cpe-match-string', + versionStart: '1.0', + versionStartType: 'including' + } + ], + [ + 'version-bearing virtualMatchString', + { + virtualMatchString: 'cpe:2.3:a:vendor:product:*', + versionStart: '1.0', + versionStartType: 'including' + } + ], + ['unpaired bound', { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0' }], + [ + 'empty bound', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '', + versionStartType: 'including' + } + ], + [ + 'invalid bound type', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '1.0', + versionStartType: 'inclusive' + } + ], + [ + 'reversed bounds', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '2.0', + versionStartType: 'including', + versionEnd: '1.0', + versionEndType: 'including' + } + ], + [ + 'empty equal bounds', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '1.0', + versionStartType: 'including', + versionEnd: '1.0', + versionEndType: 'excluding' + } + ] + ])('buildSearchRequest fails closed for a virtualMatchString range with %s', (_caseName, params) => { + expect(() => queryBuilder.buildSearchRequest(params)).toThrow( + 'Invalid virtualMatchString version range reached query construction.' + ); + }); + + test.each([ + ['start', { versionStart: '1.0', versionStartType: 'INCLUDING' }], + ['end', { versionEnd: '2.0', versionEndType: 'EXCLUDING' }] + ])('buildSearchRequest preserves a valid one-sided virtualMatchString %s range', (_bound, rangeParams) => { + const output = queryBuilder.buildSearchRequest({ + virtualMatchString: 'cpe:2.3:a:vendor:product', + ...rangeParams + }); + + expect(output.postFilters.virtualMatchStringRange).toMatchObject({ + matchString: expect.objectContaining({ + product: 'product' + }), + versionStart: rangeParams.versionStart, + versionStartType: rangeParams.versionStartType?.toLowerCase(), + versionEnd: rangeParams.versionEnd, + versionEndType: rangeParams.versionEndType?.toLowerCase() + }); + }); + test('buildCpeCandidateSearchBody limits source fields for semantic post-filtering', () => { const output = queryBuilder.buildSearchRequest({ keywordSearch: 'kernel', @@ -608,6 +700,15 @@ describe('searchQueryBuilder getFilterFields() function', () => { expect(JSON.stringify(output)).not.toContain('.keyword":{"gt"'); }); + test.each([ + ['cpeName', { cpeName: 'not-a-cpe-name' }], + ['virtualMatchString', { virtualMatchString: 'not-a-cpe-match-string' }] + ])('getFilterFields() fails closed when invalid %s reaches query construction', (param, params) => { + expect(() => queryBuilder.getFilterFields(params)).toThrow( + `Invalid ${param} value reached query construction.` + ); + }); + test('getFilterFields() builds combined cvssV3 severity filters across v3.0 and v3.1.', () => { const output = queryBuilder.getFilterFields({ cvssV3Severity: 'critical' diff --git a/utils/searchResponseValidator.js b/utils/searchResponseValidator.js index 47a4acc..8e6668b 100644 --- a/utils/searchResponseValidator.js +++ b/utils/searchResponseValidator.js @@ -1,4 +1,5 @@ const OPEN_SEARCH_RESPONSE_FAILURE_TYPES = Object.freeze({ + INCOMPLETE_PAGE_FETCH: 'incomplete_page_fetch', MALFORMED_CANDIDATE: 'malformed_candidate', MALFORMED_RESPONSE: 'malformed_response', PARTIAL_SHARDS: 'partial_shards', @@ -36,20 +37,16 @@ function validateOpenSearchResponse(body, { operation } = {}) { throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.TIMEOUT); } - if (body._shards !== undefined) { - if (!isObject(body._shards)) { - throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_RESPONSE); - } + if (body.timed_out !== false || !isObject(body._shards)) { + throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_RESPONSE); + } - if (body._shards.failed !== undefined) { - const failedShards = Number(body._shards.failed); - if (!Number.isInteger(failedShards) || failedShards < 0) { - throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_RESPONSE); - } - if (failedShards > 0) { - throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.PARTIAL_SHARDS); - } - } + const failedShards = body._shards.failed; + if (!Number.isInteger(failedShards) || failedShards < 0) { + throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.MALFORMED_RESPONSE); + } + if (failedShards > 0) { + throwOpenSearchResponseError(OPEN_SEARCH_RESPONSE_FAILURE_TYPES.PARTIAL_SHARDS); } if (!isObject(body.hits) || !Array.isArray(body.hits.hits)) {