diff --git a/README.md b/README.md index 7bf26a2..999bc58 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@ Rename `.env.example` to `.env` and replace or check the following values. - `SearchOpenSearchRequestTimeoutMs` - optional timeout for each OpenSearch request, default `20000` - `SearchRequestDeadlineMs` - optional overall `/search` processing deadline, default `30000` - `SearchReadinessTimeoutMs` - optional timeout for the OpenSearch index readiness check, default `5000` -- `SearchLogPerformanceTimings` - optional aggregate `/search` performance telemetry flag, default `false` -- `SearchPerformanceTelemetryIntervalMs` - optional telemetry aggregation interval, default `10000` and minimum `1000` - `RequestLogSuccessSampleRate` - optional fraction of successful non-health-check access logs to retain, default `0.1` - `RequestLogHealthChecks` - optional flag to retain successful health-check access logs, default `false` - `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` @@ -152,7 +150,7 @@ curl -X POST "http://localhost:3000/search" \ -d '{"noRejected":true}' ``` -When filtering with `cpeName`, provide a CPE 2.3 name with non-wildcard `part`, `vendor`, `product`, and `version` components. Invalid `cpeName` values return HTTP 404. The API compares the provided CPE Name against CPE Match Criteria, including exact match strings and version range match strings when the indexed CVE data includes range fields. Use `virtualMatchString` when you intentionally need to search for partial CPE Match String values without those required components. +When filtering with `cpeName`, provide a CPE 2.3 name beginning with `cpe:2.3` and containing concrete `part`, `vendor`, `product`, and `version` components; `part` must be `a`, `h`, or `o`. Empty components, malformed escaping, and unsupported part values return HTTP 400. The API compares the provided CPE Name against CPE Match Criteria, including exact match strings and version range match strings when the indexed CVE data includes range fields. Use `virtualMatchString` when you intentionally need to search for partial CPE Match String values without those required components. ### CPE Name Search @@ -182,7 +180,9 @@ Set `isVulnerable` to `true` with `cpeName` to require the matching CPE Match Cr ### Virtual Match String Search -Use `virtualMatchString` when a user needs CPE Match Criteria compatibility matching instead of concrete asset matching. String literal components are compared without regard to lexical case. Omitted trailing components and `*` components act as broad matches. +Use `virtualMatchString` when a user needs CPE Match Criteria compatibility matching instead of concrete asset matching. It must begin with `cpe:2.3` and include at least a valid `part` component: `a`, `h`, `o`, `*`, or `-`. A bare `cpe:2.3` prefix, empty components, malformed escaping, and unsupported part values return HTTP 400. String literal components are compared without regard to lexical case. Omitted trailing components and `*` components act as broad matches. + +Successful searches can include CPE interpretation warnings before the `records` array. `CPE_COMPONENTS_DEFAULTED` identifies trailing `cpeName` components that were omitted and treated as wildcards. `BROAD_VIRTUAL_MATCH_STRING` identifies wildcard or omitted leading `virtualMatchString` components that broadened the search. Invalid CPE strings are rejected rather than converted into warnings. Partial product example: @@ -216,7 +216,7 @@ CPE version comparisons used by both `cpeName` applicability and `virtualMatchSt Numeric segments used in version range comparisons ignore leading zeros. A stored exclusive upper bound of `10.05.1` therefore includes `10.5.0`, and CVE-2026-33280's exclusive `6.02` upper bound includes `6.1` but excludes `6.2`. This normalization applies only to range comparison. Exact CPE Match Criteria preserve leading zeros, so the CVE-2025-9392 versions `1.0.04.001` and `1.0.4.1` are not equivalent. This comparison rule is not a universal firmware-versioning rule and can differ from a vendor's actual release sequence. -Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring concrete leading components, such as `part` and `vendor` for normal searches and `part`, `vendor`, and `product` for version range searches, would reduce the most expensive public search shapes. Narrowing the accepted searches should be handled as a policy decision before implementation. +Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring a concrete `part` and `vendor` for normal searches and a concrete `part`, `vendor`, and `product` for version range searches would reduce the most expensive public search shapes. Narrowing the accepted searches beyond the current valid-part requirement should be handled as a policy decision before implementation. `POST /webSearch` is a supported raw-query endpoint. It is enabled by default and can be disabled without a code change by setting `WebSearchEnabled=false` and restarting the service. When disabled, it returns HTTP `410` with the stable error code `WEB_SEARCH_DISABLED` before validating the request or contacting OpenSearch. @@ -278,12 +278,6 @@ Every request receives an `X-Request-ID` response header. A caller-provided `X-R Access logs omit query strings and request bodies. By default, the API retains 10% of successful non-health-check access logs, suppresses successful `/api/health-check` and `/api/ready-check` access logs, and retains every HTTP 4xx or 5xx access log. Set `RequestLogSuccessSampleRate` from `0` through `1` to change successful-request sampling. Set `RequestLogHealthChecks=true` to retain successful health-check logs. -Set `SearchLogPerformanceTimings=true` during controlled performance diagnostics to emit one structured `search_performance_summary` JSON event per active aggregation interval. The interval defaults to 10 seconds and can be changed with `SearchPerformanceTelemetryIntervalMs`; values below 1 second fall back to the default. Telemetry is disabled by default and does not record request bodies, filter values, credentials, or returned records. - -Each event reports process memory and CPU, event-loop delay and utilization, searches started and completed during the interval, searches still active at its end, maximum in-flight searches, and measurements grouped by search type. Starts and completions are separate because a search can cross an interval boundary. `processCpu.percentOfOneCore` measures the process's CPU time relative to one fully occupied core and can exceed 100 when native work uses multiple cores. Direct searches report `standardOpenSearchMs`. Semantic searches report `candidateOpenSearchMs`, `candidateCount`, `postFilterMs`, `matchedCandidateCount`, and `pageFetchOpenSearchMs` when a page fetch is required. The corresponding `standardOpenSearchTookMs`, `candidateOpenSearchTookMs`, and `pageFetchOpenSearchTookMs` measurements contain OpenSearch's server-reported `took` values, allowing them to be compared with API wall-clock durations. Every completed search reports `totalRequestMs` and an outcome such as `success`, `timeout`, `client_disconnected`, `opensearch_unavailable`, or `upstream_error`. Each measurement includes its count, sampled count, average, minimum, p50, p95, p99, and maximum for the interval. Average, minimum, and maximum use every observation; percentile calculation uses a bounded reservoir of at most 2,000 observations per measurement and interval. - -Use these fields together when diagnosing load behavior. If an OpenSearch wall-clock measurement and its corresponding `TookMs` measurement rise together, execution or queueing inside OpenSearch is contributing to latency. If wall time rises while `TookMs` remains stable, transport or application-side waiting is contributing. Growth in `postFilterMs` and event-loop delay indicates application-side semantic processing pressure; growth in `activeRequests` with stage timings stable indicates waiting elsewhere in the request lifecycle. Summaries describe one Node process, so aggregate events from every ECS task log stream before comparing `startedRequests` with the external load generator's delivered request count. Correlate each event's timestamps with load-balancer, ECS, and OpenSearch metrics, then disable telemetry after the controlled run. - Each `/search` request owns a lifecycle cancellation signal. When its overall deadline expires, the API aborts the active OpenSearch transport request before returning HTTP 504. When the caller disconnects, the API aborts work that is needed only by that caller and does not attempt to write a response. This prevents timed-out or abandoned standard searches from continuing until the transport timeout. ### Semantic Post-Filtering @@ -299,7 +293,7 @@ The reported total and pagination are complete only within that bounded candidat `/search` orders search results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pagination.pageSize` preserves the requested `resultsPerPage` value, while `pagination.resultsReturned` reports the number of records actually returned and may be smaller for a partial or out-of-range page. A stable sort does not provide a point-in-time snapshot, so index updates between separate page requests can still shift results until PIT-based pagination is implemented. -The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and durable metrics collection must be supplied by the deployment infrastructure. The optional aggregate telemetry described above writes diagnostic events to standard output for collection by the deployment logging system. +The API does not cache, coalesce, queue, rate-limit, or aggregate performance telemetry in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and durable metrics collection must be supplied by the deployment infrastructure. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed the 20-second default timeout. The same response is returned when a `/search` request exceeds the 30-second default overall deadline, and the active OpenSearch request is aborted. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index fa61922..8dd63a8 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -52,16 +52,6 @@ } } }, - "404": { - "description": "Invalid cpeName value", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" - } - } - } - }, "415": { "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding", "content": { @@ -423,6 +413,26 @@ "items": { "type": "string" } + }, + "components": { + "type": "array", + "description": "CPE component names associated with this warning, when applicable.", + "items": { + "type": "string", + "enum": [ + "part", + "vendor", + "product", + "version", + "update", + "edition", + "language", + "sw_edition", + "target_sw", + "target_hw", + "other" + ] + } } } }, @@ -709,7 +719,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "oneOf": [ @@ -798,7 +808,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:o:microsoft:windows_10_1809", - "description": "Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component." + "description": "Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. The value must begin with cpe:2.3 and include a valid part component: a, h, o, *, or -. A bare prefix, empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches; wildcard or omitted leading components produce a BROAD_VIRTUAL_MATCH_STRING response warning. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component." }, "ssvcExploitation": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 999dd86..12e4b51 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -4,6 +4,7 @@ const path = require('path'); const openapi = require('./openapi.json'); const packageInfo = require('../package.json'); const constants = require('../utils/constants'); +const cpe = require('../utils/cpe'); const { generateOpenApi } = require('../routes/swagger'); function jsonRequestBodySchema(path, method) { @@ -105,6 +106,8 @@ describe('OpenAPI contract', () => { expect(schema.properties.isVulnerable.description).toContain('same CPE Match Criteria entry'); expect(schema.properties.isVulnerable.description).toContain('False or omitted'); expect(schema.properties.cpeName.description).toContain('Maven-style ordering'); + expect(schema.properties.cpeName.description).toContain('HTTP 400'); + expect(schema.properties.cpeName.description).toContain('CPE_COMPONENTS_DEFAULTED'); expect(schema.properties.cpeName.description).toContain( 'Every concrete non-version component must be compatible with the same CPE Match Criteria entry' ); @@ -132,6 +135,8 @@ describe('OpenAPI contract', () => { expect(schema.properties.virtualMatchString.description).toContain('CPE Match Criteria'); expect(schema.properties.virtualMatchString.description).toContain('compatible'); expect(schema.properties.virtualMatchString.description).toContain('insensitive to lexical case'); + expect(schema.properties.virtualMatchString.description).toContain('valid part component'); + expect(schema.properties.virtualMatchString.description).toContain('BROAD_VIRTUAL_MATCH_STRING'); expect(schema.properties.virtualMatchString.description).toContain('without a version component'); expect(schema.properties.virtualMatchString.example).toBe('cpe:2.3:o:microsoft:windows_10_1809'); expect(Object.keys(mediaType.examples)).toStrictEqual([ @@ -170,10 +175,10 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); expect(cpeName.description).toContain('logical value NA'); expect(cpeName.description).toContain('numeric range boundaries'); - expect(cpeName.description).toContain('HTTP 404'); + expect(cpeName.description).toContain('HTTP 400'); expect(cpeName.description).toContain('virtualMatchString'); expect(cpeName.example).toBe('cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*'); - expect(openapi.paths['/search'].post.responses['404'].description).toBe('Invalid cpeName value'); + expect(openapi.paths['/search'].post.responses['404']).toBeUndefined(); expect(openapi.paths['/search'].post.responses['429']).toBeUndefined(); }); @@ -211,6 +216,7 @@ describe('OpenAPI contract', () => { expect(schemas.SearchWarning.additionalProperties).toBe(false); expect(schemas.SearchWarning.required).toStrictEqual(['code', 'message']); expect(schemas.SearchWarning.properties.cveIds.items.type).toBe('string'); + expect(schemas.SearchWarning.properties.components.items.enum).toStrictEqual(cpe.CPE_COMPONENT_NAMES); expect(schemas.SearchPagination.additionalProperties).toBe(false); expect(schemas.SearchPagination.required).toStrictEqual([ 'page', diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 61a7e76..4eb1457 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -13,9 +13,7 @@ "allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts", "openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs", "requestDeadlineMs": "SearchRequestDeadlineMs", - "readinessTimeoutMs": "SearchReadinessTimeoutMs", - "performanceTelemetryEnabled": "SearchLogPerformanceTimings", - "performanceTelemetryIntervalMs": "SearchPerformanceTelemetryIntervalMs" + "readinessTimeoutMs": "SearchReadinessTimeoutMs" }, "requestLogging": { "successSampleRate": "RequestLogSuccessSampleRate", diff --git a/config/default.jsonc b/config/default.jsonc index 4870cb2..45d6a0a 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -17,11 +17,7 @@ // Limits total processing time for a /search request. "requestDeadlineMs": "30000", // Limits the minimal OpenSearch request used by /api/ready-check. - "readinessTimeoutMs": "5000", - // Emits aggregate search-stage timing summaries for controlled diagnostics. - "performanceTelemetryEnabled": "FALSE", - // Controls how often enabled performance summaries are emitted. - "performanceTelemetryIntervalMs": "10000" + "readinessTimeoutMs": "5000" }, // Access logging settings. Error responses are always logged. "requestLogging": { diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index 214b632..c94173e 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -91,10 +91,6 @@ describe('runtime container and compose configuration', () => { ); expect(customEnvironmentVariables).toContain('"requestDeadlineMs": "SearchRequestDeadlineMs"'); expect(customEnvironmentVariables).toContain('"readinessTimeoutMs": "SearchReadinessTimeoutMs"'); - expect(customEnvironmentVariables).toContain('"performanceTelemetryEnabled": "SearchLogPerformanceTimings"'); - expect(customEnvironmentVariables).toContain( - '"performanceTelemetryIntervalMs": "SearchPerformanceTelemetryIntervalMs"' - ); expect(customEnvironmentVariables).toContain('"successSampleRate": "RequestLogSuccessSampleRate"'); expect(customEnvironmentVariables).toContain('"healthChecks": "RequestLogHealthChecks"'); expect(customEnvironmentVariables).toContain('"webSearchEnabled": "WebSearchEnabled"'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 93aa66b..bc06ade 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -197,6 +197,18 @@ function validateParams(req, res, next) { } } + const parsedCpeName = params.cpeName ? cpe.parseCpeName(params.cpeName) : null; + if (parsedCpeName && !parsedCpeName.valid) { + return res.status(400).json(error.invalidCpeNameError(parsedCpeName.reason)); + } + + const parsedVirtualMatchString = params.virtualMatchString + ? cpe.parseVirtualMatchString(params.virtualMatchString) + : null; + if (parsedVirtualMatchString && !parsedVirtualMatchString.valid) { + return res.status(400).json(error.virtualMatchStringFormatError(parsedVirtualMatchString.reason)); + } + if ( typeof params.referenceURL === 'string' && params.referenceURL.trim().length < constants.MIN_REFERENCE_URL_LENGTH @@ -273,12 +285,7 @@ function validateParams(req, res, next) { } } - const virtualMatchString = cpe.parseVirtualMatchString(params.virtualMatchString); - if (!virtualMatchString.valid) { - return res.status(400).json(error.virtualMatchStringVersionRangeFormatError()); - } - - if (virtualMatchString.hasVersionComponent) { + if (parsedVirtualMatchString.hasVersionComponent) { return res.status(400).json(error.virtualMatchStringVersionComponentError()); } @@ -326,23 +333,11 @@ function validateParams(req, res, next) { return res.status(400).json(error.virtualMatchVulnError()); } - if (params?.virtualMatchString) { - const virtualMatchString = cpe.parseVirtualMatchString(params.virtualMatchString); - if (!virtualMatchString.valid) { - return res.status(400).json(error.virtualMatchStringFormatError()); - } - } - //Ensures cpeName is specified if isVulnerable is true if (isTrueParam(params?.isVulnerable) && !params.cpeName) { return res.status(400).json(error.missingCpeNameError()); } - //Ensures cpeName contains the required concrete CPE components - if (params?.cpeName && !cpe.isValidCpeName(params.cpeName)) { - return res.status(404).json(error.invalidCpeNameError()); - } - //Ensures that resultsPerPage is a positive integer if ( hasParam(params, 'resultsPerPage') && diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 5619309..037e98a 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -92,21 +92,39 @@ describe('middleware validation function', () => { }); test.each([ - ['malformed CPE name', 'not-a-cpe'], - ['wildcard part', 'cpe:2.3:*:vendor:product:1.0:*:*:*:*:*:*:*'], - ['wildcard vendor', 'cpe:2.3:a:*:product:1.0:*:*:*:*:*:*:*'], - ['wildcard product', 'cpe:2.3:a:vendor:*:1.0:*:*:*:*:*:*:*'], - ['wildcard version', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*'] - ])('ValidateParams returns an error when cpeName has %s.', (_caseName, cpeName) => { + ['malformed format', 'not-a-cpe', 'The cpeName parameter must begin with cpe:2.3.'], + [ + 'wildcard part', + 'cpe:2.3:*:vendor:product:1.0:*:*:*:*:*:*:*', + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + ], + [ + 'wildcard vendor', + 'cpe:2.3:a:*:product:1.0:*:*:*:*:*:*:*', + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + ], + [ + 'wildcard product', + 'cpe:2.3:a:vendor:*:1.0:*:*:*:*:*:*:*', + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + ], + [ + 'wildcard version', + 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + ], + ['unsupported part', 'cpe:2.3:x:vendor:product:1.0', 'The cpeName part component must be one of a, h, or o.'], + [ + 'empty component', + 'cpe:2.3:a:vendor::1.0', + 'The cpeName parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.' + ] + ])('ValidateParams returns an error when cpeName has %s.', (_caseName, cpeName, message) => { validate({ cpeName }); - expectValidationError( - 'INVALID_CPE_NAME', - 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.', - 404 - ); + expectValidationError('INVALID_CPE_NAME', message); }); test('ValidateParams accepts an incomplete cpeName when required components are present', () => { @@ -120,7 +138,7 @@ describe('middleware validation function', () => { test('ValidateParams returns an error when isVulnerable is true with virtualMatchString', () => { validate({ isVulnerable: 'true', - virtualMatchString: 'test' + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' }); expectValidationError( @@ -145,10 +163,38 @@ describe('middleware validation function', () => { expectValidationError( 'VIRTUAL_MATCH_STRING_FORMAT', - 'The virtualMatchString parameter must be a CPE 2.3 match string.' + 'The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.' ); }); + test.each([ + [ + 'a missing part', + 'cpe:2.3', + 'The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.' + ], + ['an unsupported part', 'cpe:2.3:x', 'The virtualMatchString part component must be one of a, h, o, *, or -.'], + [ + 'an empty component', + 'cpe:2.3:o::linux_kernel', + 'The virtualMatchString parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.' + ], + [ + 'unescaped whitespace', + 'cpe:2.3:o:linux foundation:linux_kernel', + 'The virtualMatchString parameter cannot contain unescaped whitespace.' + ], + [ + 'a dangling escape', + 'cpe:2.3:o:linux:linux_kernel\\', + 'The virtualMatchString parameter contains a malformed CPE escape sequence.' + ] + ])('ValidateParams returns an actionable error for virtualMatchString with %s.', (_caseName, value, message) => { + validate({ virtualMatchString: value }); + + expectValidationError('VIRTUAL_MATCH_STRING_FORMAT', message); + }); + test('ValidateParams accepts broad virtualMatchString values', () => { validate({ virtualMatchString: 'cpe:2.3:o:*:windows_10_1809' @@ -339,7 +385,7 @@ describe('middleware validation function', () => { expectValidationError('VERSION_RANGE_TYPE', `The ${param} parameter must be including or excluding.`); }); - test('ValidateParams returns an error when version range virtualMatchString is not a partial CPE 2.3 match string', () => { + test('ValidateParams returns a format error when a version range virtualMatchString is malformed', () => { validate({ virtualMatchString: 'linux kernel', versionStart: '2.2', @@ -347,8 +393,8 @@ describe('middleware validation function', () => { }); expectValidationError( - 'VIRTUAL_MATCH_STRING_RANGE_FORMAT', - 'The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.' + 'VIRTUAL_MATCH_STRING_FORMAT', + 'The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.' ); }); diff --git a/controllers/searchController.js b/controllers/searchController.js index 3d503dc..2149a56 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -5,7 +5,6 @@ const searchDeadline = require('../utils/searchDeadline'); const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); -const searchPerformanceTelemetry = require('../utils/searchPerformanceTelemetry'); const searchResponseBuilder = require('../utils/searchResponseBuilder'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); @@ -96,10 +95,9 @@ async function executeOpenSearch(body, signal) { return searchProvider.searchOpenSearch(body, { signal }); } -async function executeStandardSearch(searchOptions, queryStringObjects, signal, telemetry) { +async function executeStandardSearch(searchOptions, queryStringObjects, signal) { const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const searchData = await telemetry.measureAsync('standardOpenSearchMs', () => executeOpenSearch(body, signal)); - telemetry.recordValue('standardOpenSearchTookMs', searchData?.took); + const searchData = await executeOpenSearch(body, signal); return { totalResults: searchData.hits.total.value, @@ -109,34 +107,23 @@ async function executeStandardSearch(searchOptions, queryStringObjects, signal, }; } -async function fetchFullHitsByCveIds(cveIds, signal, telemetry) { - if (!cveIds.length) return { hits: [], took: undefined }; +async function fetchFullHitsByCveIds(cveIds, signal) { + if (!cveIds.length) return []; const { searchOptions, queryStringObjects } = searchQueryBuilder.buildCveIdsSearchRequest(cveIds); const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const pageData = await telemetry.measureAsync('pageFetchOpenSearchMs', () => executeOpenSearch(body, signal)); - telemetry.recordValue('pageFetchOpenSearchTookMs', pageData?.took); + const pageData = await executeOpenSearch(body, signal); - return { - hits: orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds), - took: pageData?.took - }; + return orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds); } -async function buildPostFilterCandidateResult(candidateBody, postFilters, signal, telemetry) { - const candidateData = await telemetry.measureAsync('candidateOpenSearchMs', () => - executeOpenSearch(candidateBody, signal) - ); - telemetry.recordValue('candidateOpenSearchTookMs', candidateData?.took); +async function buildPostFilterCandidateResult(candidateBody, postFilters, signal) { + const candidateData = await executeOpenSearch(candidateBody, signal); const candidateResp = { data: candidateData }; - const { candidateCount, filteredHits } = telemetry.measureSync('postFilterMs', () => - searchPostFilters.filterSearchHits(candidateResp, postFilters) - ); - telemetry.recordValue('candidateCount', candidateCount); - telemetry.recordValue('matchedCandidateCount', filteredHits.length); + const { candidateCount, filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); return { candidateCount, @@ -145,41 +132,34 @@ async function buildPostFilterCandidateResult(candidateBody, postFilters, signal }; } -async function executePostFilterSearch( - searchOptions, - queryStringObjects, - postFilters, - responseOptions, - signal, - telemetry -) { +async function executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { const candidateBody = searchQueryBuilder.buildPostFilterCandidateSearchBody( searchOptions, queryStringObjects, postFilters ); - const candidateResult = await buildPostFilterCandidateResult(candidateBody, postFilters, signal, telemetry); + const candidateResult = await buildPostFilterCandidateResult(candidateBody, postFilters, signal); searchDeadline.throwIfAborted(signal); const pageCveIds = paginateHits(candidateResult.filteredCveIds, responseOptions); const filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; - const pageResult = await fetchFullHitsByCveIds(pageCveIds, signal, telemetry); + const pageHits = await fetchFullHitsByCveIds(pageCveIds, signal); const candidateLimitReached = candidateResult.candidateCount >= constants.MAX_SEARCH_WINDOW; return { totalResults: filteredCount, - hits: pageResult.hits, + hits: pageHits, totalIsExact: !candidateLimitReached, warnings: candidateLimitReached ? [searchResponseBuilder.semanticCandidateLimitWarning()] : [] }; } -async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal, telemetry) { +async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { if (!searchPostFilters.hasSearchPostFilters(postFilters)) { - return executeStandardSearch(searchOptions, queryStringObjects, signal, telemetry); + return executeStandardSearch(searchOptions, queryStringObjects, signal); } - return executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal, telemetry); + return executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal); } async function searchHandler(req, res, next) { @@ -189,27 +169,21 @@ async function searchHandler(req, res, next) { res, searchProvider.getSearchRequestDeadlineMs() ); - const telemetry = searchPerformanceTelemetry.startSearchTelemetry(); - let telemetryOutcome = 'success'; - try { const searchRequest = searchQueryBuilder.buildSearchRequest(params); const searchOptions = searchRequest.searchOptions; const postFilters = searchRequest.postFilters; const responseOptions = searchRequest.responseOptions; - telemetry.setSearchType(postFilters); - const searchResult = await executeSearch( searchOptions, searchRequest.queryStringObjects, postFilters, responseOptions, - lifecycle.signal, - telemetry + lifecycle.signal ); lifecycle.throwIfAborted(); const pagingOptions = responseOptions ?? searchOptions; - const warnings = [...searchResponseBuilder.getCveIdWarnings(params.cveIds), ...searchResult.warnings]; + const warnings = [...searchResponseBuilder.getRequestWarnings(params), ...searchResult.warnings]; return res.status(200).json( searchResponseBuilder.buildSearchResponse({ @@ -220,26 +194,21 @@ async function searchHandler(req, res, next) { ); } catch (err) { if (searchDeadline.isSearchClientDisconnectedError(err)) { - telemetryOutcome = 'client_disconnected'; return; } if (searchDeadline.isSearchTimeoutError(err)) { - telemetryOutcome = 'timeout'; return res.status(504).json(error.searchTimeoutError()); } if (isOpenSearchUnavailableError(err)) { - telemetryOutcome = 'opensearch_unavailable'; if (typeof res.set === 'function') res.set('Retry-After', '5'); return res.status(503).json(error.searchUnavailableError()); } - telemetryOutcome = 'upstream_error'; return res.status(502).json(error.upstreamSearchError()); } finally { - telemetry.complete(telemetryOutcome); lifecycle.complete(); } } diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index b53c3e7..ae242dd 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -13,15 +13,6 @@ const mockIsSearchProviderContractError = jest.fn((err) => err?.code === 'SEARCH const mockGetBasicSearchManager = jest.fn(() => ({ search: mockBasicSearch })); -const mockSearchTelemetry = { - complete: jest.fn(), - measureAsync: jest.fn((_name, operation) => operation()), - measureSync: jest.fn((_name, operation) => operation()), - recordValue: jest.fn(), - setSearchType: jest.fn() -}; -const mockStartSearchTelemetry = jest.fn(() => mockSearchTelemetry); - jest.mock('cve-core', () => { return { packageInfo: { @@ -37,10 +28,6 @@ jest.mock('../utils/searchProvider', () => ({ searchOpenSearch: mockSearchOpenSearch })); -jest.mock('../utils/searchPerformanceTelemetry', () => ({ - startSearchTelemetry: mockStartSearchTelemetry -})); - const controller = require('./searchController.js'); const constants = require('../utils/constants'); const searchPostFilters = require('../utils/searchPostFilters'); @@ -170,31 +157,12 @@ describe('Search controller SEARCH handler', () => { mockGetSearchRequestDeadlineMs.mockClear(); mockIsSearchProviderContractError.mockClear(); mockGetSearchRequestDeadlineMs.mockReturnValue(30000); - mockStartSearchTelemetry.mockClear(); - Object.values(mockSearchTelemetry).forEach((mock) => mock.mockClear()); - mockSearchTelemetry.measureAsync.mockImplementation((_name, operation) => operation()); - mockSearchTelemetry.measureSync.mockImplementation((_name, operation) => operation()); - res = createResponse(); next = jest.fn(); }); afterEach(() => jest.restoreAllMocks()); - test('SEARCH records standard OpenSearch and total request telemetry', async () => { - const response = advancedResponse(0); - response.data.took = 12; - mockApiSearch.mockResolvedValue(response); - - await controller.SEARCH({ query: {} }, res, next); - - expect(mockStartSearchTelemetry).toHaveBeenCalledTimes(1); - expect(mockSearchTelemetry.setSearchType).toHaveBeenCalledWith(undefined); - expect(mockSearchTelemetry.measureAsync).toHaveBeenCalledWith('standardOpenSearchMs', expect.any(Function)); - expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('standardOpenSearchTookMs', 12); - expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('success'); - }); - test('SEARCH builds filters, query strings, date ranges, and pagination options', async () => { const hit = { _id: 'CVE-2024-0001', @@ -602,9 +570,7 @@ describe('Search controller SEARCH handler', () => { }; const candidateResponse = rawResponse(2, [applicable, notApplicable]); - candidateResponse.took = 18; const pageResponse = advancedResponse(1, [fullApplicable]); - pageResponse.data.took = 4; mockSearchRaw.mockResolvedValue(candidateResponse); mockApiSearch.mockResolvedValue(pageResponse); @@ -651,21 +617,6 @@ describe('Search controller SEARCH handler', () => { ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(1, [fullApplicable], { pageSize: 1 })); - expect(mockSearchTelemetry.setSearchType).toHaveBeenCalledWith( - expect.objectContaining({ - cpeName: expect.any(Object) - }) - ); - expect(mockSearchTelemetry.measureAsync.mock.calls.map(([name]) => name)).toEqual([ - 'candidateOpenSearchMs', - 'pageFetchOpenSearchMs' - ]); - expect(mockSearchTelemetry.measureSync).toHaveBeenCalledWith('postFilterMs', expect.any(Function)); - expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('candidateCount', 2); - expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('matchedCandidateCount', 1); - expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('candidateOpenSearchTookMs', 18); - expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('pageFetchOpenSearchTookMs', 4); - expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('success'); }); test('SEARCH semantically filters SSVC candidates and fetches full records for the requested page', async () => { @@ -1229,7 +1180,6 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_UNAVAILABLE', message: 'Search service is temporarily unavailable. Please try again later.' }); - expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('opensearch_unavailable'); }); test('SEARCH returns a sanitized 504 when OpenSearch times out', async () => { @@ -1252,7 +1202,6 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_TIMEOUT', message: 'Search request timed out. Please try again later.' }); - expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('timeout'); }); test('SEARCH returns a sanitized 504 when the overall request deadline is exceeded', async () => { @@ -1303,7 +1252,6 @@ describe('Search controller SEARCH handler', () => { expect(res.status).not.toHaveBeenCalled(); expect(res.json).not.toHaveBeenCalled(); - expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('client_disconnected'); }); test('SEARCH does not start a page fetch after a client disconnects from a CPE search', async () => { diff --git a/package-lock.json b/package-lock.json index 5c88041..ae1a9a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "morgan": "~1.11.0", + "morgan": "~1.12.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -4435,9 +4435,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -4809,9 +4809,9 @@ } }, "node_modules/morgan": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", - "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", + "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", diff --git a/package.json b/package.json index 1280649..d394289 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "morgan": "~1.11.0", + "morgan": "~1.12.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -35,7 +35,7 @@ "adm-zip": "0.6.0", "axios": "1.18.0", "brace-expansion": "5.0.9", - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "lodash": "4.18.1", "lodash.unset": "4.18.0", "path-to-regexp": "0.1.13", diff --git a/routes/cpeName.test.js b/routes/cpeName.test.js index 25bd736..18d4b13 100644 --- a/routes/cpeName.test.js +++ b/routes/cpeName.test.js @@ -31,7 +31,7 @@ describe('POST /search cpeName route behavior', () => { } }); - expect(response.status).toBe(404); + expect(response.status).toBe(400); expect(response.body).toStrictEqual({ error: 'INVALID_CPE_NAME', message: diff --git a/routes/index.js b/routes/index.js index ee990a9..28838ef 100644 --- a/routes/index.js +++ b/routes/index.js @@ -309,16 +309,6 @@ content: { } }, } -#swagger.responses[404] = { -description: 'Invalid cpeName value', -content: { - "application/json": { - schema: { - $ref: "#/components/schemas/ApiErrorResponse" - } - } -}, -} #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', content: { diff --git a/routes/swagger.js b/routes/swagger.js index e8ad6ce..8f44195 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -2,6 +2,7 @@ const path = require('path'); const swaggerAutogen = require('swagger-autogen'); const packageInfo = require('../package.json'); const constants = require('../utils/constants'); +const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); const ssvc = require('../utils/ssvc'); @@ -150,7 +151,7 @@ const searchRequestProperties = { } ), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -243,7 +244,7 @@ const searchRequestProperties = { } ), virtualMatchString: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component.', + 'Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. The value must begin with cpe:2.3 and include a valid part component: a, h, o, *, or -. A bare prefix, empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches; wildcard or omitted leading components produce a BROAD_VIRTUAL_MATCH_STRING response warning. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.virtualMatchString, @@ -395,6 +396,14 @@ const doc = { items: { type: 'string' } + }, + components: { + type: 'array', + description: 'CPE component names associated with this warning, when applicable.', + items: { + type: 'string', + enum: cpe.CPE_COMPONENT_NAMES + } } } }, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index e29b423..0b538e5 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -2414,6 +2414,122 @@ }, "response": [] }, + { + "name": "Broad virtualMatchString returns an interpretation warning", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "const expectedWarnings = [{\"code\":\"BROAD_VIRTUAL_MATCH_STRING\",\"message\":\"Wildcard or omitted leading CPE components broaden this search and may cause results to reach the semantic candidate limit.\",\"components\":[\"vendor\",\"product\"]}];", + "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Abbreviated cpeName returns a defaulted-components warning", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "const expectedWarnings = [{\"code\":\"CPE_COMPONENTS_DEFAULTED\",\"message\":\"Omitted trailing cpeName components were treated as wildcard components.\",\"components\":[\"update\",\"edition\",\"language\",\"sw_edition\",\"target_sw\",\"target_hw\",\"other\"]}];", + "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "virtualMatchString with versionStart pair", "event": [ @@ -5864,14 +5980,14 @@ "response": [] }, { - "name": "Invalid cpeName returns 404", + "name": "Invalid cpeName", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ - "pm.test('status is 404', () => pm.response.to.have.status(404));", + "pm.test('status is 400', () => pm.response.to.have.status(400));", "pm.test('X-Request-ID is present', () => {", " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", "});", @@ -5922,7 +6038,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must be a CPE 2.3 match string.\"};", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5951,6 +6067,138 @@ }, "response": [] }, + { + "name": "virtualMatchString requires a part component", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString rejects an unsupported part", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString part component must be one of a, h, o, *, or -.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:x:vendor:product\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cpeName rejects an empty component", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor::1.0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "Version range requires virtualMatchString", "event": [ @@ -6186,7 +6434,7 @@ "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_RANGE_FORMAT\",\"message\":\"The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.\"};", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 8f40601..42c495f 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -580,6 +580,37 @@ const cpeCoverage = folder('06 - CPE filters', 'Concrete CPE and virtual match-s { cveIds: ids.cpe, virtualMatchString: 'cpe:2.3:a:freetype:freetype' }, { exactTotal: 1, expectedIds: [ids.cpe] } ), + positive( + 'Broad virtualMatchString returns an interpretation warning', + { cveIds: ids.cpe, virtualMatchString: 'cpe:2.3:a:*' }, + { + exactTotal: 1, + expectedIds: [ids.cpe], + expectedWarnings: [ + { + code: 'BROAD_VIRTUAL_MATCH_STRING', + message: + 'Wildcard or omitted leading CPE components broaden this search and may cause results to reach the semantic candidate limit.', + components: ['vendor', 'product'] + } + ] + } + ), + positive( + 'Abbreviated cpeName returns a defaulted-components warning', + { cveIds: ids.cpe, cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1' }, + { + exactTotal: 1, + expectedIds: [ids.cpe], + expectedWarnings: [ + { + code: 'CPE_COMPONENTS_DEFAULTED', + message: 'Omitted trailing cpeName components were treated as wildcard components.', + components: ['update', 'edition', 'language', 'sw_edition', 'target_sw', 'target_hw', 'other'] + } + ] + } + ), positive( 'virtualMatchString with versionStart pair', { @@ -1018,19 +1049,27 @@ const cpeAndVersionFailures = folder( 'CPE formats, scope compatibility, and range pair/order rules.', [ ...cpeScopeFailures, - negative( - 'Invalid cpeName returns 404', - { cpeName: 'cpe:2.3:a:vendor:product:*' }, - error.invalidCpeNameError(), - { - status: 404 - } - ), + negative('Invalid cpeName', { cpeName: 'cpe:2.3:a:vendor:product:*' }, error.invalidCpeNameError()), negative( 'Invalid virtualMatchString', { virtualMatchString: 'not-a-cpe' }, error.virtualMatchStringFormatError() ), + negative( + 'virtualMatchString requires a part component', + { virtualMatchString: 'cpe:2.3' }, + error.virtualMatchStringFormatError('component_count') + ), + negative( + 'virtualMatchString rejects an unsupported part', + { virtualMatchString: 'cpe:2.3:x:vendor:product' }, + error.virtualMatchStringFormatError('part') + ), + negative( + 'cpeName rejects an empty component', + { cpeName: 'cpe:2.3:a:vendor::1.0' }, + error.invalidCpeNameError('empty_component') + ), negative( 'Version range requires virtualMatchString', { versionStart: '1.0', versionStartType: 'including' }, @@ -1059,7 +1098,7 @@ const cpeAndVersionFailures = folder( negative( 'Invalid ranged virtualMatchString format', { virtualMatchString: 'not-a-cpe', versionStart: '1.0', versionStartType: 'including' }, - error.virtualMatchStringVersionRangeFormatError() + error.virtualMatchStringFormatError('format') ), negative( 'Ranged virtualMatchString cannot contain version', diff --git a/utils/constants.js b/utils/constants.js index 5799eed..9e138d6 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -9,8 +9,6 @@ const MAX_CVE_ID_LENGTH = 64; const FIRST_CVE_RECORD_YEAR = 1999; const MAX_KEYWORD_SEARCH_TOKENS = 64; const MIN_REFERENCE_URL_LENGTH = 3; -const SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS = 10000; -const SEARCH_PERFORMANCE_TELEMETRY_MIN_INTERVAL_MS = 1000; const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, @@ -134,8 +132,6 @@ module.exports = { FIRST_CVE_RECORD_YEAR, MAX_KEYWORD_SEARCH_TOKENS, MIN_REFERENCE_URL_LENGTH, - SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS, - SEARCH_PERFORMANCE_TELEMETRY_MIN_INTERVAL_MS, SEARCH_STRING_LENGTH_LIMITS, SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, SEARCH_REQUEST_DEADLINE_MS, diff --git a/utils/cpe.js b/utils/cpe.js index b59719a..f6a950c 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -1,7 +1,22 @@ const CPE_23_PREFIX = ['cpe', '2.3']; const CPE_COMPONENT_COUNT = 11; +const CPE_COMPONENT_NAMES = Object.freeze([ + 'part', + 'vendor', + 'product', + 'version', + 'update', + 'edition', + 'language', + 'sw_edition', + 'target_sw', + 'target_hw', + 'other' +]); const CPE_NOT_APPLICABLE = '-'; const REQUIRED_CPE_COMPONENT_INDEXES = [0, 1, 2, 3]; +const CPE_PART_VALUES = new Set(['a', 'h', 'o', '*', CPE_NOT_APPLICABLE]); +const CONCRETE_CPE_PART_VALUES = new Set(['a', 'h', 'o']); const VERSION_QUALIFIERS = ['alpha', 'beta', 'milestone', 'rc', 'snapshot', '', 'sp']; const VERSION_QUALIFIER_ALIASES = { a: 'alpha', @@ -13,21 +28,65 @@ const VERSION_QUALIFIER_ALIASES = { release: '' }; -function parseCpe23(input) { - const value = String(input ?? '') - .trim() - .replaceAll('\\\\', '\\'); +function normalizeCpeInput(input) { + return String(input ?? '').trim(); +} + +function hasDanglingEscape(value) { + let trailingBackslashes = 0; + + for (let index = value.length - 1; index >= 0 && value[index] === '\\'; index--) { + trailingBackslashes++; + } + + return trailingBackslashes % 2 === 1; +} + +function hasUnescapedWhitespace(value) { + for (let index = 0; index < value.length; index++) { + if (!/\s/.test(value[index])) continue; + + let precedingBackslashes = 0; + for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor--) { + precedingBackslashes++; + } + + if (precedingBackslashes % 2 === 0) return true; + } + + return false; +} + +function parseCpeComponents(input, minimumComponentCount) { + const value = normalizeCpeInput(input); const parts = splitOnUnescapedColon(value); if (parts[0] !== CPE_23_PREFIX[0] || parts[1] !== CPE_23_PREFIX[1]) { return { valid: false, reason: 'format' }; } - const components = parts.slice(2); - if (components.length < 4 || components.length > CPE_COMPONENT_COUNT) { + if (hasDanglingEscape(value)) { + return { valid: false, reason: 'escape' }; + } + + if (hasUnescapedWhitespace(value)) { + return { valid: false, reason: 'whitespace' }; + } + + const rawComponents = parts.slice(2); + if (rawComponents.length < minimumComponentCount || rawComponents.length > CPE_COMPONENT_COUNT) { return { valid: false, reason: 'component_count' }; } + if (rawComponents.some((component) => component === '')) { + return { valid: false, reason: 'empty_component' }; + } + + if (!CPE_PART_VALUES.has(rawComponents[0].toLowerCase())) { + return { valid: false, reason: 'part' }; + } + + const components = [...rawComponents]; while (components.length < CPE_COMPONENT_COUNT) { components.push('*'); } @@ -36,6 +95,7 @@ function parseCpe23(input) { valid: true, value, components, + rawComponentCount: rawComponents.length, part: components[0], vendor: components[1], product: components[2], @@ -43,6 +103,10 @@ function parseCpe23(input) { }; } +function parseCpe23(input) { + return parseCpeComponents(input, 4); +} + function splitOnUnescapedColon(s) { const parts = []; let buf = ''; @@ -75,6 +139,7 @@ function parseCpeName(input) { const hasRequiredComponents = REQUIRED_CPE_COMPONENT_INDEXES.every((index) => { const component = parsed.components[index]; + if (index === 0) return CONCRETE_CPE_PART_VALUES.has(component.toLowerCase()); return component && component !== '*'; }); @@ -107,36 +172,25 @@ function getParsedCpeMatchCriteria(input, context) { } function parseVirtualMatchString(input) { - const value = String(input ?? '') - .trim() - .replaceAll('\\\\', '\\'); - const parts = splitOnUnescapedColon(value); + const parsed = parseCpeComponents(input, 1); + if (!parsed.valid) return parsed; - if (parts[0] !== CPE_23_PREFIX[0] || parts[1] !== CPE_23_PREFIX[1]) { - return { valid: false, reason: 'format' }; - } + return { + ...parsed, + hasVersionComponent: parsed.rawComponentCount >= 4 + }; +} - const rawComponents = parts.slice(2); - if (rawComponents.length > CPE_COMPONENT_COUNT) { - return { valid: false, reason: 'component_count' }; - } +function getOmittedComponentNames(parsed) { + if (!parsed?.valid) return []; + return CPE_COMPONENT_NAMES.slice(parsed.rawComponentCount); +} - const hasVersionComponent = rawComponents.length >= 4; - const components = [...rawComponents]; - while (components.length < CPE_COMPONENT_COUNT) { - components.push('*'); - } +function getBroadVirtualMatchStringComponents(parsed) { + const matchString = parsed?.valid ? parsed : parseVirtualMatchString(parsed); + if (!matchString.valid) return []; - return { - valid: true, - value, - components, - hasVersionComponent, - part: components[0], - vendor: components[1], - product: components[2], - version: components[3] - }; + return CPE_COMPONENT_NAMES.slice(0, 3).filter((_name, index) => matchString.components[index] === '*'); } function isValidCpeName(input) { @@ -536,11 +590,14 @@ function hitMatchesVirtualMatchString(hit, virtualMatchString, context) { } module.exports = { + CPE_COMPONENT_NAMES, buildCpeCriteriaRegexp, buildCompatibleCriteriaPrefixes, buildVirtualMatchStringCriteriaRegexp, compareCpeVersions, createEvaluationContext, + getBroadVirtualMatchStringComponents, + getOmittedComponentNames, cpeMatchAppliesToCpeName, cpeMatchMatchesVirtualMatchString, cpeMatchOverlapsVirtualMatchStringRange, diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 0dbce63..a9ba8d2 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -44,6 +44,15 @@ describe('CPE helpers', () => { expect(cpe.isValidCpeName(value)).toBe(false); }); + test.each([ + ['unsupported part', 'cpe:2.3:x:vendor:product:1.0', 'part'], + ['empty component', 'cpe:2.3:a:vendor::1.0', 'empty_component'], + ['unescaped whitespace', 'cpe:2.3:a:test vendor:product:1.0', 'whitespace'], + ['dangling escape', 'cpe:2.3:a:vendor:product:1.0\\', 'escape'] + ])('parseCpeName rejects %s', (_caseName, value, reason) => { + expect(cpe.parseCpeName(value)).toStrictEqual({ valid: false, reason }); + }); + test('parseCpeName preserves escaped colons inside required components', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:ven\\:dor:product\\:edition:1.0:*:*:*:*:*:*:*'); @@ -71,14 +80,46 @@ describe('CPE helpers', () => { expect(parsed.hasVersionComponent).toBe(true); }); - test('parseVirtualMatchString accepts a broad CPE 2.3 prefix-only match string', () => { - const parsed = cpe.parseVirtualMatchString('cpe:2.3'); + test('parseVirtualMatchString requires at least a part component', () => { + expect(cpe.parseVirtualMatchString('cpe:2.3')).toStrictEqual({ + valid: false, + reason: 'component_count' + }); + }); + + test('parseVirtualMatchString accepts a wildcard part', () => { + const parsed = cpe.parseVirtualMatchString('cpe:2.3:*'); expect(parsed.valid).toBe(true); expect(parsed.hasVersionComponent).toBe(false); expect(parsed.components).toStrictEqual(['*', '*', '*', '*', '*', '*', '*', '*', '*', '*', '*']); }); + test.each([ + ['unsupported part', 'cpe:2.3:x:vendor:product', 'part'], + ['empty component', 'cpe:2.3:o::linux_kernel', 'empty_component'], + ['unescaped whitespace', 'cpe:2.3:o:linux foundation:linux_kernel', 'whitespace'], + ['dangling escape', 'cpe:2.3:o:linux:linux_kernel\\', 'escape'] + ])('parseVirtualMatchString rejects %s', (_caseName, value, reason) => { + expect(cpe.parseVirtualMatchString(value)).toStrictEqual({ valid: false, reason }); + }); + + test('reports omitted and broad component names for response warnings', () => { + const cpeName = cpe.parseCpeName('cpe:2.3:o:linux:linux_kernel:6.8'); + const virtualMatchString = cpe.parseVirtualMatchString('cpe:2.3:o'); + + expect(cpe.getOmittedComponentNames(cpeName)).toStrictEqual([ + 'update', + 'edition', + 'language', + 'sw_edition', + 'target_sw', + 'target_hw', + 'other' + ]); + expect(cpe.getBroadVirtualMatchStringComponents(virtualMatchString)).toStrictEqual(['vendor', 'product']); + }); + test('splitOnUnescapedColon preserves escaped colons inside CPE components', () => { expect(cpe.splitOnUnescapedColon('a:vendor:product\\:edition:1.0')).toStrictEqual([ 'a', diff --git a/utils/errors.js b/utils/errors.js index bd168f3..951e14e 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -118,13 +118,6 @@ class errors { return makeError('DATE_RANGE_ORDER', `The ${startParam} parameter must be before or equal to ${endParam}.`); } - virtualMatchStringVersionRangeFormatError() { - return makeError( - 'VIRTUAL_MATCH_STRING_RANGE_FORMAT', - 'The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.' - ); - } - virtualMatchStringVersionComponentError() { return makeError( 'VIRTUAL_MATCH_STRING_VERSION_COMPONENT', @@ -132,10 +125,21 @@ class errors { ); } - virtualMatchStringFormatError() { + virtualMatchStringFormatError(reason) { + const messages = { + component_count: + 'The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.', + empty_component: + 'The virtualMatchString parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.', + escape: 'The virtualMatchString parameter contains a malformed CPE escape sequence.', + part: 'The virtualMatchString part component must be one of a, h, o, *, or -.', + whitespace: 'The virtualMatchString parameter cannot contain unescaped whitespace.' + }; + return makeError( 'VIRTUAL_MATCH_STRING_FORMAT', - 'The virtualMatchString parameter must be a CPE 2.3 match string.' + messages[reason] ?? + 'The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.' ); } @@ -143,10 +147,21 @@ class errors { return makeError('INVALID_PARAM', `${param} is not supported by the Search API.`); } - invalidCpeNameError() { + invalidCpeNameError(reason) { + const messages = { + component_count: 'The cpeName parameter must contain between 4 and 11 CPE components after cpe:2.3.', + empty_component: + 'The cpeName parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.', + escape: 'The cpeName parameter contains a malformed CPE escape sequence.', + format: 'The cpeName parameter must begin with cpe:2.3.', + part: 'The cpeName part component must be one of a, h, or o.', + whitespace: 'The cpeName parameter cannot contain unescaped whitespace.' + }; + return makeError( 'INVALID_CPE_NAME', - 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + messages[reason] ?? + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' ); } diff --git a/utils/errors.test.js b/utils/errors.test.js index a253d13..6c7bfbb 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -117,15 +117,6 @@ describe('error response helpers', () => { 'The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.' } ], - [ - 'virtualMatchStringVersionRangeFormatError', - [], - { - error: 'VIRTUAL_MATCH_STRING_RANGE_FORMAT', - message: - 'The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.' - } - ], [ 'virtualMatchStringVersionComponentError', [], @@ -140,7 +131,7 @@ describe('error response helpers', () => { [], { error: 'VIRTUAL_MATCH_STRING_FORMAT', - message: 'The virtualMatchString parameter must be a CPE 2.3 match string.' + message: 'The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.' } ], [ @@ -399,4 +390,24 @@ describe('error response helpers', () => { ])('%s returns the public API error shape', (methodName, args, expected) => { expect(error[methodName](...args)).toStrictEqual(expected); }); + + test.each([ + [ + 'virtualMatchStringFormatError', + 'component_count', + 'The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.' + ], + [ + 'virtualMatchStringFormatError', + 'part', + 'The virtualMatchString part component must be one of a, h, o, *, or -.' + ], + ['invalidCpeNameError', 'format', 'The cpeName parameter must begin with cpe:2.3.'], + ['invalidCpeNameError', 'part', 'The cpeName part component must be one of a, h, or o.'] + ])('%s returns an actionable CPE validation message for %s', (methodName, reason, message) => { + expect(error[methodName](reason)).toStrictEqual({ + error: methodName === 'invalidCpeNameError' ? 'INVALID_CPE_NAME' : 'VIRTUAL_MATCH_STRING_FORMAT', + message + }); + }); }); diff --git a/utils/searchPerformanceTelemetry.js b/utils/searchPerformanceTelemetry.js deleted file mode 100644 index b00d537..0000000 --- a/utils/searchPerformanceTelemetry.js +++ /dev/null @@ -1,358 +0,0 @@ -const { monitorEventLoopDelay, performance } = require('perf_hooks'); -const { AppConfig } = require('cve-core'); -const constants = require('./constants'); - -const TELEMETRY_EVENT = 'search_performance_summary'; -const MAX_SAMPLES_PER_DISTRIBUTION = 2000; -const TRACKED_MEASUREMENTS = new Set([ - 'candidateCount', - 'candidateOpenSearchMs', - 'candidateOpenSearchTookMs', - 'matchedCandidateCount', - 'pageFetchOpenSearchMs', - 'pageFetchOpenSearchTookMs', - 'postFilterMs', - 'standardOpenSearchMs', - 'standardOpenSearchTookMs', - 'totalRequestMs' -]); -const SEARCH_TYPES = Object.freeze(['cpeName', 'virtualMatchString', 'virtualMatchStringRange', 'cvss', 'ssvc', 'kev']); - -let telemetryConfig; -let telemetryState; -let writeTelemetry = (event) => process.stdout.write(`${JSON.stringify(event)}\n`); - -function normalizeBoolean(value, defaultValue = false) { - if (value === undefined || value === null || value === '') return defaultValue; - if (typeof value === 'boolean') return value; - if (typeof value === 'number') return value !== 0; - - const normalized = String(value).trim().toLowerCase(); - if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; - if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; - - return defaultValue; -} - -function readInteger(path, defaultValue, min) { - if (!AppConfig.has(path)) return defaultValue; - - const value = Number(AppConfig.get(path)); - return Number.isInteger(value) && value >= min ? value : defaultValue; -} - -function getTelemetryConfig() { - if (!telemetryConfig) { - const enabled = AppConfig.has('search.performanceTelemetryEnabled') - ? normalizeBoolean(AppConfig.get('search.performanceTelemetryEnabled')) - : false; - const intervalMs = readInteger( - 'search.performanceTelemetryIntervalMs', - constants.SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS, - constants.SEARCH_PERFORMANCE_TELEMETRY_MIN_INTERVAL_MS - ); - - telemetryConfig = Object.freeze({ enabled, intervalMs }); - } - - return telemetryConfig; -} - -function createDistribution() { - return { - count: 0, - max: Number.NEGATIVE_INFINITY, - min: Number.POSITIVE_INFINITY, - samples: [], - total: 0 - }; -} - -function recordDistribution(distribution, value) { - if (!Number.isFinite(value) || value < 0) return; - - distribution.count += 1; - distribution.max = Math.max(distribution.max, value); - distribution.min = Math.min(distribution.min, value); - distribution.total += value; - - if (distribution.samples.length < MAX_SAMPLES_PER_DISTRIBUTION) { - distribution.samples.push(value); - return; - } - - const replacementIndex = Math.floor(Math.random() * distribution.count); - if (replacementIndex < MAX_SAMPLES_PER_DISTRIBUTION) { - distribution.samples[replacementIndex] = value; - } -} - -function round(value) { - return Math.round(value * 1000) / 1000; -} - -function percentile(sortedValues, percentileValue) { - if (!sortedValues.length) return undefined; - - const index = Math.max(0, Math.ceil((percentileValue / 100) * sortedValues.length) - 1); - return sortedValues[index]; -} - -function summarizeDistribution(distribution) { - if (!distribution?.count) return undefined; - - const sortedValues = [...distribution.samples].sort((left, right) => left - right); - - return { - count: distribution.count, - sampledCount: sortedValues.length, - avg: round(distribution.total / distribution.count), - min: round(distribution.min), - p50: round(percentile(sortedValues, 50)), - p95: round(percentile(sortedValues, 95)), - p99: round(percentile(sortedValues, 99)), - max: round(distribution.max) - }; -} - -function createSearchBucket(searchType) { - return { - outcomes: {}, - requests: 0, - searchType, - measurements: new Map() - }; -} - -function recordSearch(bucket, outcome, measurements) { - bucket.requests += 1; - bucket.outcomes[outcome] = (bucket.outcomes[outcome] ?? 0) + 1; - - Object.entries(measurements).forEach(([name, value]) => { - if (!TRACKED_MEASUREMENTS.has(name)) return; - - if (!bucket.measurements.has(name)) { - bucket.measurements.set(name, createDistribution()); - } - recordDistribution(bucket.measurements.get(name), value); - }); -} - -function summarizeBucket(bucket) { - return { - searchType: bucket.searchType, - requests: bucket.requests, - outcomes: bucket.outcomes, - measurements: Object.fromEntries( - [...bucket.measurements.entries()].map(([name, distribution]) => [ - name, - summarizeDistribution(distribution) - ]) - ) - }; -} - -function eventLoopNanosecondsToMilliseconds(value) { - return Number.isFinite(value) ? round(value / 1e6) : null; -} - -function createTelemetryState(intervalMs) { - const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 }); - eventLoopDelay.enable(); - - const state = { - activeRequests: 0, - buckets: new Map(), - eventLoopDelay, - eventLoopUtilization: performance.eventLoopUtilization(), - intervalCpuUsage: process.cpuUsage(), - intervalMs, - intervalStartedAt: Date.now(), - maxActiveRequests: 0, - startedRequests: 0 - }; - state.timer = setInterval(() => { - try { - flushTelemetry(); - } catch (_err) { - // Diagnostics must never affect search availability. - } - }, intervalMs); - state.timer.unref?.(); - - return state; -} - -function ensureTelemetryState() { - if (!telemetryState) { - telemetryState = createTelemetryState(getTelemetryConfig().intervalMs); - } - - return telemetryState; -} - -function buildEventLoopSummary(histogram) { - return { - meanMs: eventLoopNanosecondsToMilliseconds(histogram.mean), - p50Ms: eventLoopNanosecondsToMilliseconds(histogram.percentile(50)), - p95Ms: eventLoopNanosecondsToMilliseconds(histogram.percentile(95)), - p99Ms: eventLoopNanosecondsToMilliseconds(histogram.percentile(99)), - maxMs: eventLoopNanosecondsToMilliseconds(histogram.max) - }; -} - -function flushTelemetry() { - const state = telemetryState; - if (!state) return undefined; - - const intervalEndedAt = Date.now(); - const searches = [...state.buckets.values()].map(summarizeBucket); - const completedRequests = searches.reduce((total, search) => total + search.requests, 0); - const shouldWrite = state.startedRequests > 0 || state.activeRequests > 0 || searches.length > 0; - const memory = process.memoryUsage(); - const cpuUsage = process.cpuUsage(state.intervalCpuUsage); - const eventLoopUtilization = performance.eventLoopUtilization(state.eventLoopUtilization); - const intervalElapsedMs = intervalEndedAt - state.intervalStartedAt; - const cpuTotalMs = (cpuUsage.user + cpuUsage.system) / 1000; - const event = { - event: TELEMETRY_EVENT, - version: 1, - intervalStartedAt: new Date(state.intervalStartedAt).toISOString(), - intervalEndedAt: new Date(intervalEndedAt).toISOString(), - intervalElapsedMs, - startedRequests: state.startedRequests, - completedRequests, - activeRequests: state.activeRequests, - maxActiveRequests: state.maxActiveRequests, - eventLoopDelay: buildEventLoopSummary(state.eventLoopDelay), - eventLoopUtilization: round(eventLoopUtilization.utilization), - processCpu: { - userMs: round(cpuUsage.user / 1000), - systemMs: round(cpuUsage.system / 1000), - totalMs: round(cpuTotalMs), - percentOfOneCore: intervalElapsedMs > 0 ? round((cpuTotalMs / intervalElapsedMs) * 100) : null - }, - memoryBytes: { - rss: memory.rss, - heapUsed: memory.heapUsed, - heapTotal: memory.heapTotal - }, - searches - }; - - state.buckets = new Map(); - state.eventLoopUtilization = performance.eventLoopUtilization(); - state.intervalCpuUsage = process.cpuUsage(); - state.intervalStartedAt = intervalEndedAt; - state.maxActiveRequests = state.activeRequests; - state.startedRequests = 0; - state.eventLoopDelay.reset(); - - if (shouldWrite) { - try { - writeTelemetry(event); - } catch (_err) { - // Diagnostics must never affect search availability. - } - } - - return event; -} - -function classifySearch(postFilters) { - const types = SEARCH_TYPES.filter((type) => Boolean(postFilters?.[type])); - return types.length ? types.join('+') : 'standard'; -} - -const disabledTracker = Object.freeze({ - complete() {}, - measureAsync(_name, operation) { - return operation(); - }, - measureSync(_name, operation) { - return operation(); - }, - recordValue() {}, - setSearchType() {} -}); - -function startSearchTelemetry() { - try { - if (!getTelemetryConfig().enabled) return disabledTracker; - - const state = ensureTelemetryState(); - const startedAt = performance.now(); - const measurements = {}; - let completed = false; - let searchType = 'unclassified'; - - state.activeRequests += 1; - state.startedRequests += 1; - state.maxActiveRequests = Math.max(state.maxActiveRequests, state.activeRequests); - - return { - async measureAsync(name, operation) { - const stageStartedAt = performance.now(); - try { - return await operation(); - } finally { - measurements[name] = (measurements[name] ?? 0) + (performance.now() - stageStartedAt); - } - }, - measureSync(name, operation) { - const stageStartedAt = performance.now(); - try { - return operation(); - } finally { - measurements[name] = (measurements[name] ?? 0) + (performance.now() - stageStartedAt); - } - }, - recordValue(name, value) { - measurements[name] = value; - }, - setSearchType(postFilters) { - searchType = classifySearch(postFilters); - }, - complete(outcome = 'success') { - if (completed) return; - - completed = true; - measurements.totalRequestMs = performance.now() - startedAt; - state.activeRequests = Math.max(0, state.activeRequests - 1); - - try { - if (!state.buckets.has(searchType)) { - state.buckets.set(searchType, createSearchBucket(searchType)); - } - recordSearch(state.buckets.get(searchType), outcome, measurements); - } catch (_err) { - // Diagnostics must never affect search availability. - } - } - }; - } catch (_err) { - return disabledTracker; - } -} - -function resetForTests() { - if (telemetryState?.timer) clearInterval(telemetryState.timer); - telemetryState?.eventLoopDelay?.disable(); - telemetryConfig = undefined; - telemetryState = undefined; - writeTelemetry = (event) => process.stdout.write(`${JSON.stringify(event)}\n`); -} - -function setOutputForTests(output) { - writeTelemetry = output; -} - -module.exports = { - classifySearch, - flushTelemetry, - getTelemetryConfig, - resetForTests, - setOutputForTests, - startSearchTelemetry, - TELEMETRY_EVENT -}; diff --git a/utils/searchPerformanceTelemetry.test.js b/utils/searchPerformanceTelemetry.test.js deleted file mode 100644 index aabdf5d..0000000 --- a/utils/searchPerformanceTelemetry.test.js +++ /dev/null @@ -1,201 +0,0 @@ -const mockAppConfigHas = jest.fn(); -const mockAppConfigGet = jest.fn(); - -jest.mock('cve-core', () => ({ - AppConfig: { - get: mockAppConfigGet, - has: mockAppConfigHas - } -})); - -const telemetry = require('./searchPerformanceTelemetry'); -const constants = require('./constants'); - -function configure(values = {}) { - mockAppConfigHas.mockImplementation((path) => Object.prototype.hasOwnProperty.call(values, path)); - mockAppConfigGet.mockImplementation((path) => values[path]); -} - -describe('searchPerformanceTelemetry', () => { - beforeEach(() => { - telemetry.resetForTests(); - mockAppConfigHas.mockReset(); - mockAppConfigGet.mockReset(); - configure(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - telemetry.resetForTests(); - }); - - test('is disabled by default and preserves wrapped operation behavior', async () => { - const operation = jest.fn(() => 'result'); - const tracker = telemetry.startSearchTelemetry(); - - expect(tracker.measureSync('postFilterMs', operation)).toBe('result'); - await expect(tracker.measureAsync('standardOpenSearchMs', async () => 'async result')).resolves.toBe( - 'async result' - ); - expect(() => tracker.complete()).not.toThrow(); - expect(telemetry.flushTelemetry()).toBeUndefined(); - expect(operation).toHaveBeenCalledTimes(1); - }); - - test('normalizes enabled and interval configuration with safe defaults', () => { - configure({ - 'search.performanceTelemetryEnabled': 'TRUE', - 'search.performanceTelemetryIntervalMs': '2500' - }); - - expect(telemetry.getTelemetryConfig()).toStrictEqual({ - enabled: true, - intervalMs: 2500 - }); - - telemetry.resetForTests(); - configure({ - 'search.performanceTelemetryEnabled': 'not-a-boolean', - 'search.performanceTelemetryIntervalMs': '10' - }); - - expect(telemetry.getTelemetryConfig()).toStrictEqual({ - enabled: false, - intervalMs: constants.SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS - }); - }); - - test('classifies standard, individual, and combined semantic searches without filter values', () => { - expect(telemetry.classifySearch()).toBe('standard'); - expect(telemetry.classifySearch({ cpeName: { version: '1.0' } })).toBe('cpeName'); - expect( - telemetry.classifySearch({ - cpeName: { version: '1.0' }, - cvss: { vector: 'AV:N' }, - ignored: { sensitive: 'value' } - }) - ).toBe('cpeName+cvss'); - }); - - test('emits aggregate stage measurements without request inputs', async () => { - configure({ - 'search.performanceTelemetryEnabled': true, - 'search.performanceTelemetryIntervalMs': 60000 - }); - const output = jest.fn(); - telemetry.setOutputForTests(output); - const tracker = telemetry.startSearchTelemetry(); - - tracker.setSearchType({ cpeName: { version: 'sensitive-version' } }); - await tracker.measureAsync('candidateOpenSearchMs', async () => 'candidate response'); - tracker.measureSync('postFilterMs', () => 'filtered response'); - tracker.recordValue('candidateCount', 10000); - tracker.recordValue('matchedCandidateCount', 25); - tracker.recordValue('untrackedSensitiveValue', 123); - tracker.complete('success'); - - const event = telemetry.flushTelemetry(); - const summary = event.searches[0]; - - expect(output).toHaveBeenCalledWith(event); - expect(event).toMatchObject({ - event: telemetry.TELEMETRY_EVENT, - version: 1, - startedRequests: 1, - completedRequests: 1, - activeRequests: 0, - maxActiveRequests: 1, - eventLoopUtilization: expect.any(Number), - processCpu: { - userMs: expect.any(Number), - systemMs: expect.any(Number), - totalMs: expect.any(Number) - } - }); - expect(event.processCpu.percentOfOneCore === null || Number.isFinite(event.processCpu.percentOfOneCore)).toBe( - true - ); - expect(summary).toMatchObject({ - searchType: 'cpeName', - requests: 1, - outcomes: { success: 1 } - }); - expect(summary.measurements.candidateOpenSearchMs.count).toBe(1); - expect(summary.measurements.postFilterMs.count).toBe(1); - expect(summary.measurements.candidateCount.avg).toBe(10000); - expect(summary.measurements.matchedCandidateCount.avg).toBe(25); - expect(summary.measurements.totalRequestMs.count).toBe(1); - expect(summary.measurements.untrackedSensitiveValue).toBeUndefined(); - expect(JSON.stringify(event)).not.toContain('sensitive-version'); - }); - - test('reports in-flight pressure across interval boundaries and completes each request once', () => { - configure({ - 'search.performanceTelemetryEnabled': true, - 'search.performanceTelemetryIntervalMs': 60000 - }); - telemetry.setOutputForTests(jest.fn()); - const first = telemetry.startSearchTelemetry(); - const second = telemetry.startSearchTelemetry(); - - first.setSearchType(); - first.complete('timeout'); - first.complete('success'); - - const firstEvent = telemetry.flushTelemetry(); - expect(firstEvent).toMatchObject({ - startedRequests: 2, - completedRequests: 1, - activeRequests: 1, - maxActiveRequests: 2 - }); - expect(firstEvent.searches[0]).toMatchObject({ - requests: 1, - outcomes: { timeout: 1 } - }); - - second.setSearchType({ virtualMatchString: {} }); - second.complete('client_disconnected'); - const secondEvent = telemetry.flushTelemetry(); - - expect(secondEvent.activeRequests).toBe(0); - expect(secondEvent.completedRequests).toBe(1); - expect(secondEvent.searches[0]).toMatchObject({ - searchType: 'virtualMatchString', - requests: 1, - outcomes: { client_disconnected: 1 } - }); - }); - - test('bounds percentile samples while retaining exact observation counts', () => { - configure({ - 'search.performanceTelemetryEnabled': true, - 'search.performanceTelemetryIntervalMs': 60000 - }); - telemetry.setOutputForTests(jest.fn()); - jest.spyOn(Math, 'random').mockReturnValue(0); - - for (let index = 0; index < 2001; index += 1) { - telemetry.startSearchTelemetry().complete('success'); - } - - const summary = telemetry.flushTelemetry().searches[0].measurements.totalRequestMs; - expect(summary.count).toBe(2001); - expect(summary.sampledCount).toBe(2000); - }); - - test('does not let telemetry output failures affect request processing', () => { - configure({ - 'search.performanceTelemetryEnabled': true, - 'search.performanceTelemetryIntervalMs': 60000 - }); - telemetry.setOutputForTests(() => { - throw new Error('logging unavailable'); - }); - const tracker = telemetry.startSearchTelemetry(); - - tracker.complete('success'); - - expect(() => telemetry.flushTelemetry()).not.toThrow(); - }); -}); diff --git a/utils/searchResponseBuilder.js b/utils/searchResponseBuilder.js index 6853413..04920e6 100644 --- a/utils/searchResponseBuilder.js +++ b/utils/searchResponseBuilder.js @@ -1,4 +1,5 @@ const constants = require('./constants'); +const cpe = require('./cpe'); const API_VERSION = '1.0'; @@ -35,6 +36,43 @@ function getCveIdWarnings(value) { ]; } +function getCpeWarnings(params = {}) { + const warnings = []; + + if (params.cpeName) { + const parsedCpeName = cpe.parseCpeName(params.cpeName); + const omittedComponents = cpe.getOmittedComponentNames(parsedCpeName); + + if (omittedComponents.length) { + warnings.push({ + code: 'CPE_COMPONENTS_DEFAULTED', + message: 'Omitted trailing cpeName components were treated as wildcard components.', + components: omittedComponents + }); + } + } + + if (params.virtualMatchString) { + const parsedVirtualMatchString = cpe.parseVirtualMatchString(params.virtualMatchString); + const broadComponents = cpe.getBroadVirtualMatchStringComponents(parsedVirtualMatchString); + + if (broadComponents.length) { + warnings.push({ + code: 'BROAD_VIRTUAL_MATCH_STRING', + message: + 'Wildcard or omitted leading CPE components broaden this search and may cause results to reach the semantic candidate limit.', + components: broadComponents + }); + } + } + + return warnings; +} + +function getRequestWarnings(params = {}) { + return [...getCpeWarnings(params), ...getCveIdWarnings(params.cveIds)]; +} + function buildSearchResponse({ totalResults, hits, pagingOptions, totalIsExact = true, warnings = [] }) { const pageSize = pagingOptions.resultsPerPage; const records = hits.map((hit) => hit._source); @@ -60,6 +98,8 @@ function buildSearchResponse({ totalResults, hits, pagingOptions, totalIsExact = module.exports = { API_VERSION, buildSearchResponse, + getCpeWarnings, getCveIdWarnings, + getRequestWarnings, semanticCandidateLimitWarning }; diff --git a/utils/searchResponseBuilder.test.js b/utils/searchResponseBuilder.test.js index 3be94b5..8e51f4e 100644 --- a/utils/searchResponseBuilder.test.js +++ b/utils/searchResponseBuilder.test.js @@ -98,4 +98,53 @@ describe('search response builder', () => { expect(searchResponseBuilder.getCveIdWarnings(value)).toStrictEqual([]); } ); + + test('warns when omitted cpeName components are defaulted to wildcards', () => { + expect( + searchResponseBuilder.getCpeWarnings({ + cpeName: 'cpe:2.3:o:linux:linux_kernel:6.8' + }) + ).toStrictEqual([ + { + code: 'CPE_COMPONENTS_DEFAULTED', + message: 'Omitted trailing cpeName components were treated as wildcard components.', + components: ['update', 'edition', 'language', 'sw_edition', 'target_sw', 'target_hw', 'other'] + } + ]); + }); + + test('warns when a virtualMatchString omits leading match components', () => { + expect( + searchResponseBuilder.getCpeWarnings({ + virtualMatchString: 'cpe:2.3:o' + }) + ).toStrictEqual([ + { + code: 'BROAD_VIRTUAL_MATCH_STRING', + message: + 'Wildcard or omitted leading CPE components broaden this search and may cause results to reach the semantic candidate limit.', + components: ['vendor', 'product'] + } + ]); + }); + + test('does not warn for explicit cpeName components or a concrete virtual match product', () => { + expect( + searchResponseBuilder.getCpeWarnings({ + cpeName: 'cpe:2.3:o:linux:linux_kernel:6.8:*:*:*:*:*:*:*', + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' + }) + ).toStrictEqual([]); + }); + + test('orders CPE interpretation warnings before CVE ID warnings', () => { + expect( + searchResponseBuilder + .getRequestWarnings({ + virtualMatchString: 'cpe:2.3:*', + cveIds: 'CVE-1998-0001' + }) + .map((warning) => warning.code) + ).toStrictEqual(['BROAD_VIRTUAL_MATCH_STRING', 'CVE_IDS_NOT_RETURNED']); + }); });