diff --git a/.env.example b/.env.example index 82a3cbd..a6ac790 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,6 @@ OpenSearchDomainEndpoint= SearchOpenSearchRequestTimeoutMs=20000 SearchRequestDeadlineMs=30000 SearchReadinessTimeoutMs=5000 -SearchLogPerformanceTimings=false -SearchPerformanceTelemetryIntervalMs=10000 RequestLogSuccessSampleRate=0.1 RequestLogHealthChecks=false WebSearchEnabled=true diff --git a/README.md b/README.md index de2d1d1..30af149 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Running the API locally -Rename `.env.example` to `.env` and replace or check the following values. +Copy `.env.example` to an untracked `.env` and replace or check the following values. Keep the tracked example in place. - `PORT` - port used for the search API, defaulted to 3000 for development - `OpenSearchCveIndex` - existing CVE index name in your OpenSearch deployment @@ -11,15 +11,15 @@ 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` +- `RequestLogSuccessSampleRate` - optional fraction of successful access logs to retain, default `0.1`; includes health checks when they are enabled for logging +- `RequestLogHealthChecks` - optional flag to include successful health checks in access-log sampling, default `false` - `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` - `NPM_CA_CERT` - optional absolute host path to a corporate or self-signed CA certificate bundle used only during local Docker Compose builds +The three timeout settings (`SearchOpenSearchRequestTimeoutMs`, `SearchRequestDeadlineMs`, and `SearchReadinessTimeoutMs`) accept integer milliseconds from `1` through `2147483647`. Invalid or out-of-range settings fall back to the defaults listed above; they do not disable timeouts. This upper bound prevents Node.js timer overflow from turning a large configured duration into a 1 ms timeout. + ```bash -mv .env.example .env +cp .env.example .env ``` ## Docker API Only @@ -50,7 +50,9 @@ The compose file runs only the Search API with `NODE_ENV=production` and `NODE_C ## Deploying with npm -**NOTE: The current locked `cve-core` dependency requires Node.js `>= 16.20.2 < 22`.** +Use Node.js 24 for the repository's current runtime and development tooling. CI and the Docker dependency-build stage use Node.js 24.14.1; the final runtime image uses Distroless Node.js 24. + +**Upstream compatibility caveat:** The locked `cve-core` package still declares Node.js `>= 16.20.2 < 22`. That metadata conflicts with this repository's Node.js 24 baseline and can cause installation warnings or failures when strict engine checks are enabled. Resolving the declaration requires coordination with the `cve-core` maintainers; it is not a recommendation to use Node.js 16 or 18 with this repository's current development tooling. 1. Set `OpenSearchDomainEndpoint` in `.env` to an existing OpenSearch deployment: @@ -61,7 +63,7 @@ OpenSearchDomainEndpoint=https://search.example.com 2. Install dependencies: ```bash -npm install +npm ci ``` 3. Run the CVE Search API in development mode: @@ -76,20 +78,28 @@ npm run dev npm start ``` -The Search API will be served at `localhost:3000`. +The Search API uses `PORT`, defaulting to `http://localhost:3000`. The tracked Postman environment defaults to port `4000`; either set `PORT=4000` before starting the API or change Postman's `baseUrl` to match the running server. ## Health Checks `GET /api/health-check` is a process liveness check. It returns HTTP 200 when the Node.js process is running and deliberately does not contact OpenSearch. Use it where a dependency outage should not restart otherwise healthy API tasks. -`GET /api/ready-check` first verifies that the configured CVE index exposes compatible field capabilities for each logical `/search` feature, then sends a minimal zero-result search. CNA, ADP, legacy, and CVSS-version fields that implement the same feature are treated as alternatives, while mapped fields must still use compatible searchable types. This checks mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when search requests can be served. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, missing logical capabilities, incompatible mapped fields, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. Each OpenSearch readiness request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. +`GET /api/ready-check` first verifies that the configured CVE index exposes compatible field capabilities for each logical `/search` feature, then sends a minimal zero-result search. CNA, ADP, legacy, and CVSS-version fields that implement the same feature are treated as alternatives, while mapped fields must still use compatible searchable types. This checks mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when these checks pass; it does not prove data completeness or capacity for every search. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, missing logical capabilities, incompatible mapped fields, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. `SearchReadinessTimeoutMs` defaults to five seconds for the overall readiness check, covering both field-capability and search operations. Each individual operation also uses that value as its transport timeout, and the overall deadline can abort it earlier. An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior. +When the configured index is an alias spanning multiple indices, every reported mapped type of each required field must be compatible and searchable. CVE ID sorting also requires aggregation support. One compatible type cannot conceal a conflicting type in another index; readiness returns HTTP 503 rather than advertising that configuration as ready. + ## API Input Validation +JSON request bodies for both search endpoints are limited to 100 KiB (102,400 bytes, measured after decompression). Larger bodies return HTTP 413 `REQUEST_BODY_TOO_LARGE`. Parser errors return sanitized JSON rather than HTML or stack traces: malformed JSON returns HTTP 400 `MALFORMED_JSON`, other unreadable bodies return HTTP 400 `INVALID_REQUEST`, and unsupported encoding returns HTTP 415 (`SEARCH_CONTENT_TYPE` for `/search`, `REQUEST_ENCODING_UNSUPPORTED` for `/webSearch`). Unexpected server errors return a generic `INTERNAL_SERVER_ERROR` response without exception details, including outside production mode. + `POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. +Successful `/search` responses use an API-owned response structure rather than exposing search-backend response objects. The top-level fields are `apiVersion`, `status`, `warnings`, `pagination`, and `records`, in that order. `records` contains complete CVE Records directly, without search-backend hit wrappers or infrastructure metadata. `pagination` contains `page`, `pageSize`, `resultsReturned`, `totalResults`, `totalPages`, and `totalIsExact`. Non-fatal conditions are reported in `warnings` before the potentially long `records` array. + +CVE Records do not exist for years before 1999. A syntactically valid pre-1999 value in `cveIds` is therefore a valid search that contributes no matches rather than a request error. The response includes a `CVE_IDS_NOT_RETURNED` warning listing those IDs. When pre-1999 and supported-year IDs are requested together, matching supported-year records are still returned. + Use the optional `scope` field to control which parts of each record participate in matching. Values are case-insensitive: `FULL` (the default) searches base record fields, CNA data, and every ADP provider; `CNA_ONLY` searches base record fields and CNA data; and `ADP_ONLY` searches base record fields and every ADP provider. Scope changes matching only, so successful responses always contain the complete CVE record. CPE applicability filters use CNA data and return HTTP 400 when combined with `ADP_ONLY`. Date filters use the API's full UTC timestamp format and inclusive boundaries. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end. @@ -106,7 +116,9 @@ Scope applies to filter matching as follows: | `cpeName`, `isVulnerable`, `virtualMatchString`, and version range filters | CNA CPE applicability data; incompatible with `ADP_ONLY` | | `resultsPerPage`, `pageNumber` | Response controls; they do not select record data | -If the selected containers do not contain the requested data, the request is valid and returns zero matches. The API does not remove unselected containers from a returned record. +Missing data in selected containers does not make an otherwise compatible request invalid. A positive filter cannot match a record that lacks the requested data. Exclusion filters behave differently: `knownExploited: false` and `disputed: false` can match records with no selected KEV metric or disputed tag, including records without a selected container. Other supplied filters must still match. The API does not remove unselected containers from a returned record. + +`keywordSearch` searches only description text selected by `scope`. By default, the API escapes caller-supplied query syntax, adds a trailing prefix wildcard to each whitespace-delimited term, and combines terms with `AND` using `simple_query_string`. For example, `remote code` searches for `remote* AND code*`. With `keywordExactMatch: true`, it instead uses an analyzed phrase query in the selected description fields. Phrase matching follows the index's text analyzer; it is not a case-sensitive, character-for-character substring comparison. SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, timestamps use the API's full UTC timestamp format with inclusive boundaries, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. @@ -148,11 +160,11 @@ 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 -Use `cpeName` when a user has a concrete asset CPE Name and needs CVE records whose CPE Match Criteria apply to that asset version. The `part`, `vendor`, `product`, and `version` components must all be concrete values, not `*`. +Use `cpeName` when a user has a concrete asset CPE Name and needs CVE records whose CPE Match Criteria apply to that asset version. The `part`, `vendor`, `product`, and `version` components must all be concrete values. Unescaped `*` or `?` characters anywhere in those required components return HTTP 400 (`INVALID_CPE_NAME`), including versions such as `2.6.*`, `2.6*`, `2.*`, and `*.*`. Backslash-escaped literal characters remain allowed. Optional components can still be `*` or omitted. Valid `cpeName` example: @@ -172,13 +184,31 @@ curl -X POST "http://localhost:3000/search" \ The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. In accordance with the CPE 2.3 Name Matching specification, CPE string literal comparisons are insensitive to lexical case, so an input component such as `macos` matches stored criteria containing `macOS`. Every concrete non-version component in the input, such as edition, language, target software, or target hardware, must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks. Optional input components set to `*` remain broad matches. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. -The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. Numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This rule can produce different range behavior than a literal reading of source CVE Record applicability fields. +The requested version does not need to appear literally in a returned record when it falls within a stored range. The API evaluates those criteria; it does not verify that a software version actually exists. An open-ended range can therefore match a hypothetical version such as `1001.1`. + +For a version interval, use `virtualMatchString` without a version component and supply explicit version boundaries. For example, to search for criteria overlapping Linux versions from `2.6` inclusive to `2.7` exclusive: + +```json +{ + "virtualMatchString": "cpe:2.3:o:linux:linux_kernel", + "versionStart": "2.6", + "versionStartType": "including", + "versionEnd": "2.7", + "versionEndType": "excluding" +} +``` + +An embedded wildcard such as `2.6.*` is not a supported `cpeName` version-family search. A literal version ending in punctuation, such as `2.6.`, is still accepted but is not a version-prefix search either. + +The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies, not that the version is unknown or unrestricted. Numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. The CPE specification defines `NA`; this treatment of numeric range boundaries is an API matching policy. This rule can produce different range behavior than a literal reading of source CVE Record applicability fields. Do not substitute `-` for `*` to search all versions; use `virtualMatchString` with an omitted version or a whole-component `*` instead. Set `isVulnerable` to `true` with `cpeName` to require the matching CPE Match Criteria entry itself to be marked vulnerable. A vulnerable flag on an unrelated CPE entry does not satisfy the request. Omitting `isVulnerable`, or setting it to `false`, does not apply a vulnerability restriction. ### 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: @@ -206,13 +236,15 @@ curl -X POST "http://localhost:3000/search" \ -d '{"virtualMatchString":"cpe:2.3:o:linux:linux_kernel","versionStart":"2.2","versionStartType":"including","versionEnd":"2.6","versionEndType":"excluding"}' ``` -Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. +Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. Stored intervals with a start after their end, or equal boundaries where either boundary is exclusive, are empty and do not match range searches. CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. 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. + +## Raw Query Endpoint `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. @@ -230,8 +262,8 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al } ``` -- `sort.property` may be `cveId` or `relevancy` -- `sort.order` may be `asc` or `desc` +- `sort.property` is required when `sort` is supplied and may be `cveId` or `relevancy` +- `sort.order` may be `asc` or `desc`; when omitted, the chosen property is sorted descending - `from` must be an integer greater than or equal to `0` - `size` must be an integer from `1` to `500` @@ -257,7 +289,7 @@ Unit, route, OpenAPI, runtime config, and error helper tests can be run with npm npm test ``` -Static quality gates can be run with npm: +Linting, formatting, and coverage checks can be run with npm. `npm run quality` runs all three, and the coverage command executes the test suite: ```bash npm run lint @@ -268,38 +300,36 @@ npm run quality An importable Postman collection for manual `/search` contract and filter testing is documented in [test/postman/README.md](test/postman/README.md). The complete collection targets a fully populated index like those used by shared managed environments; data-dependent positive assertions may fail against an incomplete local index even when the API is operating correctly. -### Operational Logging +## Operational Logging Every request receives an `X-Request-ID` response header. A caller-provided `X-Request-ID` is preserved when it contains only letters, numbers, `.`, `_`, `:`, or `-` and is no longer than 128 characters; otherwise the API generates a UUID. The same value appears as `request-id` in access logs. -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. +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. `RequestLogHealthChecks=true` includes successful health checks in that same sample; it does not bypass sampling. To retain every successful health check, enable that flag and set `RequestLogSuccessSampleRate=1`, which also retains all other successful access logs. 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 +Deadline checks also compare elapsed monotonic time, so they do not depend on the timeout callback running first. Semantic filtering checks cancellation between candidates and after the final candidate, and yields to the event loop every 100 candidates. An expired search cannot start its page fetch or return a successful empty result merely because filtering delayed the timer. Cancellation is cooperative; it does not interrupt an individual synchronous operation in progress. + +## Semantic Post-Filtering Two separate 10,000-record controls apply to `/search`: - Every requested page must fall within the first 10,000 results. Direct OpenSearch filters still report an exact total that can exceed 10,000, but pages beyond that window are rejected. -- Filters that require semantic post-filtering inspect at most 10,000 OpenSearch candidates. Their reported total and pagination describe only matches found within that candidate set. +- Filters that require semantic post-filtering inspect at most 10,000 search candidates. Their reported total and pagination describe only matches found within that candidate set. CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every slash-delimited token supplied to one CVSS vector filter must match a complete token in the same eligible vector; a base token such as `AV:N` does not match a modified token such as `MAV:N`. -The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. +The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 search candidates may not be represented. When a semantic search reaches that limit, the response sets `pagination.totalIsExact` to `false` and includes a `SEMANTIC_CANDIDATE_LIMIT_REACHED` warning. Semantic searches that remain below the limit and direct searches set `totalIsExact` to `true`. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct query and retains ordinary exact-total behavior. -`/search` orders OpenSearch results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pageSize` preserves the requested `resultsPerPage` value, while `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. +`/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. Current searches do not use a point-in-time snapshot, so index updates between separate page requests can shift results. Request-scoped PIT traversal would improve consistency within one request, but consistency across separate page requests would additionally require reusing the same snapshot. -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. -Docker runtime smoke checks can be run after building the image. The default `Dockerfile` avoids BuildKit `RUN --mount` syntax for AWS builders that do not support it: +## Docker Runtime Checks + +Docker runtime smoke checks can be run after building the image. The default `Dockerfile` avoids BuildKit `RUN --mount` syntax for AWS builders that do not support it. The example below checks packaging and process liveness only; replace the placeholder endpoint/index and check `/api/ready-check` separately to verify an actual OpenSearch dependency: ```bash docker build -t cve-search-api:runtime-smoke . diff --git a/api-docs/openapi.json b/api-docs/openapi.json index b4591de..73dacd3 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -21,7 +21,7 @@ "CVE Record" ], "summary": "Retrieves all matching CVE records after applying the JSON request body as filters", - "description": "

Expected Behavior

Retrieves all matching CVE records after applying the JSON request body as filters

Result Counts and Pagination

Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.

CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 OpenSearch candidates, so their reported totals and pages include only matches found within that candidate set.

", + "description": "

Expected Behavior

Retrieves all matching CVE records after applying the JSON request body as filters

Result Counts and Pagination

Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.

CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 search candidates, so their reported totals and pages include only matches found within that candidate set. A warning is returned when that limit is reached.

", "operationId": "search", "responses": { "200": { @@ -52,12 +52,12 @@ } } }, - "404": { - "description": "Invalid cpeName value", + "413": { + "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "$ref": "#/components/schemas/ApiError" } } } @@ -72,8 +72,18 @@ } } }, + "500": { + "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "502": { - "description": "OpenSearch returned an invalid or unexpected response", + "description": "The search backend returned an invalid or unexpected response", "content": { "application/json": { "schema": { @@ -83,7 +93,7 @@ } }, "503": { - "description": "OpenSearch is unavailable", + "description": "The search service is temporarily unavailable", "content": { "application/json": { "schema": { @@ -93,7 +103,7 @@ }, "headers": { "Retry-After": { - "description": "Seconds before retrying an unavailable OpenSearch dependency", + "description": "Seconds before retrying the unavailable search service", "schema": { "type": "integer" } @@ -101,7 +111,7 @@ } }, "504": { - "description": "Search exceeded its OpenSearch or overall processing deadline", + "description": "Search exceeded its processing deadline", "content": { "application/json": { "schema": { @@ -287,7 +297,14 @@ } }, "400": { - "description": "Bad Request" + "description": "Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } }, "410": { "description": "The endpoint is disabled by service configuration", @@ -299,6 +316,36 @@ } } }, + "413": { + "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "415": { + "description": "Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "502": { "description": "The search backend returned an error or invalid response", "content": { @@ -337,6 +384,9 @@ }, "sort": { "type": "object", + "required": [ + "property" + ], "additionalProperties": false, "properties": { "property": { @@ -403,62 +453,59 @@ } ] }, - "SearchHit": { + "SearchWarning": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "required": [ - "_source" + "code", + "message" ], "properties": { - "_id": { + "code": { "type": "string" }, - "_source": { - "type": "object", - "additionalProperties": true - } - } - }, - "SearchHits": { - "type": "object", - "additionalProperties": true, - "required": [ - "total", - "hits" - ], - "properties": { - "total": { - "type": "object", - "additionalProperties": true, - "required": [ - "value" - ], - "properties": { - "value": { - "type": "integer", - "minimum": 0, - "description": "For direct searches, the exact number of matching records, which may be greater than 10,000. For semantic post-filtered searches, the number of matches found within the bounded 10,000-candidate set." - } + "message": { + "type": "string" + }, + "cveIds": { + "type": "array", + "description": "Requested CVE IDs associated with this warning, when applicable.", + "items": { + "type": "string" } }, - "hits": { + "components": { "type": "array", + "description": "CPE component names associated with this warning, when applicable.", "items": { - "$ref": "#/components/schemas/SearchHit" + "type": "string", + "enum": [ + "part", + "vendor", + "product", + "version", + "update", + "edition", + "language", + "sw_edition", + "target_sw", + "target_hw", + "other" + ] } } } }, - "SearchResponse": { + "SearchPagination": { "type": "object", "additionalProperties": false, "required": [ "page", "pageSize", "resultsReturned", + "totalResults", "totalPages", - "status", - "data" + "totalIsExact" ], "properties": { "page": { @@ -476,10 +523,44 @@ "minimum": 0, "description": "Number of CVE records actually returned in this response." }, + "totalResults": { + "type": "integer", + "minimum": 0, + "description": "For direct searches, the exact number of matching records, which may be greater than 10,000. For semantic post-filtered searches, the number of matches found within the bounded 10,000-candidate set." + }, "totalPages": { "type": "integer", "minimum": 0, - "description": "Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded 10,000-candidate set." + "description": "Pages derived from totalResults and pageSize. Semantic post-filtered searches derive this value from matches found within the bounded 10,000-candidate set." + }, + "totalIsExact": { + "type": "boolean", + "description": "True when totalResults represents every match. False when a semantic search reaches the 10,000-candidate limit and additional matches may exist." + } + } + }, + "CveRecord": { + "type": "object", + "additionalProperties": true, + "description": "A complete CVE Record without search-backend transport metadata." + }, + "SearchResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "status", + "warnings", + "pagination", + "records" + ], + "properties": { + "apiVersion": { + "type": "string", + "enum": [ + "1.0" + ], + "description": "Version of the public search response contract." }, "status": { "type": "string", @@ -487,20 +568,20 @@ "ok" ] }, - "data": { - "type": "object", - "additionalProperties": true, - "required": [ - "hits" - ], - "properties": { - "took": { - "type": "number", - "minimum": 0 - }, - "hits": { - "$ref": "#/components/schemas/SearchHits" - } + "warnings": { + "type": "array", + "description": "Non-fatal conditions that may affect how the results should be interpreted.", + "items": { + "$ref": "#/components/schemas/SearchWarning" + } + }, + "pagination": { + "$ref": "#/components/schemas/SearchPagination" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CveRecord" } } } @@ -552,7 +633,7 @@ }, "SearchRequest": { "type": "object", - "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed 10,000, although requested pages must remain inside that window. CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most 10,000 OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.", + "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed 10,000, although requested pages must remain inside that window. CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most 10,000 search candidates, so reported totals and pages include only matches found within that candidate set.", "additionalProperties": false, "properties": { "scope": { @@ -569,7 +650,7 @@ "type": "string", "maxLength": 512, "example": "remote code execution", - "description": "Returns CVE records where a word or phrase is included in a description selected by scope. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to 512 characters and 64 whitespace-delimited terms." + "description": "Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string. For example, remote code searches for remote* AND code*. Matching follows the index text analyzer. Limited to 512 characters and 64 whitespace-delimited terms." }, "keywordExactMatch": { "oneOf": [ @@ -585,13 +666,13 @@ } ], "default": false, - "description": "When true, a multi-word keywordSearch value must appear exactly and in the same order. keywordSearch is required when keywordExactMatch is true. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." + "description": "When true, uses an analyzed phrase query instead of keyword prefix matching in the scope-selected description fields. Terms must match in order according to the index text analyzer; this is not a case-sensitive, character-for-character substring comparison. keywordSearch is required when keywordExactMatch is true. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "cveIds": { "type": "string", "maxLength": 4096, "example": "CVE-2024-1234,CVE-2024-5678", - "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." + "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." }, "noRejected": { "oneOf": [ @@ -698,7 +779,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. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. 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. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." }, "isVulnerable": { "oneOf": [ @@ -714,7 +795,7 @@ } ], "default": false, - "description": "When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." + "description": "When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable set to true; false is allowed. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "lastModStartDate": { "type": "string", @@ -787,7 +868,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 88428cf..be8da04 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) { @@ -61,7 +62,7 @@ describe('OpenAPI contract', () => { expect(schema.description).toContain('the start must be before or equal to the end'); expect(schema.description).toContain('Direct searches report exact totals'); expect(schema.description).toContain('semantic post-filtering'); - expect(schema.description).toContain('10,000 OpenSearch candidates'); + expect(schema.description).toContain('10,000 search candidates'); expect(Object.keys(schema.properties)).toStrictEqual(constants.SEARCH_QUERY_PARAMS); Object.values(schema.properties).forEach((property) => { expect(property.description).toEqual(expect.any(String)); @@ -71,6 +72,8 @@ describe('OpenAPI contract', () => { expect(schema.properties.cveIds.description).toContain('comma-separated list of CVE IDs'); expect(schema.properties.cveIds.description).toContain(`${constants.MAX_CVE_IDS} CVE IDs`); expect(schema.properties.cveIds.description).toContain(`${constants.MAX_CVE_ID_LENGTH} characters per ID`); + expect(schema.properties.cveIds.description).toContain(`before ${constants.FIRST_CVE_RECORD_YEAR}`); + expect(schema.properties.cveIds.description).toContain('non-fatal response warning'); expect(schema.properties.keywordSearch.description).toContain( `${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms` ); @@ -103,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' ); @@ -130,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([ @@ -168,10 +175,15 @@ 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('Unescaped * or ?'); + expect(cpeName.description).toContain('Backslash-escaped literal characters remain allowed'); + expect(cpeName.description).toContain('does not verify that a software version actually exists'); + expect(cpeName.description).toContain('not an unknown or unrestricted version'); + expect(cpeName.description).toContain('versionStart/versionStartType'); 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(); }); @@ -181,36 +193,59 @@ describe('OpenAPI contract', () => { expect(responses['400'].description).toContain('Malformed JSON'); expect(responses['415'].description).toContain('application/json'); expect(responses['502'].description).toContain('invalid or unexpected response'); - expect(responses['503'].description).toContain('OpenSearch'); + expect(responses['503'].description).toContain('search service'); expect(responses['503'].headers['Retry-After']).toStrictEqual({ - description: 'Seconds before retrying an unavailable OpenSearch dependency', + description: 'Seconds before retrying the unavailable search service', schema: { type: 'integer' } }); }); + test.each(['/search', '/webSearch'])('documents sanitized parser and server errors for %s', (path) => { + const responses = openapi.paths[path].post.responses; + for (const status of ['413', '415', '500']) { + expect(responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/ApiError'); + } + expect(responses['413'].description).toContain('REQUEST_BODY_TOO_LARGE'); + expect(responses['500'].description).toContain('INTERNAL_SERVER_ERROR'); + }); + test('documents reusable success and error response schemas', () => { const schemas = openapi.components.schemas; const searchResponse = schemas.SearchResponse; - expect(searchResponse.required).toStrictEqual([ + expect(searchResponse.required).toStrictEqual(['apiVersion', 'status', 'warnings', 'pagination', 'records']); + expect(Object.keys(searchResponse.properties)).toStrictEqual([ + 'apiVersion', + 'status', + 'warnings', + 'pagination', + 'records' + ]); + expect(searchResponse.properties.apiVersion.enum).toStrictEqual(['1.0']); + expect(searchResponse.properties.warnings.items.$ref).toBe('#/components/schemas/SearchWarning'); + expect(searchResponse.properties.pagination.$ref).toBe('#/components/schemas/SearchPagination'); + expect(searchResponse.properties.records.items.$ref).toBe('#/components/schemas/CveRecord'); + 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', 'pageSize', 'resultsReturned', + 'totalResults', 'totalPages', - 'status', - 'data' + 'totalIsExact' ]); - expect(searchResponse.properties.pageSize.description).toContain('requested for this page'); - expect(searchResponse.properties.resultsReturned.description).toContain('actually returned'); - expect(searchResponse.properties.totalPages.description).toContain('10,000-candidate set'); - expect(searchResponse.properties.data.properties.hits.$ref).toBe('#/components/schemas/SearchHits'); - expect(schemas.SearchHits.properties.total.properties.value.description).toContain( + expect(schemas.SearchPagination.properties.totalResults.description).toContain( 'exact number of matching records' ); - expect(schemas.SearchHits.properties.total.properties.value.description).toContain('10,000-candidate set'); - expect(schemas.SearchHits.properties.hits.items.$ref).toBe('#/components/schemas/SearchHit'); + expect(schemas.SearchPagination.properties.totalResults.description).toContain('10,000-candidate set'); + expect(schemas.SearchPagination.properties.totalIsExact.description).toContain('additional matches may exist'); + expect(schemas.CveRecord.description).toContain('without search-backend transport metadata'); expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); expect(openapi.paths['/search'].post.responses['200'].content['application/json'].schema.$ref).toBe( diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 61a7e76..045c52a 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -9,13 +9,11 @@ "search": { "providerEndpoint": "OpenSearchDomainEndpoint", "index": "OpenSearchCveIndex", - // allows local development using containers that do not have SSL certs + // TRUE disables OpenSearch server certificate verification; isolated local development only. "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..ac30f34 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -2,32 +2,27 @@ // Default configuration // These values are overridable using other *.jsonc (e.g., prod.jsonc) // as well as using environment variables (e.g., in `.env`) - // - Each configuration is mapped using custom-environment-variables.jsonc to enable environment varialbe overrides. + // - Each configuration is mapped using custom-environment-variables.jsonc to enable environment variable overrides. // - For more information, see cve-core/src/adapters/config/AppConfig.ts // NOTE for consistency, all values need to be strings for proper type when using .env overrides "appConfig": { // constants for search capability "search": { - // setting this to FALSE (recommended) requires an SSL cert to access the search server - // The only time this should be allowed to be true is when developing or testing - // using containers that do not have SSL certs + // FALSE (recommended) verifies the OpenSearch server's TLS certificate. + // TRUE disables verification; use only for isolated local development with self-signed certificates. "allowUnknownSslCerts": "FALSE", // Limits each request from the API process to OpenSearch. "openSearchRequestTimeoutMs": "20000", // 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" + // Overall /api/ready-check deadline and per-operation timeout for field capabilities and search. + "readinessTimeoutMs": "5000" }, // Access logging settings. Error responses are always logged. "requestLogging": { - // Fraction of successful non-health-check requests to log, from 0 through 1. + // Fraction of successful requests to log, including health checks when enabled, from 0 through 1. "successSampleRate": "0.1", - // Set to TRUE to retain successful health-check access logs. + // Set to TRUE to include successful health checks in the same successSampleRate sampling. "healthChecks": "FALSE" }, // Feature flags used for operator-controlled endpoint availability. diff --git a/config/devel.jsonc b/config/devel.jsonc index 0eb0fc4..e718b92 100644 --- a/config/devel.jsonc +++ b/config/devel.jsonc @@ -10,9 +10,8 @@ "providerEndpoint": "https://localhost:9200", // index on search server related to searching CVEs "index": "e2e-cve-test-index-1109", - // setting this to FALSE (recommended) requires an SSL cert to access the search server - // The only time this should be allowed to be true is when developing or testing - // using containers that do not have SSL certs + // FALSE verifies the OpenSearch server's TLS certificate. + // TRUE disables verification for isolated local development with self-signed certificates. // DO NOT USE THIS IN ANY PUBLIC OR PRODUCTION ENVIRONMENTS "allowUnknownSslCerts": "TRUE" }, 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 2422403..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.' ); }); @@ -466,6 +512,14 @@ describe('middleware validation function', () => { expectValidationError('CVE_IDS_LIMIT', 'The cveIds parameter cannot contain more than 100 CVE IDs.'); }); + test('ValidateParams accepts syntactically valid pre-1999 CVE IDs for zero-result searches', () => { + validate({ + cveIds: 'CVE-1975-1234,CVE-1998-0001,CVE-2024-0001' + }); + + expectValidationSuccess(); + }); + test.each(Object.entries(constants.SEARCH_STRING_LENGTH_LIMITS))( 'ValidateParams returns an error when %s exceeds its maximum length.', (param, maxLength) => { diff --git a/controllers/searchController.js b/controllers/searchController.js index c31fa77..a8eec9d 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -1,10 +1,11 @@ const PackageInfo = require('cve-core').packageInfo; +const constants = require('../utils/constants'); const errors = require('../utils/errors'); 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']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); @@ -16,25 +17,6 @@ function paginateHits(hits, responseOptions) { return hits.slice(from, from + resultsPerPage); } -function buildPagedSearchResponse(resp, totalValue, pagedHits) { - const hitsContainer = resp?.data?.hits; - - return { - ...resp, - data: { - ...resp.data, - hits: { - ...hitsContainer, - total: { - ...hitsContainer.total, - value: totalValue - }, - hits: pagedHits - } - } - }; -} - function getOpenSearchErrorStatusCode(err) { return Number(err?.statusCode ?? err?.meta?.statusCode ?? err?.meta?.body?.status); } @@ -109,105 +91,77 @@ function orderHitsByCveIds(hits, cveIds) { return cveIds.map((cveId) => hitsByCveId.get(cveId)).filter(Boolean); } -function combineTook(...values) { - const numericValues = values.filter((value) => Number.isFinite(value)); - if (!numericValues.length) return undefined; - - return numericValues.reduce((sum, value) => sum + value, 0); -} - 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 { - status: 'ok', - data: searchData + totalResults: searchData.hits.total.value, + hits: searchData.hits.hits, + totalIsExact: true, + warnings: [] }; } -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 } = await searchPostFilters.filterSearchHits(candidateResp, postFilters, { + signal + }); return { + candidateCount, filteredCount: filteredHits.length, - filteredCveIds: getHitCveIds(filteredHits), - took: candidateData?.took + filteredCveIds: getHitCveIds(filteredHits) }; } -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 response = { - status: 'ok', - data: { - took: combineTook(candidateResult.took, pageResult.took), - hits: { - total: { - value: filteredCount - }, - hits: [] - } - } - }; + const pageHits = await fetchFullHitsByCveIds(pageCveIds, signal); + const candidateLimitReached = candidateResult.candidateCount >= constants.MAX_SEARCH_WINDOW; - return buildPagedSearchResponse(response, filteredCount, pageResult.hits); + return { + totalResults: filteredCount, + 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) { @@ -217,63 +171,46 @@ 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 resp = await executeSearch( + const searchResult = await executeSearch( searchOptions, searchRequest.queryStringObjects, postFilters, responseOptions, - lifecycle.signal, - telemetry + lifecycle.signal ); lifecycle.throwIfAborted(); const pagingOptions = responseOptions ?? searchOptions; - - //Calculate current page and total pages to append to JSON output - const totalValue = resp.data.hits.total.value; - const requestedPageSize = pagingOptions.resultsPerPage; - const resultsReturned = resp.data.hits.hits.length; - const page = totalValue === 0 ? 0 : Math.floor(pagingOptions.from / requestedPageSize) + 1; - const totalPages = totalValue === 0 ? 0 : Math.ceil(totalValue / requestedPageSize); - - return res.status(200).json({ - page, - pageSize: requestedPageSize, - resultsReturned, - totalPages, - ...resp - }); + const warnings = [...searchResponseBuilder.getRequestWarnings(params), ...searchResult.warnings]; + + return res.status(200).json( + searchResponseBuilder.buildSearchResponse({ + ...searchResult, + warnings, + pagingOptions + }) + ); } 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.openSearchError()); + 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 d4b1e32..8319ec4 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,11 +28,10 @@ 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'); +const cpe = require('../utils/cpe'); function createResponse() { const res = {}; @@ -69,6 +59,28 @@ function rawResponse(totalValue, hits = []) { return advancedResponse(totalValue, hits).data; } +function searchSuccessResponse(totalResults, hits = [], options = {}) { + const from = options.from ?? 0; + const pageSize = options.pageSize ?? 20; + const totalIsExact = options.totalIsExact ?? true; + const warnings = options.warnings ?? []; + + return { + apiVersion: '1.0', + status: 'ok', + warnings, + pagination: { + page: totalResults === 0 ? 0 : Math.floor(from / pageSize) + 1, + pageSize, + resultsReturned: hits.length, + totalResults, + totalPages: totalResults === 0 ? 0 : Math.ceil(totalResults / pageSize), + totalIsExact + }, + records: hits.map((hit) => hit._source) + }; +} + function basicResponse({ ok = true, status = 'ok', total = 0, hits = [], notes, errors } = {}) { return { status, @@ -146,33 +158,22 @@ 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 resp = advancedResponse(25, [{ _id: 'CVE-2024-0001' }]); + const hit = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { + cveId: 'CVE-2024-0001' + } + } + }; + const resp = advancedResponse(25, [hit]); mockApiSearch.mockResolvedValue(resp); await controller.SEARCH( @@ -248,13 +249,7 @@ describe('Search controller SEARCH handler', () => { ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - page: 3, - pageSize: 10, - resultsReturned: 1, - totalPages: 3, - ...resp - }); + expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(25, [hit], { from: 20, pageSize: 10 })); }); test('SEARCH sends keywordSearch through match_phrase when keywordExactMatch is true', async () => { @@ -307,6 +302,75 @@ describe('Search controller SEARCH handler', () => { expect(mockApiSearch.mock.calls[0][0].query.bool.must).toStrictEqual([]); }); + test('SEARCH returns an empty successful response with a warning for a pre-1999 CVE ID', async () => { + mockApiSearch.mockResolvedValue(advancedResponse(0)); + + await controller.SEARCH( + { + query: { + cveIds: 'CVE-1998-0001' + } + }, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + searchSuccessResponse(0, [], { + warnings: [ + { + code: 'CVE_IDS_NOT_RETURNED', + message: + 'CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.', + cveIds: ['CVE-1998-0001'] + } + ] + }) + ); + }); + + test('SEARCH returns matching supported IDs and warns about pre-1999 IDs in the same request', async () => { + const matchingHit = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { + cveId: 'CVE-2024-0001' + } + } + }; + mockApiSearch.mockResolvedValue(advancedResponse(1, [matchingHit])); + + await controller.SEARCH( + { + query: { + cveIds: 'cve-1998-0001,CVE-2024-0001' + } + }, + res, + next + ); + + expect(mockApiSearch.mock.calls[0][0].query.bool.filter).toContainEqual({ + terms: { + 'cveMetadata.cveId.keyword': ['CVE-1998-0001', 'CVE-2024-0001'] + } + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + searchSuccessResponse(1, [matchingHit], { + warnings: [ + { + code: 'CVE_IDS_NOT_RETURNED', + message: + 'CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.', + cveIds: ['CVE-1998-0001'] + } + ] + }) + ); + }); + test('SEARCH builds rejected-state exclusions for noRejected', async () => { mockApiSearch.mockResolvedValue(advancedResponse(2)); @@ -477,8 +541,8 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json.mock.calls[0][0].status).toBe('ok'); - expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(0); - expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([]); + expect(res.json.mock.calls[0][0].pagination.totalResults).toBe(0); + expect(res.json.mock.calls[0][0].records).toStrictEqual([]); expect(mockApiSearch).not.toHaveBeenCalled(); }); @@ -507,9 +571,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); @@ -555,37 +617,7 @@ describe('Search controller SEARCH handler', () => { }) ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - resultsReturned: 1, - totalPages: 1, - status: 'ok', - data: { - took: 22, - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); - 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'); + expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(1, [fullApplicable], { pageSize: 1 })); }); test('SEARCH semantically filters SSVC candidates and fetches full records for the requested page', async () => { @@ -658,8 +690,8 @@ describe('Search controller SEARCH handler', () => { 'containers.adp.metrics.other' ]); expect(res.json.mock.calls[0][0].status).toBe('ok'); - expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(1); - expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); + expect(res.json.mock.calls[0][0].pagination.totalResults).toBe(1); + expect(res.json.mock.calls[0][0].records).toStrictEqual([fullRecord._source]); }); test('SEARCH semantically filters date-bounded KEV candidates within the selected scope', async () => { @@ -719,8 +751,8 @@ describe('Search controller SEARCH handler', () => { 'containers.adp.metrics.other' ]); expect(res.json.mock.calls[0][0].status).toBe('ok'); - expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(1); - expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); + expect(res.json.mock.calls[0][0].pagination.totalResults).toBe(1); + expect(res.json.mock.calls[0][0].records).toStrictEqual([fullRecord._source]); }); test('SEARCH applies semantic virtualMatchString version range filtering before building response metadata', async () => { @@ -783,21 +815,7 @@ describe('Search controller SEARCH handler', () => { }) ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - resultsReturned: 1, - totalPages: 1, - status: 'ok', - data: { - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); + expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(1, [fullApplicable], { pageSize: 1 })); }); test('SEARCH applies semantic virtualMatchString filtering before building response metadata', async () => { @@ -839,21 +857,7 @@ describe('Search controller SEARCH handler', () => { expect(mockSearchRaw.mock.calls[0][0].size).toBe(10000); expect(mockSearchRaw.mock.calls[0][0].from).toBe(0); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - resultsReturned: 1, - totalPages: 1, - status: 'ok', - data: { - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); + expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(1, [fullApplicable], { pageSize: 1 })); }); test('SEARCH returns sanitized upstream errors when optimized post-filter search fails', async () => { @@ -879,6 +883,37 @@ describe('Search controller SEARCH handler', () => { }); }); + test('SEARCH warns when semantic results reach the bounded candidate limit', async () => { + jest.spyOn(searchPostFilters, 'filterSearchHits').mockReturnValue({ + candidateCount: constants.MAX_SEARCH_WINDOW, + filteredHits: [] + }); + mockSearchRaw.mockResolvedValue(rawResponse(constants.MAX_SEARCH_WINDOW)); + + await controller.SEARCH( + { + query: { + virtualMatchString: 'cpe:2.3:o:microsoft:windows_10_1809' + } + }, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + searchSuccessResponse(0, [], { + totalIsExact: false, + warnings: [ + { + code: 'SEMANTIC_CANDIDATE_LIMIT_REACHED', + message: 'Results and totals reflect matches found within the first 10,000 search candidates.' + } + ] + }) + ); + }); + test('SEARCH builds version-specific CVSS v3 severity filters', async () => { mockApiSearch.mockResolvedValue(advancedResponse(1)); @@ -1079,7 +1114,7 @@ describe('Search controller SEARCH handler', () => { { regexp: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value: 'cpe:2\\.3:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*', + value: 'cpe:2\\.3:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*', case_insensitive: true } } @@ -1101,13 +1136,7 @@ describe('Search controller SEARCH handler', () => { ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - page: 0, - pageSize: 20, - resultsReturned: 0, - totalPages: 0, - ...resp - }); + expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(0)); }); test('SEARCH calculates an out-of-range page from the requested page size', async () => { @@ -1126,13 +1155,7 @@ describe('Search controller SEARCH handler', () => { ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - page: 2, - pageSize: 10, - resultsReturned: 0, - totalPages: 1, - ...resp - }); + expect(res.json).toHaveBeenCalledWith(searchSuccessResponse(5, [], { from: 10, pageSize: 10 })); }); test.each([ @@ -1155,11 +1178,9 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(503); expect(res.set).toHaveBeenCalledWith('Retry-After', '5'); expect(res.json).toHaveBeenCalledWith({ - error: 'OPEN_SEARCH_ERROR', - message: - 'OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.' + 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 () => { @@ -1182,7 +1203,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 () => { @@ -1215,6 +1235,32 @@ describe('Search controller SEARCH handler', () => { }); }); + test.each([true, false])( + 'SEARCH rejects elapsed deadlines after synchronous filtering with match=%s', + async (matches) => { + captureTimers(); + const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n); + mockGetSearchRequestDeadlineMs.mockReturnValue(10); + mockSearchRaw.mockResolvedValue( + rawResponse(1, [ + cpeHit('CVE-2024-0001', { + criteria: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*' + }) + ]) + ); + jest.spyOn(cpe, 'hitAppliesToCpeName').mockImplementation(() => { + clock.mockReturnValue(10000000n); + return matches; + }); + + await controller.SEARCH({ searchParams: { cpeName: 'cpe:2.3:a:vendor:product:1.0' } }, res, next); + + expect(res.status).toHaveBeenCalledWith(504); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'SEARCH_TIMEOUT' })); + expect(mockApiSearch).not.toHaveBeenCalled(); + } + ); + test('SEARCH stops without writing a response when the client disconnects', async () => { const req = new EventEmitter(); req.requestId = 'client-disconnect-request'; @@ -1233,7 +1279,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..aaad5e3 100644 --- a/routes/cpeName.test.js +++ b/routes/cpeName.test.js @@ -21,43 +21,49 @@ describe('POST /search cpeName route behavior', () => { jest.clearAllMocks(); }); - test('rejects cpeName with wildcard version before the controller runs', async () => { - const cpeName = 'cpe:2.3:a:artifex:ghostscript:*:*:*:*:*:*:*:*'; - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - cpeName - } - }); - - expect(response.status).toBe(404); - expect(response.body).toStrictEqual({ - error: 'INVALID_CPE_NAME', - message: - 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' - }); - expect(controller.SEARCH).not.toHaveBeenCalled(); - }); + test.each(['*', '2.6.*', '2.6*', '2.*', '*.*', '0.*', '10.*', '100.*', '2.6.?'])( + 'rejects cpeName version %s before the controller runs', + async (version) => { + const cpeName = `cpe:2.3:o:linux:linux_kernel:${version}:*:*:*:*:*:*:*`; + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + cpeName + } + }); - test('allows valid cpeName through to the controller', async () => { - const cpeName = 'cpe:2.3:a:artifex:ghostscript:10.05.1:*:*:*:*:*:*:*'; - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - cpeName - } - }); - - expect(response.status).toBe(200); - expect(controller.SEARCH).toHaveBeenCalledTimes(1); - expect(response.body).toStrictEqual({ - route: 'search', - params: { - scope: 'FULL', - cpeName - } - }); - }); + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'INVALID_CPE_NAME', + message: + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + }); + expect(controller.SEARCH).not.toHaveBeenCalled(); + } + ); + + test.each(['10.05.1', '-', '2.6.', '1001.1', '5.1991234123412341234abcdefasdfasdfasdf', String.raw`2.6.\*`])( + 'allows cpeName version %s through to the controller', + async (version) => { + const cpeName = `cpe:2.3:a:artifex:ghostscript:${version}:*:*:*:*:*:*:*`; + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + cpeName + } + }); + + expect(response.status).toBe(200); + expect(controller.SEARCH).toHaveBeenCalledTimes(1); + expect(response.body).toStrictEqual({ + route: 'search', + params: { + scope: 'FULL', + cpeName + } + }); + } + ); }); diff --git a/routes/index.js b/routes/index.js index 083db99..7e5a5a5 100644 --- a/routes/index.js +++ b/routes/index.js @@ -198,7 +198,7 @@ router.post(

Retrieves all matching CVE records after applying the JSON request body as filters

Result Counts and Pagination

Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.

-

CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 OpenSearch candidates, so their reported totals and pages include only matches found within that candidate set.

+

CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 search candidates, so their reported totals and pages include only matches found within that candidate set. A warning is returned when that limit is reached.

#swagger.requestBody = { required: false, content: { @@ -309,15 +309,21 @@ content: { } }, } -#swagger.responses[404] = { -description: 'Invalid cpeName value', +#swagger.responses[413] = { +description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', content: { "application/json": { - schema: { - $ref: "#/components/schemas/ApiErrorResponse" - } + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} +#swagger.responses[500] = { +description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } } -}, +} } #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', @@ -330,7 +336,7 @@ content: { }, } #swagger.responses[502] = { -description: 'OpenSearch returned an invalid or unexpected response', +description: 'The search backend returned an invalid or unexpected response', content: { "application/json": { schema: { @@ -340,7 +346,7 @@ content: { }, } #swagger.responses[503] = { -description: 'OpenSearch is unavailable', +description: 'The search service is temporarily unavailable', content: { "application/json": { schema: { @@ -350,7 +356,7 @@ content: { }, headers: { "Retry-After": { - description: "Seconds before retrying an unavailable OpenSearch dependency", + description: "Seconds before retrying the unavailable search service", schema: { type: "integer" } @@ -358,7 +364,7 @@ headers: { } } #swagger.responses[504] = { -description: 'Search exceeded its OpenSearch or overall processing deadline', +description: 'Search exceeded its processing deadline', content: { "application/json": { schema: { @@ -494,6 +500,7 @@ router.post( }, sort: { type: "object", + required: ["property"], additionalProperties: false, properties: { property: { @@ -538,6 +545,16 @@ headers: { } } } +#swagger.responses[400] = { +description: 'Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiErrorResponse" + } + } +}, +} #swagger.responses[504] = { description: 'Search exceeded its OpenSearch request deadline', content: { @@ -548,6 +565,30 @@ content: { } }, } +#swagger.responses[413] = { +description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} +#swagger.responses[415] = { +description: 'Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} +#swagger.responses[500] = { +description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +content: { + "application/json": { + schema: { $ref: "#/components/schemas/ApiError" } + } +} +} #swagger.responses[410] = { description: 'The endpoint is disabled by service configuration', content: { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 73a5219..63c3958 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -49,7 +49,22 @@ describe('search route integration', () => { }); test('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { - mockSearchOpenSearch.mockResolvedValue(advancedResponse(1, [{ _id: 'CVE-2024-0001' }]).data); + const record = { + dataType: 'CVE_RECORD', + cveMetadata: { + cveId: 'CVE-2024-0001' + } + }; + mockSearchOpenSearch.mockResolvedValue( + advancedResponse(1, [ + { + _index: 'private-index-name', + _id: 'CVE-2024-0001', + _score: null, + _source: record + } + ]).data + ); const response = await request(createApp(), { method: 'POST', @@ -93,10 +108,20 @@ describe('search route integration', () => { signal: expect.any(AbortSignal) } ); - expect(response.body.page).toBe(1); - expect(response.body.pageSize).toBe(1); - expect(response.body.resultsReturned).toBe(1); - expect(response.body.totalPages).toBe(1); + expect(response.body).toStrictEqual({ + apiVersion: '1.0', + status: 'ok', + warnings: [], + pagination: { + page: 1, + pageSize: 1, + resultsReturned: 1, + totalResults: 1, + totalPages: 1, + totalIsExact: true + }, + records: [record] + }); }); test('POST /search builds a scope-aware literal reference URL substring filter', async () => { diff --git a/routes/swagger.js b/routes/swagger.js index 9092d25..99abc58 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'); @@ -9,7 +10,7 @@ const projectRoot = path.join(__dirname, '..'); const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); const routes = [path.join(__dirname, 'index.js')]; const maxSearchWindowLabel = constants.MAX_SEARCH_WINDOW.toLocaleString('en-US'); -const semanticPostFilterDescription = `CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most ${maxSearchWindowLabel} OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.`; +const semanticPostFilterDescription = `CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most ${maxSearchWindowLabel} search candidates, so reported totals and pages include only matches found within that candidate set.`; function searchRequestProperty(description, schema) { return { @@ -43,7 +44,7 @@ const searchRequestProperties = { } ), keywordSearch: searchRequestProperty( - `Returns CVE records where a word or phrase is included in a description selected by scope. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, + `Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string. For example, remote code searches for remote* AND code*. Matching follows the index text analyzer. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch, @@ -51,11 +52,11 @@ const searchRequestProperties = { } ), keywordExactMatch: searchRequestProperty( - 'When true, a multi-word keywordSearch value must appear exactly and in the same order. keywordSearch is required when keywordExactMatch is true. JSON booleans and the lowercase strings "true" and "false" are accepted.', + 'When true, uses an analyzed phrase query instead of keyword prefix matching in the scope-selected description fields. Terms must match in order according to the index text analyzer; this is not a case-sensitive, character-for-character substring comparison. keywordSearch is required when keywordExactMatch is true. JSON booleans and the lowercase strings "true" and "false" are accepted.', coercibleBooleanSchema() ), cveIds: searchRequestProperty( - `Returns one or more CVE records identified by a comma-separated list of CVE IDs. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, + `Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cveIds, @@ -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. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. 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. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -158,7 +159,7 @@ const searchRequestProperties = { } ), isVulnerable: searchRequestProperty( - 'When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable. JSON booleans and the lowercase strings "true" and "false" are accepted.', + 'When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable set to true; false is allowed. JSON booleans and the lowercase strings "true" and "false" are accepted.', coercibleBooleanSchema() ), lastModStartDate: searchRequestProperty( @@ -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, @@ -378,49 +379,38 @@ const doc = { } ] }, - SearchHit: { + SearchWarning: { type: 'object', - additionalProperties: true, - required: ['_source'], + additionalProperties: false, + required: ['code', 'message'], properties: { - _id: { + code: { type: 'string' }, - _source: { - type: 'object', - additionalProperties: true - } - } - }, - SearchHits: { - type: 'object', - additionalProperties: true, - required: ['total', 'hits'], - properties: { - total: { - type: 'object', - additionalProperties: true, - required: ['value'], - properties: { - value: { - type: 'integer', - minimum: 0, - description: `For direct searches, the exact number of matching records, which may be greater than ${maxSearchWindowLabel}. For semantic post-filtered searches, the number of matches found within the bounded ${maxSearchWindowLabel}-candidate set.` - } + message: { + type: 'string' + }, + cveIds: { + type: 'array', + description: 'Requested CVE IDs associated with this warning, when applicable.', + items: { + type: 'string' } }, - hits: { + components: { type: 'array', + description: 'CPE component names associated with this warning, when applicable.', items: { - $ref: '#/components/schemas/SearchHit' + type: 'string', + enum: cpe.CPE_COMPONENT_NAMES } } } }, - SearchResponse: { + SearchPagination: { type: 'object', additionalProperties: false, - required: ['page', 'pageSize', 'resultsReturned', 'totalPages', 'status', 'data'], + required: ['page', 'pageSize', 'resultsReturned', 'totalResults', 'totalPages', 'totalIsExact'], properties: { page: { type: 'integer', @@ -437,27 +427,55 @@ const doc = { minimum: 0, description: 'Number of CVE records actually returned in this response.' }, + totalResults: { + type: 'integer', + minimum: 0, + description: `For direct searches, the exact number of matching records, which may be greater than ${maxSearchWindowLabel}. For semantic post-filtered searches, the number of matches found within the bounded ${maxSearchWindowLabel}-candidate set.` + }, totalPages: { type: 'integer', minimum: 0, - description: `Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded ${maxSearchWindowLabel}-candidate set.` + description: `Pages derived from totalResults and pageSize. Semantic post-filtered searches derive this value from matches found within the bounded ${maxSearchWindowLabel}-candidate set.` + }, + totalIsExact: { + type: 'boolean', + description: `True when totalResults represents every match. False when a semantic search reaches the ${maxSearchWindowLabel}-candidate limit and additional matches may exist.` + } + } + }, + CveRecord: { + type: 'object', + additionalProperties: true, + description: 'A complete CVE Record without search-backend transport metadata.' + }, + SearchResponse: { + type: 'object', + additionalProperties: false, + required: ['apiVersion', 'status', 'warnings', 'pagination', 'records'], + properties: { + apiVersion: { + type: 'string', + enum: ['1.0'], + description: 'Version of the public search response contract.' }, status: { type: 'string', enum: ['ok'] }, - data: { - type: 'object', - additionalProperties: true, - required: ['hits'], - properties: { - took: { - type: 'number', - minimum: 0 - }, - hits: { - $ref: '#/components/schemas/SearchHits' - } + warnings: { + type: 'array', + description: 'Non-fatal conditions that may affect how the results should be interpreted.', + items: { + $ref: '#/components/schemas/SearchWarning' + } + }, + pagination: { + $ref: '#/components/schemas/SearchPagination' + }, + records: { + type: 'array', + items: { + $ref: '#/components/schemas/CveRecord' } } } diff --git a/server.js b/server.js index 4680dcc..02f4eff 100644 --- a/server.js +++ b/server.js @@ -38,15 +38,26 @@ function createRequestLogger() { } function handleRequestError(err, req, res, next) { + if (res.headersSent) return next(err); + if (err?.type === 'entity.parse.failed') { return res.status(400).json(error.malformedJsonError()); } - if (err?.status === 415 && req.path === '/search') { - return res.status(415).json(error.searchContentTypeError()); + const errorStatus = err?.status ?? err?.statusCode; + const status = Number.isInteger(errorStatus) && errorStatus >= 400 && errorStatus <= 599 ? errorStatus : 500; + + if (err?.type === 'entity.too.large' || status === 413) { + return res.status(413).json(error.requestBodyTooLargeError()); + } + + if (status === 415) { + return res + .status(415) + .json(req.path === '/search' ? error.searchContentTypeError() : error.requestEncodingError()); } - next(err); + return res.status(status).json(status < 500 ? error.invalidRequestError() : error.internalServerError()); } function createApp() { @@ -90,6 +101,7 @@ module.exports = { createRequestLogger, getRedactedRequestPath: requestLogging.getRedactedRequestPath, getRuntimeConfig, + handleRequestError, startServer, swaggerSetupOptions }; diff --git a/server.test.js b/server.test.js index be14e86..7b0bcac 100644 --- a/server.test.js +++ b/server.test.js @@ -34,6 +34,7 @@ const { createRequestLogger, getRedactedRequestPath, getRuntimeConfig, + handleRequestError, startServer, swaggerSetupOptions } = require('./server'); @@ -183,6 +184,101 @@ describe('server application bootstrap', () => { expect(mockSearch).not.toHaveBeenCalled(); }); + test.each([ + ['development', '/search'], + ['development', '/webSearch'], + ['production', '/search'], + ['production', '/webSearch'], + [undefined, '/search'], + [undefined, '/webSearch'] + ])('oversized bodies return sanitized JSON in %s at %s', async (environment, path) => { + restoreEnv('NODE_ENV', environment); + const response = await request(createApp(), { + method: 'POST', + path, + body: { query: 'x'.repeat(102401) } + }); + + expect(response.status).toBe(413); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.headers['x-request-id']).toBeDefined(); + expect(response.body).toStrictEqual({ + error: 'REQUEST_BODY_TOO_LARGE', + message: 'The request body exceeds the allowed size limit.' + }); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockWebSearch).not.toHaveBeenCalled(); + }); + + test('unsupported webSearch encoding returns sanitized JSON', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/webSearch', + rawBody: '{"query":"apache"}', + headers: { 'Content-Type': 'application/json; charset=iso-8859-1' } + }); + + expect(response.status).toBe(415); + expect(response.body).toStrictEqual({ + error: 'REQUEST_ENCODING_UNSUPPORTED', + message: 'The request body uses an unsupported encoding.' + }); + expect(mockWebSearch).not.toHaveBeenCalled(); + }); + + test.each(['/search', '/webSearch'])('corrupt compressed bodies return sanitized JSON at %s', async (path) => { + const response = await request(createApp(), { + method: 'POST', + path, + rawBody: 'not-a-gzip-stream', + headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'INVALID_REQUEST', + message: 'The request could not be processed.' + }); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockWebSearch).not.toHaveBeenCalled(); + }); + + test.each(['development', 'production'])( + 'unexpected errors never expose exception details in %s', + async (environment) => { + process.env.NODE_ENV = environment; + mockSearch.mockImplementationOnce((_req, _res, next) => next(new Error('private backend credentials'))); + + const response = await request(createApp(), { method: 'POST', path: '/search', body: {} }); + + expect(response.status).toBe(500); + expect(response.body).toStrictEqual({ + error: 'INTERNAL_SERVER_ERROR', + message: 'An unexpected server error occurred. Please try again later.' + }); + } + ); + + test('delegates errors after headers are sent without trying to rewrite the response', () => { + const failure = new Error('already streaming'); + const res = { headersSent: true, status: jest.fn() }; + const next = jest.fn(); + handleRequestError(failure, {}, res, next); + expect(next).toHaveBeenCalledWith(failure); + expect(res.status).not.toHaveBeenCalled(); + }); + + test.each([ + [{ statusCode: 400 }, 400, 'INVALID_REQUEST'], + [{ status: 503 }, 503, 'INTERNAL_SERVER_ERROR'], + [{ status: 200 }, 500, 'INTERNAL_SERVER_ERROR'] + ])('preserves valid error statuses without forwarding details: %j', (failure, status, code) => { + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + handleRequestError(failure, {}, res, jest.fn()); + expect(res.status).toHaveBeenCalledWith(status); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: code })); + }); + test('getRuntimeConfig preserves baseURL behavior when PORT is set without BASE_URL', () => { delete process.env.BASE_URL; process.env.PORT = '4567'; diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 048dad6..c87b1c5 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -123,17 +123,21 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", + " 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 at least 1', () => pm.expect(payload.pagination.totalResults).to.be.at.least(1));", "}" ] } @@ -163,17 +167,21 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", + " 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 at least 1', () => pm.expect(payload.pagination.totalResults).to.be.at.least(1));", "}" ] } @@ -209,18 +217,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -244,6 +256,121 @@ }, "response": [] }, + { + "name": "Pre-1999 CVE ID returns no records and a 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 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "const expectedWarnings = [{\"code\":\"CVE_IDS_NOT_RETURNED\",\"message\":\"CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.\",\"cveIds\":[\"CVE-1998-0001\"]}];", + "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-1998-0001\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Mixed CVE ID years return supported matches and a 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-2024-5981\"]));", + "const expectedWarnings = [{\"code\":\"CVE_IDS_NOT_RETURNED\",\"message\":\"CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.\",\"cveIds\":[\"CVE-1998-0001\"]}];", + "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-1998-0001,CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "Multiple CVE IDs", "event": [ @@ -261,18 +388,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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 2', () => pm.expect(payload.pagination.totalResults).to.eql(2));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", "}" ] @@ -313,18 +444,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + " 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));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -364,18 +499,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + " 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));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -421,18 +560,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -473,18 +616,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -525,18 +672,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + " 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 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -576,18 +727,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -628,18 +783,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -680,18 +839,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2022-31640\"]));", "}" ] @@ -738,18 +901,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -790,18 +957,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -842,18 +1013,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -894,18 +1069,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -946,18 +1125,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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 2', () => pm.expect(payload.pagination.totalResults).to.eql(2));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", "}" ] @@ -998,18 +1181,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1056,18 +1243,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1108,18 +1299,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1160,18 +1355,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1212,18 +1411,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1264,18 +1467,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1316,18 +1523,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1368,18 +1579,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1420,18 +1635,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1472,18 +1691,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1524,18 +1747,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1576,18 +1803,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2022-31640\"]));", "}" ] @@ -1634,18 +1865,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1686,18 +1921,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1738,18 +1977,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1790,18 +2033,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1842,18 +2089,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1894,18 +2145,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -1952,18 +2207,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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\"]));", "}" ] @@ -2004,18 +2263,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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\"]));", "}" ] @@ -2056,18 +2319,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2108,18 +2375,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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\"]));", "}" ] @@ -2143,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": [ @@ -2160,18 +2547,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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\"]));", "}" ] @@ -2212,18 +2603,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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\"]));", "}" ] @@ -2264,18 +2659,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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\"]));", "}" ] @@ -2322,18 +2721,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2374,18 +2777,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2426,18 +2833,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2478,18 +2889,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2530,18 +2945,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2582,18 +3001,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2634,18 +3057,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2692,18 +3119,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2020-1472\"]));", "}" ] @@ -2744,18 +3175,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -2796,18 +3231,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2020-1472\"]));", "}" ] @@ -2848,18 +3287,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2020-1472\"]));", "}" ] @@ -2900,18 +3343,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2020-1472\"]));", "}" ] @@ -2958,18 +3405,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -3010,18 +3461,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -3062,18 +3517,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -3114,18 +3573,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + " 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 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -3165,18 +3628,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2022-38625\"]));", "}" ] @@ -3217,18 +3684,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -3275,18 +3746,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] @@ -3327,18 +3802,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2022-31640\"]));", "}" ] @@ -3379,18 +3858,22 @@ " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + " 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-2020-1472\"]));", "}" ] @@ -5497,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;", "});", @@ -5555,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);", "});", @@ -5584,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": [ @@ -5819,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);", "});", @@ -6264,7 +6879,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping OpenSearch readiness failure; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" @@ -6309,15 +6926,17 @@ "response": [] }, { - "name": "OpenSearch unavailable search", + "name": "Search service unavailable", "event": [ { "listen": "prerequest", "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping OpenSearch unavailable search; set runFaultInjection=true to enable it.');", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", + " console.log('Skipping Search service unavailable; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" ] @@ -6335,7 +6954,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\":\"OPEN_SEARCH_ERROR\",\"message\":\"OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.\"};", + "const expectedError = {\"error\":\"SEARCH_UNAVAILABLE\",\"message\":\"Search service is temporarily unavailable. Please try again later.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -6356,7 +6975,7 @@ "path": ["search"], "query": [] }, - "description": "Run only while OpenSearch connectivity is intentionally unavailable.", + "description": "Run only while search-backend connectivity is intentionally unavailable.", "body": { "mode": "raw", "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", @@ -6373,7 +6992,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping Search timeout; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" @@ -6430,7 +7051,9 @@ "script": { "type": "text/javascript", "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + "const environmentSetting = pm.environment.get('runFaultInjection');", + "const setting = environmentSetting === undefined ? pm.collectionVariables.get('runFaultInjection') : environmentSetting;", + "if (setting !== 'true') {", " console.log('Skipping Unexpected upstream response; set runFaultInjection=true to enable it.');", " pm.execution.skipRequest();", "}" diff --git a/test/postman/README.md b/test/postman/README.md index 3cc2da2..c4883b6 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -18,12 +18,12 @@ Deployment-specific environment files are intentionally ignored by Git so deploy 1. Start the API and its configured OpenSearch dependency. 2. Import `CVE-Search-API.postman_collection.json` into Postman. 3. Import `local.postman_environment.json` or an ignored deployment-specific environment file. -4. Confirm that `baseUrl` points to the intended API instance. +4. Confirm that `baseUrl` points to the intended API instance. The local environment uses port `4000`, while the API defaults to `3000`; set `PORT=4000` before starting the API or update `baseUrl` accordingly. 5. Open the collection runner and run folders `00` through `16`. Folders are grouped by service checks, positive filter coverage, intentional combinations, and expected validation failures. Positive search requests assert the response contract and use stable CVE fixtures to check totals or returned IDs. Negative requests assert the exact HTTP status, error code, and error message. -The collection checks that each response includes an `X-Request-ID`. Search responses also must contain the documented status, pagination, total, and hit-array fields. +The collection checks that each response includes an `X-Request-ID`. Search responses also must contain the documented API version, status, warnings, pagination, and CVE Record array fields, without search-backend hit wrappers. ## Environments @@ -68,12 +68,16 @@ Folder `17 - Manual fault injection - skipped by default` describes runtime erro Setting `runFaultInjection` to `true` only enables the requests; it does not create their required failure conditions. Prepare and run one request at a time because the conditions are different and can conflict. -| Request | Required condition | Expected response | -| ----------------------------- | ------------------------------------------------------------------------------------------- | --------------------------- | -| OpenSearch readiness failure | Make the configured OpenSearch index unavailable to the API. | `503 SEARCH_NOT_READY` | -| OpenSearch unavailable search | Make OpenSearch connectivity, permissions, or index access unavailable. | `503 OPEN_SEARCH_ERROR` | -| Search timeout | Deliberately lower the request deadline or delay OpenSearch beyond the configured deadline. | `504 SEARCH_TIMEOUT` | -| Unexpected upstream response | Use a test double or proxy that returns an invalid or incomplete OpenSearch response. | `502 UPSTREAM_SEARCH_ERROR` | +The selected environment's `runFaultInjection` value takes precedence over the collection variable. When the environment does not define it, the collection default is used. Only the string `true` enables these requests; `false`, an empty value, and other values keep them disabled. Set the environment value back to `false` after each approved fault check. + +| Request | Required condition | Expected response | +| ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------- | +| OpenSearch readiness failure | Make the configured OpenSearch index unavailable to the API. | `503 SEARCH_NOT_READY` | +| Search service unavailable | Make search-backend connectivity unavailable, such as by targeting a closed local port. | `503 SEARCH_UNAVAILABLE` | +| Search timeout | Deliberately lower the request deadline or delay OpenSearch beyond the configured deadline. | `504 SEARCH_TIMEOUT` | +| Unexpected upstream response | Use a test double or proxy that returns an invalid or incomplete OpenSearch response. | `502 UPSTREAM_SEARCH_ERROR` | + +Permission denial (upstream HTTP 403) and a missing index (upstream HTTP 404) instead map to `502 UPSTREAM_SEARCH_ERROR` on `/search`; they are not valid ways to trigger the collection's `503 SEARCH_UNAVAILABLE` assertion. The readiness endpoint maps either failure to `503 SEARCH_NOT_READY`. These are endpoint-specific error mappings, not contradictory test results. Run a fault check as follows: diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 412b5bb..60feeb1 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -49,7 +49,14 @@ function requestIdTests() { ]; } -function successTests({ status = 200, exactTotal, minimumTotal, expectedIds = [], expectedHitCount } = {}) { +function successTests({ + status = 200, + exactTotal, + minimumTotal, + expectedIds = [], + expectedHitCount, + expectedWarnings = [] +} = {}) { const tests = [ `pm.test('status is ${status}', () => pm.response.to.have.status(${status}));`, ...requestIdTests(), @@ -58,41 +65,52 @@ function successTests({ status = 200, exactTotal, minimumTotal, expectedIds = [] " 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.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.resultsReturned).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - ' pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);', + " 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');", '});' ]; if (exactTotal !== undefined) { tests.push( - `pm.test('total is ${exactTotal}', () => pm.expect(payload.data.hits.total.value).to.eql(${exactTotal}));` + `pm.test('total is ${exactTotal}', () => pm.expect(payload.pagination.totalResults).to.eql(${exactTotal}));` ); } else if (minimumTotal !== undefined) { tests.push( - `pm.test('total is at least ${minimumTotal}', () => pm.expect(payload.data.hits.total.value).to.be.at.least(${minimumTotal}));` + `pm.test('total is at least ${minimumTotal}', () => pm.expect(payload.pagination.totalResults).to.be.at.least(${minimumTotal}));` ); } if (expectedHitCount !== undefined) { tests.push( - `pm.test('returned hit count is ${expectedHitCount}', () => pm.expect(payload.data.hits.hits.length).to.eql(${expectedHitCount}));` + `pm.test('returned record count is ${expectedHitCount}', () => pm.expect(payload.records.length).to.eql(${expectedHitCount}));` ); } if (expectedIds.length) { tests.push( - ' const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);', + ' const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);', `pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members(${JSON.stringify(expectedIds)}));` ); } + if (expectedWarnings.length) { + tests.push( + `const expectedWarnings = ${JSON.stringify(expectedWarnings)};`, + "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));" + ); + } + tests.push('}'); return tests; } @@ -153,7 +171,9 @@ function item({ script: { type: 'text/javascript', exec: [ - `if (pm.collectionVariables.get('${skipUnless}') !== 'true') {`, + `const environmentSetting = pm.environment.get('${skipUnless}');`, + `const setting = environmentSetting === undefined ? pm.collectionVariables.get('${skipUnless}') : environmentSetting;`, + "if (setting !== 'true') {", ` console.log('Skipping ${name}; set ${skipUnless}=true to enable it.');`, ' pm.execution.skipRequest();', '}' @@ -244,6 +264,38 @@ const baselineAndPagination = folder( positive('No request body', undefined, { minimumTotal: 1 }), positive('Default empty-object search', {}, { minimumTotal: 1 }), positive('Single CVE ID', { cveIds: ids.primary }, { exactTotal: 1, expectedIds: [ids.primary] }), + positive( + 'Pre-1999 CVE ID returns no records and a warning', + { cveIds: 'CVE-1998-0001' }, + { + exactTotal: 0, + expectedHitCount: 0, + expectedWarnings: [ + { + code: 'CVE_IDS_NOT_RETURNED', + message: + 'CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.', + cveIds: ['CVE-1998-0001'] + } + ] + } + ), + positive( + 'Mixed CVE ID years return supported matches and a warning', + { cveIds: `CVE-1998-0001,${ids.primary}` }, + { + exactTotal: 1, + expectedIds: [ids.primary], + expectedWarnings: [ + { + code: 'CVE_IDS_NOT_RETURNED', + message: + 'CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.', + cveIds: ['CVE-1998-0001'] + } + ] + } + ), positive( 'Multiple CVE IDs', { cveIds: `${ids.primary},${ids.rejected}`, resultsPerPage: 2, pageNumber: 1 }, @@ -530,6 +582,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', { @@ -968,19 +1051,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' }, @@ -1009,7 +1100,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', @@ -1100,12 +1191,12 @@ const faultInjection = folder( 'GET' ), faultInjectionItem( - 'OpenSearch unavailable search', + 'Search service unavailable', '/search', { cveIds: ids.primary }, 503, - error.openSearchError(), - 'Run only while OpenSearch connectivity is intentionally unavailable.' + error.searchUnavailableError(), + 'Run only while search-backend connectivity is intentionally unavailable.' ), faultInjectionItem( 'Search timeout', diff --git a/test/postman/collection.test.js b/test/postman/collection.test.js new file mode 100644 index 0000000..d7f958d --- /dev/null +++ b/test/postman/collection.test.js @@ -0,0 +1,38 @@ +const { runInNewContext } = require('vm'); +const collection = require('./CVE-Search-API.postman_collection.json'); + +describe('Postman fault-injection opt-in guards', () => { + const requests = collection.item.find((folder) => folder.name.startsWith('17 -')).item; + + test.each([ + ['true', 'false', false], + ['false', 'true', true], + [undefined, 'true', false], + [undefined, 'false', true], + [undefined, undefined, true], + ['', 'true', true], + [null, 'true', true], + [true, 'true', true], + ['TRUE', 'true', true] + ])('environment=%s collection=%s skips=%s', (environmentSetting, collectionSetting, shouldSkip) => { + for (const request of requests) { + const skipRequest = jest.fn(); + const environmentGet = jest.fn(() => environmentSetting); + const collectionGet = jest.fn(() => collectionSetting); + const script = request.event.find((event) => event.listen === 'prerequest').script.exec.join('\n'); + + runInNewContext(script, { + pm: { + environment: { get: environmentGet }, + collectionVariables: { get: collectionGet }, + execution: { skipRequest } + }, + console: { log: jest.fn() } + }); + + expect(environmentGet).toHaveBeenCalledWith('runFaultInjection'); + expect(collectionGet).toHaveBeenCalledTimes(environmentSetting === undefined ? 1 : 0); + expect(skipRequest).toHaveBeenCalledTimes(shouldSkip ? 1 : 0); + } + }); +}); diff --git a/utils/constants.js b/utils/constants.js index 3e400ef..83f7b4d 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -6,10 +6,9 @@ const MAX_CVSS_METRIC_TOKENS = 32; const MAX_CVSS_METRIC_TOKEN_LENGTH = 32; const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; +const 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, @@ -25,6 +24,7 @@ const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ const SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS = 20 * 1000; const SEARCH_REQUEST_DEADLINE_MS = 30 * 1000; const SEARCH_READINESS_TIMEOUT_MS = 5 * 1000; +const MAX_TIMER_TIMEOUT_MS = 2147483647; const rejectedCveStates = ['REJECT', 'REJECTED', 'Rejected']; const cpeVersionRangeTypes = ['including', 'excluding']; @@ -130,14 +130,14 @@ module.exports = { MAX_CVSS_METRIC_TOKEN_LENGTH, MAX_CVE_IDS, MAX_CVE_ID_LENGTH, + 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, SEARCH_READINESS_TIMEOUT_MS, + MAX_TIMER_TIMEOUT_MS, RESULTS_PER_PAGE, REJECTED_CVE_STATES: rejectedCveStates, CPE_VERSION_RANGE_TYPES: cpeVersionRangeTypes, diff --git a/utils/cpe.js b/utils/cpe.js index b59719a..26548a4 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,81 @@ 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 hasUnescapedWildcard(value) { + let escaped = false; + + for (const character of value) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '*' || character === '?') { + 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 +111,7 @@ function parseCpe23(input) { valid: true, value, components, + rawComponentCount: rawComponents.length, part: components[0], vendor: components[1], product: components[2], @@ -43,6 +119,10 @@ function parseCpe23(input) { }; } +function parseCpe23(input) { + return parseCpeComponents(input, 4); +} + function splitOnUnescapedColon(s) { const parts = []; let buf = ''; @@ -75,7 +155,8 @@ function parseCpeName(input) { const hasRequiredComponents = REQUIRED_CPE_COMPONENT_INDEXES.every((index) => { const component = parsed.components[index]; - return component && component !== '*'; + if (index === 0) return CONCRETE_CPE_PART_VALUES.has(component.toLowerCase()); + return component && !hasUnescapedWildcard(component); }); if (!hasRequiredComponents) { @@ -107,36 +188,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) { @@ -148,7 +218,8 @@ function escapeLuceneRegexpLiteral(input) { } function compatibleComponentRegexp(userComponent) { - if (userComponent === '*') return '[^:]*'; + // Escaped colons belong to the component, not the surrounding CPE delimiters. + if (userComponent === '*') return '([^:\\\\]|\\\\.)*'; return `(${escapeLuceneRegexpLiteral(userComponent)}|\\*)`; } @@ -391,7 +462,12 @@ function lowerBoundDoesNotExceedUpperBound(lower, upper) { } function versionRangesOverlap(a, b) { - return lowerBoundDoesNotExceedUpperBound(a.start, b.end) && lowerBoundDoesNotExceedUpperBound(b.start, a.end); + return ( + lowerBoundDoesNotExceedUpperBound(a.start, a.end) && + lowerBoundDoesNotExceedUpperBound(b.start, b.end) && + lowerBoundDoesNotExceedUpperBound(a.start, b.end) && + lowerBoundDoesNotExceedUpperBound(b.start, a.end) + ); } function rangeBoundaryMatches(assetVersion, cpeMatch) { @@ -536,11 +612,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..17e668c 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -38,12 +38,40 @@ describe('CPE helpers', () => { 'cpe:2.3:a:*:product:1.0:*:*:*:*:*:*:*', 'cpe:2.3:a:vendor:*:1.0:*:*:*:*:*:*:*', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + 'cpe:2.3:a:ven*dor:product:1.0', + 'cpe:2.3:a:ven?dor:product:1.0', + 'cpe:2.3:a:vendor:prod*uct:1.0', + 'cpe:2.3:a:vendor:prod?uct:1.0', + ...[ + '2.6.*', + '2.6*', + '2.*', + '*.*', + '0.*', + '10.*', + '100.*', + '?', + '2.6.?', + '*2.6', + String.raw`2.6\\*`, + String.raw`2.6\\?`, + String.raw`2.6\**` + ].map((version) => `cpe:2.3:o:linux:linux_kernel:${version}:*:*:*:*:*:*:*`), 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*:extra' ])('parseCpeName rejects invalid cpeName value %s', (value) => { expect(cpe.parseCpeName(value).valid).toBe(false); 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:*:*:*:*:*:*:*'); @@ -52,6 +80,17 @@ describe('CPE helpers', () => { expect(parsed.product).toBe('product\\:edition'); }); + test.each([ + String.raw`cpe:2.3:a:ven\*dor:prod\?uct:1.0`, + String.raw`cpe:2.3:a:vendor:product:2.6.\*`, + String.raw`cpe:2.3:a:vendor:product:2.6.\\\?` + ])('parseCpeName preserves escaped literal wildcard characters in %s', (value) => { + const parsed = cpe.parseCpeName(value); + + expect(parsed.valid).toBe(true); + expect(parsed.components.slice(0, 4)).toStrictEqual(value.split(':').slice(2)); + }); + test('parseVirtualMatchString accepts partial CPE 2.3 match strings without a version component', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:linux:linux_kernel'); @@ -71,14 +110,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', @@ -92,7 +163,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:artifex:ghostscript:10.05.1:*:*:*:*:*:*:*'); expect(cpe.buildCpeCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):10\\.05\\.1:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):10\\.05\\.1:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -100,7 +171,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:artifex:ghostscript:10.05.1:*:*:*:*:*:*:*'); expect(cpe.buildCpeCriteriaRegexp(parsed, { versionMode: 'wildcard' })).toBe( - 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):\\*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(a|\\*):(artifex|\\*):(ghostscript|\\*):\\*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -108,7 +179,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseCpeName('cpe:2.3:a:vendor:product(name):1.0+build:*:*:*:*:*:*:*'); expect(cpe.buildCpeCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(a|\\*):(vendor|\\*):(product\\(name\\)|\\*):1\\.0\\+build:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(a|\\*):(vendor|\\*):(product\\(name\\)|\\*):1\\.0\\+build:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -116,7 +187,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:microsoft:windows_10_1809'); expect(cpe.buildVirtualMatchStringCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*' ); }); @@ -124,7 +195,7 @@ describe('CPE helpers', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:microsoft:windows_10_1809:*:*:*:*:*:*:x86:*'); expect(cpe.buildVirtualMatchStringCriteriaRegexp(parsed)).toBe( - 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:(x86|\\*):[^:]*' + 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:(x86|\\*):([^:\\\\]|\\\\.)*' ); }); @@ -207,6 +278,16 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions(left, right)).toBe(0); }); + test('cpeMatchAppliesToCpeName rejects a wildcard version instead of comparing it as a concrete version', () => { + const criteria = { + criteria: 'cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*', + versionEndExcluding: '5.19' + }; + + expect(cpe.cpeMatchAppliesToCpeName(criteria, 'cpe:2.3:o:linux:linux_kernel:2.6.*:*:*:*:*:*:*:*')).toBe(false); + expect(cpe.cpeMatchAppliesToCpeName(criteria, 'cpe:2.3:o:linux:linux_kernel:2.6.12:*:*:*:*:*:*:*')).toBe(true); + }); + test('cpeMatchAppliesToCpeName honors inclusive and exclusive version boundaries', () => { const cpeName = 'cpe:2.3:a:vendor:product:2.10:*:*:*:*:*:*:*'; @@ -515,6 +596,33 @@ describe('CPE helpers', () => { ).toBe(false); }); + test.each([ + [{ versionStartIncluding: '3', versionEndIncluding: '2' }, false], + [{ versionStartExcluding: '3', versionEndExcluding: '2' }, false], + [{ versionStartIncluding: '2', versionEndExcluding: '2' }, false], + [{ versionStartExcluding: '2', versionEndIncluding: '2' }, false], + [{ versionStartExcluding: '2', versionEndExcluding: '2' }, false], + [{ versionStartIncluding: '2', versionEndIncluding: '2' }, true], + [{ versionStartIncluding: '2' }, true], + [{ versionEndExcluding: '2' }, true] + ])('validates stored intervals before overlap: %j', (bounds, expected) => { + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + ...bounds + }, + { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'), + versionStart: '1', + versionStartType: 'including', + versionEnd: '4', + versionEndType: 'including' + } + ) + ).toBe(expected); + }); + test('cpeMatchOverlapsVirtualMatchStringRange applies qualifier precedence to range overlap', () => { const matchString = cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'); diff --git a/utils/errors.js b/utils/errors.js index 2b1bfa8..cdbaa2e 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.' ); } @@ -180,6 +195,22 @@ class errors { return makeError('MALFORMED_JSON', 'The request body contains malformed JSON.'); } + requestBodyTooLargeError() { + return makeError('REQUEST_BODY_TOO_LARGE', 'The request body exceeds the allowed size limit.'); + } + + requestEncodingError() { + return makeError('REQUEST_ENCODING_UNSUPPORTED', 'The request body uses an unsupported encoding.'); + } + + invalidRequestError() { + return makeError('INVALID_REQUEST', 'The request could not be processed.'); + } + + internalServerError() { + return makeError('INTERNAL_SERVER_ERROR', 'An unexpected server error occurred. Please try again later.'); + } + searchQueryUnsupportedError() { return makeError( 'SEARCH_QUERY_UNSUPPORTED', @@ -311,11 +342,8 @@ class errors { return makeError('WEB_SEARCH_DISABLED', 'The /webSearch endpoint is disabled by service configuration.'); } - openSearchError() { - return makeError( - 'OPEN_SEARCH_ERROR', - 'OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.' - ); + searchUnavailableError() { + return makeError('SEARCH_UNAVAILABLE', 'Search service is temporarily unavailable. Please try again later.'); } searchNotReadyError() { diff --git a/utils/errors.test.js b/utils/errors.test.js index fe3e325..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.' } ], [ @@ -381,12 +372,11 @@ describe('error response helpers', () => { } ], [ - 'openSearchError', + 'searchUnavailableError', [], { - error: 'OPEN_SEARCH_ERROR', - message: - 'OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.' + error: 'SEARCH_UNAVAILABLE', + message: 'Search service is temporarily unavailable. Please try again later.' } ], [ @@ -400,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/searchDeadline.js b/utils/searchDeadline.js index ecb33c4..df7e4ce 100644 --- a/utils/searchDeadline.js +++ b/utils/searchDeadline.js @@ -1,3 +1,5 @@ +const requestDeadlines = new WeakMap(); + class SearchDeadlineExceededError extends Error { constructor() { super('Search request deadline exceeded.'); @@ -32,6 +34,10 @@ function getAbortReason(signal) { } function throwIfAborted(signal) { + const deadline = requestDeadlines.get(signal); + if (deadline && process.hrtime.bigint() >= deadline.expiresAt) { + deadline.abort(new SearchDeadlineExceededError()); + } if (signal?.aborted) throw getAbortReason(signal); } @@ -44,6 +50,7 @@ function addOnceListener(emitter, eventName, listener) { function createSearchRequestLifecycle(req, res, timeoutMs) { const controller = new AbortController(); + const expiresAt = process.hrtime.bigint() + BigInt(timeoutMs) * 1000000n; let completed = false; const abort = (reason) => { @@ -51,6 +58,7 @@ function createSearchRequestLifecycle(req, res, timeoutMs) { controller.abort(reason); } }; + requestDeadlines.set(controller.signal, { expiresAt, abort }); const removeRequestAbortedListener = addOnceListener(req, 'aborted', () => abort(new SearchClientDisconnectedError()) ); @@ -66,6 +74,7 @@ function createSearchRequestLifecycle(req, res, timeoutMs) { if (completed) return; completed = true; + requestDeadlines.delete(controller.signal); clearTimeout(timeoutId); removeRequestAbortedListener(); removeResponseCloseListener(); diff --git a/utils/searchDeadline.test.js b/utils/searchDeadline.test.js index cafbf3c..43d3d6e 100644 --- a/utils/searchDeadline.test.js +++ b/utils/searchDeadline.test.js @@ -62,6 +62,30 @@ describe('searchDeadline', () => { expect(clearTimeout).toHaveBeenCalledTimes(1); }); + test('checks elapsed monotonic time even when the timeout callback has not run', () => { + captureTimers(); + const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n); + const { lifecycle } = createLifecycle(10); + + clock.mockReturnValue(9999999n); + expect(() => lifecycle.throwIfAborted()).not.toThrow(); + clock.mockReturnValue(10000000n); + expect(() => searchDeadline.throwIfAborted(lifecycle.signal)).toThrow( + searchDeadline.SearchDeadlineExceededError + ); + lifecycle.complete(); + }); + + test('completion removes the elapsed-time deadline as well as the timer', () => { + captureTimers(); + const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n); + const { lifecycle } = createLifecycle(10); + + lifecycle.complete(); + clock.mockReturnValue(10000000n); + expect(() => lifecycle.throwIfAborted()).not.toThrow(); + }); + test('aborts when the request emits aborted', () => { captureTimers(); const { lifecycle, req } = createLifecycle(); 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/searchPostFilters.js b/utils/searchPostFilters.js index 5cca1a0..1454484 100644 --- a/utils/searchPostFilters.js +++ b/utils/searchPostFilters.js @@ -3,6 +3,10 @@ const cvss = require('./cvss'); const searchScope = require('./searchScope'); const ssvc = require('./ssvc'); const kev = require('./kev'); +const searchDeadline = require('./searchDeadline'); +const { setImmediate: yieldToEventLoop } = require('timers/promises'); + +const FILTER_BATCH_SIZE = 100; const CPE_SOURCE_FIELDS = Object.freeze([ 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', @@ -52,7 +56,8 @@ function hitMatches(hit, postFilters, cpeEvaluationContext) { return true; } -function filterSearchHits(resp, postFilters) { +async function filterSearchHits(resp, postFilters, { signal } = {}) { + searchDeadline.throwIfAborted(signal); const hitsContainer = resp?.data?.hits; const cpeEvaluationContext = cpe.createEvaluationContext(); @@ -64,10 +69,19 @@ function filterSearchHits(resp, postFilters) { }; } + const filteredHits = []; + for (let index = 0; index < hitsContainer.hits.length; index++) { + if (index > 0 && index % FILTER_BATCH_SIZE === 0) await yieldToEventLoop(); + searchDeadline.throwIfAborted(signal); + const hit = hitsContainer.hits[index]; + if (hitMatches(hit, postFilters, cpeEvaluationContext)) filteredHits.push(hit); + } + searchDeadline.throwIfAborted(signal); + return { hitsContainer, candidateCount: hitsContainer.hits.length, - filteredHits: hitsContainer.hits.filter((hit) => hitMatches(hit, postFilters, cpeEvaluationContext)) + filteredHits }; } diff --git a/utils/searchPostFilters.test.js b/utils/searchPostFilters.test.js index be3329c..16ae220 100644 --- a/utils/searchPostFilters.test.js +++ b/utils/searchPostFilters.test.js @@ -1,4 +1,5 @@ const searchPostFilters = require('./searchPostFilters'); +const searchDeadline = require('./searchDeadline'); function response(hits) { return { @@ -26,12 +27,12 @@ function cpeHit(id, cpeMatch) { }; } -function filteredHits(hits, postFilters) { - return searchPostFilters.filterSearchHits(response(hits), postFilters).filteredHits; +async function filteredHits(hits, postFilters) { + return (await searchPostFilters.filterSearchHits(response(hits), postFilters)).filteredHits; } describe('searchPostFilters CPE semantics', () => { - test('does not combine platform and version matches from different CPE entries', () => { + test('does not combine platform and version matches from different CPE entries', async () => { const candidate = cpeHit('CVE-2026-0250', [ { criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', @@ -54,14 +55,14 @@ describe('searchPostFilters CPE semantics', () => { ]); expect( - filteredHits([candidate], { + await filteredHits([candidate], { cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([]); }); - test('matches CPE string literals without lexical case', () => { + test('matches CPE string literals without lexical case', async () => { const candidate = cpeHit('CVE-2026-0251', { criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', vulnerable: true, @@ -70,14 +71,14 @@ describe('searchPostFilters CPE semantics', () => { }); expect( - filteredHits([candidate], { + await filteredHits([candidate], { cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', isVulnerable: true }) ).toStrictEqual([candidate]); }); - test('preserves exact version padding while normalizing numeric range segments', () => { + test('preserves exact version padding while normalizing numeric range segments', async () => { const exactCandidate = cpeHit('CVE-2025-9392', { criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', vulnerable: true @@ -89,26 +90,26 @@ describe('searchPostFilters CPE semantics', () => { }); expect( - filteredHits([exactCandidate], { + await filteredHits([exactCandidate], { cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([]); expect( - filteredHits([rangeCandidate], { + await filteredHits([rangeCandidate], { cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([rangeCandidate]); expect( - filteredHits([rangeCandidate], { + await filteredHits([rangeCandidate], { cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', isVulnerable: true }) ).toStrictEqual([]); }); - test('preserves CPE NA matches across numeric version ranges', () => { + test('preserves CPE NA matches across numeric version ranges', async () => { const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; const candidates = [ cpeHit('CVE-2020-17153', { @@ -131,10 +132,34 @@ describe('searchPostFilters CPE semantics', () => { ]; expect( - filteredHits(candidates, { - cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', - isVulnerable: true - }).map((hit) => hit._id) + ( + await filteredHits(candidates, { + cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', + isVulnerable: true + }) + ).map((hit) => hit._id) ).toStrictEqual(['CVE-2020-17153', 'CVE-2024-38208', 'CVE-2026-35429']); }); + + test('preserves candidate order across filtering batches', async () => { + const hits = Array.from({ length: 201 }, (_, index) => ({ _id: String(index) })); + const result = await searchPostFilters.filterSearchHits(response(hits), {}); + expect(result.candidateCount).toBe(hits.length); + expect(result.filteredHits).toStrictEqual(hits); + }); + + test('yields between batches so a client disconnect can cancel remaining work', async () => { + const controller = new AbortController(); + const reason = new searchDeadline.SearchClientDisconnectedError(); + const abort = setImmediate(() => controller.abort(reason)); + const hits = Array.from({ length: 201 }, (_, index) => ({ _id: String(index) })); + + try { + await expect( + searchPostFilters.filterSearchHits(response(hits), {}, { signal: controller.signal }) + ).rejects.toBe(reason); + } finally { + clearImmediate(abort); + } + }); }); diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 8616d73..03b9855 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -42,11 +42,11 @@ function normalizeBoolean(value, defaultValue = false) { return defaultValue; } -function getIntegerConfig(path, defaultValue, { min = 0 } = {}) { +function getIntegerConfig(path, defaultValue, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { if (!AppConfig.has(path)) return defaultValue; const value = Number(AppConfig.get(path)); - if (Number.isInteger(value) && value >= min) return value; + if (Number.isInteger(value) && value >= min && value <= max) return value; return defaultValue; } @@ -238,18 +238,23 @@ function isSearchProviderContractError(err) { function getOpenSearchReadinessTimeoutMs() { return getIntegerConfig('search.readinessTimeoutMs', constants.SEARCH_READINESS_TIMEOUT_MS, { - min: 1 + min: 1, + max: constants.MAX_TIMER_TIMEOUT_MS }); } function getOpenSearchRequestTimeoutMs() { return getIntegerConfig('search.openSearchRequestTimeoutMs', constants.SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, { - min: 1 + min: 1, + max: constants.MAX_TIMER_TIMEOUT_MS }); } function getSearchRequestDeadlineMs() { - return getIntegerConfig('search.requestDeadlineMs', constants.SEARCH_REQUEST_DEADLINE_MS, { min: 1 }); + return getIntegerConfig('search.requestDeadlineMs', constants.SEARCH_REQUEST_DEADLINE_MS, { + min: 1, + max: constants.MAX_TIMER_TIMEOUT_MS + }); } function resetSearchProviderForTests() { diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index dcaada4..050644b 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -234,6 +234,20 @@ describe('searchProvider', () => { expect(mockClientSearch).not.toHaveBeenCalled(); }); + test('fails readiness before its search probe when an alias reports conflicting field types', async () => { + const response = fieldCapsResponse(); + response.body.fields['cveMetadata.cveId.keyword'].text = { + searchable: true, + aggregatable: false + }; + mockClientFieldCaps.mockResolvedValue(response); + + await expect(searchProvider.checkOpenSearchReadiness()).rejects.toBeInstanceOf( + searchReadiness.SearchMappingContractError + ); + expect(mockClientSearch).not.toHaveBeenCalled(); + }); + test('aborts the readiness field-capabilities request when its lifecycle is canceled', async () => { const pending = deferred(); const request = pending.promise; @@ -382,12 +396,24 @@ describe('searchProvider', () => { expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(7000); }); - test('falls back to search timeout defaults when config values are invalid', () => { + test.each([1, '1', 2147483647, '2147483647'])('accepts timeout boundary %s', (value) => { mockAppConfigHas.mockReturnValue(true); - mockAppConfigGet.mockReturnValue('not-a-number'); + mockAppConfigGet.mockReturnValue(value); - expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(20000); - expect(searchProvider.getSearchRequestDeadlineMs()).toBe(30000); - expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(5000); + expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(Number(value)); + expect(searchProvider.getSearchRequestDeadlineMs()).toBe(Number(value)); + expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(Number(value)); }); + + test.each(['not-a-number', '', 0, -1, 1.5, 2147483648, '2147483648', Number.MAX_SAFE_INTEGER, Infinity])( + 'falls back to search timeout defaults for invalid or overflowing value %s', + (value) => { + mockAppConfigHas.mockReturnValue(true); + mockAppConfigGet.mockReturnValue(value); + + expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(20000); + expect(searchProvider.getSearchRequestDeadlineMs()).toBe(30000); + expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(5000); + } + ); }); diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 335aae7..fd628fc 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -151,7 +151,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { { regexp: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value: 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:(x86|\\*):[^:]*', + value: 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:(x86|\\*):([^:\\\\]|\\\\.)*', case_insensitive: true } } @@ -160,6 +160,33 @@ describe('searchQueryBuilder buildSearchRequest()', () => { expect(output.queryStringObjects).toStrictEqual([]); }); + test.each([ + ['product\\:edition', true], + ['product\\\\edition', true], + ['product\\\\\\:edition', true], + ['product:edition', false] + ])('wildcard CPE candidate patterns preserve component boundaries for %s', (product, expected) => { + const output = queryBuilder.buildSearchRequest({ virtualMatchString: 'cpe:2.3:a' }); + const clause = output.searchOptions.filters[0].regexp; + const pattern = clause['containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword'].value; + const criteria = 'cpe:2.3:a:vendor:' + product + ':1.0:*:*:*:*:*:*:*'; + + // This pattern uses only syntax shared by JavaScript and Lucene regular expressions. + expect(new RegExp('^' + pattern + '$').test(criteria)).toBe(expected); + }); + + test('CPE name candidate patterns allow escaped colons in wildcard optional components', () => { + const output = queryBuilder.buildSearchRequest({ + cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:android:*:*' + }); + const clause = output.searchOptions.filters[0].bool.should[0].regexp; + const pattern = clause['containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword'].value; + + expect( + new RegExp('^' + pattern + '$').test('cpe:2.3:a:vendor:product:1.0:*:edition\\:name:*:*:android:*:*') + ).toBe(true); + }); + test('buildSearchRequest over-fetches virtualMatchString version range candidates for post-filtering', () => { const output = queryBuilder.buildSearchRequest({ virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js index 8442075..d65e456 100644 --- a/utils/searchReadiness.js +++ b/utils/searchReadiness.js @@ -120,12 +120,16 @@ class SearchMappingContractError extends Error { function supportsRequirement(capabilities, requirement) { if (!capabilities || typeof capabilities !== 'object') return false; - return requirement.types.some((type) => { - const capability = capabilities[type]; - return ( - capability?.searchable === true && (requirement.aggregatable !== true || capability.aggregatable === true) - ); - }); + const mappedTypes = Object.entries(capabilities).filter(([type]) => type !== 'unmapped'); + return ( + mappedTypes.length > 0 && + mappedTypes.every( + ([type, capability]) => + requirement.types.includes(type) && + capability?.searchable === true && + (requirement.aggregatable !== true || capability.aggregatable === true) + ) + ); } function hasMappedCapability(capabilities) { diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js index 8b02e14..3cc3944 100644 --- a/utils/searchReadiness.test.js +++ b/utils/searchReadiness.test.js @@ -21,6 +21,40 @@ describe('search readiness mapping contract', () => { expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); }); + test('rejects conflicting types for the same field across indices', () => { + const body = validCapabilities(); + body.fields['cveMetadata.cveId.keyword'].text = { + searchable: true, + aggregatable: false + }; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test.each([true, false])('checks searchability of every otherwise compatible type: %s', (searchable) => { + const body = validCapabilities(); + body.fields['containers.cna.descriptions.value'].keyword = { searchable, aggregatable: true }; + + if (searchable) { + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + } else { + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + } + }); + + test('requires aggregation support for the CVE ID sort field', () => { + const body = validCapabilities(); + body.fields['cveMetadata.cveId.keyword'].keyword.aggregatable = false; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + test('rejects a missing capability group', () => { const body = validCapabilities(); const group = searchReadiness.FIELD_CAPABILITY_GROUPS[0]; diff --git a/utils/searchResponseBuilder.js b/utils/searchResponseBuilder.js new file mode 100644 index 0000000..04920e6 --- /dev/null +++ b/utils/searchResponseBuilder.js @@ -0,0 +1,105 @@ +const constants = require('./constants'); +const cpe = require('./cpe'); + +const API_VERSION = '1.0'; + +function semanticCandidateLimitWarning() { + return { + code: 'SEMANTIC_CANDIDATE_LIMIT_REACHED', + message: `Results and totals reflect matches found within the first ${constants.MAX_SEARCH_WINDOW.toLocaleString('en-US')} search candidates.` + }; +} + +function getCveIdWarnings(value) { + if (typeof value !== 'string') return []; + + const cveIds = [ + ...new Set( + value + .split(',') + .map((cveId) => cveId.trim().toUpperCase()) + .filter((cveId) => { + const match = /^CVE-(\d{4})-\d{4,}$/.exec(cveId); + return match && Number(match[1]) < constants.FIRST_CVE_RECORD_YEAR; + }) + ) + ]; + + if (!cveIds.length) return []; + + return [ + { + code: 'CVE_IDS_NOT_RETURNED', + message: `CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}. The listed requested IDs cannot produce results.`, + cveIds + } + ]; +} + +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); + const page = totalResults === 0 ? 0 : Math.floor(pagingOptions.from / pageSize) + 1; + const totalPages = totalResults === 0 ? 0 : Math.ceil(totalResults / pageSize); + + return { + apiVersion: API_VERSION, + status: 'ok', + warnings, + pagination: { + page, + pageSize, + resultsReturned: records.length, + totalResults, + totalPages, + totalIsExact + }, + records + }; +} + +module.exports = { + API_VERSION, + buildSearchResponse, + getCpeWarnings, + getCveIdWarnings, + getRequestWarnings, + semanticCandidateLimitWarning +}; diff --git a/utils/searchResponseBuilder.test.js b/utils/searchResponseBuilder.test.js new file mode 100644 index 0000000..8e51f4e --- /dev/null +++ b/utils/searchResponseBuilder.test.js @@ -0,0 +1,150 @@ +const constants = require('./constants'); +const searchResponseBuilder = require('./searchResponseBuilder'); + +describe('search response builder', () => { + test('builds the public response without OpenSearch hit metadata', () => { + const record = { + dataType: 'CVE_RECORD', + cveMetadata: { + cveId: 'CVE-2024-0001' + } + }; + const response = searchResponseBuilder.buildSearchResponse({ + totalResults: 25, + hits: [ + { + _index: 'private-index-name', + _id: 'CVE-2024-0001', + _score: 12, + _source: record, + sort: ['CVE-2024-0001'] + } + ], + pagingOptions: { + from: 20, + resultsPerPage: 10 + } + }); + + expect(Object.keys(response)).toStrictEqual(['apiVersion', 'status', 'warnings', 'pagination', 'records']); + expect(response).toStrictEqual({ + apiVersion: '1.0', + status: 'ok', + warnings: [], + pagination: { + page: 3, + pageSize: 10, + resultsReturned: 1, + totalResults: 25, + totalPages: 3, + totalIsExact: true + }, + records: [record] + }); + }); + + test('preserves warnings before records and represents an empty result page', () => { + const warnings = [searchResponseBuilder.semanticCandidateLimitWarning()]; + const response = searchResponseBuilder.buildSearchResponse({ + totalResults: 0, + hits: [], + pagingOptions: { + from: 0, + resultsPerPage: 20 + }, + totalIsExact: false, + warnings + }); + + expect(Object.keys(response).indexOf('warnings')).toBeLessThan(Object.keys(response).indexOf('records')); + expect(response.warnings).toBe(warnings); + expect(response.pagination).toStrictEqual({ + page: 0, + pageSize: 20, + resultsReturned: 0, + totalResults: 0, + totalPages: 0, + totalIsExact: false + }); + expect(response.records).toStrictEqual([]); + }); + + test('describes the bounded semantic candidate limit without naming the search implementation', () => { + const warning = searchResponseBuilder.semanticCandidateLimitWarning(); + + expect(warning).toStrictEqual({ + code: 'SEMANTIC_CANDIDATE_LIMIT_REACHED', + message: `Results and totals reflect matches found within the first ${constants.MAX_SEARCH_WINDOW.toLocaleString('en-US')} search candidates.` + }); + expect(JSON.stringify(warning)).not.toMatch(/opensearch|elastic|index|shard/i); + }); + + test('warns once for normalized, unique CVE IDs from before the first CVE Record year', () => { + expect( + searchResponseBuilder.getCveIdWarnings('cve-1998-0001,CVE-2024-0001,CVE-1975-1234,CVE-1998-0001') + ).toStrictEqual([ + { + code: 'CVE_IDS_NOT_RETURNED', + message: + 'CVE Records do not exist for years before 1999. The listed requested IDs cannot produce results.', + cveIds: ['CVE-1998-0001', 'CVE-1975-1234'] + } + ]); + }); + + test.each([undefined, '', 'CVE-1999-0001,CVE-2024-0001', 'not-a-cve'])( + 'does not warn for a request without a well-formed pre-1999 CVE ID: %p', + (value) => { + 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']); + }); +});