diff --git a/.env.example b/.env.example index a90f4b1..a6ac790 100644 --- a/.env.example +++ b/.env.example @@ -2,19 +2,10 @@ PORT=3000 OpenSearchCveIndex=cve-index-local OpenSearchAllowUnknownSslCerts=false OpenSearchDomainEndpoint= -SearchLogPerformanceTimings=false -SearchPostFilterCacheTtlMs=60000 -SearchPostFilterCacheMaxEntries=100 -SearchPostFilterColdSearchConcurrency=10 -SearchPostFilterColdSearchMaxQueueDepth=500 -SearchPostFilterColdSearchMaxWaitMs=10000 SearchOpenSearchRequestTimeoutMs=20000 SearchRequestDeadlineMs=30000 SearchReadinessTimeoutMs=5000 RequestLogSuccessSampleRate=0.1 RequestLogHealthChecks=false WebSearchEnabled=true -SearchRateLimitEnabled=false -SearchRateLimitWindowMs=60000 -SearchRateLimitMaxRequests=0 NPM_CA_CERT= diff --git a/.gitignore b/.gitignore index 5e19885..e2eb191 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,12 @@ web_modules/ .env.production.local .env.local +# Local Postman deployment environments +/test/postman/dev.postman_environment.json +/test/postman/test.postman_environment.json +/test/postman/int.postman_environment.json +/test/postman/prod.postman_environment.json + # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache diff --git a/README.md b/README.md index 6201913..eb51bd5 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,12 @@ Rename `.env.example` to `.env` and replace or check the following values. - `OpenSearchCveIndex` - existing CVE index name in your OpenSearch deployment - `OpenSearchAllowUnknownSslCerts` - set to `true` only for local development against self-signed certificates - `OpenSearchDomainEndpoint` - absolute URL to an existing OpenSearch deployment. This API does not create or manage the OpenSearch index or mapping. -- `SearchLogPerformanceTimings` - optional diagnostic flag; set to `true` temporarily to log redacted `/search` timing breakdowns -- `SearchPostFilterCacheTtlMs` - optional CPE post-filter cache TTL override in milliseconds, default `60000` -- `SearchPostFilterCacheMaxEntries` - optional per-process CPE post-filter cache entry limit, default `100` -- `SearchPostFilterColdSearchConcurrency` - optional per-process concurrent cold CPE candidate search limit, default `10` -- `SearchPostFilterColdSearchMaxQueueDepth` - optional per-process waiting queue depth for cold CPE candidate searches, default `500` -- `SearchPostFilterColdSearchMaxWaitMs` - optional maximum cold CPE candidate queue wait, default `10000` - `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` - `RequestLogSuccessSampleRate` - optional fraction of successful non-health-check access logs to retain, default `0.1` - `RequestLogHealthChecks` - optional flag to retain successful health-check access logs, default `false` -- `WebSearchEnabled` - optional deprecated `/webSearch` feature flag, default `true` -- `SearchRateLimitEnabled` - optional per-process `/search` rate-limit flag, default `false` -- `SearchRateLimitWindowMs` - optional rate-limit window in milliseconds, default `60000` -- `SearchRateLimitMaxRequests` - required positive per-process request limit when rate limiting is enabled +- `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 ```bash @@ -89,13 +80,39 @@ The Search API will be served at `localhost:3000`. `GET /api/health-check` is a process liveness check. It returns HTTP 200 when the Node.js process is running and deliberately does not contact OpenSearch. Use it where a dependency outage should not restart otherwise healthy API tasks. -`GET /api/ready-check` sends a minimal zero-result search to the configured CVE index. This verifies connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when search requests can be served. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. The OpenSearch request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. +`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`. 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. ## API Input Validation -`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. 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. +`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. + +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. + +Scope applies to filter matching as follows: + +| Filters | Data used for matching | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `cveIds`, `pubStartDate`, `pubEndDate`, `lastModStartDate`, `lastModEndDate` | Base record metadata in every scope | +| `noRejected` | Base record state in every scope, plus the legacy CNA state when CNA data is selected | +| `keywordSearch`, `keywordExactMatch` | Description text in the selected CNA and/or ADP containers | +| `cweId`, all CVSS severity and metric filters | Problem types or metrics in the selected CNA and/or ADP containers | +| All SSVC filters, `knownExploited`, KEV date filters, `referenceURL`, `disputed` | Structured metrics, references, or tags in the selected CNA and/or ADP containers | +| `cpeName`, `isVulnerable`, `virtualMatchString`, and version range filters | CNA CPE applicability data; incompatible with `ADP_ONLY` | +| `resultsPerPage`, `pageNumber` | Response controls; they do not select record data | + +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. + +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`. + +Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries are inclusive, require `knownExploited: true`, and use the API's full UTC timestamp format. Stored KEV `dateAdded` values are interpreted as midnight UTC. + +Use `referenceURL` for a case-insensitive literal substring search over reference URLs in containers selected by `scope`. The filter accepts one string of at least three characters; user-supplied wildcard syntax is treated literally. + +Use the strict boolean `disputed` filter to require (`true`) or exclude (`false`) the exact `disputed` tag in containers selected by `scope`. This does not provide a general-purpose record tag filter. String inputs are bounded before query construction: @@ -107,6 +124,8 @@ String inputs are bounded before query construction: | `cpeName` | 2,048 characters | | `virtualMatchString` | 2,048 characters | | `versionStart`, `versionEnd` | 256 characters each | +| `ssvcRole` | 256 characters | +| `referenceURL` | 3 through 2,048 characters | | CVSS metric fields | 256 characters, 32 slash-delimited tokens, and 32 characters per token | Requests that exceed a limit return HTTP 400 before contacting OpenSearch. These bounds limit input size and query complexity without changing the documented CPE matching semantics. @@ -177,7 +196,7 @@ curl -X POST "http://localhost:3000/search" \ ### Virtual Match String Version Ranges -Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`. +Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. Negative numeric values and the standalone CPE logical `NA` value (`-`) are not accepted as ordered range boundaries. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`. ```bash curl -X POST "http://localhost:3000/search" \ @@ -193,7 +212,7 @@ Numeric segments used in version range comparisons ignore leading zeros. A store 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. -`POST /webSearch` is deprecated in favor of `POST /search`. It remains enabled by default during consumer migration 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. +`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. While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It also accepts optional `sort`, `from`, and `size` fields: @@ -214,15 +233,19 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al - `from` must be an integer greater than or equal to `0` - `size` must be an integer from `1` to `500` +The controller validates successful `BasicSearchManager` responses before returning HTTP 200. A response without the expected status metadata, total-hit count, or hit array is treated as an invalid upstream response and returns a sanitized HTTP 502. + ## Swagger API docs -Swagger API docs are automatically generated on `docker compose` or `npm run dev`, and can be regenerated manually using: +Swagger API docs are regenerated by `npm run dev` and can be regenerated manually using: ```bash npm run swagger-autogen ``` -The Swagger API docs will be served at `localhost:3000/api-docs` while the CVE Search API is running via Docker or npm. +Docker builds copy the committed `api-docs/openapi.json` into the runtime image; they do not regenerate it. Regenerate and commit that file whenever the route or request schema changes. + +The Swagger API docs will be served at `http://localhost:3000/api-docs` while the CVE Search API is running via Docker or npm with the default port. ## Testing @@ -241,66 +264,32 @@ npm run coverage 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 -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 and `requestId` in structured `/search` events. +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. -The API always emits a sanitized JSON `search.performance` event for `/search` queue saturation, queue wait timeout, search timeout, client disconnect, OpenSearch connection errors, and upstream search errors. These events include the request ID, outcome, HTTP status, strategy, cache state, total elapsed time, cancellation reason, and queue state when applicable. Search terms, CPE values, request bodies, credentials, and upstream error details are never included. - -Set `SearchLogPerformanceTimings=true` on the API deployment when diagnosing slow `/search` requests. Successful requests then emit the same structured event with additional redacted timing details, including candidate counts, filtered counts, returned count, OpenSearch operation types, maximum observed active OpenSearch requests in the process, OpenSearch elapsed time, OpenSearch `took`, post-filter elapsed time, cold-search queue wait time, page fetch elapsed or wait time, and total handler elapsed time. Leave it disabled during normal operation unless the deployment logging volume is acceptable. - 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. -### Application Rate Limiting - -Application rate limiting is disabled by default and applies only to `POST /search`. It does not apply to `/webSearch`, health checks, Swagger, OpenAPI, or static assets. - -The fallback limiter uses one in-memory counter per Node.js process. Set `SearchRateLimitEnabled=true`, provide a positive `SearchRateLimitMaxRequests`, and optionally change `SearchRateLimitWindowMs` to enable it. Invalid enabled configuration prevents the application from starting. Rejected requests return HTTP 429 with `SEARCH_RATE_LIMITED`, `Retry-After`, `RateLimit`, and `RateLimit-Policy` headers, and emit a sanitized `search.performance` event with the `rate_limited` outcome. - -This is a process-wide safety limit, not a precise distributed quota. Each ECS task has an independent counter, so total permitted traffic scales with the number of tasks and load-balancer routing. It deliberately does not use caller IP addresses or forwarded headers until the ALB proxy chain and Express `trust proxy` setting are confirmed. AWS WAF or another infrastructure-level distributed limit remains the preferred public-facing control. - -### CPE Post-Filter Cache - -Searches that use `cpeName`, `virtualMatchString`, or `virtualMatchString` with version range parameters use a semantic CPE post-filter. The API first asks OpenSearch for up to 10,000 lightweight candidate records, filters those candidates in Node.js, then fetches the requested page of full CVE records by CVE ID. - -The post-filter cache stores only the filtered CVE ID list and candidate count for that expensive candidate query. It does not cache full CVE records. On a cache hit, the API skips the 10,000-candidate OpenSearch query and semantic post-filtering, slices the cached CVE IDs for the requested page, and fetches that page of full CVE records by ID. - -The cache is intentionally small and short-lived: +### Semantic Post-Filtering -- TTL: 60 seconds -- Max entries: 100 -- Scope: one Node.js process -- Stored value: filtered CVE IDs and candidate count -- In-flight behavior: concurrent identical cold searches share one reference-counted candidate query per process -- Cold-search concurrency: cold CPE candidate queries are limited to 10 per process before reaching OpenSearch -- Cold-search queue depth: up to 500 unique cold searches can wait per process behind the concurrency limit -- Cold-search queue wait: up to 10 seconds before a waiting search returns HTTP 429 -- OpenSearch request timeout: 20 seconds for each operation -- Overall `/search` deadline: 30 seconds -- Eviction: oldest entries are removed when the cache exceeds its max size +Two separate 10,000-record controls apply to `/search`: -When the cold-search queue is full, new unique CPE post-filter searches fail closed with HTTP 429 and a `SEARCH_QUEUE_FULL` response. Identical requests for a search that is already in flight still wait on the shared in-flight result and do not consume additional queue slots. +- 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. -When a cold search waits in the queue for more than 10 seconds, it fails closed with HTTP 429 and a `SEARCH_QUEUE_TIMEOUT` response. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed 20 seconds. The same response is returned when a `/search` request exceeds the 30-second overall processing deadline, and the active OpenSearch request is aborted. +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`. -Healthy dev timing logs showed the impact for repeated identical `virtualMatchString` range searches: +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. -| Metric | Cold Cache | Warm Cache | -| ----------------------------- | ---------: | -----------------------: | -| Avg total handler time | 1709.34 ms | 11.72 ms | -| Median total handler time | 1701.51 ms | 7.68 ms | -| p95 total handler time | 2010.52 ms | 50.66 ms | -| Avg candidate OpenSearch time | 1435.57 ms | skipped | -| Avg post-filter time | 169.21 ms | skipped | -| Avg page fetch time | 104.25 ms | 11.53 ms | -| Avg candidates scanned | 10000 | cached from prior result | -| Avg filtered CVEs | 4 | 4 | +`/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. -This helps most when multiple users submit the same expensive CPE search within the TTL. The first request is a cold cache miss; later identical requests on the same running task are warm cache hits. If identical cold requests arrive concurrently before the first one finishes, the later requests wait on the same candidate query instead of starting duplicate 10,000-candidate OpenSearch requests. Disconnecting one waiter does not cancel work still needed by another waiter; the shared operation is aborted when its final waiter disconnects or times out. Each active request still fetches its requested page of full CVE records after the shared candidate result is available. +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 performance telemetry must be supplied by the deployment infrastructure when those capabilities are enabled. -The cache does not share entries across ECS tasks. With multiple tasks, each task warms independently depending on load balancer routing. Steady repeated traffic can warm every active task, but unique one-off searches still pay the cold-cache cost. The request lifecycle and in-flight waiter registry are separate from this result cache, so the cache can later be replaced with Redis or ElastiCache without changing cancellation behavior. The per-process TTL, entry limit, cold-search concurrency, queue depth, queue wait, OpenSearch timeout, and overall request deadline can be tuned with `SearchPostFilterCacheTtlMs`, `SearchPostFilterCacheMaxEntries`, `SearchPostFilterColdSearchConcurrency`, `SearchPostFilterColdSearchMaxQueueDepth`, `SearchPostFilterColdSearchMaxWaitMs`, `SearchOpenSearchRequestTimeoutMs`, and `SearchRequestDeadlineMs`. +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: diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 61279e4..2a2cffa 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "0.4.0-beta", + "version": "0.5.0-beta", "title": "CVE Search API", "description": "CVE Search API to retrieve all matching CVE records after applying request inputs as filters.", "contact": { @@ -21,11 +21,18 @@ "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

", + "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.

", "operationId": "search", "responses": { "200": { "description": "A list of matching CVE records", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + }, "headers": { "X-Request-ID": { "description": "Request correlation ID", @@ -36,39 +43,54 @@ } }, "400": { - "description": "Malformed JSON, invalid request body, parameter combination, or version range" + "description": "Malformed JSON, invalid request body, parameter combination, or version range", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } }, "404": { - "description": "Invalid cpeName value" - }, - "415": { - "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding" - }, - "429": { - "description": "Application rate limit exceeded, or the cold CPE search queue is full or exceeded its maximum wait", - "headers": { - "Retry-After": { - "description": "Seconds until a rate-limited request can be retried", + "description": "Invalid cpeName value", + "content": { + "application/json": { "schema": { - "type": "integer" + "$ref": "#/components/schemas/ApiErrorResponse" } - }, - "RateLimit": { - "description": "Current application rate-limit state when application limiting is enabled", + } + } + }, + "415": { + "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding", + "content": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/ApiError" } - }, - "RateLimit-Policy": { - "description": "Application rate-limit policy when application limiting is enabled", + } + } + }, + "502": { + "description": "OpenSearch returned an invalid or unexpected response", + "content": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/ApiError" } } } }, "503": { "description": "OpenSearch is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, "headers": { "Retry-After": { "description": "Seconds before retrying an unavailable OpenSearch dependency", @@ -79,7 +101,14 @@ } }, "504": { - "description": "Search exceeded its OpenSearch or overall processing deadline" + "description": "Search exceeded its OpenSearch or overall processing deadline", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } } }, "requestBody": { @@ -93,6 +122,7 @@ "allParameters": { "summary": "All supported request body parameters", "value": { + "scope": "FULL", "keywordSearch": "remote code execution", "keywordExactMatch": true, "cveIds": "CVE-2024-1234,CVE-2024-5678", @@ -120,7 +150,18 @@ "versionStartType": "including", "versionEnd": "2.6", "versionEndType": "excluding", - "virtualMatchString": "cpe:2.3:o:linux:linux_kernel" + "virtualMatchString": "cpe:2.3:o:linux:linux_kernel", + "ssvcExploitation": "ACTIVE", + "ssvcAutomatable": "YES", + "ssvcTechnicalImpact": "TOTAL", + "ssvcRole": "CISA Coordinator", + "ssvcTimestampStartDate": "2024-01-01T00:00:00.000Z", + "ssvcTimestampEndDate": "2024-12-31T23:59:59.999Z", + "knownExploited": true, + "kevDateAddedStartDate": "2024-01-01T00:00:00.000Z", + "kevDateAddedEndDate": "2024-12-31T23:59:59.999Z", + "referenceURL": "vendor.example/security/advisory", + "disputed": false } }, "keywordSearch": { @@ -177,7 +218,7 @@ "/api/ready-check": { "get": { "summary": "OpenSearch readiness check", - "description": "Sends a minimal zero-result search to the configured CVE index. Returns 200 when the index is available with search permission, or a sanitized 503 response when configuration, connectivity, timeout, permission, or index availability checks fail.", + "description": "Validates compatible field capabilities for each logical search feature, then sends a minimal zero-result search to the configured CVE index. Alternative CNA, ADP, legacy, and CVSS-version fields may satisfy the same feature. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails.", "operationId": "readinessCheck", "responses": { "200": { @@ -193,6 +234,13 @@ }, "503": { "description": "Search service is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, "headers": { "Retry-After": { "description": "Seconds before retrying the readiness check", @@ -216,12 +264,19 @@ "tags": [ "CVE Record" ], - "summary": "Deprecated raw query search", - "description": "

Expected Behavior

Retrieves all matching CVE records after applying the JSON request body. This endpoint is deprecated and can be disabled by the service operator. Use POST /search instead.

", + "summary": "Raw query search", + "description": "

Expected Behavior

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

", "operationId": "webSearch", "responses": { "200": { "description": "A list of matching CVE records", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchResponse" + } + } + }, "headers": { "X-Request-ID": { "description": "Request correlation ID", @@ -235,10 +290,34 @@ "description": "Bad Request" }, "410": { - "description": "The deprecated endpoint is disabled; use POST /search" + "description": "The endpoint is disabled by service configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "The search backend returned an error or invalid response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchResponse" + } + } + } }, "504": { - "description": "Search exceeded its OpenSearch request deadline" + "description": "Search exceeded its OpenSearch request deadline", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchResponse" + } + } + } } }, "requestBody": { @@ -289,28 +368,224 @@ } } } - }, - "deprecated": true + } } } }, "components": { "schemas": { + "ApiError": { + "type": "object", + "additionalProperties": false, + "required": [ + "error", + "message" + ], + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ApiErrorResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/ApiError" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiError" + } + } + ] + }, + "SearchHit": { + "type": "object", + "additionalProperties": true, + "required": [ + "_source" + ], + "properties": { + "_id": { + "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." + } + } + }, + "hits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchHit" + } + } + } + }, + "SearchResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "page", + "pageSize", + "resultsReturned", + "totalPages", + "status", + "data" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 0, + "description": "One-based page number, or 0 when the search has no matches." + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of CVE records requested for this page." + }, + "resultsReturned": { + "type": "integer", + "minimum": 0, + "description": "Number of CVE records actually returned in this response." + }, + "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." + }, + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "data": { + "type": "object", + "additionalProperties": true, + "required": [ + "hits" + ], + "properties": { + "took": { + "type": "number", + "minimum": 0 + }, + "hits": { + "$ref": "#/components/schemas/SearchHits" + } + } + } + } + }, + "WebSearchResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "searchMetadata", + "resultsTotal", + "data" + ], + "properties": { + "searchMetadata": { + "type": "object", + "additionalProperties": true, + "required": [ + "searchStatus", + "version", + "errors" + ], + "properties": { + "searchStatus": { + "type": "string" + }, + "version": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "resultsTotal": { + "type": "integer", + "minimum": 0 + }, + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, "SearchRequest": { "type": "object", - "description": "Optional filters and pagination controls for a CVE record search.", + "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.", "additionalProperties": false, "properties": { + "scope": { + "type": "string", + "enum": [ + "FULL", + "CNA_ONLY", + "ADP_ONLY" + ], + "default": "FULL", + "description": "Controls which containers participate in filter matching. FULL searches base record fields, CNA data, and all ADP providers. CNA_ONLY searches base record fields and CNA data. ADP_ONLY searches base record fields and all ADP providers. Scope does not remove data from returned records. Values are case-insensitive. CPE applicability filters are not available with ADP_ONLY." + }, "keywordSearch": { "type": "string", "maxLength": 512, "example": "remote code execution", - "description": "Returns CVE records where a word or phrase is included in the record's description. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to 512 characters and 64 whitespace-delimited terms." + "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." }, "keywordExactMatch": { - "type": "boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], "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." + "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." }, "cveIds": { "type": "string", @@ -319,15 +594,26 @@ "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." }, "noRejected": { - "type": "boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], "default": false, - "description": "When true, excludes CVE records with REJECT, REJECTED, or Rejected status." + "description": "When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "cweId": { "type": "string", "maxLength": 32, "example": "CWE-79", - "description": "Returns CVE records that include the provided Common Weakness Enumeration ID." + "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID." }, "cvssV2Severity": { "type": "string", @@ -336,7 +622,7 @@ "MEDIUM", "HIGH" ], - "description": "Returns CVE records that contain the provided CVSS v2 qualitative severity rating." + "description": "Returns CVE records with a scope-selected CVSS v2 base score in the range represented by the provided qualitative severity. Values are case-insensitive." }, "cvssV3Severity": { "type": "string", @@ -346,7 +632,7 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v3.0 or v3.1 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive." }, "cvssV3_0Severity": { "type": "string", @@ -356,7 +642,7 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v3.0 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive." }, "cvssV3_1Severity": { "type": "string", @@ -366,7 +652,7 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v3.1 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive." }, "cvssV4Severity": { "type": "string", @@ -376,37 +662,37 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v4.0 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive." }, "cvssV2Metrics": { "type": "string", "maxLength": 256, "example": "AV:N/AC:L/Au:N/C:P/I:P/A:P", - "description": "Returns CVE records that contain the provided CVSS v2 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records that contain the provided CVSS v3.0 or v3.1 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_0Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records that contain the provided CVSS v3.0 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_1Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records that contain the provided CVSS v3.1 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV4Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", - "description": "Returns CVE records that contain the provided CVSS v4.0 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cpeName": { "type": "string", @@ -415,9 +701,20 @@ "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." }, "isVulnerable": { - "type": "boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], "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." + "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." }, "lastModStartDate": { "type": "string", @@ -449,20 +746,20 @@ "minimum": 1, "maximum": 500, "default": 20, - "description": "Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first 10000 results." + "description": "Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search." }, "pageNumber": { "type": "integer", "format": "int32", "minimum": 1, "default": 1, - "description": "Specifies the page to return based on resultsPerPage. The requested page must remain within the first 10000 results." + "description": "Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search." }, "versionStart": { "type": "string", "maxLength": 256, "example": "2.2", - "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -476,7 +773,7 @@ "type": "string", "maxLength": 256, "example": "2.6", - "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", @@ -491,6 +788,76 @@ "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." + }, + "ssvcExploitation": { + "type": "string", + "enum": [ + "NONE", + "POC", + "ACTIVE" + ], + "description": "Returns records with a scope-selected SSVC metric whose Exploitation decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry." + }, + "ssvcAutomatable": { + "type": "string", + "enum": [ + "YES", + "NO" + ], + "description": "Returns records with a scope-selected SSVC metric whose Automatable decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry." + }, + "ssvcTechnicalImpact": { + "type": "string", + "enum": [ + "PARTIAL", + "TOTAL" + ], + "description": "Returns records with a scope-selected SSVC metric whose Technical Impact decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry." + }, + "ssvcRole": { + "type": "string", + "maxLength": 256, + "example": "CISA Coordinator", + "description": "Returns records with a scope-selected SSVC metric whose role exactly matches this case-insensitive value." + }, + "ssvcTimestampStartDate": { + "type": "string", + "format": "date-time", + "example": "2024-01-01T00:00:00.000Z", + "description": "Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + }, + "ssvcTimestampEndDate": { + "type": "string", + "format": "date-time", + "example": "2024-12-31T23:59:59.999Z", + "description": "Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + }, + "knownExploited": { + "type": "boolean", + "description": "When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied." + }, + "kevDateAddedStartDate": { + "type": "string", + "format": "date-time", + "example": "2024-01-01T00:00:00.000Z", + "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." + }, + "kevDateAddedEndDate": { + "type": "string", + "format": "date-time", + "example": "2024-12-31T23:59:59.999Z", + "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." + }, + "referenceURL": { + "type": "string", + "minLength": 3, + "maxLength": 2048, + "example": "vendor.example/security/advisory", + "description": "Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported. The substring must contain at least 3 characters." + }, + "disputed": { + "type": "boolean", + "description": "When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 0b49ee4..88428cf 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -58,6 +58,10 @@ describe('OpenAPI contract', () => { expect(operation.parameters || []).toStrictEqual([]); expect(mediaType.schema).toStrictEqual({ $ref: '#/components/schemas/SearchRequest' }); expect(schema.additionalProperties).toBe(false); + 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(Object.keys(schema.properties)).toStrictEqual(constants.SEARCH_QUERY_PARAMS); Object.values(schema.properties).forEach((property) => { expect(property.description).toEqual(expect.any(String)); @@ -73,8 +77,29 @@ describe('OpenAPI contract', () => { Object.entries(constants.SEARCH_STRING_LENGTH_LIMITS).forEach(([field, maxLength]) => { expect(schema.properties[field].maxLength).toBe(maxLength); }); - expect(schema.properties.noRejected.type).toBe('boolean'); + expect(schema.properties.noRejected.oneOf).toStrictEqual([ + { + type: 'boolean' + }, + { + type: 'string', + enum: ['true', 'false'] + } + ]); expect(schema.properties.noRejected.description).toContain('When true'); + expect(schema.properties.keywordExactMatch.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); + expect(schema.properties.isVulnerable.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); + expect(schema.properties.scope.enum).toStrictEqual(['FULL', 'CNA_ONLY', 'ADP_ONLY']); + expect(schema.properties.scope.default).toBe('FULL'); + expect(schema.properties.scope.description).toContain('does not remove data'); + expect(schema.properties.ssvcExploitation.enum).toStrictEqual(['NONE', 'POC', 'ACTIVE']); + expect(schema.properties.ssvcAutomatable.enum).toStrictEqual(['YES', 'NO']); + expect(schema.properties.ssvcTechnicalImpact.enum).toStrictEqual(['PARTIAL', 'TOTAL']); + expect(schema.properties.knownExploited.type).toBe('boolean'); + expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); + expect(schema.properties.referenceURL.description).toContain('literal URL substring'); + expect(schema.properties.referenceURL.minLength).toBe(constants.MIN_REFERENCE_URL_LENGTH); + expect(schema.properties.disputed.type).toBe('boolean'); 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'); @@ -87,14 +112,18 @@ describe('OpenAPI contract', () => { expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); + expect(schema.properties.resultsPerPage.description).toContain('does not cap the exact total'); + expect(schema.properties.pageNumber.description).toContain('first 10,000 results'); expect(schema.properties.cveId).toBeUndefined(); expect(schema.properties.sourceIdentifier).toBeUndefined(); expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros'); expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); + expect(schema.properties.versionStart.description).toContain('Negative numeric values'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros'); + expect(schema.properties.versionEnd.description).toContain('standalone CPE logical NA value'); expect(schema.properties.versionEnd.description).toContain( 'prerelease qualifiers before an unqualified release' ); @@ -116,8 +145,8 @@ describe('OpenAPI contract', () => { const operation = openapi.paths['/webSearch'].post; const schema = operation.requestBody.content['application/json'].schema; - expect(operation.deprecated).toBe(true); - expect(operation.description).toContain('Use POST /search instead'); + expect(operation.deprecated).toBeUndefined(); + expect(operation.description).toContain('raw query expression'); expect(operation.parameters || []).toStrictEqual([]); expect(operation.requestBody.required).toBe(true); expect(schema.required).toStrictEqual(['query']); @@ -126,7 +155,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.sort.additionalProperties).toBe(false); expect(schema.properties.sort.properties.property.enum).toStrictEqual(['cveId', 'relevancy']); expect(schema.properties.size.maximum).toBe(constants.MAX_RESULTS_LIMIT); - expect(operation.responses['410'].description).toContain('use POST /search'); + expect(operation.responses['410'].description).toContain('disabled by service configuration'); }); test('documents cpeName as a CPE Match Criteria filter with required components', () => { @@ -143,7 +172,7 @@ describe('OpenAPI contract', () => { 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['429'].description).toContain('rate limit'); + expect(openapi.paths['/search'].post.responses['429']).toBeUndefined(); }); test('documents fail-closed request and dependency responses for POST /search', () => { @@ -151,6 +180,7 @@ 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'].headers['Retry-After']).toStrictEqual({ description: 'Seconds before retrying an unavailable OpenSearch dependency', @@ -160,6 +190,37 @@ describe('OpenAPI contract', () => { }); }); + test('documents reusable success and error response schemas', () => { + const schemas = openapi.components.schemas; + const searchResponse = schemas.SearchResponse; + + expect(searchResponse.required).toStrictEqual([ + 'page', + 'pageSize', + 'resultsReturned', + 'totalPages', + 'status', + 'data' + ]); + 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( + '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.ApiError.required).toStrictEqual(['error', 'message']); + expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); + expect(openapi.paths['/search'].post.responses['200'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/SearchResponse' + ); + expect(openapi.paths['/webSearch'].post.responses['200'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/WebSearchResponse' + ); + }); + test('documents request correlation headers', () => { expect(openapi.paths['/search'].post.responses['200'].headers['X-Request-ID']).toStrictEqual({ description: 'Request correlation ID', diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 3470a97..4eb1457 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -11,12 +11,6 @@ "index": "OpenSearchCveIndex", // allows local development using containers that do not have SSL certs "allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts", - "logPerformanceTimings": "SearchLogPerformanceTimings", - "postFilterCacheTtlMs": "SearchPostFilterCacheTtlMs", - "postFilterCacheMaxEntries": "SearchPostFilterCacheMaxEntries", - "postFilterColdSearchConcurrency": "SearchPostFilterColdSearchConcurrency", - "postFilterColdSearchMaxQueueDepth": "SearchPostFilterColdSearchMaxQueueDepth", - "postFilterColdSearchMaxWaitMs": "SearchPostFilterColdSearchMaxWaitMs", "openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs", "requestDeadlineMs": "SearchRequestDeadlineMs", "readinessTimeoutMs": "SearchReadinessTimeoutMs" @@ -28,13 +22,6 @@ "features": { "webSearchEnabled": "WebSearchEnabled" }, - "rateLimiting": { - "search": { - "enabled": "SearchRateLimitEnabled", - "windowMs": "SearchRateLimitWindowMs", - "maxRequests": "SearchRateLimitMaxRequests" - } - }, // constants for testing node-config // these values are only used to test node-config in AppConfig.test.int.ts // DO NOT USE THIS FOR ANYTHING ELSE diff --git a/config/default.jsonc b/config/default.jsonc index 8cdef78..45d6a0a 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -8,23 +8,10 @@ "appConfig": { // constants for search capability "search": { - // minimum versions for servers that are compatible with current code - "minServer": ["elasticsearch:7.10.2", "opensearch:3.5.0"], // 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 "allowUnknownSslCerts": "FALSE", - // set to TRUE temporarily when diagnosing slow /search requests - "logPerformanceTimings": "FALSE", - // CPE post-filter candidate result cache. These values are per Node.js process. - "postFilterCacheTtlMs": "60000", - "postFilterCacheMaxEntries": "100", - // Limits concurrent cold CPE candidate OpenSearch requests per Node.js process. - "postFilterColdSearchConcurrency": "10", - // Limits queued cold CPE candidate searches waiting behind the concurrency cap per Node.js process. - "postFilterColdSearchMaxQueueDepth": "500", - // Limits how long a cold CPE candidate search can wait in the per-process queue. - "postFilterColdSearchMaxWaitMs": "10000", // Limits each request from the API process to OpenSearch. "openSearchRequestTimeoutMs": "20000", // Limits total processing time for a /search request. @@ -39,20 +26,10 @@ // Set to TRUE to retain successful health-check access logs. "healthChecks": "FALSE" }, - // Feature flags used for controlled endpoint migrations. + // Feature flags used for operator-controlled endpoint availability. "features": { - // Keep enabled until existing consumers have migrated to POST /search. + // Set to FALSE only when an environment must disable POST /webSearch. "webSearchEnabled": "TRUE" - }, - // Application-level fallback protection. Infrastructure-level enforcement remains preferred. - "rateLimiting": { - "search": { - // Disabled until a per-task threshold is approved. - "enabled": "FALSE", - "windowMs": "60000", - // A positive value is required when enabled. - "maxRequests": "0" - } } } } diff --git a/config/devel.jsonc b/config/devel.jsonc index 0a6569f..0eb0fc4 100644 --- a/config/devel.jsonc +++ b/config/devel.jsonc @@ -6,8 +6,6 @@ "appConfig": { // constants for search capability "search": { - // minimum versions for servers that are compatible with current code - "minServer": ["opensearch:3.5.0"], // URL to reach search server "providerEndpoint": "https://localhost:9200", // index on search server related to searching CVEs diff --git a/config/prod.jsonc b/config/prod.jsonc index 5aeb014..eb4d49c 100644 --- a/config/prod.jsonc +++ b/config/prod.jsonc @@ -5,7 +5,6 @@ // NOTE for consistency, all values need to be strings for proper type when using .env overrides "appConfig": { "search": { - "minServer": ["elasticsearch:7.10.2", "opensearch:3.5.0"], "providerEndpoint": "", // preference is to specify this in a (secret) environment variable on production platforms "index": "", // preference is to specify this in a (secret) environment variable on production platforms "allowUnknownSslCerts": "FALSE" diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index 350a6b1..c94173e 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -86,18 +86,6 @@ describe('runtime container and compose configuration', () => { expect(customEnvironmentVariables).toContain('"providerEndpoint": "OpenSearchDomainEndpoint"'); expect(customEnvironmentVariables).toContain('"index": "OpenSearchCveIndex"'); expect(customEnvironmentVariables).toContain('"allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts"'); - expect(customEnvironmentVariables).toContain('"logPerformanceTimings": "SearchLogPerformanceTimings"'); - expect(customEnvironmentVariables).toContain('"postFilterCacheTtlMs": "SearchPostFilterCacheTtlMs"'); - expect(customEnvironmentVariables).toContain('"postFilterCacheMaxEntries": "SearchPostFilterCacheMaxEntries"'); - expect(customEnvironmentVariables).toContain( - '"postFilterColdSearchConcurrency": "SearchPostFilterColdSearchConcurrency"' - ); - expect(customEnvironmentVariables).toContain( - '"postFilterColdSearchMaxQueueDepth": "SearchPostFilterColdSearchMaxQueueDepth"' - ); - expect(customEnvironmentVariables).toContain( - '"postFilterColdSearchMaxWaitMs": "SearchPostFilterColdSearchMaxWaitMs"' - ); expect(customEnvironmentVariables).toContain( '"openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs"' ); @@ -106,18 +94,14 @@ describe('runtime container and compose configuration', () => { expect(customEnvironmentVariables).toContain('"successSampleRate": "RequestLogSuccessSampleRate"'); expect(customEnvironmentVariables).toContain('"healthChecks": "RequestLogHealthChecks"'); expect(customEnvironmentVariables).toContain('"webSearchEnabled": "WebSearchEnabled"'); - expect(customEnvironmentVariables).toContain('"enabled": "SearchRateLimitEnabled"'); - expect(customEnvironmentVariables).toContain('"windowMs": "SearchRateLimitWindowMs"'); - expect(customEnvironmentVariables).toContain('"maxRequests": "SearchRateLimitMaxRequests"'); }); test.each([['config/default.jsonc'], ['config/devel.jsonc'], ['config/prod.jsonc']])( - '%s references OpenSearch 3.5 as the minimum OpenSearch version', + '%s does not retain the unused minServer capability configuration', (filePath) => { const config = readProjectFile(filePath); - expect(config).toContain('"opensearch:3.5.0"'); - expect(config).not.toContain('"opensearch:2.10.0"'); + expect(config).not.toContain('"minServer"'); } ); diff --git a/controllers/middleware.js b/controllers/middleware.js index 8eedf02..93aa66b 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -1,6 +1,8 @@ const errors = require('../utils/errors'); const constants = require('../utils/constants'); const cpe = require('../utils/cpe'); +const searchScope = require('../utils/searchScope'); +const ssvc = require('../utils/ssvc'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -35,9 +37,30 @@ const severityValidators = [ } ]; +const ssvcEnumValidators = [ + { param: 'ssvcExploitation', allowed: ssvc.EXPLOITATION_VALUES }, + { param: 'ssvcAutomatable', allowed: ssvc.AUTOMATABLE_VALUES }, + { param: 'ssvcTechnicalImpact', allowed: ssvc.TECHNICAL_IMPACT_VALUES } +]; + const cvssMetricsParams = ['cvssV2Metrics', 'cvssV3Metrics', 'cvssV3_0Metrics', 'cvssV3_1Metrics', 'cvssV4Metrics']; -const dateParams = ['lastModStartDate', 'lastModEndDate', 'pubStartDate', 'pubEndDate']; +const dateParams = [ + 'lastModStartDate', + 'lastModEndDate', + 'pubStartDate', + 'pubEndDate', + 'ssvcTimestampStartDate', + 'ssvcTimestampEndDate', + 'kevDateAddedStartDate', + 'kevDateAddedEndDate' +]; +const dateRangePairs = [ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] +]; const versionRangeParams = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; const versionRangeValueParams = ['versionStart', 'versionEnd']; const versionRangeTypeParams = ['versionStartType', 'versionEndType']; @@ -46,6 +69,8 @@ const nonStringSearchParams = [ 'cveIds', 'isVulnerable', 'noRejected', + 'knownExploited', + 'disputed', 'resultsPerPage', 'pageNumber', ...versionRangeParams @@ -67,6 +92,15 @@ function isSingleNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +function isUnsupportedVersionRangeBoundary(value) { + const normalized = String(value ?? '').trim(); + return normalized === '-' || /^-\d/.test(normalized); +} + +function isBoolean(value) { + return value === true || value === false; +} + function hasAnyParam(params, paramList) { return paramList.some((param) => hasParam(params, param)); } @@ -146,12 +180,32 @@ function validateParams(req, res, next) { } } + if (!searchScope.isValidScope(params.scope)) { + return res.status(400).json(error.scopeValueError()); + } + + const incompatibleParams = searchScope.getIncompatibleParams(params); + if (incompatibleParams.length) { + return res + .status(400) + .json(error.scopeCompatibilityError(searchScope.normalizeScope(params.scope), incompatibleParams)); + } + for (const [param, maxLength] of Object.entries(constants.SEARCH_STRING_LENGTH_LIMITS)) { if (typeof params[param] === 'string' && params[param].length > maxLength) { return res.status(400).json(error.searchFieldLengthError(param, maxLength)); } } + if ( + typeof params.referenceURL === 'string' && + params.referenceURL.trim().length < constants.MIN_REFERENCE_URL_LENGTH + ) { + return res + .status(400) + .json(error.searchFieldMinimumLengthError('referenceURL', constants.MIN_REFERENCE_URL_LENGTH)); + } + if ( typeof params.keywordSearch === 'string' && countWhitespaceDelimitedTokens(params.keywordSearch) > constants.MAX_KEYWORD_SEARCH_TOKENS @@ -178,6 +232,14 @@ function validateParams(req, res, next) { return res.status(400).json(error.noRejectedValueError()); } + if (hasParam(params, 'knownExploited') && !isBoolean(params.knownExploited)) { + return res.status(400).json(error.knownExploitedInputError()); + } + + if (hasParam(params, 'disputed') && !isBoolean(params.disputed)) { + return res.status(400).json(error.disputedInputError()); + } + if (hasAnyParam(params, versionRangeParams)) { if (!params.virtualMatchString) { return res.status(400).json(error.versionRangeVirtualMatchStringRequiredError()); @@ -195,6 +257,10 @@ function validateParams(req, res, next) { if (hasParam(params, param) && !isSingleNonEmptyString(params[param])) { return res.status(400).json(error.versionRangeValueError(param)); } + + if (hasParam(params, param) && isUnsupportedVersionRangeBoundary(params[param])) { + return res.status(400).json(error.versionRangeBoundaryError(param)); + } } for (const param of versionRangeTypeParams) { @@ -236,6 +302,20 @@ function validateParams(req, res, next) { } } + for (const { param, allowed } of ssvcEnumValidators) { + const value = params?.[param]; + if (value && !allowed.includes(value.toUpperCase())) { + return res.status(400).json(error.enumValueError(param, allowed)); + } + } + + if ( + hasAnyParam(params, ['kevDateAddedStartDate', 'kevDateAddedEndDate']) && + !isTrueParam(params.knownExploited) + ) { + return res.status(400).json(error.kevDateRequiresKnownExploitedError()); + } + //Ensures keywordSearch is specified if keyWordExactMatch is true if (isTrueParam(params?.keywordExactMatch) && !params.keywordSearch) { return res.status(400).json(error.missingKeywordSearchError()); @@ -302,6 +382,16 @@ function validateParams(req, res, next) { } } + for (const [startParam, endParam] of dateRangePairs) { + if ( + hasParam(params, startParam) && + hasParam(params, endParam) && + Date.parse(params[startParam]) > Date.parse(params[endParam]) + ) { + return res.status(400).json(error.dateRangeOrderError(startParam, endParam)); + } + } + next(); } catch (err) { next(err); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 5079be4..2422403 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -308,6 +308,23 @@ describe('middleware validation function', () => { expectValidationError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`); }); + test.each([ + ['versionStart', '-1'], + ['versionEnd', '-1'], + ['versionStart', '-'] + ])('ValidateParams rejects unsupported ordered version boundary %s=%s.', (param, value) => { + validate({ + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + [param]: value, + [`${param}Type`]: 'including' + }); + + expectValidationError( + 'VERSION_RANGE_BOUNDARY', + `The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.` + ); + }); + test.each([ ['versionStartType', 'inclusive'], ['versionEndType', ['excluding']] @@ -370,6 +387,7 @@ describe('middleware validation function', () => { ['keywordSearch', ''], ['cpeName', ' '], ['virtualMatchString', ''], + ['referenceURL', ''], ['pubStartDate', ''], ['cvssV3Metrics', ''], ['cvssV3Severity', ''] @@ -405,7 +423,8 @@ describe('middleware validation function', () => { ['pubEndDate', '2024-01-04T00:00:00.000Z'], ['resultsPerPage', '10'], ['pageNumber', '2'], - ['virtualMatchString', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*'] + ['virtualMatchString', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*'], + ['referenceURL', 'vendor.example/security/advisory'] ])('ValidateParams accepts supported search parameter %s.', (param, value) => { validate({ [param]: value @@ -816,6 +835,43 @@ describe('middleware validation function', () => { } ); + test.each([ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] + ])('ValidateParams rejects an inverted %s and %s range.', (startParam, endParam) => { + const params = { + [startParam]: '2024-12-31T00:00:00.000Z', + [endParam]: '2024-01-01T00:00:00.000Z' + }; + if (startParam.startsWith('kev')) params.knownExploited = true; + + validate(params); + + expectValidationError( + 'DATE_RANGE_ORDER', + `The ${startParam} parameter must be before or equal to ${endParam}.` + ); + }); + + test.each([ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] + ])('ValidateParams accepts equal inclusive %s and %s boundaries.', (startParam, endParam) => { + const params = { + [startParam]: '2024-01-01T00:00:00.000Z', + [endParam]: '2024-01-01T00:00:00.000Z' + }; + if (startParam.startsWith('kev')) params.knownExploited = true; + + validate(params); + + expectValidationSuccess(); + }); + test.each([ ['2024'], ['2024-01'], @@ -872,6 +928,103 @@ describe('middleware validation function', () => { } ); + test.each(['FULL', 'full', 'CNA_ONLY', 'adp_only'])( + 'ValidateParams accepts case-insensitive scope value %s.', + (scope) => { + validate({ scope }); + expectValidationSuccess(); + } + ); + + test('ValidateParams rejects unsupported scope values', () => { + validate({ scope: 'CNA_AND_ADP' }); + expectValidationError('SCOPE_VALUE', 'The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.'); + }); + + test('ValidateParams rejects CNA-only CPE filters with ADP_ONLY', () => { + validate({ + scope: 'ADP_ONLY', + virtualMatchString: 'cpe:2.3:a:vendor:product' + }); + expectValidationError( + 'SCOPE_FILTER_INCOMPATIBLE', + 'The virtualMatchString parameter is not supported with scope ADP_ONLY.' + ); + }); + + test.each([ + ['ssvcExploitation', 'active'], + ['ssvcAutomatable', 'yes'], + ['ssvcTechnicalImpact', 'partial'] + ])('ValidateParams accepts case-insensitive SSVC enum %s.', (param, value) => { + validate({ [param]: value }); + expectValidationSuccess(); + }); + + test('ValidateParams rejects unsupported SSVC enum values', () => { + validate({ ssvcExploitation: 'unknown' }); + expectValidationError( + 'INVALID_ENUM_VALUE', + 'The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.' + ); + }); + + test.each(['ssvcTimestampStartDate', 'ssvcTimestampEndDate'])( + 'ValidateParams validates SSVC timestamp %s.', + (param) => { + validate({ [param]: '2024-02-30T00:00:00.000Z' }); + expectValidationError( + 'DATE_TIME_FORMAT', + `The ${param} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + ); + } + ); + + test('ValidateParams requires knownExploited true for KEV date filters', () => { + validate({ + knownExploited: false, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z' + }); + expectValidationError( + 'KEV_DATE_REQUIRES_KNOWN_EXPLOITED', + 'knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.' + ); + }); + + test('ValidateParams accepts KEV dates with knownExploited true', () => { + validate({ + knownExploited: true, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z', + kevDateAddedEndDate: '2024-12-31T23:59:59.999Z' + }); + expectValidationSuccess(); + }); + + test('ValidateParams requires knownExploited to be a JSON boolean.', () => { + validate({ knownExploited: 'true' }); + expectValidationError('KNOWN_EXPLOITED_BOOLEAN', 'The knownExploited parameter must be a boolean.'); + }); + + test('ValidateParams requires disputed to be a JSON boolean.', () => { + validate({ disputed: 'false' }); + expectValidationError('DISPUTED_BOOLEAN', 'The disputed parameter must be a boolean.'); + }); + + test('ValidateParams rejects referenceURL substrings shorter than the minimum', () => { + validate({ referenceURL: 'ab' }); + + expectValidationError( + 'SEARCH_FIELD_MINIMUM_LENGTH', + `The referenceURL field must be at least ${constants.MIN_REFERENCE_URL_LENGTH} characters.` + ); + }); + + test('ValidateParams accepts referenceURL substrings at the minimum length', () => { + validate({ referenceURL: 'abc' }); + + expectValidationSuccess(); + }); + test('ValidateParams delegates unexpected errors to next()', () => { const err = new Error('query failure'); Object.defineProperty(req, 'query', { diff --git a/controllers/searchController.js b/controllers/searchController.js index 5deabdb..a8f5a8b 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -1,69 +1,13 @@ const PackageInfo = require('cve-core').packageInfo; const errors = require('../utils/errors'); -const cpe = require('../utils/cpe'); -const searchPerformance = require('../utils/searchPerformance'); const searchDeadline = require('../utils/searchDeadline'); -const searchPostFilterCache = require('../utils/searchPostFilterCache'); -const searchInFlightRegistry = require('../utils/searchInFlightRegistry'); -const searchPostFilterWorkQueue = require('../utils/searchPostFilterWorkQueue'); const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); +const searchPostFilters = require('../utils/searchPostFilters'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); -function hasSearchPostFilters(postFilters) { - return Boolean(postFilters?.cpeName || postFilters?.virtualMatchString || postFilters?.virtualMatchStringRange); -} - -function getHitsContainer(resp) { - return resp?.data?.hits; -} - -function filterSearchHits(resp, postFilters) { - const hitsContainer = getHitsContainer(resp); - const cpeEvaluationContext = cpe.createEvaluationContext(); - - if (!Array.isArray(hitsContainer?.hits)) { - return { - hitsContainer, - candidateCount: 0, - filteredHits: [] - }; - } - - return { - hitsContainer, - candidateCount: hitsContainer.hits.length, - filteredHits: hitsContainer.hits.filter((hit) => { - if ( - postFilters?.cpeName && - !cpe.hitAppliesToCpeName(hit, postFilters.cpeName, cpeEvaluationContext, { - requireVulnerable: postFilters.isVulnerable === true - }) - ) { - return false; - } - - if ( - postFilters?.virtualMatchString && - !cpe.hitMatchesVirtualMatchString(hit, postFilters.virtualMatchString, cpeEvaluationContext) - ) { - return false; - } - - if ( - postFilters?.virtualMatchStringRange && - !cpe.hitOverlapsVirtualMatchStringRange(hit, postFilters.virtualMatchStringRange, cpeEvaluationContext) - ) { - return false; - } - - return true; - }) - }; -} - function paginateHits(hits, responseOptions) { const from = responseOptions?.from ?? 0; const resultsPerPage = responseOptions?.resultsPerPage ?? hits.length; @@ -72,7 +16,7 @@ function paginateHits(hits, responseOptions) { } function buildPagedSearchResponse(resp, totalValue, pagedHits) { - const hitsContainer = getHitsContainer(resp); + const hitsContainer = resp?.data?.hits; return { ...resp, @@ -90,15 +34,6 @@ function buildPagedSearchResponse(resp, totalValue, pagedHits) { }; } -function buildPostFilteredResponse( - resp, - filteredHits, - responseOptions, - pagedHits = paginateHits(filteredHits, responseOptions) -) { - return buildPagedSearchResponse(resp, filteredHits.length, pagedHits); -} - function getOpenSearchErrorStatusCode(err) { return Number(err?.statusCode ?? err?.meta?.statusCode ?? err?.meta?.body?.status); } @@ -137,20 +72,24 @@ function buildWebSearchSort(sort) { return undefined; } -function applySearchPostFilters(resp, postFilters, responseOptions, metrics) { - if (!hasSearchPostFilters(postFilters)) return resp; +function getWebSearchSuccessData(searchResponse) { + if (typeof searchResponse?.isOk !== 'function' || searchResponse.isOk() !== true) return null; - const { hitsContainer, candidateCount, filteredHits } = filterSearchHits(resp, postFilters); - if (!Array.isArray(hitsContainer?.hits)) return resp; + const hits = searchResponse?.data?.hits; + const totalValue = hits?.total?.value; + const hasValidMetadata = + typeof searchResponse.status === 'string' && + Array.isArray(searchResponse.notes) && + Array.isArray(searchResponse.errors); - if (metrics) { - const pagedHits = paginateHits(filteredHits, responseOptions); - metrics.candidateCount = candidateCount; - metrics.filteredCount = filteredHits.length; - metrics.returnedCount = pagedHits.length; + if (!hasValidMetadata || !Array.isArray(hits?.hits) || !Number.isSafeInteger(totalValue) || totalValue < 0) { + return null; } - return buildPostFilteredResponse(resp, filteredHits, responseOptions); + return { + hits: hits.hits, + totalValue + }; } function getHitCveId(hit) { @@ -174,276 +113,140 @@ function combineTook(...values) { return numericValues.reduce((sum, value) => sum + value, 0); } -function recordOpenSearchStart(metrics, operation, state) { - metrics.openSearchOperations ??= []; - metrics.openSearchOperations.push(operation); - metrics.maxOpenSearchActiveRequests = Math.max(metrics.maxOpenSearchActiveRequests ?? 0, state.activeCount); -} - -async function executeOpenSearch(body, operation, signal, metrics) { - return searchProvider.searchOpenSearch(body, { - signal, - operation, - onStart: (state) => recordOpenSearchStart(metrics, operation, state) - }); +async function executeOpenSearch(body, signal) { + return searchProvider.searchOpenSearch(body, { signal }); } -async function executeStandardSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal) { - metrics.strategy = 'standard'; - - const searchStart = searchPerformance.markTime(); +async function executeStandardSearch(searchOptions, queryStringObjects, signal) { const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const searchData = await executeOpenSearch(body, 'standard', signal, metrics); - const searchResp = { + const searchData = await executeOpenSearch(body, signal); + + return { status: 'ok', data: searchData }; - metrics.openSearchElapsedMs = searchPerformance.elapsedMs(searchStart); - metrics.openSearchTookMs = searchData?.took; - - const postFilterStart = searchPerformance.markTime(); - const resp = applySearchPostFilters(searchResp, postFilters, responseOptions, metrics); - metrics.postFilterElapsedMs = searchPerformance.elapsedMs(postFilterStart); - - return resp; } -async function fetchFullHitsByCveIds(cveIds, metrics, signal) { - if (!cveIds.length) return []; - - const inFlightKey = searchInFlightRegistry.buildKey('pageFetch', cveIds); - const waitStart = searchPerformance.markTime(); - const inFlight = searchInFlightRegistry.acquire( - inFlightKey, - async (operationSignal) => { - const { searchOptions, queryStringObjects } = searchQueryBuilder.buildCveIdsSearchRequest(cveIds); - const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const pageFetchStart = searchPerformance.markTime(); - const pageData = await executeOpenSearch(body, 'page-fetch', operationSignal, metrics); - metrics.pageFetchElapsedMs = searchPerformance.elapsedMs(pageFetchStart); - metrics.pageFetchTookMs = pageData?.took; - - return orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds); - }, - { signal } - ); +async function fetchFullHitsByCveIds(cveIds, signal) { + if (!cveIds.length) return { hits: [], took: undefined }; - metrics.pageFetchInFlightHit = inFlight.shared; - const result = await inFlight.promise; - if (inFlight.shared) { - metrics.pageFetchWaitElapsedMs = searchPerformance.elapsedMs(waitStart); - } + const { searchOptions, queryStringObjects } = searchQueryBuilder.buildCveIdsSearchRequest(cveIds); + const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); + const pageData = await executeOpenSearch(body, signal); - return result; + return { + hits: orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds), + took: pageData?.took + }; } -async function buildPostFilterCandidateResult(candidateBody, postFilters, metrics, signal) { - const candidateStart = searchPerformance.markTime(); - const candidateData = await executeOpenSearch(candidateBody, 'cpe-candidate', signal, metrics); - metrics.openSearchElapsedMs = searchPerformance.elapsedMs(candidateStart); - metrics.openSearchTookMs = candidateData?.took; +async function buildPostFilterCandidateResult(candidateBody, postFilters, signal) { + const candidateData = await executeOpenSearch(candidateBody, signal); const candidateResp = { data: candidateData }; - const postFilterStart = searchPerformance.markTime(); - const { candidateCount, filteredHits } = filterSearchHits(candidateResp, postFilters); - metrics.postFilterElapsedMs = searchPerformance.elapsedMs(postFilterStart); + const { filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); return { - candidateCount, filteredCount: filteredHits.length, - filteredCveIds: getHitCveIds(filteredHits) + filteredCveIds: getHitCveIds(filteredHits), + took: candidateData?.took }; } -async function getPostFilterCandidateResult(cacheKey, candidateBody, postFilters, metrics, signal) { - const cachedResult = searchPostFilterCache.get(cacheKey); - if (cachedResult) { - metrics.cacheHit = true; - metrics.inFlightHit = false; - return cachedResult; - } - - metrics.cacheHit = false; - const inFlightKey = searchInFlightRegistry.buildKey('candidate', cacheKey); - const queueStart = searchPerformance.markTime(); - const waitStart = searchPerformance.markTime(); - const inFlight = searchInFlightRegistry.acquire( - inFlightKey, - (operationSignal) => - searchPostFilterWorkQueue - .run( - () => { - metrics.coldSearchQueueWaitElapsedMs = searchPerformance.elapsedMs(queueStart); - metrics.coldSearchQueueState = searchPostFilterWorkQueue.getState(); - return buildPostFilterCandidateResult(candidateBody, postFilters, metrics, operationSignal); - }, - { signal: operationSignal } - ) - .then((result) => { - searchPostFilterCache.set(cacheKey, result); - return result; - }), - { signal } +async function executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { + const candidateBody = searchQueryBuilder.buildPostFilterCandidateSearchBody( + searchOptions, + queryStringObjects, + postFilters ); - - metrics.inFlightHit = inFlight.shared; - const result = await inFlight.promise; - if (inFlight.shared) { - metrics.inFlightWaitElapsedMs = searchPerformance.elapsedMs(waitStart); - } - - return result; -} - -async function executeOptimizedPostFilterSearch( - searchOptions, - queryStringObjects, - postFilters, - responseOptions, - metrics, - signal -) { - metrics.strategy = 'cpe-candidate'; - - const candidateBody = searchQueryBuilder.buildCpeCandidateSearchBody(searchOptions, queryStringObjects); - const cacheKey = searchPostFilterCache.buildCacheKey(candidateBody, postFilters); - const candidateResult = await getPostFilterCandidateResult(cacheKey, candidateBody, postFilters, metrics, signal); + const candidateResult = await buildPostFilterCandidateResult(candidateBody, postFilters, signal); searchDeadline.throwIfAborted(signal); const pageCveIds = paginateHits(candidateResult.filteredCveIds, responseOptions); - metrics.candidateCount = candidateResult.candidateCount; - metrics.filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; - metrics.returnedCount = pageCveIds.length; + const filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; - const fullPageHits = await fetchFullHitsByCveIds(pageCveIds, metrics, signal); + const pageResult = await fetchFullHitsByCveIds(pageCveIds, signal); const response = { + status: 'ok', data: { - took: combineTook(metrics.openSearchTookMs, metrics.pageFetchTookMs), + took: combineTook(candidateResult.took, pageResult.took), hits: { total: { - value: metrics.filteredCount + value: filteredCount }, hits: [] } } }; - return buildPagedSearchResponse(response, metrics.filteredCount, fullPageHits); + return buildPagedSearchResponse(response, filteredCount, pageResult.hits); } -async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal) { - if (!hasSearchPostFilters(postFilters)) { - return executeStandardSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal); +async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { + if (!searchPostFilters.hasSearchPostFilters(postFilters)) { + return executeStandardSearch(searchOptions, queryStringObjects, signal); } - return executeOptimizedPostFilterSearch( - searchOptions, - queryStringObjects, - postFilters, - responseOptions, - metrics, - signal - ); + return executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal); } async function searchHandler(req, res, next) { const params = req.searchParams ?? req.query ?? {}; - const metrics = { - requestId: req.requestId - }; - const handlerStart = searchPerformance.markTime(); const lifecycle = searchDeadline.createSearchRequestLifecycle( req, res, searchProvider.getSearchRequestDeadlineMs() ); - let searchOptions; - let postFilters; - let responseOptions; try { const searchRequest = searchQueryBuilder.buildSearchRequest(params); - searchOptions = searchRequest.searchOptions; - postFilters = searchRequest.postFilters; - responseOptions = searchRequest.responseOptions; + const searchOptions = searchRequest.searchOptions; + const postFilters = searchRequest.postFilters; + const responseOptions = searchRequest.responseOptions; const resp = await executeSearch( searchOptions, searchRequest.queryStringObjects, postFilters, responseOptions, - metrics, lifecycle.signal ); lifecycle.throwIfAborted(); const pagingOptions = responseOptions ?? searchOptions; - metrics.outcome = 'success'; - metrics.statusCode = 200; //Calculate current page and total pages to append to JSON output const totalValue = resp.data.hits.total.value; const requestedPageSize = pagingOptions.resultsPerPage; - const pageSize = Math.min(totalValue, requestedPageSize); + 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, + pageSize: requestedPageSize, + resultsReturned, totalPages, ...resp }); } catch (err) { if (searchDeadline.isSearchClientDisconnectedError(err)) { - metrics.outcome = 'client_disconnected'; - metrics.statusCode = 499; - metrics.abortReason = 'client_disconnect'; return; } - if (searchPostFilterWorkQueue.isQueueFullError(err)) { - metrics.outcome = 'queue_full'; - metrics.statusCode = 429; - metrics.coldSearchQueueState = err.state; - if (typeof res.set === 'function') res.set('Retry-After', '5'); - - return res.status(429).json(error.searchQueueFullError()); - } - - if (searchPostFilterWorkQueue.isQueueTimeoutError(err)) { - metrics.outcome = 'queue_timeout'; - metrics.statusCode = 429; - metrics.coldSearchQueueState = err.state; - metrics.coldSearchQueueWaitElapsedMs = err.waitElapsedMs; - if (typeof res.set === 'function') res.set('Retry-After', '5'); - - return res.status(429).json(error.searchQueueTimeoutError()); - } - if (searchDeadline.isSearchTimeoutError(err)) { - metrics.outcome = 'search_timeout'; - metrics.statusCode = 504; - metrics.timeoutSource = err?.name === 'TimeoutError' ? 'opensearch' : 'request_deadline'; - metrics.abortReason = metrics.timeoutSource; return res.status(504).json(error.searchTimeoutError()); } if (isOpenSearchUnavailableError(err)) { - metrics.outcome = 'opensearch_unavailable'; - metrics.statusCode = 503; if (typeof res.set === 'function') res.set('Retry-After', '5'); return res.status(503).json(error.openSearchError()); } - metrics.outcome = 'upstream_error'; - metrics.statusCode = 502; return res.status(502).json(error.upstreamSearchError()); } finally { lifecycle.complete(); - metrics.totalElapsedMs = searchPerformance.elapsedMs(handlerStart); - searchPerformance.logSearchPerformance(metrics, searchOptions, responseOptions, postFilters); } } @@ -479,21 +282,14 @@ async function webSearchHandler(req, res, next) { const queryTime = Date.now() - _startTime; body.searchMetadata.queryTime_ms = queryTime; - /* build response */ - const respData = searchResponse?.data; - let resultsTotal = 0; - let data = []; - if (respData && respData['hits'] && respData['hits']['hits']) { - resultsTotal = searchResponse?.data['hits']['total']['value'] || 0; - data = searchResponse?.data['hits']['hits'] || []; - } - if (searchResponse.isOk()) { + const successData = getWebSearchSuccessData(searchResponse); + if (successData) { status = 200; body.searchMetadata.searchStatus = searchResponse.status; body.searchMetadata.notes = searchResponse.notes; body.searchMetadata.errors = searchResponse.errors; - body.resultsTotal = resultsTotal; - body.data = data; + body.resultsTotal = successData.totalValue; + body.data = successData.hits; } else { status = 502; body.searchMetadata.errors = [error.upstreamSearchError()]; @@ -510,7 +306,6 @@ async function webSearchHandler(req, res, next) { } module.exports = { - applySearchPostFilters, buildWebSearchSort, SEARCH: searchHandler, WEB_SEARCH: webSearchHandler diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 2a116f5..ad42d84 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -4,23 +4,10 @@ const mockApiSearch = jest.fn(); const mockBasicSearch = jest.fn(); const mockSearchRaw = jest.fn(); const mockSearchOpenSearch = jest.fn((body, options) => { - const operation = options?.operation; - const search = operation === 'cpe-candidate' ? mockSearchRaw : mockApiSearch; - options?.onStart?.({ - activeCount: 1, - activeByOperation: { - [operation]: 1 - } - }); + const search = Array.isArray(body?._source) ? mockSearchRaw : mockApiSearch; return Promise.resolve(search(body, options)).then((result) => result?.data ?? result); }); -const mockShouldLogSearchPerformance = jest.fn(); -const mockGetPostFilterCacheTtlMs = jest.fn(() => 60000); -const mockGetPostFilterCacheMaxEntries = jest.fn(() => 100); -const mockGetPostFilterColdSearchConcurrency = jest.fn(() => 10); -const mockGetPostFilterColdSearchMaxQueueDepth = jest.fn(() => 500); -const mockGetPostFilterColdSearchMaxWaitMs = jest.fn(() => 10000); const mockGetSearchRequestDeadlineMs = jest.fn(() => 30000); const mockIsSearchProviderContractError = jest.fn((err) => err?.code === 'SEARCH_PROVIDER_CONTRACT'); const mockGetBasicSearchManager = jest.fn(() => ({ @@ -37,20 +24,12 @@ jest.mock('cve-core', () => { jest.mock('../utils/searchProvider', () => ({ getBasicSearchManager: mockGetBasicSearchManager, - getPostFilterCacheMaxEntries: mockGetPostFilterCacheMaxEntries, - getPostFilterCacheTtlMs: mockGetPostFilterCacheTtlMs, - getPostFilterColdSearchConcurrency: mockGetPostFilterColdSearchConcurrency, - getPostFilterColdSearchMaxQueueDepth: mockGetPostFilterColdSearchMaxQueueDepth, - getPostFilterColdSearchMaxWaitMs: mockGetPostFilterColdSearchMaxWaitMs, getSearchRequestDeadlineMs: mockGetSearchRequestDeadlineMs, isSearchProviderContractError: mockIsSearchProviderContractError, - searchOpenSearch: mockSearchOpenSearch, - shouldLogSearchPerformance: mockShouldLogSearchPerformance + searchOpenSearch: mockSearchOpenSearch })); const controller = require('./searchController.js'); -const searchInFlightRegistry = require('../utils/searchInFlightRegistry'); -const searchPostFilterCache = require('../utils/searchPostFilterCache'); function createResponse() { const res = {}; @@ -95,13 +74,15 @@ function basicResponse({ ok = true, status = 'ok', total = 0, hits = [], notes = }; } -function deferred() { - const state = {}; - state.promise = new Promise((resolve, reject) => { - state.resolve = resolve; - state.reject = reject; - }); - return state; +function cvssTokenRegexp(field, token) { + return { + regexp: { + [`${field}.keyword`]: { + value: `(.*/)?${token}(/.*)?`, + case_insensitive: true + } + } + }; } function captureTimers() { @@ -149,34 +130,16 @@ describe('Search controller SEARCH handler', () => { mockBasicSearch.mockReset(); mockSearchRaw.mockReset(); mockSearchOpenSearch.mockClear(); - mockShouldLogSearchPerformance.mockReset(); - mockShouldLogSearchPerformance.mockReturnValue(false); mockGetBasicSearchManager.mockClear(); - mockGetPostFilterCacheTtlMs.mockClear(); - mockGetPostFilterCacheMaxEntries.mockClear(); - mockGetPostFilterColdSearchConcurrency.mockClear(); - mockGetPostFilterColdSearchMaxQueueDepth.mockClear(); - mockGetPostFilterColdSearchMaxWaitMs.mockClear(); mockGetSearchRequestDeadlineMs.mockClear(); mockIsSearchProviderContractError.mockClear(); - mockGetPostFilterCacheTtlMs.mockReturnValue(60000); - mockGetPostFilterCacheMaxEntries.mockReturnValue(100); - mockGetPostFilterColdSearchConcurrency.mockReturnValue(10); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(500); - mockGetPostFilterColdSearchMaxWaitMs.mockReturnValue(10000); mockGetSearchRequestDeadlineMs.mockReturnValue(30000); - searchPostFilterCache.clear(); - searchInFlightRegistry.clear(); - jest.spyOn(console, 'log').mockImplementation(() => {}); res = createResponse(); next = jest.fn(); }); - afterEach(() => { - searchInFlightRegistry.clear(); - jest.restoreAllMocks(); - }); + afterEach(() => jest.restoreAllMocks()); test('SEARCH builds filters, query strings, date ranges, and pagination options', async () => { const resp = advancedResponse(25, [{ _id: 'CVE-2024-0001' }]); @@ -185,6 +148,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: 'remote code', cveIds: 'CVE-2024-0001', pubStartDate: '2024-01-01T00:00:00.000Z', @@ -239,12 +203,17 @@ describe('Search controller SEARCH handler', () => { }, track_total_hits: true, size: 10, - from: 20 + from: 20, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }, expect.objectContaining({ - operation: 'standard', - signal: expect.any(AbortSignal), - onStart: expect.any(Function) + signal: expect.any(AbortSignal) }) ); @@ -252,6 +221,7 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 3, pageSize: 10, + resultsReturned: 1, totalPages: 3, ...resp }); @@ -263,6 +233,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: 'remote code', keywordExactMatch: 'true' } @@ -312,6 +283,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', noRejected: true } }, @@ -399,6 +371,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cweId: 'CWE-79', cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', isVulnerable: 'true' @@ -473,6 +446,7 @@ 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(mockApiSearch).not.toHaveBeenCalled(); @@ -533,10 +507,16 @@ describe('Search controller SEARCH handler', () => { }, track_total_hits: true, size: 1, - from: 0 + from: 0, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }, expect.objectContaining({ - operation: 'page-fetch', signal: expect.any(AbortSignal) }) ); @@ -544,7 +524,9 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 1, pageSize: 1, + resultsReturned: 1, totalPages: 1, + status: 'ok', data: { hits: { total: { @@ -556,164 +538,139 @@ describe('Search controller SEARCH handler', () => { }); }); - test('applySearchPostFilters does not combine platform and version matches from different CPE entries', () => { - const candidate = cpeHit('CVE-2026-0250', { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.0', - versionEndExcluding: '6.1' - }); - candidate._source.containers.cna.cpeApplicability.nodes[0].cpeMatch.push( - { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.2', - versionEndExcluding: '6.2.7' - }, - { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.1', - versionEndExcluding: '6.1.13' - }, - { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:chrome_os:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.1', - versionEndExcluding: '6.1.13' + test('SEARCH semantically filters SSVC candidates and fetches full records for the requested page', async () => { + const matching = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { + adp: [ + { + metrics: [ + { + other: { + type: 'ssvc', + content: { + role: 'CISA Coordinator', + options: [{ Exploitation: 'active' }, { Automatable: 'yes' }] + } + } + } + ] + } + ] + } } - ); - - const response = controller.applySearchPostFilters( - advancedResponse(1, [candidate]), - { - cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 + }; + const splitAcrossEntries = { + _id: 'CVE-2024-0002', + _source: { + cveMetadata: { cveId: 'CVE-2024-0002' }, + containers: { + adp: [ + { + metrics: [ + { other: { type: 'ssvc', content: { options: [{ Exploitation: 'active' }] } } }, + { other: { type: 'ssvc', content: { options: [{ Automatable: 'yes' }] } } } + ] + } + ] + } } - ); - - expect(response.data.hits.total.value).toBe(0); - expect(response.data.hits.hits).toStrictEqual([]); - }); - - test('applySearchPostFilters matches CPE string literals without lexical case', () => { - const candidate = cpeHit('CVE-2026-0251', { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', - vulnerable: true, - versionStartIncluding: '6.0', - versionEndExcluding: '6.1' - }); + }; + const fullRecord = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } + } + }; + mockSearchRaw.mockResolvedValue(rawResponse(2, [matching, splitAcrossEntries])); + mockApiSearch.mockResolvedValue(advancedResponse(1, [fullRecord])); - const response = controller.applySearchPostFilters( - advancedResponse(1, [candidate]), + await controller.SEARCH( { - cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', - isVulnerable: true + searchParams: { + scope: 'ADP_ONLY', + ssvcExploitation: 'ACTIVE', + ssvcAutomatable: 'YES', + resultsPerPage: 1, + pageNumber: 1 + } }, - { - from: 0, - resultsPerPage: 20 - } + res, + next ); - expect(response.data.hits.total.value).toBe(1); - expect(response.data.hits.hits).toStrictEqual([candidate]); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); + expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ + 'cveMetadata.cveId', + '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]); }); - test('applySearchPostFilters preserves exact version padding while normalizing numeric range segments', () => { - const exactCandidate = cpeHit('CVE-2025-9392', { - criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', - vulnerable: true - }); - const rangeCandidate = cpeHit('CVE-2026-33280', { - criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', - vulnerable: true, - versionEndExcluding: '6.02' - }); - - const exactResponse = controller.applySearchPostFilters( - advancedResponse(1, [exactCandidate]), - { - cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 + test('SEARCH semantically filters date-bounded KEV candidates within the selected scope', async () => { + const matching = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { + adp: [ + { + metrics: [{ other: { type: 'kev', content: { dateAdded: '2024-06-15' } } }] + } + ] + } } - ); - const includedRangeResponse = controller.applySearchPostFilters( - advancedResponse(1, [rangeCandidate]), - { - cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 + }; + const outsideRange = { + _id: 'CVE-2024-0002', + _source: { + cveMetadata: { cveId: 'CVE-2024-0002' }, + containers: { + adp: [ + { + metrics: [{ other: { type: 'kev', content: { dateAdded: '2023-12-31' } } }] + } + ] + } } - ); - const excludedRangeResponse = controller.applySearchPostFilters( - advancedResponse(1, [rangeCandidate]), - { - cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 + }; + const fullRecord = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } } - ); - - expect(exactResponse.data.hits.total.value).toBe(0); - expect(includedRangeResponse.data.hits.hits).toStrictEqual([rangeCandidate]); - expect(excludedRangeResponse.data.hits.total.value).toBe(0); - }); - - test('applySearchPostFilters preserves CPE NA matches across numeric version ranges', () => { - const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; - const candidates = [ - cpeHit('CVE-2020-17153', { - criteria, - vulnerable: true, - versionStartIncluding: '-' - }), - cpeHit('CVE-2024-38208', { - criteria, - vulnerable: true, - versionStartIncluding: '1.0.0', - versionEndExcluding: '128.0.2739.42' - }), - cpeHit('CVE-2026-35429', { - criteria, - vulnerable: true, - versionStartIncluding: '1.0.0', - versionEndExcluding: '148.0.3967.55' - }) - ]; + }; + mockSearchRaw.mockResolvedValue(rawResponse(2, [matching, outsideRange])); + mockApiSearch.mockResolvedValue(advancedResponse(1, [fullRecord])); - const response = controller.applySearchPostFilters( - advancedResponse(candidates.length, candidates), + await controller.SEARCH( { - cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', - isVulnerable: true + searchParams: { + scope: 'ADP_ONLY', + knownExploited: true, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z', + resultsPerPage: 1, + pageNumber: 1 + } }, - { - from: 0, - resultsPerPage: 20 - } + res, + next ); - expect(response.data.hits.total.value).toBe(3); - expect(response.data.hits.hits.map((hit) => hit._id)).toStrictEqual([ - 'CVE-2020-17153', - 'CVE-2024-38208', - 'CVE-2026-35429' + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); + expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ + 'cveMetadata.cveId', + '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]); }); test('SEARCH applies semantic virtualMatchString version range filtering before building response metadata', async () => { @@ -772,7 +729,6 @@ describe('Search controller SEARCH handler', () => { }); expect(mockApiSearch.mock.calls[0][1]).toEqual( expect.objectContaining({ - operation: 'page-fetch', signal: expect.any(AbortSignal) }) ); @@ -780,7 +736,9 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 1, pageSize: 1, + resultsReturned: 1, totalPages: 1, + status: 'ok', data: { hits: { total: { @@ -792,182 +750,6 @@ describe('Search controller SEARCH handler', () => { }); }); - test('SEARCH reuses cached semantic post-filter IDs for repeated virtualMatchString range searches', async () => { - const applicable = cpeHit('CVE-2024-0001', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' - }); - const notApplicable = cpeHit('CVE-2024-0002', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.6:*:*:*:*:*:*:*' - }); - const fullApplicable = { - _id: 'CVE-2024-0001', - _source: { - cveMetadata: { - cveId: 'CVE-2024-0001' - } - } - }; - const req = { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - versionStart: '2.2', - versionStartType: 'including', - versionEnd: '2.6', - versionEndType: 'excluding', - resultsPerPage: '1', - pageNumber: '1' - } - }; - - mockSearchRaw.mockResolvedValue(rawResponse(2, [applicable, notApplicable])); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); - - await controller.SEARCH(req, res, next); - - const secondRes = createResponse(); - await controller.SEARCH(req, secondRes, next); - - expect(mockSearchRaw).toHaveBeenCalledTimes(1); - expect(mockApiSearch).toHaveBeenCalledTimes(2); - expect(mockApiSearch.mock.calls[1][0].query.bool.filter).toStrictEqual([ - { - terms: { - 'cveMetadata.cveId.keyword': ['CVE-2024-0001'] - } - } - ]); - expect(mockApiSearch.mock.calls[1][0]).toMatchObject({ - track_total_hits: true, - size: 1, - from: 0 - }); - expect(secondRes.status).toHaveBeenCalledWith(200); - expect(secondRes.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - totalPages: 1, - data: { - took: undefined, - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); - }); - - test('SEARCH coalesces concurrent identical cold semantic post-filter searches', async () => { - const candidateSearch = deferred(); - const applicable = cpeHit('CVE-2024-0001', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' - }); - const notApplicable = cpeHit('CVE-2024-0002', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.6:*:*:*:*:*:*:*' - }); - const fullApplicable = { - _id: 'CVE-2024-0001', - _source: { - cveMetadata: { - cveId: 'CVE-2024-0001' - } - } - }; - const req = { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - versionStart: '2.2', - versionStartType: 'including', - versionEnd: '2.6', - versionEndType: 'excluding', - resultsPerPage: '1', - pageNumber: '1' - } - }; - const firstRes = createResponse(); - const secondRes = createResponse(); - - mockSearchRaw.mockReturnValue(candidateSearch.promise); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); - - const firstSearch = controller.SEARCH(req, firstRes, next); - await Promise.resolve(); - const secondSearch = controller.SEARCH(req, secondRes, next); - - candidateSearch.resolve(rawResponse(2, [applicable, notApplicable])); - await Promise.all([firstSearch, secondSearch]); - - expect(mockSearchRaw).toHaveBeenCalledTimes(1); - expect(mockApiSearch).toHaveBeenCalledTimes(1); - expect(firstRes.status).toHaveBeenCalledWith(200); - expect(secondRes.status).toHaveBeenCalledWith(200); - expect(secondRes.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - totalPages: 1, - data: { - took: undefined, - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); - }); - - test('SEARCH keeps shared CPE work active when one client disconnects', async () => { - const candidateSearch = deferred(); - const applicable = cpeHit('CVE-2024-0001', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' - }); - const fullApplicable = { - _id: 'CVE-2024-0001', - _source: { - cveMetadata: { - cveId: 'CVE-2024-0001' - } - } - }; - const firstReq = new EventEmitter(); - const secondReq = new EventEmitter(); - const query = { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - resultsPerPage: 1, - pageNumber: 1 - }; - firstReq.query = query; - secondReq.query = query; - const firstRes = createResponse(); - const secondRes = createResponse(); - let operationSignal; - - mockSearchRaw.mockImplementation((_body, { signal }) => { - operationSignal = signal; - return candidateSearch.promise; - }); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); - - const firstSearch = controller.SEARCH(firstReq, firstRes, next); - await Promise.resolve(); - const secondSearch = controller.SEARCH(secondReq, secondRes, next); - await Promise.resolve(); - - firstReq.emit('aborted'); - await firstSearch; - - expect(operationSignal.aborted).toBe(false); - candidateSearch.resolve(rawResponse(1, [applicable])); - await secondSearch; - - expect(mockSearchRaw).toHaveBeenCalledTimes(1); - expect(mockApiSearch).toHaveBeenCalledTimes(1); - expect(firstRes.status).not.toHaveBeenCalled(); - expect(secondRes.status).toHaveBeenCalledWith(200); - }); - test('SEARCH applies semantic virtualMatchString filtering before building response metadata', async () => { const applicable = cpeHit('CVE-2024-0001', { criteria: 'cpe:2.3:o:microsoft:windows_10_1809:-:*:*:*:*:*:x86:*' @@ -1010,7 +792,9 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 1, pageSize: 1, + resultsReturned: 1, totalPages: 1, + status: 'ok', data: { hits: { total: { @@ -1022,128 +806,6 @@ describe('Search controller SEARCH handler', () => { }); }); - test('SEARCH returns 429 when the cold semantic post-filter queue is full', async () => { - const candidateSearch = deferred(); - const firstRes = createResponse(); - const secondRes = createResponse(); - - mockGetPostFilterColdSearchConcurrency.mockReturnValue(1); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(0); - mockSearchRaw.mockReturnValue(candidateSearch.promise); - - const firstSearch = controller.SEARCH( - { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - resultsPerPage: '1', - pageNumber: '1' - } - }, - firstRes, - next - ); - await Promise.resolve(); - - await controller.SEARCH( - { - requestId: 'queue-full-request', - query: { - virtualMatchString: 'cpe:2.3:o:microsoft:windows_10_1809', - resultsPerPage: '1', - pageNumber: '1' - } - }, - secondRes, - next - ); - - expect(secondRes.set).toHaveBeenCalledWith('Retry-After', '5'); - expect(secondRes.status).toHaveBeenCalledWith(429); - expect(secondRes.json).toHaveBeenCalledWith({ - error: 'SEARCH_QUEUE_FULL', - message: 'Search service is busy. Please retry later.' - }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'queue-full-request', - outcome: 'queue_full', - statusCode: 429, - strategy: 'cpe-candidate', - cacheStatus: 'miss', - coldSearchQueueState: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 1, - maxQueueDepth: 0, - maxWaitMs: 10000 - } - }); - - candidateSearch.resolve(rawResponse(0, [])); - await firstSearch; - }); - - test('SEARCH returns 429 when a cold semantic post-filter search exceeds the queue wait deadline', async () => { - const timers = captureTimers(); - const candidateSearch = deferred(); - const firstRes = createResponse(); - const secondRes = createResponse(); - - mockGetPostFilterColdSearchConcurrency.mockReturnValue(1); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(1); - mockGetPostFilterColdSearchMaxWaitMs.mockReturnValue(10000); - mockSearchRaw.mockReturnValue(candidateSearch.promise); - - const firstSearch = controller.SEARCH( - { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - resultsPerPage: '1', - pageNumber: '1' - } - }, - firstRes, - next - ); - await Promise.resolve(); - - const secondSearch = controller.SEARCH( - { - requestId: 'queue-timeout-request', - query: { - virtualMatchString: 'cpe:2.3:o:microsoft:windows_10_1809', - resultsPerPage: '1', - pageNumber: '1' - } - }, - secondRes, - next - ); - await Promise.resolve(); - - timers.find(({ delay }) => delay === 10000).callback(); - await secondSearch; - - expect(secondRes.set).toHaveBeenCalledWith('Retry-After', '5'); - expect(secondRes.status).toHaveBeenCalledWith(429); - expect(secondRes.json).toHaveBeenCalledWith({ - error: 'SEARCH_QUEUE_TIMEOUT', - message: 'Search service is busy. Please retry later.' - }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'queue-timeout-request', - outcome: 'queue_timeout', - statusCode: 429, - strategy: 'cpe-candidate', - cacheStatus: 'miss', - coldSearchQueueWaitElapsedMs: expect.any(Number) - }); - - candidateSearch.resolve(rawResponse(0, [])); - await firstSearch; - }); - test('SEARCH returns sanitized upstream errors when optimized post-filter search fails', async () => { mockSearchRaw.mockRejectedValue(new Error('candidate search failed')); @@ -1167,51 +829,13 @@ describe('Search controller SEARCH handler', () => { }); }); - test('SEARCH logs redacted performance timing when enabled', async () => { - mockShouldLogSearchPerformance.mockReturnValue(true); - mockApiSearch.mockResolvedValue(advancedResponse(1, [{ _id: 'CVE-2024-0001' }])); - - await controller.SEARCH( - { - requestId: 'request-123', - query: { - keywordSearch: 'remote code', - resultsPerPage: '1' - } - }, - res, - next - ); - - expect(console.log).toHaveBeenCalledTimes(1); - const timingLog = JSON.parse(console.log.mock.calls[0][0]); - expect(timingLog).toMatchObject({ - event: 'search.performance', - route: '/search', - requestId: 'request-123', - outcome: 'success', - statusCode: 200, - strategy: 'standard', - cacheStatus: 'not_applicable', - optimizedFallback: false, - cacheHit: false, - inFlightHit: false, - openSearchOperations: ['standard'], - maxOpenSearchActiveRequests: 1, - postFilters: [], - requestedFrom: 0, - requestedSize: 1, - openSearchSize: 1 - }); - expect(console.log.mock.calls[0][0]).not.toContain('remote code'); - }); - test('SEARCH builds version-specific CVSS v3 severity filters', async () => { mockApiSearch.mockResolvedValue(advancedResponse(1)); await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cvssV3_0Severity: 'high', cvssV3_1Severity: 'critical' } @@ -1235,11 +859,12 @@ describe('Search controller SEARCH handler', () => { }); test('SEARCH builds cvssV2 metric filters', async () => { - mockApiSearch.mockResolvedValue(advancedResponse(1)); + mockSearchRaw.mockResolvedValue(rawResponse(0)); await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cvssV2Metrics: 'AV:N/AC:L/Au:N' } }, @@ -1247,43 +872,27 @@ describe('Search controller SEARCH handler', () => { next ); - expect(mockApiSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + expect(mockSearchRaw.mock.calls[0][0].query.bool.filter).toStrictEqual([ { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AC:L*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*Au:N*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AC:L'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AU:N') ] } } ]); + expect(res.json.mock.calls[0][0].status).toBe('ok'); }); test('SEARCH builds version-specific CVSS v3 metric filters', async () => { - mockApiSearch.mockResolvedValue(advancedResponse(1)); + mockSearchRaw.mockResolvedValue(rawResponse(0)); await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cvssV3_0Metrics: 'CVSS:3.0/AV:N/AC:L', cvssV3_1Metrics: 'CVSS:3.1/PR:N/UI:R' } @@ -1292,48 +901,25 @@ describe('Search controller SEARCH handler', () => { next ); - expect(mockApiSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + expect(mockSearchRaw.mock.calls[0][0].query.bool.filter).toStrictEqual([ { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AC:L*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AC:L') ] } }, { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*PR:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*UI:R*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'PR:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'UI:R') ] } } ]); + expect(res.json.mock.calls[0][0].status).toBe('ok'); }); test('SEARCH escapes Lucene reserved characters in keywordSearch terms', async () => { @@ -1342,6 +928,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: 'c++ path:/tmp (test)' } }, @@ -1366,6 +953,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: ' ' } }, @@ -1450,7 +1038,7 @@ describe('Search controller SEARCH handler', () => { expect(mockApiSearch).not.toHaveBeenCalled(); }); - test('SEARCH returns zeroed pagination metadata when OpenSearch returns zero hits', async () => { + test('SEARCH retains requested page size and reports zero returned results when OpenSearch returns zero hits', async () => { const resp = advancedResponse(0); mockApiSearch.mockResolvedValue(resp); @@ -1465,7 +1053,8 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 0, - pageSize: 0, + pageSize: 20, + resultsReturned: 0, totalPages: 0, ...resp }); @@ -1489,7 +1078,8 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 2, - pageSize: 5, + pageSize: 10, + resultsReturned: 0, totalPages: 1, ...resp }); @@ -1519,12 +1109,6 @@ describe('Search controller SEARCH handler', () => { message: 'OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'connection-error-request', - outcome: 'opensearch_unavailable', - statusCode: 503 - }); }); test('SEARCH returns a sanitized 504 when OpenSearch times out', async () => { @@ -1547,13 +1131,6 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_TIMEOUT', message: 'Search request timed out. Please try again later.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'timeout-request', - outcome: 'search_timeout', - statusCode: 504, - timeoutSource: 'opensearch' - }); }); test('SEARCH returns a sanitized 504 when the overall request deadline is exceeded', async () => { @@ -1584,14 +1161,6 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_TIMEOUT', message: 'Search request timed out. Please try again later.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'request-deadline-request', - outcome: 'search_timeout', - statusCode: 504, - timeoutSource: 'request_deadline', - abortReason: 'request_deadline' - }); }); test('SEARCH stops without writing a response when the client disconnects', async () => { @@ -1612,14 +1181,6 @@ describe('Search controller SEARCH handler', () => { expect(res.status).not.toHaveBeenCalled(); expect(res.json).not.toHaveBeenCalled(); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'client-disconnect-request', - outcome: 'client_disconnected', - statusCode: 499, - abortReason: 'client_disconnect', - strategy: 'standard' - }); }); test('SEARCH does not start a page fetch after a client disconnects from a CPE search', async () => { @@ -1671,12 +1232,6 @@ describe('Search controller SEARCH handler', () => { error: 'UPSTREAM_SEARCH_ERROR', message: 'Search backend returned an error. Please try again later.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'upstream-error-request', - outcome: 'upstream_error', - statusCode: 502 - }); }); }); @@ -1688,21 +1243,14 @@ describe('Search controller WEB_SEARCH handler', () => { mockBasicSearch.mockReset(); mockSearchRaw.mockReset(); mockSearchOpenSearch.mockClear(); - mockShouldLogSearchPerformance.mockReset(); - mockShouldLogSearchPerformance.mockReturnValue(false); mockGetBasicSearchManager.mockClear(); - searchPostFilterCache.clear(); - searchInFlightRegistry.clear(); jest.spyOn(console, 'log').mockImplementation(() => {}); res = createResponse(); next = jest.fn(); }); - afterEach(() => { - searchInFlightRegistry.clear(); - jest.restoreAllMocks(); - }); + afterEach(() => jest.restoreAllMocks()); test('WEB_SEARCH sends cveId ascending sort, paging, and response metadata', async () => { const hits = [{ _id: 'CVE-2024-0001' }, { _id: 'CVE-2024-0002' }]; @@ -1908,6 +1456,46 @@ describe('Search controller WEB_SEARCH handler', () => { }); }); + test('WEB_SEARCH fails closed when cve-core reports success without a complete hits response', async () => { + mockBasicSearch.mockResolvedValue({ + status: 'ok', + notes: [], + errors: [], + data: {}, + isOk: jest.fn(() => true) + }); + + await controller.WEB_SEARCH( + { + body: { + query: 'apache' + } + }, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json.mock.calls[0][0]).toStrictEqual({ + searchMetadata: { + searchStatus: 'errors', + version: '2025-08-26', + cveCore: 'test-core-version', + cveMitreCore: 'test-core-version', + notes: [], + errors: [ + { + error: 'UPSTREAM_SEARCH_ERROR', + message: 'Search backend returned an error. Please try again later.' + } + ], + queryTime_ms: expect.any(Number) + }, + resultsTotal: 0, + data: [] + }); + }); + test('WEB_SEARCH returns a sanitized error when BasicSearchManager throws', async () => { mockBasicSearch.mockRejectedValue(new Error('boom')); diff --git a/jest.config.js b/jest.config.js index dd834cb..36247ba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,13 +2,7 @@ const config = { clearMocks: true, collectCoverage: false, - collectCoverageFrom: [ - 'controllers/**/*.js', - 'routes/index.js', - 'server.js', - 'utils/**/*.js', - '!controllers/wrapper.js' - ], + collectCoverageFrom: ['controllers/**/*.js', 'routes/index.js', 'server.js', 'utils/**/*.js'], coverageDirectory: 'coverage', coverageProvider: 'v8', coverageReporters: ['json', 'text', 'lcov'], diff --git a/package-lock.json b/package-lock.json index 61f936e..cfad6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,18 @@ { "name": "cve-search-api", - "version": "0.4.0-beta", + "version": "0.5.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-search-api", - "version": "0.4.0-beta", + "version": "0.5.0-beta", "dependencies": { "cors": "^2.8.5", "cve-core": "github:CVEProject/cve-core.git#dev-package-test", - "debug": "~3.2.7", "dotenv": "^6.1.0", "express": "^4.22.2", - "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", - "http-errors": "~1.8.1", "morgan": "~1.11.0", "swagger-ui-express": "^5.0.1" }, @@ -1513,12 +1510,12 @@ } }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/ajv": { @@ -1595,14 +1592,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -1858,9 +1852,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2323,15 +2317,6 @@ "date-fns": "2.x" } }, - "node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -2765,20 +2750,6 @@ "node": ">=0.4.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -2937,42 +2908,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", - "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-rate-limit/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/express-validator": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", @@ -3467,40 +3402,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-errors/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3630,15 +3531,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4543,14 +4435,23 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -5976,13 +5877,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 2a59a9f..1f07d5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cve-search-api", - "version": "0.4.0-beta", + "version": "0.5.0-beta", "scripts": { "dev": "node routes/swagger.js && nodemon server.js", "coverage": "jest --coverage", @@ -16,12 +16,9 @@ "dependencies": { "cors": "^2.8.5", "cve-core": "github:CVEProject/cve-core.git#dev-package-test", - "debug": "~3.2.7", "dotenv": "^6.1.0", "express": "^4.22.2", - "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", - "http-errors": "~1.8.1", "morgan": "~1.11.0", "swagger-ui-express": "^5.0.1" }, @@ -35,8 +32,10 @@ "swagger-autogen": "^2.23.7" }, "overrides": { + "adm-zip": "0.6.0", "axios": "1.18.0", - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", + "js-yaml": "4.3.1", "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 89cc369..25bd736 100644 --- a/routes/cpeName.test.js +++ b/routes/cpeName.test.js @@ -55,6 +55,7 @@ describe('POST /search cpeName route behavior', () => { expect(response.body).toStrictEqual({ route: 'search', params: { + scope: 'FULL', cpeName } }); diff --git a/routes/index.js b/routes/index.js index adca945..083db99 100644 --- a/routes/index.js +++ b/routes/index.js @@ -7,14 +7,22 @@ const constants = require('../utils/constants'); const featureFlags = require('../utils/featureFlags'); const searchDeadline = require('../utils/searchDeadline'); const searchProvider = require('../utils/searchProvider'); -const searchRateLimiter = require('../utils/searchRateLimiter'); +const searchScope = require('../utils/searchScope'); const error = new errors.errors(); -const limitSearchRequests = searchRateLimiter.createSearchRateLimiter(); const SEARCH_BODY_FIELDS = new Set(constants.SEARCH_QUERY_PARAMS); const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( (field) => - !['keywordExactMatch', 'cveIds', 'isVulnerable', 'noRejected', 'resultsPerPage', 'pageNumber'].includes(field) + ![ + 'keywordExactMatch', + 'cveIds', + 'isVulnerable', + 'noRejected', + 'knownExploited', + 'disputed', + 'resultsPerPage', + 'pageNumber' + ].includes(field) ); const WEB_SEARCH_BODY_FIELDS = new Set(['query', 'sort', 'from', 'size']); const WEB_SEARCH_SORT_FIELDS = new Set(['property', 'order']); @@ -101,6 +109,10 @@ function isStrictBoolean(value) { return value === true || value === false || value === 'true' || value === 'false'; } +function isJsonBoolean(value) { + return value === true || value === false; +} + function toBooleanValue(value) { if (value === 'true') return true; if (value === 'false') return false; @@ -145,11 +157,31 @@ function requireWebSearchEnabled(req, res, next) { function normalizeSearchParams(req, res, next) { const searchParams = { ...(req.body ?? {}) }; + const upperCaseEnums = [ + 'scope', + 'cvssV2Severity', + 'cvssV3Severity', + 'cvssV3_0Severity', + 'cvssV3_1Severity', + 'cvssV4Severity', + 'ssvcExploitation', + 'ssvcAutomatable', + 'ssvcTechnicalImpact' + ]; + const lowerCaseEnums = ['versionStartType', 'versionEndType']; if (searchParams.noRejected === false) { delete searchParams.noRejected; } + searchParams.scope = searchScope.normalizeScope(searchParams.scope); + upperCaseEnums.forEach((param) => { + if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toUpperCase(); + }); + lowerCaseEnums.forEach((param) => { + if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toLowerCase(); + }); + req.searchParams = searchParams; next(); } @@ -164,6 +196,9 @@ router.post( #swagger.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.

#swagger.requestBody = { required: false, content: { @@ -175,6 +210,7 @@ router.post( allParameters: { summary: "All supported request body parameters", value: { + scope: "FULL", keywordSearch: "remote code execution", keywordExactMatch: true, cveIds: "CVE-2024-1234,CVE-2024-5678", @@ -202,7 +238,18 @@ router.post( versionStartType: "including", versionEnd: "2.6", versionEndType: "excluding", - virtualMatchString: "cpe:2.3:o:linux:linux_kernel" + virtualMatchString: "cpe:2.3:o:linux:linux_kernel", + ssvcExploitation: "ACTIVE", + ssvcAutomatable: "YES", + ssvcTechnicalImpact: "TOTAL", + ssvcRole: "CISA Coordinator", + ssvcTimestampStartDate: "2024-01-01T00:00:00.000Z", + ssvcTimestampEndDate: "2024-12-31T23:59:59.999Z", + knownExploited: true, + kevDateAddedStartDate: "2024-01-01T00:00:00.000Z", + kevDateAddedEndDate: "2024-12-31T23:59:59.999Z", + referenceURL: "vendor.example/security/advisory", + disputed: false } }, keywordSearch: { @@ -236,6 +283,13 @@ router.post( } #swagger.responses[200] = { description: 'A list of matching CVE records', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/SearchResponse" + } + } +}, headers: { "X-Request-ID": { description: "Request correlation ID", @@ -247,38 +301,53 @@ headers: { } #swagger.responses[400] = { description: 'Malformed JSON, invalid request body, parameter combination, or version range', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiErrorResponse" + } + } +}, } #swagger.responses[404] = { description: 'Invalid cpeName value', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiErrorResponse" + } + } +}, } #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', -} -#swagger.responses[429] = { -description: 'Application rate limit exceeded, or the cold CPE search queue is full or exceeded its maximum wait', -headers: { - "Retry-After": { - description: "Seconds until a rate-limited request can be retried", - schema: { - type: "integer" - } - }, - "RateLimit": { - description: "Current application rate-limit state when application limiting is enabled", +content: { + "application/json": { schema: { - type: "string" + $ref: "#/components/schemas/ApiError" } - }, - "RateLimit-Policy": { - description: "Application rate-limit policy when application limiting is enabled", + } +}, +} +#swagger.responses[502] = { +description: 'OpenSearch returned an invalid or unexpected response', +content: { + "application/json": { schema: { - type: "string" + $ref: "#/components/schemas/ApiError" } } -} +}, } #swagger.responses[503] = { description: 'OpenSearch is unavailable', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, headers: { "Retry-After": { description: "Seconds before retrying an unavailable OpenSearch dependency", @@ -290,10 +359,16 @@ headers: { } #swagger.responses[504] = { description: 'Search exceeded its OpenSearch or overall processing deadline', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, } */ requireSearchJsonContentType, - limitSearchRequests, rejectSearchQueryParams, body().custom(isSearchBodyObject).withMessage(error.searchBodyError()), body().custom(hasOnlySearchBodyFields), @@ -313,6 +388,8 @@ description: 'Search exceeded its OpenSearch or overall processing deadline', .custom(isStrictBoolean) .withMessage(error.noRejectedValueError()) .customSanitizer(toBooleanValue), + body('knownExploited').optional().custom(isJsonBoolean).withMessage(error.knownExploitedInputError()), + body('disputed').optional().custom(isJsonBoolean).withMessage(error.disputedInputError()), body(['resultsPerPage']) .optional() .isInt({ max: constants.MAX_RESULTS_LIMIT }) @@ -353,7 +430,7 @@ router.get( /* #swagger.operationId = 'readinessCheck' #swagger.summary = "OpenSearch readiness check" -#swagger.description = "Sends a minimal zero-result search to the configured CVE index. Returns 200 when the index is available with search permission, or a sanitized 503 response when configuration, connectivity, timeout, permission, or index availability checks fail." +#swagger.description = "Validates compatible field capabilities for each logical search feature, then sends a minimal zero-result search to the configured CVE index. Alternative CNA, ADP, legacy, and CVSS-version fields may satisfy the same feature. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails." #swagger.responses[200] = { description: 'API is ready to serve search requests', headers: { @@ -367,6 +444,13 @@ headers: { } #swagger.responses[503] = { description: 'Search service is not ready', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, headers: { "Retry-After": { description: "Seconds before retrying the readiness check", @@ -391,11 +475,10 @@ router.post( /* #swagger.tags = ['CVE Record'] #swagger.operationId = 'webSearch' -#swagger.deprecated = true -#swagger.summary = "Deprecated raw query search" +#swagger.summary = "Raw query search" #swagger.description = "

Expected Behavior

-

Retrieves all matching CVE records after applying the JSON request body. This endpoint is deprecated and can be disabled by the service operator. Use POST /search instead.

+

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

#swagger.requestBody = { required: true, content: { @@ -439,6 +522,13 @@ router.post( } #swagger.responses[200] = { description: 'A list of matching CVE records', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebSearchResponse" + } + } +}, headers: { "X-Request-ID": { description: "Request correlation ID", @@ -450,9 +540,33 @@ headers: { } #swagger.responses[504] = { description: 'Search exceeded its OpenSearch request deadline', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebSearchResponse" + } + } +}, } #swagger.responses[410] = { -description: 'The deprecated endpoint is disabled; use POST /search', +description: 'The endpoint is disabled by service configuration', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, +} +#swagger.responses[502] = { +description: 'The search backend returned an error or invalid response', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebSearchResponse" + } + } +}, } */ requireWebSearchEnabled, diff --git a/routes/index.test.js b/routes/index.test.js index c1cd095..9be9c4d 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -23,13 +23,8 @@ jest.mock('../utils/featureFlags', () => ({ isWebSearchEnabled: jest.fn(() => true) })); -const mockLimitSearchRequests = jest.fn((req, res, next) => next()); const mockCheckOpenSearchReadiness = jest.fn(); -jest.mock('../utils/searchRateLimiter', () => ({ - createSearchRateLimiter: jest.fn(() => mockLimitSearchRequests) -})); - jest.mock('../utils/searchProvider', () => ({ checkOpenSearchReadiness: mockCheckOpenSearchReadiness, getOpenSearchReadinessTimeoutMs: jest.fn(() => 5000) @@ -47,7 +42,6 @@ describe('routes/index router', () => { app = createRouterApp(router); jest.clearAllMocks(); featureFlags.isWebSearchEnabled.mockReturnValue(true); - mockLimitSearchRequests.mockImplementation((req, res, next) => next()); mockCheckOpenSearchReadiness.mockResolvedValue(true); }); @@ -100,12 +94,12 @@ describe('routes/index router', () => { }); expect(response.status).toBe(200); - expect(mockLimitSearchRequests).toHaveBeenCalledTimes(1); expect(mw.validateParams).toHaveBeenCalledTimes(1); expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', params: { + scope: 'FULL', keywordSearch: 'test', keywordExactMatch: false } @@ -119,16 +113,17 @@ describe('routes/index router', () => { }); expect(response.status).toBe(200); - expect(mockLimitSearchRequests).toHaveBeenCalledTimes(1); expect(mw.validateParams).toHaveBeenCalledTimes(1); expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', - params: {} + params: { + scope: 'FULL' + } }); }); - test('POST /search rejects a non-empty body with an unsupported media type before rate limiting', async () => { + test('POST /search rejects a non-empty body with an unsupported media type before validation', async () => { const response = await request(app, { method: 'POST', path: '/search', @@ -143,29 +138,6 @@ describe('routes/index router', () => { error: 'SEARCH_CONTENT_TYPE', message: 'The /search request body must use the application/json media type with UTF-8 encoding.' }); - expect(mockLimitSearchRequests).not.toHaveBeenCalled(); - expect(mw.validateParams).not.toHaveBeenCalled(); - expect(controller.SEARCH).not.toHaveBeenCalled(); - }); - - test('POST /search returns a rate-limit response before validation and controller work', async () => { - mockLimitSearchRequests.mockImplementation((req, res) => - res.status(429).json({ - error: 'SEARCH_RATE_LIMITED', - message: 'Too many search requests. Please retry later.' - }) - ); - - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - keywordSearch: 'test' - } - }); - - expect(response.status).toBe(429); - expect(response.body.error).toBe('SEARCH_RATE_LIMITED'); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -258,6 +230,26 @@ describe('routes/index router', () => { expect(controller.SEARCH).not.toHaveBeenCalled(); }); + test('POST /search rejects an array for referenceURL before custom middleware runs', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + referenceURL: ['example.test/advisory'] + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual([ + { + error: 'SEARCH_FIELD_STRING', + message: 'The referenceURL field must be a non-empty string.' + } + ]); + expect(mw.validateParams).not.toHaveBeenCalled(); + expect(controller.SEARCH).not.toHaveBeenCalled(); + }); + test('POST /search lets cveIds reach custom middleware for format validation', async () => { const response = await request(app, { method: 'POST', @@ -273,6 +265,7 @@ describe('routes/index router', () => { expect(response.body).toStrictEqual({ route: 'search', params: { + scope: 'FULL', cveIds: ['CVE-2024-0001', 'CVE-2024-0002'] } }); @@ -292,8 +285,57 @@ describe('routes/index router', () => { expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', - params: {} + params: { + scope: 'FULL' + } + }); + }); + + test('POST /search normalizes case-insensitive enums before custom middleware runs', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + cvssV3Severity: 'critical', + ssvcExploitation: 'active', + versionStartType: 'INCLUDING' + } + }); + + expect(response.status).toBe(200); + expect(response.body.params).toStrictEqual({ + scope: 'ADP_ONLY', + cvssV3Severity: 'CRITICAL', + ssvcExploitation: 'ACTIVE', + versionStartType: 'including' + }); + }); + + test('POST /search rejects a string value for knownExploited', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + knownExploited: 'true' + } }); + + expect(response.status).toBe(400); + expect(controller.SEARCH).not.toHaveBeenCalled(); + }); + + test('POST /search rejects a string value for disputed', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + disputed: 'false' + } + }); + + expect(response.status).toBe(400); + expect(controller.SEARCH).not.toHaveBeenCalled(); }); test('POST /search rejects resultsPerPage above the configured max before custom middleware runs', async () => { @@ -332,7 +374,6 @@ describe('routes/index router', () => { }); expect(response.status).toBe(200); - expect(mockLimitSearchRequests).not.toHaveBeenCalled(); expect(controller.WEB_SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'webSearch', @@ -360,7 +401,7 @@ describe('routes/index router', () => { expect(response.status).toBe(410); expect(response.body).toStrictEqual({ error: 'WEB_SEARCH_DISABLED', - message: 'The /webSearch endpoint is disabled. Use POST /search instead.' + message: 'The /webSearch endpoint is disabled by service configuration.' }); expect(controller.WEB_SEARCH).not.toHaveBeenCalled(); }); diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 5319044..c97c124 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -9,8 +9,7 @@ jest.mock('../utils/searchProvider', () => ({ search: mockBasicSearch })), getSearchRequestDeadlineMs: jest.fn(() => 30000), - searchOpenSearch: mockSearchOpenSearch, - shouldLogSearchPerformance: jest.fn(() => false) + searchOpenSearch: mockSearchOpenSearch })); const { createApp } = require('../server'); @@ -74,7 +73,7 @@ describe('search route integration', () => { simple_query_string: { query: 'remote* code*', default_operator: 'AND', - fields: ['containers.cna.descriptions.value'] + fields: ['containers.cna.descriptions.value', 'containers.adp.descriptions.value'] } } ], @@ -83,19 +82,75 @@ describe('search route integration', () => { }, track_total_hits: true, size: 1, - from: 0 + from: 0, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }, - expect.objectContaining({ - operation: 'standard', - signal: expect.any(AbortSignal), - onStart: expect.any(Function) - }) + { + 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); }); + test('POST /search builds a scope-aware literal reference URL substring filter', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + referenceURL: 'EXAMPLE.test/a*?b' + } + }); + + expect(response.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + { + wildcard: { + 'containers.adp.references.url.keyword': { + value: '*EXAMPLE.test/a\\*\\?b*', + case_insensitive: true + } + } + } + ]); + }); + + test('POST /search builds a scope-aware disputed tag filter', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + disputed: true + } + }); + + expect(response.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + { + wildcard: { + 'containers.adp.tags.keyword': { + value: 'disputed', + case_insensitive: true + } + } + } + ]); + }); + test('POST /search rejects invalid dates before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', @@ -132,6 +187,38 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('POST /search rejects CPE applicability filters with ADP_ONLY scope', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + virtualMatchString: 'cpe:2.3:a:vendor:product' + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'SCOPE_FILTER_INCOMPATIBLE', + message: 'The virtualMatchString parameter is not supported with scope ADP_ONLY.' + }); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test('POST /search rejects KEV date filters unless knownExploited is explicitly true', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('KEV_DATE_REQUIRES_KNOWN_EXPLOITED'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /search rejects an inverted virtualMatchString version range before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', @@ -154,6 +241,26 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('POST /search rejects a negative numeric version bound before invoking OpenSearch', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + versionEnd: '-1', + versionEndType: 'including' + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'VERSION_RANGE_BOUNDARY', + message: + 'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.' + }); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /webSearch normalizes body fields and formats BasicSearchManager responses', async () => { mockBasicSearch.mockResolvedValue(basicResponse({ total: 1, hits: [{ _id: 'CVE-2024-0002' }] })); diff --git a/routes/swagger.js b/routes/swagger.js index 1cf65db..9092d25 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -2,10 +2,14 @@ const path = require('path'); const swaggerAutogen = require('swagger-autogen'); const packageInfo = require('../package.json'); const constants = require('../utils/constants'); +const searchScope = require('../utils/searchScope'); +const ssvc = require('../utils/ssvc'); 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.`; function searchRequestProperty(description, schema) { return { @@ -14,9 +18,32 @@ function searchRequestProperty(description, schema) { }; } +function coercibleBooleanSchema(defaultValue = false) { + return { + oneOf: [ + { + type: 'boolean' + }, + { + type: 'string', + enum: ['true', 'false'] + } + ], + default: defaultValue + }; +} + const searchRequestProperties = { + scope: searchRequestProperty( + 'Controls which containers participate in filter matching. FULL searches base record fields, CNA data, and all ADP providers. CNA_ONLY searches base record fields and CNA data. ADP_ONLY searches base record fields and all ADP providers. Scope does not remove data from returned records. Values are case-insensitive. CPE applicability filters are not available with ADP_ONLY.', + { + type: 'string', + enum: searchScope.SEARCH_SCOPE_VALUES, + default: searchScope.SEARCH_SCOPES.FULL + } + ), keywordSearch: searchRequestProperty( - `Returns CVE records where a word or phrase is included in the record's description. 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.`, + `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.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch, @@ -24,11 +51,8 @@ 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.', - { - type: 'boolean', - default: false - } + '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.', + 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.`, @@ -38,52 +62,55 @@ const searchRequestProperties = { example: 'CVE-2024-1234,CVE-2024-5678' } ), - noRejected: searchRequestProperty('When true, excludes CVE records with REJECT, REJECTED, or Rejected status.', { - type: 'boolean', - default: false - }), - cweId: searchRequestProperty('Returns CVE records that include the provided Common Weakness Enumeration ID.', { - type: 'string', - maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, - example: 'CWE-79' - }), + noRejected: searchRequestProperty( + 'When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings "true" and "false" are accepted.', + coercibleBooleanSchema() + ), + cweId: searchRequestProperty( + 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID.', + { + type: 'string', + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, + example: 'CWE-79' + } + ), cvssV2Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v2 qualitative severity rating.', + 'Returns CVE records with a scope-selected CVSS v2 base score in the range represented by the provided qualitative severity. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] } ), cvssV3Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v3.0 or v3.1 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_0Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v3.0 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_1Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v3.1 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV4Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v4.0 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV2Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v2 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -91,7 +118,7 @@ const searchRequestProperties = { } ), cvssV3Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v3.0 or v3.1 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -99,7 +126,7 @@ const searchRequestProperties = { } ), cvssV3_0Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v3.0 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -107,7 +134,7 @@ const searchRequestProperties = { } ), cvssV3_1Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v3.1 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -115,7 +142,7 @@ const searchRequestProperties = { } ), cvssV4Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v4.0 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -131,11 +158,8 @@ 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.', - { - type: 'boolean', - default: false - } + '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.', + coercibleBooleanSchema() ), lastModStartDate: searchRequestProperty( 'Returns CVE records with dateUpdated timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', @@ -170,7 +194,7 @@ const searchRequestProperties = { } ), resultsPerPage: searchRequestProperty( - `Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first ${constants.MAX_SEARCH_WINDOW} results.`, + `Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first ${maxSearchWindowLabel} results. This pagination window does not cap the exact total reported by a direct search.`, { type: 'integer', format: 'int32', @@ -180,7 +204,7 @@ const searchRequestProperties = { } ), pageNumber: searchRequestProperty( - `Specifies the page to return based on resultsPerPage. The requested page must remain within the first ${constants.MAX_SEARCH_WINDOW} results.`, + `Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first ${maxSearchWindowLabel} results. This pagination window does not cap the exact total reported by a direct search.`, { type: 'integer', format: 'int32', @@ -189,7 +213,7 @@ const searchRequestProperties = { } ), versionStart: searchRequestProperty( - 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -204,7 +228,7 @@ const searchRequestProperties = { } ), versionEnd: searchRequestProperty( - 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. 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. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, @@ -225,6 +249,88 @@ const searchRequestProperties = { maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.virtualMatchString, example: 'cpe:2.3:o:microsoft:windows_10_1809' } + ), + ssvcExploitation: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose Exploitation decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry.', + { + type: 'string', + enum: ssvc.EXPLOITATION_VALUES + } + ), + ssvcAutomatable: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose Automatable decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry.', + { + type: 'string', + enum: ssvc.AUTOMATABLE_VALUES + } + ), + ssvcTechnicalImpact: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose Technical Impact decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry.', + { + type: 'string', + enum: ssvc.TECHNICAL_IMPACT_VALUES + } + ), + ssvcRole: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose role exactly matches this case-insensitive value.', + { + type: 'string', + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.ssvcRole, + example: 'CISA Coordinator' + } + ), + ssvcTimestampStartDate: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', + { + type: 'string', + format: 'date-time', + example: '2024-01-01T00:00:00.000Z' + } + ), + ssvcTimestampEndDate: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', + { + type: 'string', + format: 'date-time', + example: '2024-12-31T23:59:59.999Z' + } + ), + knownExploited: searchRequestProperty( + 'When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied.', + { + type: 'boolean' + } + ), + kevDateAddedStartDate: searchRequestProperty( + 'Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC.', + { + type: 'string', + format: 'date-time', + example: '2024-01-01T00:00:00.000Z' + } + ), + kevDateAddedEndDate: searchRequestProperty( + 'Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC.', + { + type: 'string', + format: 'date-time', + example: '2024-12-31T23:59:59.999Z' + } + ), + referenceURL: searchRequestProperty( + `Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported. The substring must contain at least ${constants.MIN_REFERENCE_URL_LENGTH} characters.`, + { + type: 'string', + minLength: constants.MIN_REFERENCE_URL_LENGTH, + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.referenceURL, + example: 'vendor.example/security/advisory' + } + ), + disputed: searchRequestProperty( + 'When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag.', + { + type: 'boolean' + } ) }; @@ -246,9 +352,156 @@ const doc = { ], components: { '@schemas': { + ApiError: { + type: 'object', + additionalProperties: false, + required: ['error', 'message'], + properties: { + error: { + type: 'string' + }, + message: { + type: 'string' + } + } + }, + ApiErrorResponse: { + oneOf: [ + { + $ref: '#/components/schemas/ApiError' + }, + { + type: 'array', + items: { + $ref: '#/components/schemas/ApiError' + } + } + ] + }, + SearchHit: { + type: 'object', + additionalProperties: true, + required: ['_source'], + properties: { + _id: { + 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.` + } + } + }, + hits: { + type: 'array', + items: { + $ref: '#/components/schemas/SearchHit' + } + } + } + }, + SearchResponse: { + type: 'object', + additionalProperties: false, + required: ['page', 'pageSize', 'resultsReturned', 'totalPages', 'status', 'data'], + properties: { + page: { + type: 'integer', + minimum: 0, + description: 'One-based page number, or 0 when the search has no matches.' + }, + pageSize: { + type: 'integer', + minimum: 1, + description: 'Maximum number of CVE records requested for this page.' + }, + resultsReturned: { + type: 'integer', + minimum: 0, + description: 'Number of CVE records actually returned in this response.' + }, + 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.` + }, + status: { + type: 'string', + enum: ['ok'] + }, + data: { + type: 'object', + additionalProperties: true, + required: ['hits'], + properties: { + took: { + type: 'number', + minimum: 0 + }, + hits: { + $ref: '#/components/schemas/SearchHits' + } + } + } + } + }, + WebSearchResponse: { + type: 'object', + additionalProperties: false, + required: ['searchMetadata', 'resultsTotal', 'data'], + properties: { + searchMetadata: { + type: 'object', + additionalProperties: true, + required: ['searchStatus', 'version', 'errors'], + properties: { + searchStatus: { + type: 'string' + }, + version: { + type: 'string' + }, + errors: { + type: 'array', + items: { + $ref: '#/components/schemas/ApiError' + } + } + } + }, + resultsTotal: { + type: 'integer', + minimum: 0 + }, + data: { + type: 'array', + items: { + type: 'object', + additionalProperties: true + } + } + } + }, SearchRequest: { type: 'object', - description: 'Optional filters and pagination controls for a CVE record search.', + 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 ${maxSearchWindowLabel}, although requested pages must remain inside that window. ${semanticPostFilterDescription}`, additionalProperties: false, properties: searchRequestProperties } diff --git a/server.js b/server.js index 4c94a56..4680dcc 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,6 @@ const swaggerUi = require('swagger-ui-express'); const openApiSpecification = require('./api-docs/openapi.json'); const http = require('http'); const logger = require('morgan'); -const path = require('path'); const router = require('./routes/index'); const errors = require('./utils/errors'); const requestLogging = require('./utils/requestLogging'); @@ -56,7 +55,6 @@ function createApp() { app.use(requestLogging.assignRequestId); app.use(createRequestLogger()); app.use(cors()); - app.use(express.static(path.join(__dirname, 'public'))); app.use(express.json()); app.get('/api-docs/openapi.json', (req, res) => res.json(openApiSpecification)); diff --git a/server.test.js b/server.test.js index 51ce3c8..be14e86 100644 --- a/server.test.js +++ b/server.test.js @@ -138,7 +138,10 @@ describe('server application bootstrap', () => { expect(mockSearch).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', - params: body + params: { + scope: 'FULL', + ...body + } }); }); diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json new file mode 100644 index 0000000..048dad6 --- /dev/null +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -0,0 +1,6485 @@ +{ + "info": { + "_postman_id": "28911974-2b15-4ddf-a88f-3fa6fe001a50", + "name": "CVE Search API - Manual Contract Tests", + "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. 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. The separate /webSearch contract is intentionally excluded.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:4000", "type": "string" }, + { "key": "runFaultInjection", "value": "false", "type": "string" } + ], + "item": [ + { + "name": "00 - Service checks", + "description": "Basic API, dependency, and contract availability.", + "item": [ + { + "name": "Liveness returns 200", + "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;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/health-check", + "host": ["{{baseUrl}}"], + "path": ["api", "health-check"], + "query": [] + } + }, + "response": [] + }, + { + "name": "Readiness returns 200", + "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;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ready-check", + "host": ["{{baseUrl}}"], + "path": ["api", "ready-check"], + "query": [] + } + }, + "response": [] + }, + { + "name": "OpenAPI document is available", + "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 document = pm.response.json();", + "pm.test('OpenAPI includes POST /search', () => pm.expect(document.paths?.['/search']?.post).to.be.an('object'));" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api-docs/openapi.json", + "host": ["{{baseUrl}}"], + "path": ["api-docs", "openapi.json"], + "query": [] + } + }, + "response": [] + } + ] + }, + { + "name": "01 - Baseline and pagination", + "description": "Default search behavior and shallow-pagination boundaries.", + "item": [ + { + "name": "No request body", + "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.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));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [], + "url": { "raw": "{{baseUrl}}/search", "host": ["{{baseUrl}}"], "path": ["search"], "query": [] } + }, + "response": [] + }, + { + "name": "Default empty-object search", + "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.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));", + "}" + ] + } + } + ], + "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": "{}", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Single CVE ID", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Multiple CVE IDs", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "}" + ] + } + } + ], + "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-2024-5981,CVE-2022-31279\",\n \"resultsPerPage\": 2,\n \"pageNumber\": 1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Second valid page can be empty", + "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.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));", + "}" + ] + } + } + ], + "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-2024-5981\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 2\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Maximum valid search window boundary", + "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.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));", + "}" + ] + } + } + ], + "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-2024-5981\",\n \"resultsPerPage\": 500,\n \"pageNumber\": 20\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "02 - Scope coverage", + "description": "Base, CNA, and ADP matching behavior.", + "item": [ + { + "name": "FULL scope accepts lowercase input", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"full\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CNA_ONLY keyword match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"cna_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY excludes a CNA-only keyword match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.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));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"adp_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Base CVE ID participates in ADP_ONLY", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CNA_ONLY CWE match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY CWE match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "03 - Keyword and record filters", + "description": "Description, ID, and record-state filters.", + "item": [ + { + "name": "keywordSearch", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch true", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch false without keyword", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordExactMatch\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "noRejected true excludes rejected records", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": true,\n \"resultsPerPage\": 2\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "noRejected false does not filter", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "}" + ] + } + } + ], + "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-2024-5981,CVE-2022-31279\",\n \"noRejected\": false,\n \"resultsPerPage\": 2\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cweId", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "04 - CVSS filters", + "description": "Every supported CVSS severity and vector filter.", + "item": [ + { + "name": "cvssV2Severity is case-insensitive", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV2Metrics", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Metrics\": \"AV:N/AC:L/Au:S\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3Severity", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3Metrics", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Metrics\": \"AV:N/AC:L/PR:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_0Severity", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_0Metrics", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Metrics\": \"CVSS:3.0/AV:N/AC:L/PR:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_1Severity", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_1Metrics", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Metrics\": \"CVSS:3.1/AV:N/AC:L/PR:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV4Severity", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV4Metrics", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Metrics\": \"CVSS:4.0/AV:N/AC:L/AT:N\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP CVSS and SSVC must match selected containers", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cvssV3_1Severity\": \"high\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"none\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "05 - Record date filters", + "description": "Publication and modification boundaries.", + "item": [ + { + "name": "pubStartDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "pubEndDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Publication date range", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "lastModStartDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "lastModEndDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Modification date range", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "06 - CPE filters", + "description": "Concrete CPE and virtual match-string behavior.", + "item": [ + { + "name": "cpeName", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "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": "cpeName with isVulnerable true", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "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 \"isVulnerable\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "isVulnerable false without cpeName", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"isVulnerable\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "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:freetype:freetype\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with versionStart pair", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "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:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"INCLUDING\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with versionEnd pair", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "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:freetype:freetype\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with complete range", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "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:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "07 - SSVC filters", + "description": "Individual and same-entry SSVC matching.", + "item": [ + { + "name": "ssvcExploitation", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"none\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcAutomatable", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcAutomatable\": \"no\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcTechnicalImpact", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTechnicalImpact\": \"partial\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcRole is case-insensitive", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcRole\": \"cisa coordinator\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcTimestampStartDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcTimestampEndDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Complete SSVC same-entry combination", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"PARTIAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "08 - KEV filters", + "description": "Structured KEV presence and inclusive date filtering.", + "item": [ + { + "name": "knownExploited true", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited false", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "kevDateAddedStartDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "kevDateAddedEndDate", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inclusive KEV date range", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "09 - Reference and dispute filters", + "description": "Reference URL substring and disputed-tag behavior.", + "item": [ + { + "name": "referenceURL FULL", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "referenceURL CNA_ONLY", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "referenceURL ADP_ONLY", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "referenceURL treats wildcard characters literally", + "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.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));", + "}" + ] + } + } + ], + "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-2024-5981\",\n \"referenceURL\": \"github.com/*/cve/issues/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed true", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-38625\"]));", + "}" + ] + } + } + ], + "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-2022-38625\",\n \"disputed\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed false", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "10 - Intentional combinations", + "description": "Representative AND combinations across related and independent filter groups.", + "item": [ + { + "name": "CNA keyword, CWE, CVSS, dates, and status", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweId\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP CWE, CVSS, SSVC, and reference", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "FULL cross-container KEV, SSVC, CVSS, and reference", + "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.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.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"FULL\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\",\n \"ssvcExploitation\": \"ACTIVE\",\n \"ssvcAutomatable\": \"YES\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"referenceURL\": \"cisa.gov/known-exploited-vulnerabilities-catalog\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "11 - Expected failures - request contract", + "description": "Transport and JSON body failures.", + "item": [ + { + "name": "Query parameters are rejected", + "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\":\"SEARCH_QUERY_UNSUPPORTED\",\"message\":\"The /search endpoint accepts JSON request body fields, not query parameters.\"};", + "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?keywordSearch=apache", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [{ "key": "keywordSearch", "value": "apache" }] + }, + "body": { "mode": "raw", "raw": "{}", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Non-JSON content type is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 415', () => pm.response.to.have.status(415));", + "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\":\"SEARCH_CONTENT_TYPE\",\"message\":\"The /search request body must use the application/json media type with UTF-8 encoding.\"};", + "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": "text/plain", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { "mode": "raw", "raw": "{}", "options": { "raw": { "language": "text" } } } + }, + "response": [] + }, + { + "name": "Malformed JSON is rejected", + "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\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "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": "{\"keywordSearch\":", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Array body is rejected", + "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\":\"SEARCH_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", + "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": "[]", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Top-level JSON string is rejected by the parser", + "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\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "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": "\"search\"", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Unsupported body field is rejected", + "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_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "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 \"unsupportedFilter\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Empty string field is rejected", + "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\":\"SEARCH_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", + "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 \"keywordSearch\": \" \"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Array-valued enum is rejected", + "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\":\"SEARCH_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", + "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 \"scope\": [\n \"FULL\"\n ]\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "12 - Expected failures - limits and pagination", + "description": "Input complexity and result-window limits.", + "item": [ + { + "name": "String length limit", + "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\":\"SEARCH_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", + "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 \"keywordSearch\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Keyword token limit", + "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\":\"SEARCH_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", + "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 \"keywordSearch\": \"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Reference URL minimum length", + "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\":\"SEARCH_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", + "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 \"referenceURL\": \"ab\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID format", + "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\":\"CVE_IDS_FORMAT\",\"message\":\"The cveIds parameter must be a comma-separated list of CVE IDs.\"};", + "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 \"cveIds\": \"not-a-cve\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID length", + "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\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", + "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 \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID count limit", + "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\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", + "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 \"cveIds\": \"CVE-2024-1000,CVE-2024-1001,CVE-2024-1002,CVE-2024-1003,CVE-2024-1004,CVE-2024-1005,CVE-2024-1006,CVE-2024-1007,CVE-2024-1008,CVE-2024-1009,CVE-2024-1010,CVE-2024-1011,CVE-2024-1012,CVE-2024-1013,CVE-2024-1014,CVE-2024-1015,CVE-2024-1016,CVE-2024-1017,CVE-2024-1018,CVE-2024-1019,CVE-2024-1020,CVE-2024-1021,CVE-2024-1022,CVE-2024-1023,CVE-2024-1024,CVE-2024-1025,CVE-2024-1026,CVE-2024-1027,CVE-2024-1028,CVE-2024-1029,CVE-2024-1030,CVE-2024-1031,CVE-2024-1032,CVE-2024-1033,CVE-2024-1034,CVE-2024-1035,CVE-2024-1036,CVE-2024-1037,CVE-2024-1038,CVE-2024-1039,CVE-2024-1040,CVE-2024-1041,CVE-2024-1042,CVE-2024-1043,CVE-2024-1044,CVE-2024-1045,CVE-2024-1046,CVE-2024-1047,CVE-2024-1048,CVE-2024-1049,CVE-2024-1050,CVE-2024-1051,CVE-2024-1052,CVE-2024-1053,CVE-2024-1054,CVE-2024-1055,CVE-2024-1056,CVE-2024-1057,CVE-2024-1058,CVE-2024-1059,CVE-2024-1060,CVE-2024-1061,CVE-2024-1062,CVE-2024-1063,CVE-2024-1064,CVE-2024-1065,CVE-2024-1066,CVE-2024-1067,CVE-2024-1068,CVE-2024-1069,CVE-2024-1070,CVE-2024-1071,CVE-2024-1072,CVE-2024-1073,CVE-2024-1074,CVE-2024-1075,CVE-2024-1076,CVE-2024-1077,CVE-2024-1078,CVE-2024-1079,CVE-2024-1080,CVE-2024-1081,CVE-2024-1082,CVE-2024-1083,CVE-2024-1084,CVE-2024-1085,CVE-2024-1086,CVE-2024-1087,CVE-2024-1088,CVE-2024-1089,CVE-2024-1090,CVE-2024-1091,CVE-2024-1092,CVE-2024-1093,CVE-2024-1094,CVE-2024-1095,CVE-2024-1096,CVE-2024-1097,CVE-2024-1098,CVE-2024-1099,CVE-2024-1100\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "resultsPerPage must be positive", + "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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": -1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "resultsPerPage maximum", + "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\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", + "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 \"resultsPerPage\": 501\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "pageNumber must be positive", + "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\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", + "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 \"pageNumber\": 0\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Pagination window limit", + "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\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", + "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 \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector total length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector token count", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV4Metrics\": \"T0:A/T1:A/T2:A/T3:A/T4:A/T5:A/T6:A/T7:A/T8:A/T9:A/T10:A/T11:A/T12:A/T13:A/T14:A/T15:A/T16:A/T17:A/T18:A/T19:A/T20:A/T21:A/T22:A/T23:A/T24:A/T25:A/T26:A/T27:A/T28:A/T29:A/T30:A/T31:A/T32:A\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector token length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "13 - Expected failures - booleans and enums", + "description": "Strict booleans and enum domains.", + "item": [ + { + "name": "isVulnerable boolean", + "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\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", + "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 \"isVulnerable\": \"yes\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch boolean", + "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\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", + "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 \"keywordExactMatch\": \"yes\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "noRejected boolean", + "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\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", + "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 \"noRejected\": \"yes\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited boolean", + "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\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", + "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 \"knownExploited\": \"true\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed boolean", + "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\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", + "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 \"disputed\": \"false\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "scope enum", + "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\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", + "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 \"scope\": \"CNA_AND_ADP\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v2 severity enum", + "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_CVSSV2_SEVERITY\",\"message\":\"CRITICAL is not an allowed input for the cvssV2Severity. Please use LOW, MEDIUM, or HIGH.\"};", + "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 \"cvssV2Severity\": \"CRITICAL\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v3 severity enum", + "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_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "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 \"cvssV3Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v3.0 severity enum", + "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_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_0Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v3.1 severity enum", + "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_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_1Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v4 severity enum", + "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_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "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 \"cvssV4Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "SSVC exploitation enum", + "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_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", + "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 \"ssvcExploitation\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "SSVC automatable enum", + "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_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", + "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 \"ssvcAutomatable\": \"MAYBE\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "SSVC technical impact enum", + "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_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", + "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 \"ssvcTechnicalImpact\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "14 - Expected failures - dependent filters", + "description": "Filters whose validity depends on another field.", + "item": [ + { + "name": "keywordExactMatch requires keywordSearch", + "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\":\"MISSING_KEYWORD_SEARCH_PARAM\",\"message\":\"The keywordSearch parameter is required when the keywordExactMatch parameter is provided.\"};", + "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 \"keywordExactMatch\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "isVulnerable requires cpeName", + "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\":\"MISSING_CPE_NAME_PARAM\",\"message\":\"The cpeName parameter is required when the isVulnerable parameter is provided.\"};", + "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 \"isVulnerable\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "isVulnerable conflicts with virtualMatchString", + "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_AND_IS_VULN\",\"message\":\"The virtualMatchString parameter is not allowed when the isVulnerable parameter is provided. Please use cpeName instead.\"};", + "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 \"isVulnerable\": true,\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "KEV start date requires knownExploited true", + "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\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", + "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 \"knownExploited\": false,\n \"kevDateAddedStartDate\": \"2022-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "KEV end date requires knownExploited true", + "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\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", + "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 \"kevDateAddedEndDate\": \"2022-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "15 - Expected failures - CPE and version ranges", + "description": "CPE formats, scope compatibility, and range pair/order rules.", + "item": [ + { + "name": "ADP_ONLY rejects cpeName", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects isVulnerable", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"isVulnerable\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects virtualMatchString", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionStart", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"versionStart\": \"2.2\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionStartType", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionEnd", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"versionEnd\": \"2.6\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionEndType", + "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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", + "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 \"scope\": \"ADP_ONLY\",\n \"versionEndType\": \"excluding\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid cpeName returns 404", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 404', () => pm.response.to.have.status(404));", + "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 must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.\"};", + "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:product:*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid virtualMatchString", + "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 be a CPE 2.3 match string.\"};", + "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\": \"not-a-cpe\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Version range requires virtualMatchString", + "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\":\"VERSION_RANGE_VIRTUAL_MATCH_STRING_REQUIRED\",\"message\":\"The virtualMatchString parameter is required when versionStart, versionStartType, versionEnd, or versionEndType is provided.\"};", + "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 \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "versionStart requires versionStartType", + "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\":\"VERSION_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", + "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:a:vendor:product\",\n \"versionStart\": \"1.0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "versionEnd requires versionEndType", + "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\":\"VERSION_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", + "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:a:vendor:product\",\n \"versionEnd\": \"2.0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid version range type", + "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\":\"VERSION_RANGE_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", + "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:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"before\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Negative numeric version range boundary", + "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\":\"VERSION_RANGE_BOUNDARY\",\"message\":\"The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.\"};", + "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:o:linux:linux_kernel\",\n \"versionEnd\": \"-1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid ranged virtualMatchString format", + "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_RANGE_FORMAT\",\"message\":\"The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.\"};", + "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\": \"not-a-cpe\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Ranged virtualMatchString cannot contain version", + "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_VERSION_COMPONENT\",\"message\":\"The virtualMatchString parameter cannot include a version component when versionStart or versionEnd is provided.\"};", + "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:a:vendor:product:1.0\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted version range", + "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\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "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:a:vendor:product\",\n \"versionStart\": \"2.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Empty equal exclusive version range", + "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\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "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:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"excluding\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "16 - Expected failures - dates", + "description": "Timestamp format, calendar validity, and range order.", + "item": [ + { + "name": "Partial timestamp", + "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\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "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 \"pubStartDate\": \"2024-01-01\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Impossible calendar date", + "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\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "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 \"lastModStartDate\": \"2024-02-30T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted publication date range", + "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\":\"DATE_RANGE_ORDER\",\"message\":\"The pubStartDate parameter must be before or equal to pubEndDate.\"};", + "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 \"pubStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"pubEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted modification date range", + "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\":\"DATE_RANGE_ORDER\",\"message\":\"The lastModStartDate parameter must be before or equal to lastModEndDate.\"};", + "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 \"lastModStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"lastModEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted SSVC timestamp range", + "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\":\"DATE_RANGE_ORDER\",\"message\":\"The ssvcTimestampStartDate parameter must be before or equal to ssvcTimestampEndDate.\"};", + "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 \"ssvcTimestampStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted KEV date range", + "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\":\"DATE_RANGE_ORDER\",\"message\":\"The kevDateAddedStartDate parameter must be before or equal to kevDateAddedEndDate.\"};", + "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 \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + }, + { + "name": "17 - Manual fault injection - skipped by default", + "description": "These requests require deliberate infrastructure or application configuration. Setting runFaultInjection=true without preparing the named condition will make them fail rather than create that condition.", + "item": [ + { + "name": "OpenSearch readiness failure", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping OpenSearch readiness failure; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 503', () => pm.response.to.have.status(503));", + "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\":\"SEARCH_NOT_READY\",\"message\":\"Search service is not ready. Please try again later.\"};", + "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": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ready-check", + "host": ["{{baseUrl}}"], + "path": ["api", "ready-check"], + "query": [] + }, + "description": "Run only while the API cannot reach or query its configured OpenSearch index." + }, + "response": [] + }, + { + "name": "OpenSearch unavailable search", + "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.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 503', () => pm.response.to.have.status(503));", + "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\":\"OPEN_SEARCH_ERROR\",\"message\":\"OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.\"};", + "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": [] + }, + "description": "Run only while OpenSearch connectivity is intentionally unavailable.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Search timeout", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Search timeout; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 504', () => pm.response.to.have.status(504));", + "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\":\"SEARCH_TIMEOUT\",\"message\":\"Search request timed out. Please try again later.\"};", + "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": [] + }, + "description": "Run only with a deliberately reduced request deadline or delayed OpenSearch response.", + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Unexpected upstream response", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Unexpected upstream response; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 502', () => pm.response.to.have.status(502));", + "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\":\"UPSTREAM_SEARCH_ERROR\",\"message\":\"Search backend returned an error. Please try again later.\"};", + "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": [] + }, + "description": "Requires an intentionally malformed or unexpected OpenSearch response.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] + } + ] +} diff --git a/test/postman/README.md b/test/postman/README.md new file mode 100644 index 0000000..3cc2da2 --- /dev/null +++ b/test/postman/README.md @@ -0,0 +1,106 @@ +# Postman Manual Contract Tests + +This directory contains an importable Postman collection for manually validating the CVE Search API. It covers every documented `POST /search` request field, representative intentional filter combinations, and the API's deterministic validation errors. The separate `/webSearch` contract is intentionally excluded. + +> [!IMPORTANT] +> The complete collection is designed to run against a fully populated CVE index, such as the indexes used by the shared managed environments. An incomplete local index may not contain the fixture records, container data, mappings, or matching values required by positive assertions. Those data-dependent requests can therefore fail or return zero results even when the API is behaving correctly. The deterministic request-validation folders do not generally depend on indexed CVE data. + +## Files + +- `CVE-Search-API.postman_collection.json`: generated Postman collection. +- `local.postman_environment.json`: local environment with `baseUrl` set to `http://localhost:4000`. +- `buildCollection.js`: maintainable source used to generate and consistency-check the JSON artifacts. + +Deployment-specific environment files are intentionally ignored by Git so deployment URLs remain local. Use the local-only filenames `dev.postman_environment.json`, `test.postman_environment.json`, `int.postman_environment.json`, and `prod.postman_environment.json`. + +## Run The Collection + +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. +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. + +## Environments + +The collection uses the `baseUrl` variable and does not embed deployment credentials or URLs. The tracked localhost environment is a safe template for creating a deployment environment: + +1. Duplicate `local.postman_environment.json`. +2. Rename the copy for the target deployment using one of the ignored filenames listed above. +3. Give the environment a distinct Postman `id` and `name`. +4. Replace only the `baseUrl` value with the approved deployment URL. +5. Keep `runFaultInjection` set to `false` unless performing an explicitly approved fault test. + +The deployment file should resemble: + +```json +{ + "name": "CVE Search API - ", + "values": [ + { "key": "baseUrl", "value": "https://", "type": "default", "enabled": true }, + { "key": "runFaultInjection", "value": "false", "type": "default", "enabled": true } + ] +} +``` + +Review and approve tests before targeting a shared or production deployment. The included fixtures were calibrated using the shared dev CVE index and were spot-checked against a smaller 1,111-record local index on August 20, 2026. That local spot check does not make the complete collection portable to every partial index; running all positive folders requires the listed records and relevant nested data to be present. + +| Fixture | Coverage | +| ---------------- | --------------------------------------------------------- | +| `CVE-2024-5981` | CNA keyword, CWE, CVSS v2-v4, SSVC, references, and dates | +| `CVE-2022-31640` | ADP CWE, CVSS, SSVC, and references | +| `CVE-2020-1472` | KEV, SSVC, CVSS, and cross-container matching | +| `CVE-2022-31279` | Rejected-record filtering | +| `CVE-2022-38625` | Disputed-record filtering | +| `CVE-2025-23022` | CPE name, virtual match string, and version boundaries | + +Index-wide searches assert only that at least one result exists. Exact totals are limited to requests constrained by one or more fixture `cveIds`, so different index sizes do not cause false failures. Constrained zero-result requests intentionally verify scope exclusion or literal substring behavior. + +Because CVE records can be updated, refresh a fixture when a positive request stops satisfying its documented filter rather than weakening its assertions without investigating the data change. Before treating a local failure as an API defect, confirm that the local index contains the fixture record and the specific CNA, ADP, CPE, CVSS, SSVC, KEV, reference, or tag data the request expects. A local index must include the fixtures above and their relevant nested fields to run every positive folder unchanged; validation-error folders do not depend on index contents. + +## Fault-Injection Folder + +Folder `17 - Manual fault injection - skipped by default` describes runtime errors that cannot be created by an ordinary black-box request. Its requests are skipped while `runFaultInjection` is `false`. + +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` | + +Run a fault check as follows: + +1. Target a disposable local environment and prepare exactly one condition from the table. +2. Set `runFaultInjection` to `true` in the selected Postman environment. +3. Open folder `17` and send only the request for the prepared condition. +4. Confirm the asserted HTTP status, sanitized error code, message, and `X-Request-ID`. +5. Set `runFaultInjection` back to `false` and restore the normal application and dependency configuration. + +Do not enable fault injection against a shared or production environment without the target owner's approval. A WAF or load balancer can return a different error contract than the application-level response asserted by this folder. + +## Known Validation Boundary + +`VERSION_RANGE_VALUE` remains a defined middleware error, but it is not independently reachable through the current HTTP route. Empty or non-string `versionStart` and `versionEnd` values are rejected earlier by the route's shared string validation as `SEARCH_FIELD_STRING`. The collection tests the observable route contract and does not include an unreachable black-box assertion. + +## Regenerate Or Check Artifacts + +After changing the `/search` request schema, validation errors, or collection source, regenerate the Postman JSON files: + +```bash +node test/postman/buildCollection.js +``` + +Check that committed artifacts match the builder without rewriting them: + +```bash +node test/postman/buildCollection.js --check +``` + +The builder fails when a documented `SearchRequest` property has no positive Postman example. This protects field coverage but does not replace unit, integration, or live environment testing. diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js new file mode 100644 index 0000000..412b5bb --- /dev/null +++ b/test/postman/buildCollection.js @@ -0,0 +1,1227 @@ +const fs = require('fs'); +const path = require('path'); +const prettier = require('prettier'); +const constants = require('../../utils/constants'); +const { errors: SearchErrors } = require('../../utils/errors'); +const openApi = require('../../api-docs/openapi.json'); + +const error = new SearchErrors(); +const outputDirectory = __dirname; +const collectionPath = path.join(outputDirectory, 'CVE-Search-API.postman_collection.json'); +const environmentPath = path.join(outputDirectory, 'local.postman_environment.json'); +const positiveCoveredFields = new Set(); + +const ids = Object.freeze({ + primary: 'CVE-2024-5981', + adp: 'CVE-2022-31640', + rejected: 'CVE-2022-31279', + kev: 'CVE-2020-1472', + cpe: 'CVE-2025-23022', + disputed: 'CVE-2022-38625' +}); + +function jsonBody(value) { + return JSON.stringify(value, null, 2); +} + +function requestUrl(requestPath) { + return { + raw: `{{baseUrl}}${requestPath}`, + host: ['{{baseUrl}}'], + path: requestPath.split('?')[0].split('/').filter(Boolean), + query: requestPath.includes('?') + ? requestPath + .split('?')[1] + .split('&') + .map((entry) => { + const [key, value] = entry.split('='); + return { key, value }; + }) + : [] + }; +} + +function requestIdTests() { + return [ + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + '});' + ]; +} + +function successTests({ status = 200, exactTotal, minimumTotal, expectedIds = [], expectedHitCount } = {}) { + const tests = [ + `pm.test('status is ${status}', () => pm.response.to.have.status(${status}));`, + ...requestIdTests(), + `const isExpectedSuccess = pm.response.code === ${status};`, + '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.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);', + '});' + ]; + + if (exactTotal !== undefined) { + tests.push( + `pm.test('total is ${exactTotal}', () => pm.expect(payload.data.hits.total.value).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}));` + ); + } + + if (expectedHitCount !== undefined) { + tests.push( + `pm.test('returned hit count is ${expectedHitCount}', () => pm.expect(payload.data.hits.hits.length).to.eql(${expectedHitCount}));` + ); + } + + if (expectedIds.length) { + tests.push( + ' const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);', + `pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members(${JSON.stringify(expectedIds)}));` + ); + } + + tests.push('}'); + return tests; +} + +function errorTests(expectedStatus, expectedError) { + return [ + `pm.test('status is ${expectedStatus}', () => pm.response.to.have.status(${expectedStatus}));`, + ...requestIdTests(), + "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 = ${JSON.stringify(expectedError)};`, + "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);', + '});' + ]; +} + +function item({ + name, + description, + method = 'POST', + requestPath = '/search', + body, + rawBody, + contentType = 'application/json', + tests = [], + skipUnless +}) { + const request = { + method, + header: [], + url: requestUrl(requestPath), + description + }; + + if (body !== undefined || rawBody !== undefined) { + request.header.push({ key: 'Content-Type', value: contentType, type: 'text' }); + request.body = { + mode: 'raw', + raw: rawBody ?? jsonBody(body), + options: { + raw: { + language: contentType === 'application/json' ? 'json' : 'text' + } + } + }; + } + + const events = []; + if (skipUnless) { + events.push({ + listen: 'prerequest', + script: { + type: 'text/javascript', + exec: [ + `if (pm.collectionVariables.get('${skipUnless}') !== 'true') {`, + ` console.log('Skipping ${name}; set ${skipUnless}=true to enable it.');`, + ' pm.execution.skipRequest();', + '}' + ] + } + }); + } + if (tests.length) { + events.push({ + listen: 'test', + script: { + type: 'text/javascript', + exec: tests + } + }); + } + + return { + name, + event: events, + request, + response: [] + }; +} + +function positive(name, body, options = {}) { + Object.keys(body ?? {}).forEach((field) => positiveCoveredFields.add(field)); + + return item({ + name, + description: options.description, + body, + tests: successTests(options) + }); +} + +function negative(name, body, expectedError, options = {}) { + return item({ + name, + description: options.description, + body, + rawBody: options.rawBody, + contentType: options.contentType, + method: options.method, + requestPath: options.requestPath, + tests: errorTests(options.status ?? 400, expectedError) + }); +} + +function folder(name, description, items) { + return { + name, + description, + item: items + }; +} + +const serviceChecks = folder('00 - Service checks', 'Basic API, dependency, and contract availability.', [ + item({ + name: 'Liveness returns 200', + method: 'GET', + requestPath: '/api/health-check', + tests: [`pm.test('status is 200', () => pm.response.to.have.status(200));`, ...requestIdTests()] + }), + item({ + name: 'Readiness returns 200', + method: 'GET', + requestPath: '/api/ready-check', + tests: [`pm.test('status is 200', () => pm.response.to.have.status(200));`, ...requestIdTests()] + }), + item({ + name: 'OpenAPI document is available', + method: 'GET', + requestPath: '/api-docs/openapi.json', + tests: [ + `pm.test('status is 200', () => pm.response.to.have.status(200));`, + ...requestIdTests(), + 'const document = pm.response.json();', + "pm.test('OpenAPI includes POST /search', () => pm.expect(document.paths?.['/search']?.post).to.be.an('object'));" + ] + }) +]); + +const baselineAndPagination = folder( + '01 - Baseline and pagination', + 'Default search behavior and shallow-pagination boundaries.', + [ + 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( + 'Multiple CVE IDs', + { cveIds: `${ids.primary},${ids.rejected}`, resultsPerPage: 2, pageNumber: 1 }, + { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } + ), + positive( + 'Second valid page can be empty', + { cveIds: ids.primary, resultsPerPage: 1, pageNumber: 2 }, + { + exactTotal: 1, + expectedHitCount: 0 + } + ), + positive( + 'Maximum valid search window boundary', + { cveIds: ids.primary, resultsPerPage: 500, pageNumber: 20 }, + { exactTotal: 1, expectedHitCount: 0 } + ) + ] +); + +const scopeCoverage = folder('02 - Scope coverage', 'Base, CNA, and ADP matching behavior.', [ + positive( + 'FULL scope accepts lowercase input', + { scope: 'full', cveIds: ids.primary }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'CNA_ONLY keyword match', + { scope: 'cna_only', cveIds: ids.primary, keywordSearch: 'sql injection' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'ADP_ONLY excludes a CNA-only keyword match', + { scope: 'adp_only', cveIds: ids.primary, keywordSearch: 'sql injection' }, + { exactTotal: 0, expectedHitCount: 0 } + ), + positive( + 'Base CVE ID participates in ADP_ONLY', + { scope: 'ADP_ONLY', cveIds: ids.primary }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'CNA_ONLY CWE match', + { scope: 'CNA_ONLY', cveIds: ids.primary, cweId: 'CWE-89' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ADP_ONLY CWE match', + { scope: 'ADP_ONLY', cveIds: ids.adp, cweId: 'CWE-367' }, + { + exactTotal: 1, + expectedIds: [ids.adp] + } + ) +]); + +const keywordAndRecordFilters = folder( + '03 - Keyword and record filters', + 'Description, ID, and record-state filters.', + [ + positive( + 'keywordSearch', + { cveIds: ids.primary, keywordSearch: 'sql injection' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'keywordExactMatch true', + { cveIds: ids.primary, keywordSearch: 'sql injection', keywordExactMatch: true }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'keywordExactMatch false without keyword', + { cveIds: ids.primary, keywordExactMatch: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'noRejected true excludes rejected records', + { cveIds: `${ids.primary},${ids.rejected}`, noRejected: true, resultsPerPage: 2 }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'noRejected false does not filter', + { cveIds: `${ids.primary},${ids.rejected}`, noRejected: false, resultsPerPage: 2 }, + { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } + ), + positive('cweId', { cveIds: ids.primary, cweId: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] }) + ] +); + +const cvssCoverage = folder('04 - CVSS filters', 'Every supported CVSS severity and vector filter.', [ + positive( + 'cvssV2Severity is case-insensitive', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV2Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV2Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV2Metrics: 'AV:N/AC:L/Au:S' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3Metrics: 'AV:N/AC:L/PR:L' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_0Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_0Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_0Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_0Metrics: 'CVSS:3.0/AV:N/AC:L/PR:L' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_1Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_1Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_1Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L/PR:L' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV4Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV4Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV4Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV4Metrics: 'CVSS:4.0/AV:N/AC:L/AT:N' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ADP CVSS and SSVC must match selected containers', + { + scope: 'ADP_ONLY', + cveIds: ids.adp, + cvssV3_1Severity: 'high', + cvssV3_1Metrics: 'AV:L/AC:H/PR:L', + ssvcExploitation: 'none' + }, + { exactTotal: 1, expectedIds: [ids.adp] } + ) +]); + +const recordDates = folder('05 - Record date filters', 'Publication and modification boundaries.', [ + positive( + 'pubStartDate', + { cveIds: ids.primary, pubStartDate: '2024-01-01T00:00:00.000Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'pubEndDate', + { cveIds: ids.primary, pubEndDate: '2024-12-31T23:59:59.999Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'Publication date range', + { + cveIds: ids.primary, + pubStartDate: '2024-01-01T00:00:00.000Z', + pubEndDate: '2024-12-31T23:59:59.999Z' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'lastModStartDate', + { cveIds: ids.primary, lastModStartDate: '2024-01-01T00:00:00.000Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'lastModEndDate', + { cveIds: ids.primary, lastModEndDate: '2030-12-31T23:59:59.999Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'Modification date range', + { + cveIds: ids.primary, + lastModStartDate: '2024-01-01T00:00:00.000Z', + lastModEndDate: '2030-12-31T23:59:59.999Z' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ) +]); + +const cpeCoverage = folder('06 - CPE filters', 'Concrete CPE and virtual match-string behavior.', [ + positive( + 'cpeName', + { cveIds: ids.cpe, cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*' }, + { + exactTotal: 1, + expectedIds: [ids.cpe] + } + ), + positive( + 'cpeName with isVulnerable true', + { + cveIds: ids.cpe, + cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', + isVulnerable: true + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'isVulnerable false without cpeName', + { cveIds: ids.primary, isVulnerable: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'virtualMatchString', + { cveIds: ids.cpe, virtualMatchString: 'cpe:2.3:a:freetype:freetype' }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'virtualMatchString with versionStart pair', + { + cveIds: ids.cpe, + virtualMatchString: 'cpe:2.3:a:freetype:freetype', + versionStart: '2.8', + versionStartType: 'INCLUDING' + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'virtualMatchString with versionEnd pair', + { + cveIds: ids.cpe, + virtualMatchString: 'cpe:2.3:a:freetype:freetype', + versionEnd: '2.9', + versionEndType: 'excluding' + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'virtualMatchString with complete range', + { + cveIds: ids.cpe, + virtualMatchString: 'cpe:2.3:a:freetype:freetype', + versionStart: '2.8', + versionStartType: 'including', + versionEnd: '2.9', + versionEndType: 'excluding' + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ) +]); + +const ssvcCoverage = folder('07 - SSVC filters', 'Individual and same-entry SSVC matching.', [ + positive( + 'ssvcExploitation', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcExploitation: 'none' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcAutomatable', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcAutomatable: 'no' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcTechnicalImpact', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcTechnicalImpact: 'partial' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcRole is case-insensitive', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcRole: 'cisa coordinator' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcTimestampStartDate', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcTimestampStartDate: '2024-01-01T00:00:00.000Z' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'ssvcTimestampEndDate', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcTimestampEndDate: '2030-12-31T23:59:59.999Z' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'Complete SSVC same-entry combination', + { + scope: 'ADP_ONLY', + cveIds: ids.primary, + ssvcExploitation: 'NONE', + ssvcAutomatable: 'NO', + ssvcTechnicalImpact: 'PARTIAL', + ssvcRole: 'CISA Coordinator', + ssvcTimestampStartDate: '2024-01-01T00:00:00.000Z', + ssvcTimestampEndDate: '2030-12-31T23:59:59.999Z' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ) +]); + +const kevCoverage = folder('08 - KEV filters', 'Structured KEV presence and inclusive date filtering.', [ + positive( + 'knownExploited true', + { scope: 'ADP_ONLY', cveIds: ids.kev, knownExploited: true }, + { + exactTotal: 1, + expectedIds: [ids.kev] + } + ), + positive( + 'knownExploited false', + { scope: 'ADP_ONLY', cveIds: ids.primary, knownExploited: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'kevDateAddedStartDate', + { + scope: 'ADP_ONLY', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedStartDate: '2021-11-03T00:00:00.000Z' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ), + positive( + 'kevDateAddedEndDate', + { + scope: 'ADP_ONLY', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedEndDate: '2021-11-03T00:00:00.000Z' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ), + positive( + 'Inclusive KEV date range', + { + scope: 'ADP_ONLY', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedStartDate: '2021-11-03T00:00:00.000Z', + kevDateAddedEndDate: '2021-11-03T00:00:00.000Z' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ) +]); + +const referenceAndTags = folder( + '09 - Reference and dispute filters', + 'Reference URL substring and disputed-tag behavior.', + [ + positive( + 'referenceURL FULL', + { cveIds: ids.primary, referenceURL: 'github.com/liuyongxiang-git/cve/issues/1' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'referenceURL CNA_ONLY', + { + scope: 'CNA_ONLY', + cveIds: ids.primary, + referenceURL: 'GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'referenceURL ADP_ONLY', + { + scope: 'ADP_ONLY', + cveIds: ids.primary, + referenceURL: 'github.com/liuyongxiang-git/cve/issues/1' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'referenceURL treats wildcard characters literally', + { cveIds: ids.primary, referenceURL: 'github.com/*/cve/issues/1' }, + { exactTotal: 0, expectedHitCount: 0 } + ), + positive( + 'disputed true', + { cveIds: ids.disputed, disputed: true }, + { + exactTotal: 1, + expectedIds: [ids.disputed] + } + ), + positive( + 'disputed false', + { cveIds: ids.primary, disputed: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ) + ] +); + +const intentionalCombinations = folder( + '10 - Intentional combinations', + 'Representative AND combinations across related and independent filter groups.', + [ + positive( + 'CNA keyword, CWE, CVSS, dates, and status', + { + scope: 'CNA_ONLY', + cveIds: ids.primary, + noRejected: true, + keywordSearch: 'sql injection', + keywordExactMatch: true, + cweId: 'CWE-89', + cvssV3_1Severity: 'MEDIUM', + cvssV3_1Metrics: 'AV:N/AC:L/PR:L', + pubStartDate: '2024-01-01T00:00:00.000Z', + pubEndDate: '2024-12-31T23:59:59.999Z', + lastModStartDate: '2024-01-01T00:00:00.000Z', + lastModEndDate: '2030-12-31T23:59:59.999Z', + resultsPerPage: 1, + pageNumber: 1 + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'ADP CWE, CVSS, SSVC, and reference', + { + scope: 'ADP_ONLY', + cveIds: ids.adp, + cweId: 'CWE-367', + cvssV3_1Severity: 'HIGH', + cvssV3_1Metrics: 'AV:L/AC:H/PR:L', + ssvcExploitation: 'NONE', + ssvcAutomatable: 'NO', + ssvcTechnicalImpact: 'TOTAL', + ssvcRole: 'CISA Coordinator', + referenceURL: 'support.hp.com/us-en/document/ish_6662920' + }, + { exactTotal: 1, expectedIds: [ids.adp] } + ), + positive( + 'FULL cross-container KEV, SSVC, CVSS, and reference', + { + scope: 'FULL', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedStartDate: '2021-11-03T00:00:00.000Z', + kevDateAddedEndDate: '2021-11-03T00:00:00.000Z', + ssvcExploitation: 'ACTIVE', + ssvcAutomatable: 'YES', + ssvcTechnicalImpact: 'TOTAL', + cvssV3_1Severity: 'MEDIUM', + referenceURL: 'cisa.gov/known-exploited-vulnerabilities-catalog' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ) + ] +); + +const contractFailures = folder('11 - Expected failures - request contract', 'Transport and JSON body failures.', [ + negative('Query parameters are rejected', {}, error.searchQueryUnsupportedError(), { + requestPath: '/search?keywordSearch=apache' + }), + negative('Non-JSON content type is rejected', undefined, error.searchContentTypeError(), { + rawBody: '{}', + contentType: 'text/plain', + status: 415 + }), + negative('Malformed JSON is rejected', undefined, error.malformedJsonError(), { + rawBody: '{"keywordSearch":' + }), + negative('Array body is rejected', [], error.searchBodyError()), + negative('Top-level JSON string is rejected by the parser', undefined, error.malformedJsonError(), { + rawBody: '"search"' + }), + negative( + 'Unsupported body field is rejected', + { unsupportedFilter: true }, + error.invalidParamError('unsupportedFilter') + ), + negative('Empty string field is rejected', { keywordSearch: ' ' }, error.searchFieldStringError('keywordSearch')), + negative('Array-valued enum is rejected', { scope: ['FULL'] }, error.searchFieldStringError('scope')) +]); + +const limitFailures = folder( + '12 - Expected failures - limits and pagination', + 'Input complexity and result-window limits.', + [ + negative( + 'String length limit', + { keywordSearch: 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch + 1) }, + error.searchFieldLengthError('keywordSearch', constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch) + ), + negative( + 'Keyword token limit', + { + keywordSearch: Array(constants.MAX_KEYWORD_SEARCH_TOKENS + 1) + .fill('a') + .join(' ') + }, + error.searchFieldTokenLimitError('keywordSearch', constants.MAX_KEYWORD_SEARCH_TOKENS) + ), + negative( + 'Reference URL minimum length', + { referenceURL: 'ab' }, + error.searchFieldMinimumLengthError('referenceURL', 3) + ), + negative('CVE ID format', { cveIds: 'not-a-cve' }, error.cveIdsFormatError()), + negative('CVE ID length', { cveIds: `CVE-2024-${'1'.repeat(56)}` }, error.cveIdLengthError()), + negative( + 'CVE ID count limit', + { + cveIds: Array.from( + { length: constants.MAX_CVE_IDS + 1 }, + (_value, index) => `CVE-2024-${String(index + 1000).padStart(4, '0')}` + ).join(',') + }, + error.cveIdsLimitError() + ), + negative('resultsPerPage must be positive', { resultsPerPage: -1 }, error.resultsPerPageError()), + negative( + 'resultsPerPage maximum', + { resultsPerPage: constants.MAX_RESULTS_LIMIT + 1 }, + error.maxResultsError() + ), + negative('pageNumber must be positive', { pageNumber: 0 }, error.pageNumberError()), + negative('Pagination window limit', { resultsPerPage: 500, pageNumber: 21 }, error.searchWindowError()), + negative( + 'CVSS vector total length', + { cvssV3Metrics: 'A'.repeat(constants.MAX_CVSS_METRICS_LENGTH + 1) }, + error.cvssMetricsLimitError('cvssV3Metrics') + ), + negative( + 'CVSS vector token count', + { + cvssV4Metrics: Array.from( + { length: constants.MAX_CVSS_METRIC_TOKENS + 1 }, + (_value, index) => `T${index}:A` + ).join('/') + }, + error.cvssMetricsLimitError('cvssV4Metrics') + ), + negative( + 'CVSS vector token length', + { cvssV2Metrics: `AV:${'A'.repeat(constants.MAX_CVSS_METRIC_TOKEN_LENGTH + 1)}` }, + error.cvssMetricsLimitError('cvssV2Metrics') + ) + ] +); + +const booleanAndEnumFailures = folder( + '13 - Expected failures - booleans and enums', + 'Strict booleans and enum domains.', + [ + negative('isVulnerable boolean', { isVulnerable: 'yes' }, error.isVulnInputError()), + negative('keywordExactMatch boolean', { keywordExactMatch: 'yes' }, error.exactMatchInputError()), + negative('noRejected boolean', { noRejected: 'yes' }, error.noRejectedValueError()), + negative('knownExploited boolean', { knownExploited: 'true' }, error.knownExploitedInputError()), + negative('disputed boolean', { disputed: 'false' }, error.disputedInputError()), + negative('scope enum', { scope: 'CNA_AND_ADP' }, error.scopeValueError()), + negative('CVSS v2 severity enum', { cvssV2Severity: 'CRITICAL' }, error.cvssV2SeverityError('CRITICAL')), + negative('CVSS v3 severity enum', { cvssV3Severity: 'UNKNOWN' }, error.cvssV3SeverityError('UNKNOWN')), + negative('CVSS v3.0 severity enum', { cvssV3_0Severity: 'UNKNOWN' }, error.cvssV3_0SeverityError('UNKNOWN')), + negative('CVSS v3.1 severity enum', { cvssV3_1Severity: 'UNKNOWN' }, error.cvssV3_1SeverityError('UNKNOWN')), + negative('CVSS v4 severity enum', { cvssV4Severity: 'UNKNOWN' }, error.cvssV4SeverityError('UNKNOWN')), + negative( + 'SSVC exploitation enum', + { ssvcExploitation: 'UNKNOWN' }, + error.enumValueError('ssvcExploitation', ['NONE', 'POC', 'ACTIVE']) + ), + negative( + 'SSVC automatable enum', + { ssvcAutomatable: 'MAYBE' }, + error.enumValueError('ssvcAutomatable', ['YES', 'NO']) + ), + negative( + 'SSVC technical impact enum', + { ssvcTechnicalImpact: 'UNKNOWN' }, + error.enumValueError('ssvcTechnicalImpact', ['PARTIAL', 'TOTAL']) + ) + ] +); + +const dependencyFailures = folder( + '14 - Expected failures - dependent filters', + 'Filters whose validity depends on another field.', + [ + negative( + 'keywordExactMatch requires keywordSearch', + { keywordExactMatch: true }, + error.missingKeywordSearchError() + ), + negative('isVulnerable requires cpeName', { isVulnerable: true }, error.missingCpeNameError()), + negative( + 'isVulnerable conflicts with virtualMatchString', + { isVulnerable: true, virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' }, + error.virtualMatchVulnError() + ), + negative( + 'KEV start date requires knownExploited true', + { knownExploited: false, kevDateAddedStartDate: '2022-01-01T00:00:00.000Z' }, + error.kevDateRequiresKnownExploitedError() + ), + negative( + 'KEV end date requires knownExploited true', + { kevDateAddedEndDate: '2022-12-31T23:59:59.999Z' }, + error.kevDateRequiresKnownExploitedError() + ) + ] +); + +const cpeScopeFailures = []; +for (const param of [ + 'cpeName', + 'isVulnerable', + 'virtualMatchString', + 'versionStart', + 'versionStartType', + 'versionEnd', + 'versionEndType' +]) { + const values = { + cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', + isVulnerable: false, + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + versionStart: '2.2', + versionStartType: 'including', + versionEnd: '2.6', + versionEndType: 'excluding' + }; + cpeScopeFailures.push( + negative( + `ADP_ONLY rejects ${param}`, + { scope: 'ADP_ONLY', [param]: values[param] }, + error.scopeCompatibilityError('ADP_ONLY', [param]) + ) + ); +} + +const cpeAndVersionFailures = folder( + '15 - Expected failures - CPE and version ranges', + '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 virtualMatchString', + { virtualMatchString: 'not-a-cpe' }, + error.virtualMatchStringFormatError() + ), + negative( + 'Version range requires virtualMatchString', + { versionStart: '1.0', versionStartType: 'including' }, + error.versionRangeVirtualMatchStringRequiredError() + ), + negative( + 'versionStart requires versionStartType', + { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0' }, + error.versionStartPairError() + ), + negative( + 'versionEnd requires versionEndType', + { virtualMatchString: 'cpe:2.3:a:vendor:product', versionEnd: '2.0' }, + error.versionEndPairError() + ), + negative( + 'Invalid version range type', + { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0', versionStartType: 'before' }, + error.versionRangeTypeError('versionStartType') + ), + negative( + 'Negative numeric version range boundary', + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', versionEnd: '-1', versionEndType: 'including' }, + error.versionRangeBoundaryError('versionEnd') + ), + negative( + 'Invalid ranged virtualMatchString format', + { virtualMatchString: 'not-a-cpe', versionStart: '1.0', versionStartType: 'including' }, + error.virtualMatchStringVersionRangeFormatError() + ), + negative( + 'Ranged virtualMatchString cannot contain version', + { virtualMatchString: 'cpe:2.3:a:vendor:product:1.0', versionStart: '1.0', versionStartType: 'including' }, + error.virtualMatchStringVersionComponentError() + ), + negative( + 'Inverted version range', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '2.0', + versionStartType: 'including', + versionEnd: '1.0', + versionEndType: 'including' + }, + error.versionRangeOrderError() + ), + negative( + 'Empty equal exclusive version range', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '1.0', + versionStartType: 'excluding', + versionEnd: '1.0', + versionEndType: 'including' + }, + error.versionRangeOrderError() + ) + ] +); + +const dateFailures = folder('16 - Expected failures - dates', 'Timestamp format, calendar validity, and range order.', [ + negative('Partial timestamp', { pubStartDate: '2024-01-01' }, error.dateTimeError('pubStartDate')), + negative( + 'Impossible calendar date', + { lastModStartDate: '2024-02-30T00:00:00.000Z' }, + error.dateTimeError('lastModStartDate') + ), + negative( + 'Inverted publication date range', + { pubStartDate: '2024-12-31T00:00:00.000Z', pubEndDate: '2024-01-01T00:00:00.000Z' }, + error.dateRangeOrderError('pubStartDate', 'pubEndDate') + ), + negative( + 'Inverted modification date range', + { lastModStartDate: '2024-12-31T00:00:00.000Z', lastModEndDate: '2024-01-01T00:00:00.000Z' }, + error.dateRangeOrderError('lastModStartDate', 'lastModEndDate') + ), + negative( + 'Inverted SSVC timestamp range', + { ssvcTimestampStartDate: '2024-12-31T00:00:00.000Z', ssvcTimestampEndDate: '2024-01-01T00:00:00.000Z' }, + error.dateRangeOrderError('ssvcTimestampStartDate', 'ssvcTimestampEndDate') + ), + negative( + 'Inverted KEV date range', + { + knownExploited: true, + kevDateAddedStartDate: '2024-12-31T00:00:00.000Z', + kevDateAddedEndDate: '2024-01-01T00:00:00.000Z' + }, + error.dateRangeOrderError('kevDateAddedStartDate', 'kevDateAddedEndDate') + ) +]); + +function faultInjectionItem(name, requestPath, body, status, expectedError, description, method = 'POST') { + return item({ + name, + description, + method, + requestPath, + body, + skipUnless: 'runFaultInjection', + tests: errorTests(status, expectedError) + }); +} + +const faultInjection = folder( + '17 - Manual fault injection - skipped by default', + 'These requests require deliberate infrastructure or application configuration. Setting runFaultInjection=true without preparing the named condition will make them fail rather than create that condition.', + [ + faultInjectionItem( + 'OpenSearch readiness failure', + '/api/ready-check', + undefined, + 503, + error.searchNotReadyError(), + 'Run only while the API cannot reach or query its configured OpenSearch index.', + 'GET' + ), + faultInjectionItem( + 'OpenSearch unavailable search', + '/search', + { cveIds: ids.primary }, + 503, + error.openSearchError(), + 'Run only while OpenSearch connectivity is intentionally unavailable.' + ), + faultInjectionItem( + 'Search timeout', + '/search', + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' }, + 504, + error.searchTimeoutError(), + 'Run only with a deliberately reduced request deadline or delayed OpenSearch response.' + ), + faultInjectionItem( + 'Unexpected upstream response', + '/search', + { cveIds: ids.primary }, + 502, + error.upstreamSearchError(), + 'Requires an intentionally malformed or unexpected OpenSearch response.' + ) + ] +); + +const folders = [ + serviceChecks, + baselineAndPagination, + scopeCoverage, + keywordAndRecordFilters, + cvssCoverage, + recordDates, + cpeCoverage, + ssvcCoverage, + kevCoverage, + referenceAndTags, + intentionalCombinations, + contractFailures, + limitFailures, + booleanAndEnumFailures, + dependencyFailures, + cpeAndVersionFailures, + dateFailures, + faultInjection +]; + +const collection = { + info: { + _postman_id: '28911974-2b15-4ddf-a88f-3fa6fe001a50', + name: 'CVE Search API - Manual Contract Tests', + description: + 'Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. 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. The separate /webSearch contract is intentionally excluded.', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + variable: [ + { key: 'baseUrl', value: 'http://localhost:4000', type: 'string' }, + { key: 'runFaultInjection', value: 'false', type: 'string' } + ], + item: folders +}; + +const documentedFields = Object.keys(openApi.components.schemas.SearchRequest.properties); +const acceptedFields = constants.SEARCH_QUERY_PARAMS; +const undocumentedFields = acceptedFields.filter((field) => !documentedFields.includes(field)); +const unsupportedDocumentedFields = documentedFields.filter((field) => !acceptedFields.includes(field)); +if (undocumentedFields.length || unsupportedDocumentedFields.length) { + throw new Error( + `SearchRequest and accepted search fields differ. Undocumented: ${undocumentedFields.join(', ') || 'none'}. Unsupported: ${unsupportedDocumentedFields.join(', ') || 'none'}.` + ); +} + +const missingFields = documentedFields.filter((field) => !positiveCoveredFields.has(field)); +if (missingFields.length) { + throw new Error(`Postman positive coverage is missing SearchRequest fields: ${missingFields.join(', ')}`); +} + +const environment = { + id: '5f714896-d2bb-45cb-9bfa-c7db3a4e1141', + name: 'CVE Search API - Local', + values: [ + { key: 'baseUrl', value: 'http://localhost:4000', type: 'default', enabled: true }, + { key: 'runFaultInjection', value: 'false', type: 'default', enabled: true } + ], + _postman_variable_scope: 'environment', + _postman_exported_at: '2026-08-20T00:00:00.000Z', + _postman_exported_using: 'CVE-Search-API test/postman/buildCollection.js' +}; + +async function serialize(value, filePath, prettierConfig) { + return prettier.format(JSON.stringify(value), { + ...prettierConfig, + filepath: filePath + }); +} + +function writeOrCheck(filePath, contents) { + if (process.argv.includes('--check')) { + const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; + if (existing !== contents) { + throw new Error(`${path.basename(filePath)} is out of date. Run node test/postman/buildCollection.js.`); + } + return; + } + + fs.mkdirSync(outputDirectory, { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +async function buildArtifacts() { + const prettierConfig = (await prettier.resolveConfig(collectionPath)) ?? {}; + const artifacts = [ + [collectionPath, collection], + [environmentPath, environment] + ]; + + for (const [filePath, value] of artifacts) { + writeOrCheck(filePath, await serialize(value, filePath, prettierConfig)); + } +} + +buildArtifacts().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/test/postman/local.postman_environment.json b/test/postman/local.postman_environment.json new file mode 100644 index 0000000..c9f7efd --- /dev/null +++ b/test/postman/local.postman_environment.json @@ -0,0 +1,11 @@ +{ + "id": "5f714896-d2bb-45cb-9bfa-c7db3a4e1141", + "name": "CVE Search API - Local", + "values": [ + { "key": "baseUrl", "value": "http://localhost:4000", "type": "default", "enabled": true }, + { "key": "runFaultInjection", "value": "false", "type": "default", "enabled": true } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-08-20T00:00:00.000Z", + "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" +} diff --git a/utils/constants.js b/utils/constants.js index 85ae9f3..a86aecc 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -7,6 +7,8 @@ const MAX_CVSS_METRIC_TOKEN_LENGTH = 32; const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; const MAX_KEYWORD_SEARCH_TOKENS = 64; +const MIN_REFERENCE_URL_LENGTH = 3; +const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, cveIds: 4096, @@ -14,19 +16,15 @@ const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ cpeName: 2048, versionStart: 256, versionEnd: 256, - virtualMatchString: 2048 + virtualMatchString: 2048, + ssvcRole: 256, + referenceURL: 2048 }); -const SEARCH_POST_FILTER_CACHE_TTL_MS = 60 * 1000; -const SEARCH_POST_FILTER_CACHE_MAX_ENTRIES = 100; -const SEARCH_POST_FILTER_COLD_SEARCH_CONCURRENCY = 10; -const SEARCH_POST_FILTER_COLD_SEARCH_MAX_QUEUE_DEPTH = 500; -const SEARCH_POST_FILTER_COLD_SEARCH_MAX_WAIT_MS = 10 * 1000; const SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS = 20 * 1000; const SEARCH_REQUEST_DEADLINE_MS = 30 * 1000; const SEARCH_READINESS_TIMEOUT_MS = 5 * 1000; const rejectedCveStates = ['REJECT', 'REJECTED', 'Rejected']; -const rejectedCveStateFields = ['cveMetadata.state', 'containers.cna.x_legacyV4Record.CVE_data_meta.STATE']; const cpeVersionRangeTypes = ['including', 'excluding']; const cpeMatchFields = { @@ -62,27 +60,10 @@ const cpeMatchFields = { * versionEnd: string, upper version bound used with virtualMatchString * versionEndType: including or excluding */ -const fields = { - keywordSearch: 'containers.cna.descriptions.value', - cveIds: 'cveMetadata.cveId', - noRejected: rejectedCveStateFields, - cweId: 'containers.cna.problemTypes.descriptions.cweId', - cvssV2Severity: 'containers.cna.metrics.cvssV2_0.baseScore', - cvssV2Metrics: 'containers.cna.metrics.cvssV2_0.vectorString', - cvssV3_0Severity: 'containers.cna.metrics.cvssV3_0.baseSeverity', - cvssV3_0Metrics: 'containers.cna.metrics.cvssV3_0.vectorString', - cvssV3_1Severity: 'containers.cna.metrics.cvssV3_1.baseSeverity', - cvssV3_1Metrics: 'containers.cna.metrics.cvssV3_1.vectorString', - cvssV3Severity: ['containers.cna.metrics.cvssV3_0.baseSeverity', 'containers.cna.metrics.cvssV3_1.baseSeverity'], - cvssV3Metrics: ['containers.cna.metrics.cvssV3_0.vectorString', 'containers.cna.metrics.cvssV3_1.vectorString'], - cvssV4Severity: 'containers.cna.metrics.cvssV4_0.baseSeverity', - cvssV4Metrics: 'containers.cna.metrics.cvssV4_0.vectorString', - cpeName: cpeMatchFields.criteria, - isVulnerable: cpeMatchFields.vulnerable, - virtualMatchString: cpeMatchFields.criteria -}; +const fields = searchScope.getFullFields(); const searchControlParams = [ + 'scope', 'keywordExactMatch', 'resultsPerPage', 'pageNumber', @@ -97,6 +78,7 @@ const searchControlParams = [ ]; const searchQueryParams = [ + 'scope', 'keywordSearch', 'keywordExactMatch', 'cveIds', @@ -124,7 +106,18 @@ const searchQueryParams = [ 'versionStartType', 'versionEnd', 'versionEndType', - 'virtualMatchString' + 'virtualMatchString', + 'ssvcExploitation', + 'ssvcAutomatable', + 'ssvcTechnicalImpact', + 'ssvcRole', + 'ssvcTimestampStartDate', + 'ssvcTimestampEndDate', + 'knownExploited', + 'kevDateAddedStartDate', + 'kevDateAddedEndDate', + 'referenceURL', + 'disputed' ]; module.exports = { @@ -136,12 +129,8 @@ module.exports = { MAX_CVE_IDS, MAX_CVE_ID_LENGTH, MAX_KEYWORD_SEARCH_TOKENS, + MIN_REFERENCE_URL_LENGTH, SEARCH_STRING_LENGTH_LIMITS, - SEARCH_POST_FILTER_CACHE_TTL_MS, - SEARCH_POST_FILTER_CACHE_MAX_ENTRIES, - SEARCH_POST_FILTER_COLD_SEARCH_CONCURRENCY, - SEARCH_POST_FILTER_COLD_SEARCH_MAX_QUEUE_DEPTH, - SEARCH_POST_FILTER_COLD_SEARCH_MAX_WAIT_MS, SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, SEARCH_REQUEST_DEADLINE_MS, SEARCH_READINESS_TIMEOUT_MS, diff --git a/utils/cpe.js b/utils/cpe.js index 7301566..b59719a 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -230,6 +230,10 @@ function tokenizeVersion(value) { .match(/\d+|[a-z]+/g); } +function isNegativeNumericVersion(value) { + return /^-\d/.test(String(value ?? '').trim()); +} + function compareNumericTokens(a, b) { const normalizedA = a.replace(/^0+/, '') || '0'; const normalizedB = b.replace(/^0+/, '') || '0'; @@ -270,6 +274,16 @@ function compareVersionTokenToRelease(token) { } function compareCpeVersions(a, b) { + const negativeA = isNegativeNumericVersion(a); + const negativeB = isNegativeNumericVersion(b); + + if (negativeA !== negativeB) return negativeA ? -1 : 1; + + if (negativeA && negativeB) { + const comparison = compareCpeVersions(String(a).trim().slice(1), String(b).trim().slice(1)); + return comparison === 0 ? 0 : -comparison; + } + const tokensA = tokenizeVersion(a); const tokensB = tokenizeVersion(b); diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 916a0d9..0dbce63 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -169,6 +169,13 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions('1.0', '1.0.1')).toBeLessThan(0); }); + test('compareCpeVersions preserves the sign of negative numeric versions', () => { + expect(cpe.compareCpeVersions('-1', '1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('1', '-1')).toBeGreaterThan(0); + expect(cpe.compareCpeVersions('-2', '-1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('-1.0', '-1')).toBe(0); + }); + test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { const orderedVersions = [ '1.0.0-alpha1', diff --git a/utils/cvss.js b/utils/cvss.js new file mode 100644 index 0000000..0641e7d --- /dev/null +++ b/utils/cvss.js @@ -0,0 +1,69 @@ +const searchContainerMetrics = require('./searchContainerMetrics'); +const searchScope = require('./searchScope'); + +const VECTOR_FIELDS = Object.freeze({ + cvssV2Metrics: ['cvssV2_0'], + cvssV3Metrics: ['cvssV3_0', 'cvssV3_1'], + cvssV3_0Metrics: ['cvssV3_0'], + cvssV3_1Metrics: ['cvssV3_1'], + cvssV4Metrics: ['cvssV4_0'] +}); +const PARAMS = Object.freeze(Object.keys(VECTOR_FIELDS)); + +function parseMetricTokens(inputVectorString) { + return String(inputVectorString ?? '') + .split('/') + .map((token) => token.trim().toUpperCase()) + .filter(Boolean) + .filter((token) => !token.startsWith('CVSS:') && !token.startsWith('CVSS#')); +} + +function buildPostFilter(params) { + const vectors = Object.fromEntries( + PARAMS.filter((param) => params?.[param] !== undefined).map((param) => [param, params[param]]) + ); + if (!Object.keys(vectors).length) return null; + + return { + scope: searchScope.normalizeScope(params.scope), + vectors + }; +} + +function vectorContainsEveryToken(vector, tokens) { + if (typeof vector !== 'string') return false; + + const vectorTokens = new Set(parseMetricTokens(vector)); + return tokens.every((token) => vectorTokens.has(String(token).trim().toUpperCase())); +} + +function metricMatches(metric, fieldNames, tokens) { + return fieldNames.some((fieldName) => vectorContainsEveryToken(metric?.[fieldName]?.vectorString, tokens)); +} + +function hitMatches(hit, filter) { + const metrics = searchContainerMetrics.getMetrics(hit?._source, filter.scope); + + return Object.entries(filter.vectors).every(([param, inputVectorString]) => { + const tokens = parseMetricTokens(inputVectorString); + return tokens.length > 0 && metrics.some((metric) => metricMatches(metric, VECTOR_FIELDS[param], tokens)); + }); +} + +function getCandidateSourceFields(filter) { + const fieldNames = new Set(Object.keys(filter.vectors).flatMap((param) => VECTOR_FIELDS[param])); + + return [...fieldNames].flatMap((fieldName) => + searchScope.resolveContainerPaths(`metrics.${fieldName}.vectorString`, filter.scope) + ); +} + +module.exports = { + PARAMS, + VECTOR_FIELDS, + buildPostFilter, + getCandidateSourceFields, + hitMatches, + parseMetricTokens, + vectorContainsEveryToken +}; diff --git a/utils/cvss.test.js b/utils/cvss.test.js new file mode 100644 index 0000000..98b6a6f --- /dev/null +++ b/utils/cvss.test.js @@ -0,0 +1,61 @@ +const cvss = require('./cvss'); + +function hit(metrics) { + return { + _source: { + containers: { + adp: [{ metrics }] + } + } + }; +} + +describe('CVSS semantic vector matching', () => { + test('requires every token to occur in one eligible vector', () => { + const filter = cvss.buildPostFilter({ + scope: 'ADP_ONLY', + cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L' + }); + const splitAcrossVectors = hit([ + { cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:H' } }, + { cvssV3_1: { vectorString: 'CVSS:3.1/AV:L/AC:L' } } + ]); + const sameVector = hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:L' } }]); + + expect(cvss.hitMatches(splitAcrossVectors, filter)).toBe(false); + expect(cvss.hitMatches(sameVector, filter)).toBe(true); + }); + + test('combined CVSS v3 filters accept either v3.0 or v3.1 vectors', () => { + const filter = cvss.buildPostFilter({ + scope: 'ADP_ONLY', + cvssV3Metrics: 'AV:N/AC:L' + }); + + expect(cvss.hitMatches(hit([{ cvssV3_0: { vectorString: 'CVSS:3.0/AV:N/AC:L' } }]), filter)).toBe(true); + expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:L' } }]), filter)).toBe(true); + }); + + test('matches complete metric tokens without confusing base and modified metrics', () => { + const filter = cvss.buildPostFilter({ + scope: 'ADP_ONLY', + cvssV3_1Metrics: 'AV:N' + }); + + expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:L/MAV:N' } }]), filter)).toBe(false); + expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/MAV:L' } }]), filter)).toBe(true); + }); + + test('returns only source fields required by supplied vector filters and scope', () => { + const filter = cvss.buildPostFilter({ + scope: 'CNA_ONLY', + cvssV2Metrics: 'AV:N', + cvssV4Metrics: 'AV:N' + }); + + expect(cvss.getCandidateSourceFields(filter)).toStrictEqual([ + 'containers.cna.metrics.cvssV2_0.vectorString', + 'containers.cna.metrics.cvssV4_0.vectorString' + ]); + }); +}); diff --git a/utils/errors.js b/utils/errors.js index 4c126e0..2b1bfa8 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -5,6 +5,36 @@ function makeError(error, message) { } class errors { + scopeValueError() { + return makeError('SCOPE_VALUE', 'The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.'); + } + + scopeCompatibilityError(scope, params) { + return makeError( + 'SCOPE_FILTER_INCOMPATIBLE', + `The ${params.join(', ')} parameter${params.length === 1 ? ' is' : 's are'} not supported with scope ${scope}.` + ); + } + + enumValueError(param, allowed) { + return makeError('INVALID_ENUM_VALUE', `The ${param} parameter must be one of: ${allowed.join(', ')}.`); + } + + kevDateRequiresKnownExploitedError() { + return makeError( + 'KEV_DATE_REQUIRES_KNOWN_EXPLOITED', + 'knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.' + ); + } + + knownExploitedInputError() { + return makeError('KNOWN_EXPLOITED_BOOLEAN', 'The knownExploited parameter must be a boolean.'); + } + + disputedInputError() { + return makeError('DISPUTED_BOOLEAN', 'The disputed parameter must be a boolean.'); + } + maxResultsError() { return makeError( 'MAX_RESULTS_LIMIT', @@ -66,6 +96,13 @@ class errors { return makeError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`); } + versionRangeBoundaryError(param) { + return makeError( + 'VERSION_RANGE_BOUNDARY', + `The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.` + ); + } + versionRangeTypeError(param) { return makeError('VERSION_RANGE_TYPE', `The ${param} parameter must be including or excluding.`); } @@ -77,6 +114,10 @@ class errors { ); } + dateRangeOrderError(startParam, endParam) { + return makeError('DATE_RANGE_ORDER', `The ${startParam} parameter must be before or equal to ${endParam}.`); + } + virtualMatchStringVersionRangeFormatError() { return makeError( 'VIRTUAL_MATCH_STRING_RANGE_FORMAT', @@ -154,6 +195,10 @@ class errors { return makeError('SEARCH_FIELD_LENGTH', `The ${field} field must be ${maxLength} characters or fewer.`); } + searchFieldMinimumLengthError(field, minLength) { + return makeError('SEARCH_FIELD_MINIMUM_LENGTH', `The ${field} field must be at least ${minLength} characters.`); + } + searchFieldTokenLimitError(field, maxTokens) { return makeError('SEARCH_FIELD_TOKEN_LIMIT', `The ${field} field must contain ${maxTokens} or fewer terms.`); } @@ -263,7 +308,7 @@ class errors { } webSearchDisabledError() { - return makeError('WEB_SEARCH_DISABLED', 'The /webSearch endpoint is disabled. Use POST /search instead.'); + return makeError('WEB_SEARCH_DISABLED', 'The /webSearch endpoint is disabled by service configuration.'); } openSearchError() { @@ -281,21 +326,9 @@ class errors { return makeError('UPSTREAM_SEARCH_ERROR', 'Search backend returned an error. Please try again later.'); } - searchQueueFullError() { - return makeError('SEARCH_QUEUE_FULL', 'Search service is busy. Please retry later.'); - } - - searchQueueTimeoutError() { - return makeError('SEARCH_QUEUE_TIMEOUT', 'Search service is busy. Please retry later.'); - } - searchTimeoutError() { return makeError('SEARCH_TIMEOUT', 'Search request timed out. Please try again later.'); } - - searchRateLimitedError() { - return makeError('SEARCH_RATE_LIMITED', 'Too many search requests. Please retry later.'); - } } module.exports = { diff --git a/utils/errors.test.js b/utils/errors.test.js index 682af30..fe3e325 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -91,6 +91,15 @@ describe('error response helpers', () => { message: 'The versionStart parameter must be a non-empty string.' } ], + [ + 'versionRangeBoundaryError', + ['versionEnd'], + { + error: 'VERSION_RANGE_BOUNDARY', + message: + 'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.' + } + ], [ 'versionRangeTypeError', ['versionEndType'], @@ -175,22 +184,6 @@ describe('error response helpers', () => { message: 'The noRejected field must be a boolean.' } ], - [ - 'searchQueueFullError', - [], - { - error: 'SEARCH_QUEUE_FULL', - message: 'Search service is busy. Please retry later.' - } - ], - [ - 'searchQueueTimeoutError', - [], - { - error: 'SEARCH_QUEUE_TIMEOUT', - message: 'Search service is busy. Please retry later.' - } - ], [ 'searchTimeoutError', [], @@ -199,14 +192,6 @@ describe('error response helpers', () => { message: 'Search request timed out. Please try again later.' } ], - [ - 'searchRateLimitedError', - [], - { - error: 'SEARCH_RATE_LIMITED', - message: 'Too many search requests. Please retry later.' - } - ], [ 'searchNotReadyError', [], @@ -392,7 +377,7 @@ describe('error response helpers', () => { [], { error: 'WEB_SEARCH_DISABLED', - message: 'The /webSearch endpoint is disabled. Use POST /search instead.' + message: 'The /webSearch endpoint is disabled by service configuration.' } ], [ diff --git a/utils/kev.js b/utils/kev.js new file mode 100644 index 0000000..fe49131 --- /dev/null +++ b/utils/kev.js @@ -0,0 +1,46 @@ +const searchContainerMetrics = require('./searchContainerMetrics'); +const searchScope = require('./searchScope'); + +const DATE_PARAMS = Object.freeze(['kevDateAddedStartDate', 'kevDateAddedEndDate']); + +function parseStoredDate(value) { + if (typeof value !== 'string') return Number.NaN; + const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value; + return Date.parse(dateOnly); +} + +function entryMatches(other, filter) { + if (String(other?.type ?? '').toLowerCase() !== 'kev') return false; + if (!other?.content || typeof other.content !== 'object') return false; + if (!filter.dateAddedStartDate && !filter.dateAddedEndDate) return true; + + const dateAdded = parseStoredDate(other.content.dateAdded); + if (!Number.isFinite(dateAdded)) return false; + if (filter.dateAddedStartDate && dateAdded < Date.parse(filter.dateAddedStartDate)) return false; + if (filter.dateAddedEndDate && dateAdded > Date.parse(filter.dateAddedEndDate)) return false; + return true; +} + +function buildPostFilter(params) { + if (params?.knownExploited !== true || !DATE_PARAMS.some((param) => params[param] !== undefined)) return null; + + return { + scope: searchScope.normalizeScope(params.scope), + dateAddedStartDate: params.kevDateAddedStartDate, + dateAddedEndDate: params.kevDateAddedEndDate + }; +} + +function hitMatches(hit, filter) { + return searchContainerMetrics + .getOtherMetrics(hit?._source, filter.scope) + .some((other) => entryMatches(other, filter)); +} + +module.exports = { + DATE_PARAMS, + buildPostFilter, + entryMatches, + hitMatches, + parseStoredDate +}; diff --git a/utils/kev.test.js b/utils/kev.test.js new file mode 100644 index 0000000..44cb8f4 --- /dev/null +++ b/utils/kev.test.js @@ -0,0 +1,35 @@ +const kev = require('./kev'); + +function hit(dateAdded, container = 'adp') { + const metrics = [{ other: { type: 'kev', content: { dateAdded, reference: 'https://example.test' } } }]; + return { + _source: { + containers: container === 'cna' ? { cna: { metrics } } : { adp: [{ metrics }] } + } + }; +} + +describe('KEV semantic filtering', () => { + test('interprets stored date-only values as midnight UTC with inclusive bounds', () => { + expect( + kev.hitMatches(hit('2024-06-15'), { + scope: 'ADP_ONLY', + dateAddedStartDate: '2024-06-15T00:00:00.000Z', + dateAddedEndDate: '2024-06-15T00:00:00.000Z' + }) + ).toBe(true); + }); + + test('rejects dates outside the requested range', () => { + expect( + kev.hitMatches(hit('2024-06-14'), { + scope: 'ADP_ONLY', + dateAddedStartDate: '2024-06-15T00:00:00.000Z' + }) + ).toBe(false); + }); + + test('ignores KEV entries outside the selected scope', () => { + expect(kev.hitMatches(hit('2024-06-15', 'cna'), { scope: 'ADP_ONLY' })).toBe(false); + }); +}); diff --git a/utils/searchContainerMetrics.js b/utils/searchContainerMetrics.js new file mode 100644 index 0000000..4135874 --- /dev/null +++ b/utils/searchContainerMetrics.js @@ -0,0 +1,18 @@ +const searchScope = require('./searchScope'); + +function getMetrics(source, scope) { + return searchScope + .getSelectedContainers(source, scope) + .flatMap((container) => (Array.isArray(container?.metrics) ? container.metrics : [])); +} + +function getOtherMetrics(source, scope) { + return getMetrics(source, scope) + .map((metric) => metric?.other) + .filter(Boolean); +} + +module.exports = { + getMetrics, + getOtherMetrics +}; diff --git a/utils/searchInFlightRegistry.js b/utils/searchInFlightRegistry.js deleted file mode 100644 index 07f4e85..0000000 --- a/utils/searchInFlightRegistry.js +++ /dev/null @@ -1,118 +0,0 @@ -const searchDeadline = require('./searchDeadline'); - -const entries = new Map(); - -class SearchOperationAbandonedError extends Error { - constructor() { - super('Shared search operation no longer has active waiters.'); - this.name = 'SearchOperationAbandonedError'; - this.code = 'SEARCH_OPERATION_ABANDONED'; - } -} - -function buildKey(type, value) { - return `${type}:${JSON.stringify(value)}`; -} - -function settleEntry(key, entry) { - entry.settled = true; - if (entries.get(key) === entry) { - entries.delete(key); - } -} - -function createEntry(key, createOperation) { - const controller = new AbortController(); - const entry = { - controller, - key, - promise: undefined, - settled: false, - waiterCount: 0 - }; - - entries.set(key, entry); - entry.promise = Promise.resolve().then(() => createOperation(controller.signal)); - entry.promise.then( - () => settleEntry(key, entry), - () => settleEntry(key, entry) - ); - - return entry; -} - -function releaseWaiter(entry) { - entry.waiterCount = Math.max(0, entry.waiterCount - 1); - if (!entry.settled && entry.waiterCount === 0 && !entry.controller.signal.aborted) { - settleEntry(entry.key, entry); - entry.controller.abort(new SearchOperationAbandonedError()); - } -} - -function waitForEntry(entry, signal) { - entry.waiterCount += 1; - - return new Promise((resolve, reject) => { - let settled = false; - - const finish = (callback, value) => { - if (settled) return; - - settled = true; - signal?.removeEventListener('abort', onAbort); - releaseWaiter(entry); - callback(value); - }; - const onAbort = () => finish(reject, searchDeadline.getAbortReason(signal)); - - if (signal?.aborted) { - onAbort(); - return; - } - - signal?.addEventListener('abort', onAbort, { once: true }); - entry.promise.then( - (value) => finish(resolve, value), - (err) => finish(reject, err) - ); - }); -} - -function acquire(key, createOperation, { signal } = {}) { - searchDeadline.throwIfAborted(signal); - - let entry = entries.get(key); - const shared = Boolean(entry); - if (!entry) { - entry = createEntry(key, createOperation); - } - - return { - promise: waitForEntry(entry, signal), - shared - }; -} - -function clear() { - for (const entry of entries.values()) { - if (!entry.controller.signal.aborted) { - entry.controller.abort(new SearchOperationAbandonedError()); - } - } - entries.clear(); -} - -function getState() { - return { - operationCount: entries.size, - waiterCount: [...entries.values()].reduce((total, entry) => total + entry.waiterCount, 0) - }; -} - -module.exports = { - acquire, - buildKey, - clear, - getState, - SearchOperationAbandonedError -}; diff --git a/utils/searchInFlightRegistry.test.js b/utils/searchInFlightRegistry.test.js deleted file mode 100644 index aed7ad9..0000000 --- a/utils/searchInFlightRegistry.test.js +++ /dev/null @@ -1,133 +0,0 @@ -const searchDeadline = require('./searchDeadline'); -const searchInFlightRegistry = require('./searchInFlightRegistry'); - -function deferred() { - const state = {}; - state.promise = new Promise((resolve, reject) => { - state.resolve = resolve; - state.reject = reject; - }); - return state; -} - -describe('searchInFlightRegistry', () => { - beforeEach(() => { - searchInFlightRegistry.clear(); - }); - - afterEach(() => { - searchInFlightRegistry.clear(); - }); - - test('coalesces waiters for the same operation key', async () => { - const operation = deferred(); - const createOperation = jest.fn(() => operation.promise); - const first = searchInFlightRegistry.acquire('same-key', createOperation); - const second = searchInFlightRegistry.acquire('same-key', createOperation); - await Promise.resolve(); - - expect(first.shared).toBe(false); - expect(second.shared).toBe(true); - expect(createOperation).toHaveBeenCalledTimes(1); - expect(searchInFlightRegistry.getState()).toStrictEqual({ - operationCount: 1, - waiterCount: 2 - }); - - operation.resolve('result'); - - await expect(first.promise).resolves.toBe('result'); - await expect(second.promise).resolves.toBe('result'); - expect(searchInFlightRegistry.getState()).toStrictEqual({ - operationCount: 0, - waiterCount: 0 - }); - }); - - test('keeps shared work active while another waiter remains', async () => { - const operation = deferred(); - const firstController = new AbortController(); - const secondController = new AbortController(); - let operationSignal; - const createOperation = jest.fn((signal) => { - operationSignal = signal; - return operation.promise; - }); - const first = searchInFlightRegistry.acquire('same-key', createOperation, { - signal: firstController.signal - }); - const second = searchInFlightRegistry.acquire('same-key', createOperation, { - signal: secondController.signal - }); - await Promise.resolve(); - - firstController.abort(new searchDeadline.SearchClientDisconnectedError()); - - await expect(first.promise).rejects.toMatchObject({ - code: 'SEARCH_CLIENT_DISCONNECTED' - }); - expect(operationSignal.aborted).toBe(false); - expect(searchInFlightRegistry.getState().waiterCount).toBe(1); - - operation.resolve('result'); - await expect(second.promise).resolves.toBe('result'); - }); - - test('aborts shared work after its final waiter cancels', async () => { - const operation = deferred(); - const controller = new AbortController(); - let operationSignal; - const acquired = searchInFlightRegistry.acquire( - 'same-key', - (signal) => { - operationSignal = signal; - return operation.promise; - }, - { - signal: controller.signal - } - ); - await Promise.resolve(); - - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - await expect(acquired.promise).rejects.toMatchObject({ - code: 'SEARCH_TIMEOUT' - }); - expect(operationSignal.reason).toMatchObject({ - code: 'SEARCH_OPERATION_ABANDONED' - }); - expect(searchInFlightRegistry.getState().operationCount).toBe(0); - - const replacementOperation = jest.fn(() => Promise.resolve('replacement')); - const replacement = searchInFlightRegistry.acquire('same-key', replacementOperation); - - expect(replacement.shared).toBe(false); - await expect(replacement.promise).resolves.toBe('replacement'); - expect(replacementOperation).toHaveBeenCalledTimes(1); - - operation.reject(operationSignal.reason); - await expect(operation.promise).rejects.toMatchObject({ - code: 'SEARCH_OPERATION_ABANDONED' - }); - }); - - test('does not start work for an already-aborted waiter', () => { - const controller = new AbortController(); - const createOperation = jest.fn(); - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - expect(() => - searchInFlightRegistry.acquire('same-key', createOperation, { - signal: controller.signal - }) - ).toThrow(searchDeadline.SearchDeadlineExceededError); - expect(createOperation).not.toHaveBeenCalled(); - }); - - test('buildKey preserves operation type boundaries', () => { - expect(searchInFlightRegistry.buildKey('candidate', ['CVE-2024-0001'])).not.toBe( - searchInFlightRegistry.buildKey('pageFetch', ['CVE-2024-0001']) - ); - }); -}); diff --git a/utils/searchPerformance.js b/utils/searchPerformance.js deleted file mode 100644 index 1240cfc..0000000 --- a/utils/searchPerformance.js +++ /dev/null @@ -1,77 +0,0 @@ -const searchProvider = require('./searchProvider'); - -function markTime() { - return process.hrtime.bigint(); -} - -function elapsedMs(start) { - return Number(process.hrtime.bigint() - start) / 1e6; -} - -function roundMs(value) { - return value === undefined ? undefined : Math.round(value * 100) / 100; -} - -function getCacheStatus(metrics, postFilters) { - if (!Object.keys(postFilters ?? {}).length) return 'not_applicable'; - if (metrics.cacheHit === true) return 'hit'; - if (metrics.inFlightHit === true) return 'in_flight'; - - return 'miss'; -} - -function logSearchPerformance(metrics, searchOptions, responseOptions, postFilters) { - const outcome = metrics.outcome ?? 'success'; - const includeDetailedTimings = searchProvider.shouldLogSearchPerformance(); - if (outcome === 'success' && !includeDetailedTimings) return; - - const event = { - event: 'search.performance', - route: '/search', - requestId: metrics.requestId, - outcome, - statusCode: metrics.statusCode, - timeoutSource: metrics.timeoutSource, - abortReason: metrics.abortReason, - strategy: metrics.strategy, - cacheStatus: getCacheStatus(metrics, postFilters), - coldSearchQueueWaitElapsedMs: roundMs(metrics.coldSearchQueueWaitElapsedMs), - coldSearchQueueState: metrics.coldSearchQueueState, - totalElapsedMs: roundMs(metrics.totalElapsedMs) - }; - - if (includeDetailedTimings) { - Object.assign(event, { - optimizedFallback: metrics.optimizedFallback === true, - cacheHit: metrics.cacheHit === true, - inFlightHit: metrics.inFlightHit === true, - pageFetchInFlightHit: metrics.pageFetchInFlightHit === true, - openSearchOperations: metrics.openSearchOperations ?? [], - maxOpenSearchActiveRequests: metrics.maxOpenSearchActiveRequests, - postFilters: Object.keys(postFilters ?? {}), - requestedFrom: responseOptions?.from ?? searchOptions?.from, - requestedSize: responseOptions?.resultsPerPage ?? searchOptions?.resultsPerPage, - openSearchSize: searchOptions?.resultsPerPage, - candidateCount: metrics.candidateCount, - filteredCount: metrics.filteredCount, - returnedCount: metrics.returnedCount, - openSearchTookMs: metrics.openSearchTookMs, - pageFetchTookMs: metrics.pageFetchTookMs, - openSearchElapsedMs: roundMs(metrics.openSearchElapsedMs), - postFilterElapsedMs: roundMs(metrics.postFilterElapsedMs), - inFlightWaitElapsedMs: roundMs(metrics.inFlightWaitElapsedMs), - pageFetchElapsedMs: roundMs(metrics.pageFetchElapsedMs), - pageFetchWaitElapsedMs: roundMs(metrics.pageFetchWaitElapsedMs) - }); - } - - console.log(JSON.stringify(event)); -} - -module.exports = { - elapsedMs, - getCacheStatus, - logSearchPerformance, - markTime, - roundMs -}; diff --git a/utils/searchPerformance.test.js b/utils/searchPerformance.test.js deleted file mode 100644 index 5ec42f1..0000000 --- a/utils/searchPerformance.test.js +++ /dev/null @@ -1,165 +0,0 @@ -const mockShouldLogSearchPerformance = jest.fn(); - -jest.mock('./searchProvider', () => ({ - shouldLogSearchPerformance: mockShouldLogSearchPerformance -})); - -const searchPerformance = require('./searchPerformance'); - -describe('searchPerformance', () => { - beforeEach(() => { - mockShouldLogSearchPerformance.mockReset(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('logSearchPerformance emits redacted search timing metadata when enabled', () => { - mockShouldLogSearchPerformance.mockReturnValue(true); - - searchPerformance.logSearchPerformance( - { - requestId: 'request-123', - outcome: 'success', - statusCode: 200, - strategy: 'cpe-candidate', - cacheHit: true, - inFlightHit: true, - pageFetchInFlightHit: true, - openSearchOperations: ['cpe-candidate', 'page-fetch'], - maxOpenSearchActiveRequests: 3, - candidateCount: 10000, - filteredCount: 4, - returnedCount: 1, - openSearchTookMs: 97, - pageFetchTookMs: 8, - openSearchElapsedMs: 123.456, - postFilterElapsedMs: 7.891, - coldSearchQueueWaitElapsedMs: 2.345, - coldSearchQueueState: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 4 - }, - inFlightWaitElapsedMs: 5.678, - pageFetchElapsedMs: 10.234, - pageFetchWaitElapsedMs: 3.456, - totalElapsedMs: 150.567 - }, - { - resultsPerPage: 10000, - from: 0 - }, - { - resultsPerPage: 1, - from: 0 - }, - { - virtualMatchStringRange: {} - } - ); - - expect(console.log).toHaveBeenCalledTimes(1); - expect(JSON.parse(console.log.mock.calls[0][0])).toStrictEqual({ - event: 'search.performance', - route: '/search', - requestId: 'request-123', - outcome: 'success', - statusCode: 200, - strategy: 'cpe-candidate', - cacheStatus: 'hit', - optimizedFallback: false, - cacheHit: true, - inFlightHit: true, - pageFetchInFlightHit: true, - openSearchOperations: ['cpe-candidate', 'page-fetch'], - maxOpenSearchActiveRequests: 3, - postFilters: ['virtualMatchStringRange'], - requestedFrom: 0, - requestedSize: 1, - openSearchSize: 10000, - candidateCount: 10000, - filteredCount: 4, - returnedCount: 1, - openSearchTookMs: 97, - pageFetchTookMs: 8, - openSearchElapsedMs: 123.46, - postFilterElapsedMs: 7.89, - coldSearchQueueWaitElapsedMs: 2.35, - coldSearchQueueState: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 4 - }, - inFlightWaitElapsedMs: 5.68, - pageFetchElapsedMs: 10.23, - pageFetchWaitElapsedMs: 3.46, - totalElapsedMs: 150.57 - }); - }); - - test('logSearchPerformance does not emit logs when disabled', () => { - mockShouldLogSearchPerformance.mockReturnValue(false); - - searchPerformance.logSearchPerformance( - { - outcome: 'success' - }, - { resultsPerPage: 1, from: 0 }, - undefined, - {} - ); - - expect(console.log).not.toHaveBeenCalled(); - }); - - test('logSearchPerformance always emits sanitized failure outcomes', () => { - mockShouldLogSearchPerformance.mockReturnValue(false); - - searchPerformance.logSearchPerformance( - { - requestId: 'request-456', - outcome: 'queue_timeout', - statusCode: 429, - strategy: 'cpe-candidate', - coldSearchQueueWaitElapsedMs: 10000.123, - coldSearchQueueState: { - activeCount: 10, - queuedCount: 499, - maxConcurrent: 10, - maxQueueDepth: 500, - maxWaitMs: 10000 - }, - totalElapsedMs: 10001.456 - }, - undefined, - undefined, - { - virtualMatchString: 'sensitive-value' - } - ); - - expect(console.log).toHaveBeenCalledTimes(1); - expect(JSON.parse(console.log.mock.calls[0][0])).toStrictEqual({ - event: 'search.performance', - route: '/search', - requestId: 'request-456', - outcome: 'queue_timeout', - statusCode: 429, - strategy: 'cpe-candidate', - cacheStatus: 'miss', - coldSearchQueueWaitElapsedMs: 10000.12, - coldSearchQueueState: { - activeCount: 10, - queuedCount: 499, - maxConcurrent: 10, - maxQueueDepth: 500, - maxWaitMs: 10000 - }, - totalElapsedMs: 10001.46 - }); - expect(console.log.mock.calls[0][0]).not.toContain('sensitive-value'); - }); -}); diff --git a/utils/searchPostFilterCache.js b/utils/searchPostFilterCache.js deleted file mode 100644 index f7ed9d8..0000000 --- a/utils/searchPostFilterCache.js +++ /dev/null @@ -1,73 +0,0 @@ -const searchProvider = require('./searchProvider'); - -const entries = new Map(); - -function normalizeForKey(value) { - if (Array.isArray(value)) return value.map(normalizeForKey); - - if (value && typeof value === 'object') { - return Object.keys(value) - .sort() - .reduce((normalized, key) => { - if (value[key] !== undefined) { - normalized[key] = normalizeForKey(value[key]); - } - return normalized; - }, {}); - } - - return value; -} - -function buildCacheKey(candidateBody, postFilters) { - return JSON.stringify( - normalizeForKey({ - candidateBody, - postFilters - }) - ); -} - -function get(cacheKey, now = Date.now()) { - const entry = entries.get(cacheKey); - if (!entry) return null; - - if (entry.expiresAt <= now) { - entries.delete(cacheKey); - return null; - } - - entries.delete(cacheKey); - entries.set(cacheKey, entry); - - return entry.value; -} - -function set(cacheKey, value, now = Date.now()) { - const ttlMs = searchProvider.getPostFilterCacheTtlMs(); - const maxEntries = searchProvider.getPostFilterCacheMaxEntries(); - - if (!maxEntries || maxEntries < 1 || !ttlMs || ttlMs < 1) return; - - entries.set(cacheKey, { - expiresAt: now + ttlMs, - value - }); - - while (entries.size > maxEntries) { - const oldestKey = entries.keys().next().value; - entries.delete(oldestKey); - } -} - -function clear() { - entries.clear(); -} - -module.exports = { - buildCacheKey, - clear, - get, - normalizeForKey, - set -}; diff --git a/utils/searchPostFilterCache.test.js b/utils/searchPostFilterCache.test.js deleted file mode 100644 index 77aff79..0000000 --- a/utils/searchPostFilterCache.test.js +++ /dev/null @@ -1,61 +0,0 @@ -const searchPostFilterCache = require('./searchPostFilterCache'); - -describe('searchPostFilterCache', () => { - beforeEach(() => { - searchPostFilterCache.clear(); - }); - - test('buildCacheKey is stable for equivalent object key ordering', () => { - const left = searchPostFilterCache.buildCacheKey( - { - query: { - bool: { - filter: [{ term: { b: '2' } }], - must: [] - } - }, - size: 10000, - from: 0 - }, - { - virtualMatchStringRange: { - versionEnd: '2.6', - versionStart: '2.2' - } - } - ); - const right = searchPostFilterCache.buildCacheKey( - { - from: 0, - size: 10000, - query: { - bool: { - must: [], - filter: [{ term: { b: '2' } }] - } - } - }, - { - virtualMatchStringRange: { - versionStart: '2.2', - versionEnd: '2.6' - } - } - ); - - expect(left).toBe(right); - }); - - test('get returns cached values until the entry expires', () => { - const key = 'cache-key'; - const value = { - candidateCount: 2, - filteredCveIds: ['CVE-2024-0001'] - }; - - searchPostFilterCache.set(key, value, 1000); - - expect(searchPostFilterCache.get(key, 1001)).toBe(value); - expect(searchPostFilterCache.get(key, 61001)).toBeNull(); - }); -}); diff --git a/utils/searchPostFilterWorkQueue.js b/utils/searchPostFilterWorkQueue.js deleted file mode 100644 index 8180f97..0000000 --- a/utils/searchPostFilterWorkQueue.js +++ /dev/null @@ -1,149 +0,0 @@ -const searchProvider = require('./searchProvider'); -const searchDeadline = require('./searchDeadline'); - -let activeCount = 0; -const queue = []; - -class SearchPostFilterQueueFullError extends Error { - constructor(state) { - super('CPE post-filter cold search queue is full.'); - this.name = 'SearchPostFilterQueueFullError'; - this.code = 'SEARCH_QUEUE_FULL'; - this.state = state; - } -} - -class SearchPostFilterQueueTimeoutError extends Error { - constructor(state, waitElapsedMs) { - super('CPE post-filter cold search queue wait timed out.'); - this.name = 'SearchPostFilterQueueTimeoutError'; - this.code = 'SEARCH_QUEUE_TIMEOUT'; - this.state = state; - this.waitElapsedMs = waitElapsedMs; - } -} - -function getMaxConcurrent() { - return searchProvider.getPostFilterColdSearchConcurrency(); -} - -function getMaxQueueDepth() { - return searchProvider.getPostFilterColdSearchMaxQueueDepth(); -} - -function getMaxWaitMs() { - return searchProvider.getPostFilterColdSearchMaxWaitMs(); -} - -function getState() { - return { - activeCount, - queuedCount: queue.length, - maxConcurrent: getMaxConcurrent(), - maxQueueDepth: getMaxQueueDepth(), - maxWaitMs: getMaxWaitMs() - }; -} - -function isQueueFullError(err) { - return err instanceof SearchPostFilterQueueFullError || err?.code === 'SEARCH_QUEUE_FULL'; -} - -function isQueueTimeoutError(err) { - return err instanceof SearchPostFilterQueueTimeoutError || err?.code === 'SEARCH_QUEUE_TIMEOUT'; -} - -function drain() { - const maxConcurrent = getMaxConcurrent(); - - while (activeCount < maxConcurrent && queue.length) { - const item = queue.shift(); - clearTimeout(item.timeoutId); - item.signal?.removeEventListener('abort', item.onAbort); - activeCount += 1; - - void (async () => { - try { - searchDeadline.throwIfAborted(item.signal); - item.resolve(await item.task(item.signal)); - } catch (err) { - item.reject(err); - } finally { - activeCount -= 1; - drain(); - } - })(); - } -} - -function run(task, { signal } = {}) { - return new Promise((resolve, reject) => { - try { - searchDeadline.throwIfAborted(signal); - } catch (err) { - reject(err); - return; - } - - if (activeCount >= getMaxConcurrent() && queue.length >= getMaxQueueDepth()) { - reject(new SearchPostFilterQueueFullError(getState())); - return; - } - - const item = { - task, - resolve, - reject, - queuedAt: process.hrtime.bigint(), - timeoutId: undefined, - signal, - onAbort: undefined - }; - - if (activeCount >= getMaxConcurrent()) { - item.onAbort = () => { - const queueIndex = queue.indexOf(item); - if (queueIndex === -1) return; - - queue.splice(queueIndex, 1); - clearTimeout(item.timeoutId); - reject(searchDeadline.getAbortReason(signal)); - drain(); - }; - signal?.addEventListener('abort', item.onAbort, { once: true }); - item.timeoutId = setTimeout(() => { - const queueIndex = queue.indexOf(item); - if (queueIndex === -1) return; - - queue.splice(queueIndex, 1); - signal?.removeEventListener('abort', item.onAbort); - const waitElapsedMs = Number(process.hrtime.bigint() - item.queuedAt) / 1e6; - reject(new SearchPostFilterQueueTimeoutError(getState(), waitElapsedMs)); - drain(); - }, getMaxWaitMs()); - item.timeoutId.unref?.(); - } - - queue.push(item); - drain(); - }); -} - -function resetForTests() { - activeCount = 0; - queue.forEach((item) => { - clearTimeout(item.timeoutId); - item.signal?.removeEventListener('abort', item.onAbort); - }); - queue.length = 0; -} - -module.exports = { - SearchPostFilterQueueFullError, - SearchPostFilterQueueTimeoutError, - getState, - isQueueFullError, - isQueueTimeoutError, - resetForTests, - run -}; diff --git a/utils/searchPostFilterWorkQueue.test.js b/utils/searchPostFilterWorkQueue.test.js deleted file mode 100644 index 02ef0b1..0000000 --- a/utils/searchPostFilterWorkQueue.test.js +++ /dev/null @@ -1,200 +0,0 @@ -const mockGetPostFilterColdSearchConcurrency = jest.fn(); -const mockGetPostFilterColdSearchMaxQueueDepth = jest.fn(); -const mockGetPostFilterColdSearchMaxWaitMs = jest.fn(); - -jest.mock('./searchProvider', () => ({ - getPostFilterColdSearchConcurrency: mockGetPostFilterColdSearchConcurrency, - getPostFilterColdSearchMaxQueueDepth: mockGetPostFilterColdSearchMaxQueueDepth, - getPostFilterColdSearchMaxWaitMs: mockGetPostFilterColdSearchMaxWaitMs -})); - -const searchPostFilterWorkQueue = require('./searchPostFilterWorkQueue'); -const searchDeadline = require('./searchDeadline'); - -function deferred() { - const state = {}; - state.promise = new Promise((resolve, reject) => { - state.resolve = resolve; - state.reject = reject; - }); - return state; -} - -function captureTimers() { - const timers = []; - jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => { - const timer = { - unref: jest.fn() - }; - timers.push({ - callback, - delay, - timer - }); - return timer; - }); - jest.spyOn(global, 'clearTimeout').mockImplementation(() => {}); - - return timers; -} - -describe('searchPostFilterWorkQueue', () => { - beforeEach(() => { - searchPostFilterWorkQueue.resetForTests(); - mockGetPostFilterColdSearchConcurrency.mockReset(); - mockGetPostFilterColdSearchMaxQueueDepth.mockReset(); - mockGetPostFilterColdSearchMaxWaitMs.mockReset(); - mockGetPostFilterColdSearchConcurrency.mockReturnValue(1); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(1); - mockGetPostFilterColdSearchMaxWaitMs.mockReturnValue(10000); - }); - - afterEach(() => { - searchPostFilterWorkQueue.resetForTests(); - jest.restoreAllMocks(); - }); - - test('runs no more than the configured number of cold searches concurrently', async () => { - const first = deferred(); - const second = deferred(); - const firstTask = jest.fn(() => first.promise); - const secondTask = jest.fn(() => second.promise); - - const firstRun = searchPostFilterWorkQueue.run(firstTask); - const secondRun = searchPostFilterWorkQueue.run(secondTask); - await Promise.resolve(); - - expect(firstTask).toHaveBeenCalledTimes(1); - expect(secondTask).not.toHaveBeenCalled(); - expect(searchPostFilterWorkQueue.getState()).toStrictEqual({ - activeCount: 1, - queuedCount: 1, - maxConcurrent: 1, - maxQueueDepth: 1, - maxWaitMs: 10000 - }); - - first.resolve('first'); - await firstRun; - await Promise.resolve(); - - expect(secondTask).toHaveBeenCalledTimes(1); - second.resolve('second'); - - await expect(secondRun).resolves.toBe('second'); - }); - - test('continues draining after a cold search rejects', async () => { - const first = deferred(); - const secondTask = jest.fn(() => 'second'); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(secondTask); - await Promise.resolve(); - - first.reject(new Error('failed')); - await expect(firstRun).rejects.toThrow('failed'); - await expect(secondRun).resolves.toBe('second'); - }); - - test('rejects new cold searches when the waiting queue is full', async () => { - const first = deferred(); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(() => 'second'); - await Promise.resolve(); - - await expect(searchPostFilterWorkQueue.run(() => 'third')).rejects.toMatchObject({ - code: 'SEARCH_QUEUE_FULL', - state: { - activeCount: 1, - queuedCount: 1, - maxConcurrent: 1, - maxQueueDepth: 1, - maxWaitMs: 10000 - } - }); - - first.resolve('first'); - await expect(firstRun).resolves.toBe('first'); - await expect(secondRun).resolves.toBe('second'); - }); - - test('allows immediate execution when queue depth is zero and concurrency is available', async () => { - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(0); - - await expect(searchPostFilterWorkQueue.run(() => 'first')).resolves.toBe('first'); - }); - - test('rejects queued work that exceeds the configured maximum wait', async () => { - const timers = captureTimers(); - const first = deferred(); - const secondTask = jest.fn(() => 'second'); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(secondTask); - const timeoutExpectation = expect(secondRun).rejects.toMatchObject({ - code: 'SEARCH_QUEUE_TIMEOUT', - waitElapsedMs: expect.any(Number), - state: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 1, - maxQueueDepth: 1, - maxWaitMs: 10000 - } - }); - await Promise.resolve(); - - timers.find(({ delay }) => delay === 10000).callback(); - await timeoutExpectation; - expect(secondTask).not.toHaveBeenCalled(); - - first.resolve('first'); - await expect(firstRun).resolves.toBe('first'); - }); - - test('removes queued work when its operation is aborted', async () => { - const first = deferred(); - const controller = new AbortController(); - const secondTask = jest.fn(); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(secondTask, { - signal: controller.signal - }); - await Promise.resolve(); - - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - await expect(secondRun).rejects.toMatchObject({ - code: 'SEARCH_TIMEOUT' - }); - expect(secondTask).not.toHaveBeenCalled(); - expect(searchPostFilterWorkQueue.getState().queuedCount).toBe(0); - - first.resolve('first'); - await expect(firstRun).resolves.toBe('first'); - }); - - test('passes the operation signal to active work', async () => { - const controller = new AbortController(); - const task = jest.fn( - (signal) => - new Promise((resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }) - ); - const run = searchPostFilterWorkQueue.run(task, { - signal: controller.signal - }); - await Promise.resolve(); - - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - await expect(run).rejects.toMatchObject({ - code: 'SEARCH_TIMEOUT' - }); - expect(task).toHaveBeenCalledWith(controller.signal); - }); -}); diff --git a/utils/searchPostFilters.js b/utils/searchPostFilters.js new file mode 100644 index 0000000..5cca1a0 --- /dev/null +++ b/utils/searchPostFilters.js @@ -0,0 +1,93 @@ +const cpe = require('./cpe'); +const cvss = require('./cvss'); +const searchScope = require('./searchScope'); +const ssvc = require('./ssvc'); +const kev = require('./kev'); + +const CPE_SOURCE_FIELDS = Object.freeze([ + 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', + 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' +]); + +function hasCpePostFilters(postFilters) { + return Boolean(postFilters?.cpeName || postFilters?.virtualMatchString || postFilters?.virtualMatchStringRange); +} + +function hasSearchPostFilters(postFilters) { + return hasCpePostFilters(postFilters) || Boolean(postFilters?.cvss || postFilters?.ssvc || postFilters?.kev); +} + +function hitMatches(hit, postFilters, cpeEvaluationContext) { + if ( + postFilters?.cpeName && + !cpe.hitAppliesToCpeName(hit, postFilters.cpeName, cpeEvaluationContext, { + requireVulnerable: postFilters.isVulnerable === true + }) + ) { + return false; + } + + if ( + postFilters?.virtualMatchString && + !cpe.hitMatchesVirtualMatchString(hit, postFilters.virtualMatchString, cpeEvaluationContext) + ) { + return false; + } + + if ( + postFilters?.virtualMatchStringRange && + !cpe.hitOverlapsVirtualMatchStringRange(hit, postFilters.virtualMatchStringRange, cpeEvaluationContext) + ) { + return false; + } + + if (postFilters?.ssvc && !ssvc.hitMatches(hit, postFilters.ssvc)) return false; + if (postFilters?.kev && !kev.hitMatches(hit, postFilters.kev)) return false; + if (postFilters?.cvss && !cvss.hitMatches(hit, postFilters.cvss)) return false; + + return true; +} + +function filterSearchHits(resp, postFilters) { + const hitsContainer = resp?.data?.hits; + const cpeEvaluationContext = cpe.createEvaluationContext(); + + if (!Array.isArray(hitsContainer?.hits)) { + return { + hitsContainer, + candidateCount: 0, + filteredHits: [] + }; + } + + return { + hitsContainer, + candidateCount: hitsContainer.hits.length, + filteredHits: hitsContainer.hits.filter((hit) => hitMatches(hit, postFilters, cpeEvaluationContext)) + }; +} + +function getCandidateSourceFields(postFilters) { + const fields = ['cveMetadata.cveId']; + if (hasCpePostFilters(postFilters)) fields.push(...CPE_SOURCE_FIELDS); + if (postFilters?.ssvc || postFilters?.kev) { + fields.push( + ...searchScope.resolveContainerPaths('metrics.other', postFilters.ssvc?.scope ?? postFilters.kev?.scope) + ); + } + if (postFilters?.cvss) fields.push(...cvss.getCandidateSourceFields(postFilters.cvss)); + + return [...new Set(fields)]; +} + +module.exports = { + filterSearchHits, + getCandidateSourceFields, + hasCpePostFilters, + hasSearchPostFilters, + hitMatches +}; diff --git a/utils/searchPostFilters.test.js b/utils/searchPostFilters.test.js new file mode 100644 index 0000000..be3329c --- /dev/null +++ b/utils/searchPostFilters.test.js @@ -0,0 +1,140 @@ +const searchPostFilters = require('./searchPostFilters'); + +function response(hits) { + return { + data: { + hits: { + total: { value: hits.length }, + hits + } + } + }; +} + +function cpeHit(id, cpeMatch) { + return { + _id: id, + _source: { + containers: { + cna: { + cpeApplicability: { + nodes: [{ cpeMatch: Array.isArray(cpeMatch) ? cpeMatch : [cpeMatch] }] + } + } + } + } + }; +} + +function filteredHits(hits, postFilters) { + return searchPostFilters.filterSearchHits(response(hits), postFilters).filteredHits; +} + +describe('searchPostFilters CPE semantics', () => { + test('does not combine platform and version matches from different CPE entries', () => { + const candidate = cpeHit('CVE-2026-0250', [ + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }, + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.2', + versionEndExcluding: '6.2.7' + }, + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.1', + versionEndExcluding: '6.1.13' + } + ]); + + expect( + 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', () => { + const candidate = cpeHit('CVE-2026-0251', { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }); + + expect( + 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', () => { + const exactCandidate = cpeHit('CVE-2025-9392', { + criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', + vulnerable: true + }); + const rangeCandidate = cpeHit('CVE-2026-33280', { + criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', + vulnerable: true, + versionEndExcluding: '6.02' + }); + + expect( + filteredHits([exactCandidate], { + cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + expect( + filteredHits([rangeCandidate], { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([rangeCandidate]); + expect( + 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', () => { + const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; + const candidates = [ + cpeHit('CVE-2020-17153', { + criteria, + vulnerable: true, + versionStartIncluding: '-' + }), + cpeHit('CVE-2024-38208', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '128.0.2739.42' + }), + cpeHit('CVE-2026-35429', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '148.0.3967.55' + }) + ]; + + expect( + 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']); + }); +}); diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 929ec6b..8616d73 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -1,6 +1,8 @@ const { AppConfig, BasicSearchManager, SearchProviderSpec, SearchReader } = require('cve-core'); const constants = require('./constants'); const searchDeadline = require('./searchDeadline'); +const searchReadiness = require('./searchReadiness'); +const searchResponseValidator = require('./searchResponseValidator'); class SearchProviderContractError extends Error { constructor() { @@ -13,8 +15,6 @@ class SearchProviderContractError extends Error { let searchProviderConfig; let basicSearchManager; let searchReader; -let activeOpenSearchRequestCount = 0; -const activeOpenSearchRequestsByOperation = new Map(); function configureClientRequestTimeout(client) { if (client?.transport) { @@ -96,8 +96,9 @@ function getBasicSearchManager() { function validateSearchReader(reader) { const hasIndex = typeof reader?._cveIndex === 'string' && reader._cveIndex.trim() !== ''; const hasSearchClient = typeof reader?._client?.search === 'function'; + const hasFieldCapsClient = typeof reader?._client?.fieldCaps === 'function'; - if (!hasIndex || !hasSearchClient) { + if (!hasIndex || !hasSearchClient || !hasFieldCapsClient) { throw new SearchProviderContractError(); } @@ -119,31 +120,6 @@ function getSearchReader() { return searchReader; } -function getOpenSearchRequestState() { - return { - activeCount: activeOpenSearchRequestCount, - activeByOperation: Object.fromEntries(activeOpenSearchRequestsByOperation) - }; -} - -function beginOpenSearchRequest(operation) { - activeOpenSearchRequestCount += 1; - activeOpenSearchRequestsByOperation.set(operation, (activeOpenSearchRequestsByOperation.get(operation) ?? 0) + 1); - - return getOpenSearchRequestState(); -} - -function endOpenSearchRequest(operation) { - activeOpenSearchRequestCount = Math.max(0, activeOpenSearchRequestCount - 1); - - const operationCount = activeOpenSearchRequestsByOperation.get(operation) ?? 0; - if (operationCount <= 1) { - activeOpenSearchRequestsByOperation.delete(operation); - } else { - activeOpenSearchRequestsByOperation.set(operation, operationCount - 1); - } -} - function awaitAbortableRequest(request, signal) { if (!signal) return Promise.resolve(request); @@ -176,21 +152,13 @@ function awaitAbortableRequest(request, signal) { }); } -async function executeOpenSearchRequest(createRequest, { signal, operation, onStart } = {}) { +async function executeOpenSearchRequest(createRequest, { signal } = {}) { searchDeadline.throwIfAborted(signal); - const state = beginOpenSearchRequest(operation); - - try { - onStart?.(state); - const response = await awaitAbortableRequest(createRequest(), signal); - return response; - } finally { - endOpenSearchRequest(operation); - } + return awaitAbortableRequest(createRequest(), signal); } -async function searchOpenSearch(body, { signal, operation = 'search', onStart } = {}) { +async function searchOpenSearch(body, { signal } = {}) { searchDeadline.throwIfAborted(signal); const reader = getSearchReader(); const response = await executeOpenSearchRequest( @@ -198,6 +166,7 @@ async function searchOpenSearch(body, { signal, operation = 'search', onStart } reader._client.search( { index: reader._cveIndex, + allow_partial_search_results: false, body }, { @@ -205,24 +174,40 @@ async function searchOpenSearch(body, { signal, operation = 'search', onStart } requestTimeout: getOpenSearchRequestTimeoutMs() } ), - { - signal, - operation, - onStart - } + { signal } ); - return response.body; + return searchResponseValidator.validateOpenSearchResponse(response.body); } async function checkOpenSearchReadiness({ signal } = {}) { searchDeadline.throwIfAborted(signal); const reader = getSearchReader(); - await executeOpenSearchRequest( + const requestOptions = { + maxRetries: 0, + requestTimeout: getOpenSearchReadinessTimeoutMs() + }; + const fieldCapsResponse = await executeOpenSearchRequest( + () => + reader._client.fieldCaps( + { + index: reader._cveIndex, + fields: searchReadiness.getRequiredFieldNames().join(','), + include_unmapped: true + }, + requestOptions + ), + { signal } + ); + searchReadiness.validateFieldCapabilities(fieldCapsResponse.body); + searchDeadline.throwIfAborted(signal); + + const response = await executeOpenSearchRequest( () => reader._client.search( { index: reader._cveIndex, + allow_partial_search_results: false, body: { query: { match_all: {} @@ -232,22 +217,23 @@ async function checkOpenSearchReadiness({ signal } = {}) { track_total_hits: false } }, - { - maxRetries: 0, - requestTimeout: getOpenSearchReadinessTimeoutMs() - } + requestOptions ), - { - signal, - operation: 'readiness' - } + { signal } ); + searchResponseValidator.validateOpenSearchResponse(response.body); + return true; } function isSearchProviderContractError(err) { - return err instanceof SearchProviderContractError || err?.code === 'SEARCH_PROVIDER_CONTRACT'; + return ( + err instanceof SearchProviderContractError || + err instanceof searchReadiness.SearchMappingContractError || + err?.code === 'SEARCH_PROVIDER_CONTRACT' || + err?.code === 'SEARCH_MAPPING_CONTRACT' + ); } function getOpenSearchReadinessTimeoutMs() { @@ -256,44 +242,6 @@ function getOpenSearchReadinessTimeoutMs() { }); } -function shouldLogSearchPerformance() { - if (!AppConfig.has('search.logPerformanceTimings')) return false; - - return normalizeBoolean(AppConfig.get('search.logPerformanceTimings')); -} - -function getPostFilterCacheTtlMs() { - return getIntegerConfig('search.postFilterCacheTtlMs', constants.SEARCH_POST_FILTER_CACHE_TTL_MS); -} - -function getPostFilterCacheMaxEntries() { - return getIntegerConfig('search.postFilterCacheMaxEntries', constants.SEARCH_POST_FILTER_CACHE_MAX_ENTRIES); -} - -function getPostFilterColdSearchConcurrency() { - return getIntegerConfig( - 'search.postFilterColdSearchConcurrency', - constants.SEARCH_POST_FILTER_COLD_SEARCH_CONCURRENCY, - { min: 1 } - ); -} - -function getPostFilterColdSearchMaxQueueDepth() { - return getIntegerConfig( - 'search.postFilterColdSearchMaxQueueDepth', - constants.SEARCH_POST_FILTER_COLD_SEARCH_MAX_QUEUE_DEPTH, - { min: 0 } - ); -} - -function getPostFilterColdSearchMaxWaitMs() { - return getIntegerConfig( - 'search.postFilterColdSearchMaxWaitMs', - constants.SEARCH_POST_FILTER_COLD_SEARCH_MAX_WAIT_MS, - { min: 1 } - ); -} - function getOpenSearchRequestTimeoutMs() { return getIntegerConfig('search.openSearchRequestTimeoutMs', constants.SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, { min: 1 @@ -308,8 +256,6 @@ function resetSearchProviderForTests() { searchProviderConfig = undefined; basicSearchManager = undefined; searchReader = undefined; - activeOpenSearchRequestCount = 0; - activeOpenSearchRequestsByOperation.clear(); } module.exports = { @@ -317,13 +263,7 @@ module.exports = { getBasicSearchManager, getAllowUnknownSslCerts, getIntegerConfig, - getOpenSearchRequestState, getOpenSearchReadinessTimeoutMs, - getPostFilterCacheMaxEntries, - getPostFilterCacheTtlMs, - getPostFilterColdSearchConcurrency, - getPostFilterColdSearchMaxQueueDepth, - getPostFilterColdSearchMaxWaitMs, getOpenSearchRequestTimeoutMs, getSearchRequestDeadlineMs, getSearchProviderConfig, @@ -332,7 +272,6 @@ module.exports = { normalizeBoolean, resetSearchProviderForTests, searchOpenSearch, - shouldLogSearchPerformance, validateSearchReader, SearchProviderContractError }; diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index c817ad9..dcaada4 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -10,11 +10,13 @@ const mockBasicSearchManager = jest.fn(function BasicSearchManager(spec) { }; }); const mockClientSearch = jest.fn(); +const mockClientFieldCaps = jest.fn(); const mockSearchReader = jest.fn(function SearchReader(endpoint, index, options) { this._openSearchDomainEndpoint = endpoint; this._cveIndex = index; this._options = options; this._client = { + fieldCaps: mockClientFieldCaps, search: mockClientSearch, transport: { maxRetries: 3, @@ -41,6 +43,7 @@ jest.mock('cve-core', () => ({ const searchProvider = require('./searchProvider'); const searchDeadline = require('./searchDeadline'); +const searchReadiness = require('./searchReadiness'); function deferred() { const state = {}; @@ -51,12 +54,50 @@ function deferred() { return state; } +function openSearchResponse(hits = []) { + return { + body: { + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0 + }, + hits: { + total: { value: hits.length }, + hits + } + } + }; +} + +function fieldCapsResponse() { + return { + body: { + fields: Object.fromEntries( + searchReadiness.FIELD_CAPABILITY_REQUIREMENTS.map((requirement) => [ + requirement.field, + { + [requirement.types[0]]: { + searchable: true, + aggregatable: requirement.aggregatable === true + } + } + ]) + ) + } + }; +} + describe('searchProvider', () => { beforeEach(() => { searchProvider.resetSearchProviderForTests(); mockBasicSearchManager.mockClear(); mockSearchReader.mockClear(); mockClientSearch.mockReset(); + mockClientFieldCaps.mockReset(); + mockClientFieldCaps.mockResolvedValue(fieldCapsResponse()); mockGetDefaultSearchProviderSpec.mockReset(); mockGetDefaultSearchProviderSpec.mockReturnValue({ providerEndpoint: 'https://search.example', @@ -105,16 +146,7 @@ describe('searchProvider', () => { }); test('reuses one search reader per process for OpenSearch searches', async () => { - mockClientSearch.mockResolvedValue({ - body: { - hits: { - total: { - value: 0 - }, - hits: [] - } - } - }); + mockClientSearch.mockResolvedValue(openSearchResponse()); const first = searchProvider.getSearchReader(); const second = searchProvider.getSearchReader(); @@ -123,11 +155,7 @@ describe('searchProvider', () => { match_all: {} } }; - const onStart = jest.fn(); - const result = await searchProvider.searchOpenSearch(body, { - operation: 'standard', - onStart - }); + const result = await searchProvider.searchOpenSearch(body); expect(first).toBe(second); expect(mockSearchReader).toHaveBeenCalledTimes(1); @@ -137,6 +165,7 @@ describe('searchProvider', () => { expect(mockClientSearch).toHaveBeenCalledWith( { index: 'test-index', + allow_partial_search_results: false, body }, { @@ -146,40 +175,29 @@ describe('searchProvider', () => { ); expect(first._client.transport.maxRetries).toBe(0); expect(first._client.transport.requestTimeout).toBe(20000); - expect(result).toStrictEqual({ - hits: { - total: { - value: 0 - }, - hits: [] - } - }); - expect(onStart).toHaveBeenCalledWith({ - activeCount: 1, - activeByOperation: { - standard: 1 - } - }); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); + expect(result).toStrictEqual(openSearchResponse().body); }); test('checks OpenSearch readiness through the configured CVE index', async () => { - mockClientSearch.mockResolvedValue({ - body: { - hits: { - hits: [] - } - } - }); + mockClientSearch.mockResolvedValue(openSearchResponse()); await expect(searchProvider.checkOpenSearchReadiness()).resolves.toBe(true); + expect(mockClientFieldCaps).toHaveBeenCalledWith( + { + index: 'test-index', + fields: searchReadiness.getRequiredFieldNames().join(','), + include_unmapped: true + }, + { + maxRetries: 0, + requestTimeout: 5000 + } + ); expect(mockClientSearch).toHaveBeenCalledWith( { index: 'test-index', + allow_partial_search_results: false, body: { query: { match_all: {} @@ -194,10 +212,6 @@ describe('searchProvider', () => { requestTimeout: 5000 } ); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('fails readiness when the configured CVE index is unavailable', async () => { @@ -207,11 +221,24 @@ describe('searchProvider', () => { await expect(searchProvider.checkOpenSearchReadiness()).rejects.toBe(unavailable); }); - test('aborts the readiness transport request when its lifecycle is canceled', async () => { + test('fails readiness before searching when required field mappings are unavailable', async () => { + mockClientFieldCaps.mockResolvedValue({ + body: { + fields: {} + } + }); + + await expect(searchProvider.checkOpenSearchReadiness()).rejects.toMatchObject({ + code: 'SEARCH_MAPPING_CONTRACT' + }); + expect(mockClientSearch).not.toHaveBeenCalled(); + }); + + test('aborts the readiness field-capabilities request when its lifecycle is canceled', async () => { const pending = deferred(); const request = pending.promise; request.abort = jest.fn(); - mockClientSearch.mockReturnValue(request); + mockClientFieldCaps.mockReturnValue(request); const controller = new AbortController(); const readiness = searchProvider.checkOpenSearchReadiness({ signal: controller.signal @@ -224,10 +251,6 @@ describe('searchProvider', () => { code: 'SEARCH_TIMEOUT' }); expect(request.abort).toHaveBeenCalledTimes(1); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('does not construct a SearchReader for an already-canceled readiness check', async () => { @@ -271,8 +294,7 @@ describe('searchProvider', () => { } }, { - signal: controller.signal, - operation: 'standard' + signal: controller.signal } ); await Promise.resolve(); @@ -283,56 +305,6 @@ describe('searchProvider', () => { code: 'SEARCH_TIMEOUT' }); expect(request.abort).toHaveBeenCalledTimes(1); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); - }); - - test('tracks concurrent OpenSearch requests by operation type', async () => { - const candidate = deferred(); - const pageFetch = deferred(); - const candidateStart = jest.fn(); - const pageFetchStart = jest.fn(); - mockClientSearch.mockReturnValueOnce(candidate.promise).mockReturnValueOnce(pageFetch.promise); - - const candidateSearch = searchProvider.searchOpenSearch( - {}, - { - operation: 'cpe-candidate', - onStart: candidateStart - } - ); - const pageSearch = searchProvider.searchOpenSearch( - {}, - { - operation: 'page-fetch', - onStart: pageFetchStart - } - ); - - expect(candidateStart).toHaveBeenCalledWith({ - activeCount: 1, - activeByOperation: { - 'cpe-candidate': 1 - } - }); - expect(pageFetchStart).toHaveBeenCalledWith({ - activeCount: 2, - activeByOperation: { - 'cpe-candidate': 1, - 'page-fetch': 1 - } - }); - - candidate.resolve({ body: { hits: { hits: [] } } }); - pageFetch.resolve({ body: { hits: { hits: [] } } }); - await Promise.all([candidateSearch, pageSearch]); - - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('does not start an OpenSearch request for an already-aborted signal', async () => { @@ -392,33 +364,12 @@ describe('searchProvider', () => { expect(searchProvider.getSearchProviderConfig().allowUnknownSslCerts).toBe(true); }); - test('reads search performance timing flag from config', () => { - mockAppConfigHas.mockImplementation((key) => key === 'search.logPerformanceTimings'); - mockAppConfigGet.mockImplementation((key) => (key === 'search.logPerformanceTimings' ? 'TRUE' : undefined)); - - expect(searchProvider.shouldLogSearchPerformance()).toBe(true); - }); - - test('reads CPE post-filter runtime tuning from config', () => { + test('reads search timeout runtime settings from config', () => { mockAppConfigHas.mockImplementation((key) => - [ - 'search.postFilterCacheTtlMs', - 'search.postFilterCacheMaxEntries', - 'search.postFilterColdSearchConcurrency', - 'search.postFilterColdSearchMaxQueueDepth', - 'search.postFilterColdSearchMaxWaitMs', - 'search.openSearchRequestTimeoutMs', - 'search.requestDeadlineMs', - 'search.readinessTimeoutMs' - ].includes(key) + ['search.openSearchRequestTimeoutMs', 'search.requestDeadlineMs', 'search.readinessTimeoutMs'].includes(key) ); mockAppConfigGet.mockImplementation((key) => { const values = { - 'search.postFilterCacheTtlMs': '120000', - 'search.postFilterCacheMaxEntries': '250', - 'search.postFilterColdSearchConcurrency': '6', - 'search.postFilterColdSearchMaxQueueDepth': '50', - 'search.postFilterColdSearchMaxWaitMs': '15000', 'search.openSearchRequestTimeoutMs': '25000', 'search.requestDeadlineMs': '45000', 'search.readinessTimeoutMs': '7000' @@ -426,25 +377,15 @@ describe('searchProvider', () => { return values[key]; }); - expect(searchProvider.getPostFilterCacheTtlMs()).toBe(120000); - expect(searchProvider.getPostFilterCacheMaxEntries()).toBe(250); - expect(searchProvider.getPostFilterColdSearchConcurrency()).toBe(6); - expect(searchProvider.getPostFilterColdSearchMaxQueueDepth()).toBe(50); - expect(searchProvider.getPostFilterColdSearchMaxWaitMs()).toBe(15000); expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(25000); expect(searchProvider.getSearchRequestDeadlineMs()).toBe(45000); expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(7000); }); - test('falls back to CPE post-filter runtime defaults when config values are invalid', () => { + test('falls back to search timeout defaults when config values are invalid', () => { mockAppConfigHas.mockReturnValue(true); mockAppConfigGet.mockReturnValue('not-a-number'); - expect(searchProvider.getPostFilterCacheTtlMs()).toBe(60000); - expect(searchProvider.getPostFilterCacheMaxEntries()).toBe(100); - expect(searchProvider.getPostFilterColdSearchConcurrency()).toBe(10); - expect(searchProvider.getPostFilterColdSearchMaxQueueDepth()).toBe(500); - expect(searchProvider.getPostFilterColdSearchMaxWaitMs()).toBe(10000); expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(20000); expect(searchProvider.getSearchRequestDeadlineMs()).toBe(30000); expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(5000); diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 07894bb..1a95324 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -1,5 +1,10 @@ const constants = require('./constants'); const cpe = require('./cpe'); +const cvss = require('./cvss'); +const searchScope = require('./searchScope'); +const searchPostFilters = require('./searchPostFilters'); +const ssvc = require('./ssvc'); +const kev = require('./kev'); const { MAX_RESULTS_LIMIT, RESULTS_PER_PAGE } = constants; @@ -7,30 +12,24 @@ const matchParams = Object.keys(constants.FIELDS); const pubDatePath = 'cveMetadata.datePublished'; const modDatePath = 'cveMetadata.dateUpdated'; -const cpeCandidateSourceFields = [ - 'cveMetadata.cveId', - 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', - 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' -]; - function buildSearchRequest(params) { + const scope = searchScope.normalizeScope(params?.scope); const processedParams = processParams(params); const resultsPerPage = getResultsPerPage(params); const from = getFrom(params, resultsPerPage); const postFilters = buildPostFilters(params); const searchOptions = { - filters: getFilterFields(processedParams.matchPhrases), + filters: [ + ...getFilterFields(processedParams.matchPhrases, scope), + ...buildSemanticCandidateFilters(params, scope) + ], rangeObjects: buildDateRangeObjects(params), resultsPerPage, from }; const request = { searchOptions, - queryStringObjects: buildQueryStrings(processedParams.queryStrings) + queryStringObjects: buildQueryStrings(processedParams.queryStrings, scope) }; if (Object.keys(postFilters).length) { @@ -60,7 +59,14 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc }, track_total_hits: trackTotalHits, size: searchOptions.resultsPerPage, - from: searchOptions.from + from: searchOptions.from, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }; if (sourceIncludes?.length) { @@ -70,9 +76,9 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc return body; } -function buildCpeCandidateSearchBody(searchOptions, queryStringObjects = []) { +function buildPostFilterCandidateSearchBody(searchOptions, queryStringObjects = [], postFilters = {}) { return buildOpenSearchBody(searchOptions, queryStringObjects, { - sourceIncludes: cpeCandidateSourceFields, + sourceIncludes: searchPostFilters.getCandidateSourceFields(postFilters), trackTotalHits: false }); } @@ -96,15 +102,29 @@ function buildPostFilters(params) { const cpeNamePostFilter = getCpeNamePostFilter(params); const virtualMatchStringPostFilter = getVirtualMatchStringPostFilter(params); const virtualMatchStringRangePostFilter = getVirtualMatchStringRangePostFilter(params); + const ssvcPostFilter = ssvc.buildPostFilter(params); + const kevPostFilter = kev.buildPostFilter(params); + const cvssPostFilter = cvss.buildPostFilter(params); if (cpeNamePostFilter) postFilters.cpeName = cpeNamePostFilter; if (cpeNamePostFilter && isTrueValue(params?.isVulnerable)) postFilters.isVulnerable = true; if (virtualMatchStringRangePostFilter) postFilters.virtualMatchStringRange = virtualMatchStringRangePostFilter; else if (virtualMatchStringPostFilter) postFilters.virtualMatchString = virtualMatchStringPostFilter; + if (ssvcPostFilter) postFilters.ssvc = ssvcPostFilter; + if (kevPostFilter) postFilters.kev = kevPostFilter; + if (cvssPostFilter) postFilters.cvss = cvssPostFilter; return postFilters; } +function buildSemanticCandidateFilters(params, scope) { + const filters = []; + if (ssvc.buildPostFilter(params)) filters.push(buildSsvcCandidateFilter(params, scope)); + if (params.knownExploited === true) filters.push(buildKevCandidateFilter(params, scope)); + if (params.knownExploited === false) filters.push(buildNoKevFilter(scope)); + return filters; +} + function getResultsPerPage(params) { const requested = Number(params?.resultsPerPage); if (Number.isInteger(requested) && requested >= 1 && requested <= MAX_RESULTS_LIMIT) { @@ -228,39 +248,24 @@ function escapeQueryStringTerm(term, { escapeWildcards = false } = {}) { return s.replace(re, '\\$1'); } -function buildQueryStrings(params) { - const queryStringObjects = []; +function buildQueryStrings(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { + if (params?.keywordSearch === undefined) return []; - Object.keys(params).forEach((param) => { - if (param === 'virtualMatchString') return; - - const fieldSpec = constants.FIELDS[param]; - const fieldList = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; - - if (param === 'keywordSearch') { - queryStringObjects.push({ - simple_query_string: { - query: params[param], - default_operator: 'AND', - fields: fieldList - } - }); - return; - } + const fieldList = searchScope.resolveFields('keywordSearch', scope); + if (!fieldList.length) return []; - queryStringObjects.push({ - query_string: { - query: params[param], + return [ + { + simple_query_string: { + query: params.keywordSearch, default_operator: 'AND', fields: fieldList } - }); - }); - - return queryStringObjects; + } + ]; } -function getFilterFields(params) { +function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { let fieldsToReturn = []; const validFields = Object.keys(constants.FIELDS); @@ -277,8 +282,8 @@ function getFilterFields(params) { Object.keys(params).forEach((param) => { if (!validFields.includes(param)) return; - const fieldSpec = constants.FIELDS[param]; - if (!fieldSpec) return; + const fieldSpec = searchScope.resolveFields(param, scope); + if (!fieldSpec.length) return; if (param === 'noRejected') { if (params[param] === true || params[param] === '') { @@ -288,7 +293,7 @@ function getFilterFields(params) { } if (param === 'cveIds') { - const filter = buildCveIdsFilter(params[param], fieldSpec); + const filter = buildCveIdsFilter(params[param], fieldSpec[0]); if (filter) fieldsToReturn.push(filter); return; } @@ -303,7 +308,7 @@ function getFilterFields(params) { if (isTrueValue(params[param])) { fieldsToReturn.push({ term: { - [fieldSpec]: true + [fieldSpec[0]]: true } }); } @@ -316,13 +321,21 @@ function getFilterFields(params) { return; } + if (param === 'referenceURL') { + fieldsToReturn.push(buildLiteralSubstringAcrossFields(params[param], fieldSpec)); + return; + } + + if (param === 'disputed') { + fieldsToReturn.push(buildDisputedFilter(params[param], fieldSpec)); + return; + } + if (param === 'cvssV2Severity') { const r = cvssV2SeverityToRange(params[param]); if (!r) return; - fieldsToReturn.push({ - range: { [fieldSpec]: r } - }); + fieldsToReturn.push(buildRangeAcrossFields(r, fieldSpec)); return; } @@ -347,6 +360,10 @@ function escapeForWildcard(s) { return String(s).replace(/([\\*?])/g, '\\$1'); } +function escapeForRegexp(s) { + return String(s).replace(/([\\.?+*|{}[\]()"#@&<>~])/g, '\\$1'); +} + function parseCveIds(value) { return String(value ?? '') .split(',') @@ -379,6 +396,117 @@ function buildNoRejectedFilter(fieldSpec) { }; } +function buildSsvcCandidateFilter(params, scope) { + const clauses = [ + buildCaseInsensitiveExactAcrossFields('ssvc', searchScope.resolveContainerPaths('metrics.other.type', scope)) + ]; + const exactFields = [ + ['ssvcExploitation', 'metrics.other.content.options.Exploitation'], + ['ssvcAutomatable', 'metrics.other.content.options.Automatable'], + ['ssvcTechnicalImpact', 'metrics.other.content.options.Technical Impact'], + ['ssvcRole', 'metrics.other.content.role'] + ]; + + exactFields.forEach(([param, relativePath]) => { + if (params[param] !== undefined) { + clauses.push( + buildCaseInsensitiveExactAcrossFields( + params[param], + searchScope.resolveContainerPaths(relativePath, scope) + ) + ); + } + }); + + if (params.ssvcTimestampStartDate || params.ssvcTimestampEndDate) { + const bounds = {}; + if (params.ssvcTimestampStartDate) bounds.gte = params.ssvcTimestampStartDate; + if (params.ssvcTimestampEndDate) bounds.lte = params.ssvcTimestampEndDate; + clauses.push( + buildRangeAcrossFields(bounds, searchScope.resolveContainerPaths('metrics.other.content.timestamp', scope)) + ); + } + + return { + bool: { + filter: clauses + } + }; +} + +function buildKevCandidateFilter(params, scope) { + const clauses = [ + buildCaseInsensitiveExactAcrossFields('kev', searchScope.resolveContainerPaths('metrics.other.type', scope)) + ]; + + if (params.kevDateAddedStartDate || params.kevDateAddedEndDate) { + const bounds = {}; + if (params.kevDateAddedStartDate) bounds.gte = params.kevDateAddedStartDate; + if (params.kevDateAddedEndDate) bounds.lte = params.kevDateAddedEndDate; + clauses.push( + buildRangeAcrossFields(bounds, searchScope.resolveContainerPaths('metrics.other.content.dateAdded', scope)) + ); + } + + return { + bool: { + filter: clauses + } + }; +} + +function buildNoKevFilter(scope) { + return { + bool: { + must_not: [ + buildCaseInsensitiveExactAcrossFields( + 'kev', + searchScope.resolveContainerPaths('metrics.other.type', scope) + ) + ] + } + }; +} + +function buildCaseInsensitiveExactAcrossFields(value, fieldSpec) { + const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + const clauses = fields.map((field) => ({ + wildcard: { + [`${field}.keyword`]: { + value: escapeForWildcard(value), + case_insensitive: true + } + } + })); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + +function buildLiteralSubstringAcrossFields(value, fieldSpec) { + const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + const clauses = fields.map((field) => ({ + wildcard: { + [`${field}.keyword`]: { + value: `*${escapeForWildcard(value)}*`, + case_insensitive: true + } + } + })); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + +function buildDisputedFilter(value, fieldSpec) { + const exactFilter = buildCaseInsensitiveExactAcrossFields('disputed', fieldSpec); + if (value === true) return exactFilter; + + return { + bool: { + must_not: [exactFilter] + } + }; +} + function buildCpeNameFilter(cpeName) { const parsedCpe = cpe.parseCpeName(cpeName); if (!parsedCpe.valid) return null; @@ -495,14 +623,7 @@ function buildHasAnyRangeFieldFilter(rangeFields) { } function buildCvssWildcardFilter(inputVectorString, fieldSpec) { - const tokens = String(inputVectorString) - .split('/') - .map((t) => t.trim()) - .filter(Boolean) - .filter((t) => { - const up = t.toUpperCase(); - return !up.startsWith('CVSS:') && !up.startsWith('CVSS#'); - }); + const tokens = cvss.parseMetricTokens(inputVectorString); if (!tokens.length) return null; @@ -513,8 +634,11 @@ function buildCvssWildcardFilter(inputVectorString, fieldSpec) { return { bool: { filter: tokens.map((t) => ({ - wildcard: { - [keywordField]: { value: `*${escapeForWildcard(t)}*` } + regexp: { + [keywordField]: { + value: `(.*/)?${escapeForRegexp(t)}(/.*)?`, + case_insensitive: true + } } })) } @@ -552,6 +676,15 @@ function buildMatchPhraseAcrossFields(value, fieldSpec) { return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; } +function buildRangeAcrossFields(value, fieldSpec) { + const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + const clauses = fields.map((field) => ({ + range: { [field]: value } + })); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + function cvssV2SeverityToRange(sev) { const s = String(sev).toUpperCase(); if (s === 'LOW') return { gte: 0.0, lte: 3.9 }; @@ -561,9 +694,9 @@ function cvssV2SeverityToRange(sev) { } module.exports = { - buildCpeCandidateSearchBody, buildCveIdsSearchRequest, buildOpenSearchBody, + buildPostFilterCandidateSearchBody, buildQueryStrings, buildSearchRequest, getFilterFields, diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index b26f879..335aae7 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -20,9 +20,21 @@ function prefixShould(prefixes) { }; } +function cvssTokenRegexp(field, token) { + return { + regexp: { + [`${field}.keyword`]: { + value: `(.*/)?${token}(/.*)?`, + case_insensitive: true + } + } + }; +} + describe('searchQueryBuilder buildSearchRequest()', () => { test('buildSearchRequest builds filters, query strings, date ranges, and pagination options', () => { const output = queryBuilder.buildSearchRequest({ + scope: 'CNA_ONLY', keywordSearch: 'remote code', cveIds: 'CVE-2024-0001', pubStartDate: '2024-01-01T00:00:00.000Z', @@ -187,8 +199,9 @@ describe('searchQueryBuilder buildSearchRequest()', () => { expect(output.queryStringObjects).toStrictEqual([]); }); - test('buildCpeCandidateSearchBody limits source fields for semantic post-filtering', () => { + test('buildPostFilterCandidateSearchBody limits source fields for semantic CPE filtering', () => { const output = queryBuilder.buildSearchRequest({ + scope: 'CNA_ONLY', keywordSearch: 'kernel', virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', versionStart: '2.2', @@ -199,40 +212,52 @@ describe('searchQueryBuilder buildSearchRequest()', () => { pageNumber: '2' }); - expect(queryBuilder.buildCpeCandidateSearchBody(output.searchOptions, output.queryStringObjects)).toStrictEqual( - { - query: { - bool: { - must: [ - { - simple_query_string: { - query: 'kernel*', - default_operator: 'AND', - fields: ['containers.cna.descriptions.value'] - } + expect( + queryBuilder.buildPostFilterCandidateSearchBody( + output.searchOptions, + output.queryStringObjects, + output.postFilters + ) + ).toStrictEqual({ + query: { + bool: { + must: [ + { + simple_query_string: { + query: 'kernel*', + default_operator: 'AND', + fields: ['containers.cna.descriptions.value'] } - ], - filter: output.searchOptions.filters + } + ], + filter: output.searchOptions.filters + } + }, + track_total_hits: false, + size: 10000, + from: 0, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' } - }, - track_total_hits: false, - size: 10000, - from: 0, - _source: [ - 'cveMetadata.cveId', - 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', - 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' - ] - } - ); + } + ], + _source: [ + 'cveMetadata.cveId', + 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', + 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' + ] + }); }); test('buildOpenSearchBody builds the complete standard search request', () => { const output = queryBuilder.buildSearchRequest({ + scope: 'CNA_ONLY', keywordSearch: 'remote code', cweId: 'CWE-79', resultsPerPage: 10, @@ -262,7 +287,14 @@ describe('searchQueryBuilder buildSearchRequest()', () => { }, track_total_hits: true, size: 10, - from: 20 + from: 20, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }); }); @@ -426,7 +458,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() builds order-independent wildcard filters for cvssV2 metrics.', () => { + test('getFilterFields() builds token-boundary filters for cvssV2 metrics.', () => { const output = queryBuilder.getFilterFields({ cvssV2Metrics: 'AV:N/AC:L/Au:N' }); @@ -435,27 +467,9 @@ describe('searchQueryBuilder getFilterFields() function', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AC:L*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*Au:N*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AC:L'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AU:N') ] } } @@ -631,7 +645,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() builds order-independent wildcard filters for combined cvssV3 metrics.', () => { + test('getFilterFields() builds token-boundary filters for combined cvssV3 metrics.', () => { const output = queryBuilder.getFilterFields({ cvssV3Metrics: 'CVSS:3.1/AV:N/AC:L' }); @@ -643,40 +657,16 @@ describe('searchQueryBuilder getFilterFields() function', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AC:L*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AC:L') ] } }, { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*AC:L*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'AC:L') ] } } @@ -687,7 +677,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() escapes wildcard metacharacters in CVSS metric filters.', () => { + test('getFilterFields() escapes regexp metacharacters in CVSS metric filters.', () => { const output = queryBuilder.getFilterFields({ cvssV4Metrics: 'CVSS#4.0/AV:N/AC:*?/UI:P' }); @@ -696,27 +686,9 @@ describe('searchQueryBuilder getFilterFields() function', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV4_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV4_0.vectorString.keyword': { - value: '*AC:\\*\\?*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV4_0.vectorString.keyword': { - value: '*UI:P*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AC:\\*\\?'), + cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'UI:P') ] } } @@ -732,3 +704,158 @@ describe('searchQueryBuilder getFilterFields() function', () => { expect(output).toStrictEqual([]); }); }); + +describe('scope-aware search filters', () => { + test('FULL searches description fields in CNA and ADP containers', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + keywordSearch: 'remote code' + }); + + expect(output.queryStringObjects[0].simple_query_string.fields).toStrictEqual([ + 'containers.cna.descriptions.value', + 'containers.adp.descriptions.value' + ]); + }); + + test('ADP_ONLY searches only ADP CVSS fields', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + cvssV4Severity: 'CRITICAL' + }); + + expect(output.searchOptions.filters).toStrictEqual([ + { + term: { + 'containers.adp.metrics.cvssV4_0.baseSeverity.keyword': 'CRITICAL' + } + } + ]); + }); + + test('CVSS vector filters retain an OpenSearch prefilter and add same-vector semantic filtering', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L', + resultsPerPage: 10 + }); + const body = queryBuilder.buildPostFilterCandidateSearchBody( + output.searchOptions, + output.queryStringObjects, + output.postFilters + ); + + expect(output.searchOptions).toMatchObject({ resultsPerPage: 10000, from: 0 }); + expect(output.responseOptions).toStrictEqual({ resultsPerPage: 10, from: 0 }); + expect(output.postFilters.cvss).toStrictEqual({ + scope: 'ADP_ONLY', + vectors: { + cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L' + } + }); + expect(body._source).toStrictEqual(['cveMetadata.cveId', 'containers.adp.metrics.cvssV3_1.vectorString']); + expect(JSON.stringify(body.query.bool.filter)).toContain('containers.adp.metrics.cvssV3_1.vectorString'); + }); + + test('SSVC filters use bounded semantic candidates with selected metric source fields', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + ssvcExploitation: 'ACTIVE', + ssvcRole: 'CISA Coordinator', + resultsPerPage: 10, + pageNumber: 2 + }); + const body = queryBuilder.buildPostFilterCandidateSearchBody( + output.searchOptions, + output.queryStringObjects, + output.postFilters + ); + + expect(output.searchOptions).toMatchObject({ resultsPerPage: 10000, from: 0 }); + expect(output.responseOptions).toStrictEqual({ resultsPerPage: 10, from: 10 }); + expect(output.postFilters.ssvc).toMatchObject({ + scope: 'ADP_ONLY', + exploitation: 'ACTIVE', + role: 'CISA Coordinator' + }); + expect(body._source).toStrictEqual(['cveMetadata.cveId', 'containers.adp.metrics.other']); + expect(JSON.stringify(body.query.bool.filter)).toContain( + 'containers.adp.metrics.other.content.options.Exploitation' + ); + expect(JSON.stringify(body.query.bool.filter)).not.toContain('containers.cna.metrics.other'); + }); + + test('positive KEV dates use semantic candidates and negative KEV is a direct exclusion', () => { + const positive = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + knownExploited: true, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z' + }); + const negative = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + knownExploited: false + }); + + expect(positive.postFilters.kev).toMatchObject({ + scope: 'ADP_ONLY', + dateAddedStartDate: '2024-01-01T00:00:00.000Z' + }); + expect(positive.searchOptions.resultsPerPage).toBe(10000); + expect(negative.postFilters).toBeUndefined(); + expect(negative.searchOptions.resultsPerPage).toBe(20); + expect(JSON.stringify(negative.searchOptions.filters)).toContain('containers.adp.metrics.other.type.keyword'); + }); + + test('knownExploited without date boundaries remains a direct search', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + knownExploited: true + }); + + expect(output.postFilters).toBeUndefined(); + expect(output.searchOptions.resultsPerPage).toBe(20); + expect(JSON.stringify(output.searchOptions.filters)).toContain('metrics.other.type.keyword'); + }); + + test('referenceURL escapes wildcard syntax and uses a server-controlled substring query', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + referenceURL: 'EXAMPLE.test/a*?b' + }); + const clauses = output.searchOptions.filters[0].bool.should; + + expect(clauses).toStrictEqual([ + { + wildcard: { + 'containers.cna.references.url.keyword': { + value: '*EXAMPLE.test/a\\*\\?b*', + case_insensitive: true + } + } + }, + { + wildcard: { + 'containers.adp.references.url.keyword': { + value: '*EXAMPLE.test/a\\*\\?b*', + case_insensitive: true + } + } + } + ]); + }); + + test.each([ + [true, 'should'], + [false, 'must_not'] + ])('disputed=%s builds the expected selected-container tag filter', (value, expectedClause) => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + disputed: value + }); + const filter = output.searchOptions.filters[0]; + + expect(JSON.stringify(filter)).toContain('containers.cna.tags.keyword'); + expect(JSON.stringify(filter)).toContain('containers.adp.tags.keyword'); + expect(JSON.stringify(filter)).toContain(expectedClause); + }); +}); diff --git a/utils/searchRateLimiter.js b/utils/searchRateLimiter.js deleted file mode 100644 index 00026e1..0000000 --- a/utils/searchRateLimiter.js +++ /dev/null @@ -1,100 +0,0 @@ -const { AppConfig } = require('cve-core'); -const { rateLimit } = require('express-rate-limit'); -const errors = require('./errors'); -const searchPerformance = require('./searchPerformance'); - -const DEFAULT_SEARCH_RATE_LIMIT_ENABLED = false; -const DEFAULT_SEARCH_RATE_LIMIT_WINDOW_MS = 60000; -const GLOBAL_SEARCH_RATE_LIMIT_KEY = 'search-api-process'; - -const error = new errors.errors(); - -function parseBoolean(value) { - if (typeof value === 'boolean') return value; - - 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 undefined; -} - -function readBooleanConfig(path, defaultValue) { - if (!AppConfig.has(path)) return defaultValue; - - const value = parseBoolean(AppConfig.get(path)); - if (value === undefined) throw new Error(`${path} must be a boolean.`); - - return value; -} - -function readPositiveIntegerConfig(path, defaultValue) { - if (!AppConfig.has(path)) return defaultValue; - - const value = Number(AppConfig.get(path)); - if (!Number.isInteger(value) || value < 1) return undefined; - - return value; -} - -function getSearchRateLimitConfig() { - const enabled = readBooleanConfig('rateLimiting.search.enabled', DEFAULT_SEARCH_RATE_LIMIT_ENABLED); - if (!enabled) { - return Object.freeze({ - enabled, - windowMs: DEFAULT_SEARCH_RATE_LIMIT_WINDOW_MS - }); - } - - const windowMs = readPositiveIntegerConfig('rateLimiting.search.windowMs', DEFAULT_SEARCH_RATE_LIMIT_WINDOW_MS); - const maxRequests = readPositiveIntegerConfig('rateLimiting.search.maxRequests'); - - if (!windowMs) throw new Error('rateLimiting.search.windowMs must be a positive integer when enabled.'); - if (!maxRequests) throw new Error('rateLimiting.search.maxRequests must be a positive integer when enabled.'); - - return Object.freeze({ - enabled, - windowMs, - maxRequests - }); -} - -function disabledRateLimiter(_req, _res, next) { - next(); -} - -function handleRateLimit(req, res, _next, options) { - searchPerformance.logSearchPerformance( - { - requestId: req.requestId, - outcome: 'rate_limited', - statusCode: options.statusCode - }, - undefined, - undefined, - undefined - ); - - return res.status(options.statusCode).json(error.searchRateLimitedError()); -} - -function createSearchRateLimiter(config = getSearchRateLimitConfig()) { - if (!config.enabled) return disabledRateLimiter; - - return rateLimit({ - windowMs: config.windowMs, - limit: config.maxRequests, - standardHeaders: 'draft-8', - legacyHeaders: false, - identifier: GLOBAL_SEARCH_RATE_LIMIT_KEY, - keyGenerator: () => GLOBAL_SEARCH_RATE_LIMIT_KEY, - handler: handleRateLimit, - passOnStoreError: false - }); -} - -module.exports = { - createSearchRateLimiter, - getSearchRateLimitConfig, - handleRateLimit -}; diff --git a/utils/searchRateLimiter.test.js b/utils/searchRateLimiter.test.js deleted file mode 100644 index da00ea6..0000000 --- a/utils/searchRateLimiter.test.js +++ /dev/null @@ -1,127 +0,0 @@ -const express = require('express'); -const { request } = require('../test/http'); - -const mockAppConfigHas = jest.fn(); -const mockAppConfigGet = jest.fn(); -const mockLogSearchPerformance = jest.fn(); - -jest.mock('cve-core', () => ({ - AppConfig: { - get: mockAppConfigGet, - has: mockAppConfigHas - } -})); - -jest.mock('./searchPerformance', () => ({ - logSearchPerformance: mockLogSearchPerformance -})); - -const searchRateLimiter = require('./searchRateLimiter'); - -describe('searchRateLimiter', () => { - beforeEach(() => { - mockAppConfigHas.mockReset(); - mockAppConfigGet.mockReset(); - mockLogSearchPerformance.mockReset(); - mockAppConfigHas.mockReturnValue(false); - }); - - test('defaults to a disabled limiter that passes requests through', () => { - const next = jest.fn(); - const limiter = searchRateLimiter.createSearchRateLimiter(); - - limiter({}, {}, next); - - expect(next).toHaveBeenCalledTimes(1); - }); - - test('resolves enabled rate limit configuration from AppConfig', () => { - const values = { - 'rateLimiting.search.enabled': 'TRUE', - 'rateLimiting.search.windowMs': '30000', - 'rateLimiting.search.maxRequests': '25' - }; - mockAppConfigHas.mockImplementation((path) => Object.hasOwn(values, path)); - mockAppConfigGet.mockImplementation((path) => values[path]); - - expect(searchRateLimiter.getSearchRateLimitConfig()).toStrictEqual({ - enabled: true, - windowMs: 30000, - maxRequests: 25 - }); - }); - - test.each([ - [ - { - 'rateLimiting.search.enabled': 'invalid' - }, - 'rateLimiting.search.enabled must be a boolean.' - ], - [ - { - 'rateLimiting.search.enabled': 'TRUE', - 'rateLimiting.search.windowMs': '0', - 'rateLimiting.search.maxRequests': '25' - }, - 'rateLimiting.search.windowMs must be a positive integer when enabled.' - ], - [ - { - 'rateLimiting.search.enabled': 'TRUE', - 'rateLimiting.search.windowMs': '60000', - 'rateLimiting.search.maxRequests': '0' - }, - 'rateLimiting.search.maxRequests must be a positive integer when enabled.' - ] - ])('rejects invalid explicit configuration', (values, expectedMessage) => { - mockAppConfigHas.mockImplementation((path) => Object.hasOwn(values, path)); - mockAppConfigGet.mockImplementation((path) => values[path]); - - expect(() => searchRateLimiter.getSearchRateLimitConfig()).toThrow(expectedMessage); - }); - - test('enforces a process-wide limit with standard headers and sanitized telemetry', async () => { - const app = express(); - const limiter = searchRateLimiter.createSearchRateLimiter({ - enabled: true, - windowMs: 60000, - maxRequests: 1 - }); - - app.use((req, res, next) => { - req.requestId = 'request-123'; - next(); - }); - app.post('/search', limiter, (req, res) => res.status(200).json({ ok: true })); - - const accepted = await request(app, { - method: 'POST', - path: '/search' - }); - const rejected = await request(app, { - method: 'POST', - path: '/search' - }); - - expect(accepted.status).toBe(200); - expect(accepted.headers.ratelimit).toBeDefined(); - expect(accepted.headers['ratelimit-policy']).toBeDefined(); - expect(rejected.status).toBe(429); - expect(rejected.headers['retry-after']).toBeDefined(); - expect(rejected.body).toStrictEqual({ - error: 'SEARCH_RATE_LIMITED', - message: 'Too many search requests. Please retry later.' - }); - expect(mockLogSearchPerformance).toHaveBeenCalledWith( - { - requestId: 'request-123', - outcome: 'rate_limited', - statusCode: 429 - }, - undefined, - undefined, - undefined - ); - }); -}); diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js new file mode 100644 index 0000000..8442075 --- /dev/null +++ b/utils/searchReadiness.js @@ -0,0 +1,182 @@ +const searchScope = require('./searchScope'); + +const NUMERIC_FIELD_TYPES = Object.freeze([ + 'byte', + 'short', + 'integer', + 'long', + 'half_float', + 'float', + 'double', + 'scaled_float' +]); + +function unique(values) { + return [...new Set(values)]; +} + +function keywordRequirements(params) { + return unique(params.flatMap((param) => searchScope.resolveFields(param))).map((field) => ({ + field: `${field}.keyword`, + types: ['keyword'] + })); +} + +function containerKeywordRequirements(relativePaths) { + return unique(relativePaths.flatMap((path) => searchScope.resolveContainerPaths(path))).map((field) => ({ + field: `${field}.keyword`, + types: ['keyword'] + })); +} + +function containerDateRequirements(relativePaths) { + return unique(relativePaths.flatMap((path) => searchScope.resolveContainerPaths(path))).map((field) => ({ + field, + types: ['date'] + })); +} + +function capabilityGroup(name, requirements) { + return Object.freeze({ + name, + requirements: Object.freeze(requirements) + }); +} + +const FIELD_CAPABILITY_GROUPS = Object.freeze([ + capabilityGroup('cveId', [ + { + field: 'cveMetadata.cveId.keyword', + types: ['keyword'], + aggregatable: true + } + ]), + capabilityGroup('datePublished', [{ field: 'cveMetadata.datePublished', types: ['date'] }]), + capabilityGroup('dateUpdated', [{ field: 'cveMetadata.dateUpdated', types: ['date'] }]), + capabilityGroup('noRejected', keywordRequirements(['noRejected'])), + capabilityGroup('cvssV3_0Severity', keywordRequirements(['cvssV3_0Severity'])), + capabilityGroup('cvssV3_1Severity', keywordRequirements(['cvssV3_1Severity'])), + capabilityGroup('cvssV4Severity', keywordRequirements(['cvssV4Severity'])), + capabilityGroup('cvssV2Metrics', keywordRequirements(['cvssV2Metrics'])), + capabilityGroup('cvssV3_0Metrics', keywordRequirements(['cvssV3_0Metrics'])), + capabilityGroup('cvssV3_1Metrics', keywordRequirements(['cvssV3_1Metrics'])), + capabilityGroup('cvssV4Metrics', keywordRequirements(['cvssV4Metrics'])), + capabilityGroup('cpeApplicability', keywordRequirements(['cpeName'])), + capabilityGroup('referenceURL', keywordRequirements(['referenceURL'])), + capabilityGroup('disputed', keywordRequirements(['disputed'])), + capabilityGroup( + 'keywordSearch', + unique(searchScope.resolveFields('keywordSearch')).map((field) => ({ + field, + types: ['text', 'keyword'] + })) + ), + capabilityGroup( + 'cweId', + unique(searchScope.resolveFields('cweId')).map((field) => ({ + field, + types: ['text', 'keyword'] + })) + ), + capabilityGroup( + 'cvssV2Severity', + unique(searchScope.resolveFields('cvssV2Severity')).map((field) => ({ + field, + types: NUMERIC_FIELD_TYPES + })) + ), + capabilityGroup( + 'isVulnerable', + unique(searchScope.resolveFields('isVulnerable')).map((field) => ({ + field, + types: ['boolean'] + })) + ), + capabilityGroup('ssvcType', containerKeywordRequirements(['metrics.other.type'])), + capabilityGroup('ssvcExploitation', containerKeywordRequirements(['metrics.other.content.options.Exploitation'])), + capabilityGroup('ssvcAutomatable', containerKeywordRequirements(['metrics.other.content.options.Automatable'])), + capabilityGroup( + 'ssvcTechnicalImpact', + containerKeywordRequirements(['metrics.other.content.options.Technical Impact']) + ), + capabilityGroup('ssvcRole', containerKeywordRequirements(['metrics.other.content.role'])), + capabilityGroup('ssvcTimestamp', containerDateRequirements(['metrics.other.content.timestamp'])), + capabilityGroup('kevDateAdded', containerDateRequirements(['metrics.other.content.dateAdded'])) +]); + +const FIELD_CAPABILITY_REQUIREMENTS = Object.freeze( + FIELD_CAPABILITY_GROUPS.flatMap(({ requirements }) => requirements) +); + +class SearchMappingContractError extends Error { + constructor(fields) { + super('Configured CVE index does not expose the field mappings required by the Search API.'); + this.name = 'SearchMappingContractError'; + this.code = 'SEARCH_MAPPING_CONTRACT'; + this.fields = fields; + } +} + +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) + ); + }); +} + +function hasMappedCapability(capabilities) { + return ( + capabilities && + typeof capabilities === 'object' && + !Array.isArray(capabilities) && + Object.keys(capabilities).some((type) => type !== 'unmapped') + ); +} + +function validateFieldCapabilities(body) { + const capabilities = body?.fields; + if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) { + throw new SearchMappingContractError(FIELD_CAPABILITY_REQUIREMENTS.map(({ field }) => field)); + } + + const incompatibleFields = []; + + for (const group of FIELD_CAPABILITY_GROUPS) { + let hasCompatibleAlternative = false; + + for (const requirement of group.requirements) { + const fieldCapabilities = capabilities[requirement.field]; + if (!hasMappedCapability(fieldCapabilities)) continue; + + if (supportsRequirement(fieldCapabilities, requirement)) { + hasCompatibleAlternative = true; + } else { + incompatibleFields.push(requirement.field); + } + } + + if (!hasCompatibleAlternative) { + incompatibleFields.push(...group.requirements.map(({ field }) => field)); + } + } + + if (incompatibleFields.length) throw new SearchMappingContractError(unique(incompatibleFields)); + + return true; +} + +function getRequiredFieldNames() { + return FIELD_CAPABILITY_REQUIREMENTS.map(({ field }) => field); +} + +module.exports = { + FIELD_CAPABILITY_GROUPS, + FIELD_CAPABILITY_REQUIREMENTS, + SearchMappingContractError, + getRequiredFieldNames, + validateFieldCapabilities +}; diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js new file mode 100644 index 0000000..8b02e14 --- /dev/null +++ b/utils/searchReadiness.test.js @@ -0,0 +1,90 @@ +const searchReadiness = require('./searchReadiness'); + +function validCapabilities() { + return { + fields: Object.fromEntries( + searchReadiness.FIELD_CAPABILITY_REQUIREMENTS.map((requirement) => [ + requirement.field, + { + [requirement.types[0]]: { + searchable: true, + aggregatable: requirement.aggregatable === true + } + } + ]) + ) + }; +} + +describe('search readiness mapping contract', () => { + test('accepts searchable fields with the required types', () => { + expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); + }); + + test('rejects a missing capability group', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS[0]; + group.requirements.forEach(({ field }) => delete body.fields[field]); + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test('accepts an unmapped optional field when another field provides the capability', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS.find(({ requirements }) => requirements.length > 1); + body.fields[group.requirements[0].field] = { + unmapped: { + searchable: false, + aggregatable: false + } + }; + + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + }); + + test('accepts the optional legacy and container variants absent from the dev index', () => { + const body = validCapabilities(); + [ + 'containers.cna.x_legacyV4Record.CVE_data_meta.STATE.keyword', + 'containers.adp.metrics.cvssV3_0.baseSeverity.keyword', + 'containers.adp.metrics.cvssV2_0.vectorString.keyword', + 'containers.adp.metrics.cvssV3_0.vectorString.keyword', + 'containers.adp.metrics.cvssV2_0.baseScore', + 'containers.cna.metrics.other.content.dateAdded' + ].forEach((field) => delete body.fields[field]); + + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + }); + + test('rejects an incompatible mapped alternative even when the group has a compatible field', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS.find(({ requirements }) => requirements.length > 1); + body.fields[group.requirements[0].field] = { + text: { + searchable: false, + aggregatable: false + } + }; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test('rejects a non-searchable or incompatible field type', () => { + const body = validCapabilities(); + const requirement = searchReadiness.FIELD_CAPABILITY_REQUIREMENTS.find(({ aggregatable }) => aggregatable); + body.fields[requirement.field] = { + text: { + searchable: true, + aggregatable: false + } + }; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); +}); diff --git a/utils/searchResponseValidator.js b/utils/searchResponseValidator.js new file mode 100644 index 0000000..bc5d17e --- /dev/null +++ b/utils/searchResponseValidator.js @@ -0,0 +1,51 @@ +class InvalidOpenSearchResponseError extends Error { + constructor(message) { + super(message); + this.name = 'InvalidOpenSearchResponseError'; + this.code = 'INVALID_OPEN_SEARCH_RESPONSE'; + } +} + +function fail(message) { + throw new InvalidOpenSearchResponseError(message); +} + +function validateShardMetadata(shards) { + if (!shards || typeof shards !== 'object' || !Number.isInteger(shards.failed)) { + fail('OpenSearch response is missing valid shard metadata.'); + } + + if (shards.failed !== 0) { + fail('OpenSearch response contains failed shards.'); + } +} + +function validateHits(hits) { + if (!hits || typeof hits !== 'object' || !Array.isArray(hits.hits)) { + fail('OpenSearch response is missing a valid hits collection.'); + } + + if (hits.hits.some((hit) => !hit || typeof hit !== 'object' || !hit._source || typeof hit._source !== 'object')) { + fail('OpenSearch response contains an incomplete search hit.'); + } +} + +function validateOpenSearchResponse(body) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + fail('OpenSearch response body is invalid.'); + } + + if (body.timed_out !== false) { + fail('OpenSearch response timed out or omitted timeout status.'); + } + + validateShardMetadata(body._shards); + validateHits(body.hits); + + return body; +} + +module.exports = { + InvalidOpenSearchResponseError, + validateOpenSearchResponse +}; diff --git a/utils/searchResponseValidator.test.js b/utils/searchResponseValidator.test.js new file mode 100644 index 0000000..5562b9d --- /dev/null +++ b/utils/searchResponseValidator.test.js @@ -0,0 +1,39 @@ +const searchResponseValidator = require('./searchResponseValidator'); + +function validResponse(overrides = {}) { + return { + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0 + }, + hits: { + total: { value: 1 }, + hits: [{ _id: 'CVE-2024-0001', _source: { cveMetadata: { cveId: 'CVE-2024-0001' } } }] + }, + ...overrides + }; +} + +describe('searchResponseValidator', () => { + test('returns a complete OpenSearch response unchanged', () => { + const response = validResponse(); + + expect(searchResponseValidator.validateOpenSearchResponse(response)).toBe(response); + }); + + test.each([ + ['a timed-out response', { timed_out: true }], + ['missing timeout status', { timed_out: undefined }], + ['missing shard metadata', { _shards: undefined }], + ['failed shards', { _shards: { failed: 1 } }], + ['a malformed hits collection', { hits: { hits: null } }], + ['an incomplete hit', { hits: { hits: [{ _id: 'CVE-2024-0001' }] } }] + ])('rejects %s', (_description, override) => { + expect(() => searchResponseValidator.validateOpenSearchResponse(validResponse(override))).toThrow( + searchResponseValidator.InvalidOpenSearchResponseError + ); + }); +}); diff --git a/utils/searchScope.js b/utils/searchScope.js new file mode 100644 index 0000000..c25d1d4 --- /dev/null +++ b/utils/searchScope.js @@ -0,0 +1,170 @@ +const SEARCH_SCOPES = Object.freeze({ + FULL: 'FULL', + CNA_ONLY: 'CNA_ONLY', + ADP_ONLY: 'ADP_ONLY' +}); + +const SEARCH_SCOPE_VALUES = Object.freeze(Object.values(SEARCH_SCOPES)); + +const FIELD_PATHS = Object.freeze({ + keywordSearch: { + cna: ['containers.cna.descriptions.value'], + adp: ['containers.adp.descriptions.value'] + }, + cveIds: { + base: ['cveMetadata.cveId'] + }, + noRejected: { + base: ['cveMetadata.state'], + cna: ['containers.cna.x_legacyV4Record.CVE_data_meta.STATE'] + }, + cweId: { + cna: ['containers.cna.problemTypes.descriptions.cweId'], + adp: ['containers.adp.problemTypes.descriptions.cweId'] + }, + cvssV2Severity: { + cna: ['containers.cna.metrics.cvssV2_0.baseScore'], + adp: ['containers.adp.metrics.cvssV2_0.baseScore'] + }, + cvssV2Metrics: { + cna: ['containers.cna.metrics.cvssV2_0.vectorString'], + adp: ['containers.adp.metrics.cvssV2_0.vectorString'] + }, + cvssV3_0Severity: { + cna: ['containers.cna.metrics.cvssV3_0.baseSeverity'], + adp: ['containers.adp.metrics.cvssV3_0.baseSeverity'] + }, + cvssV3_0Metrics: { + cna: ['containers.cna.metrics.cvssV3_0.vectorString'], + adp: ['containers.adp.metrics.cvssV3_0.vectorString'] + }, + cvssV3_1Severity: { + cna: ['containers.cna.metrics.cvssV3_1.baseSeverity'], + adp: ['containers.adp.metrics.cvssV3_1.baseSeverity'] + }, + cvssV3_1Metrics: { + cna: ['containers.cna.metrics.cvssV3_1.vectorString'], + adp: ['containers.adp.metrics.cvssV3_1.vectorString'] + }, + cvssV3Severity: { + cna: ['containers.cna.metrics.cvssV3_0.baseSeverity', 'containers.cna.metrics.cvssV3_1.baseSeverity'], + adp: ['containers.adp.metrics.cvssV3_0.baseSeverity', 'containers.adp.metrics.cvssV3_1.baseSeverity'] + }, + cvssV3Metrics: { + cna: ['containers.cna.metrics.cvssV3_0.vectorString', 'containers.cna.metrics.cvssV3_1.vectorString'], + adp: ['containers.adp.metrics.cvssV3_0.vectorString', 'containers.adp.metrics.cvssV3_1.vectorString'] + }, + cvssV4Severity: { + cna: ['containers.cna.metrics.cvssV4_0.baseSeverity'], + adp: ['containers.adp.metrics.cvssV4_0.baseSeverity'] + }, + cvssV4Metrics: { + cna: ['containers.cna.metrics.cvssV4_0.vectorString'], + adp: ['containers.adp.metrics.cvssV4_0.vectorString'] + }, + cpeName: { + cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.criteria'] + }, + isVulnerable: { + cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable'] + }, + virtualMatchString: { + cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.criteria'] + }, + referenceURL: { + cna: ['containers.cna.references.url'], + adp: ['containers.adp.references.url'] + }, + disputed: { + cna: ['containers.cna.tags'], + adp: ['containers.adp.tags'] + } +}); + +const CPE_SCOPE_PARAMS = Object.freeze([ + 'cpeName', + 'isVulnerable', + 'virtualMatchString', + 'versionStart', + 'versionStartType', + 'versionEnd', + 'versionEndType' +]); + +function normalizeScope(value) { + if (value === undefined) return SEARCH_SCOPES.FULL; + return String(value).trim().toUpperCase(); +} + +function isValidScope(value) { + return SEARCH_SCOPE_VALUES.includes(normalizeScope(value)); +} + +function resolveFields(param, value = SEARCH_SCOPES.FULL) { + const scope = normalizeScope(value); + const paths = FIELD_PATHS[param]; + if (!paths) return []; + + const fields = [...(paths.base ?? [])]; + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.CNA_ONLY) fields.push(...(paths.cna ?? [])); + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.ADP_ONLY) fields.push(...(paths.adp ?? [])); + + return [...new Set(fields)]; +} + +function getFullFields() { + return Object.fromEntries( + Object.keys(FIELD_PATHS).map((param) => { + const fields = resolveFields(param); + return [param, fields.length === 1 ? fields[0] : fields]; + }) + ); +} + +function getIncompatibleParams(params) { + if (normalizeScope(params?.scope) !== SEARCH_SCOPES.ADP_ONLY) return []; + return CPE_SCOPE_PARAMS.filter((param) => params?.[param] !== undefined); +} + +function getSelectedContainers(source, value = SEARCH_SCOPES.FULL) { + const scope = normalizeScope(value); + const containers = []; + + if ((scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.CNA_ONLY) && source?.containers?.cna) { + containers.push(source.containers.cna); + } + + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.ADP_ONLY) { + const adp = source?.containers?.adp; + if (Array.isArray(adp)) containers.push(...adp); + else if (adp) containers.push(adp); + } + + return containers; +} + +function resolveContainerPaths(relativePath, value = SEARCH_SCOPES.FULL) { + const scope = normalizeScope(value); + const paths = []; + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.CNA_ONLY) { + paths.push(`containers.cna.${relativePath}`); + } + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.ADP_ONLY) { + paths.push(`containers.adp.${relativePath}`); + } + return paths; +} + +module.exports = { + CPE_SCOPE_PARAMS, + FIELD_PATHS, + SEARCH_SCOPES, + SEARCH_SCOPE_VALUES, + getFullFields, + getIncompatibleParams, + getSelectedContainers, + isValidScope, + normalizeScope, + resolveContainerPaths, + resolveFields +}; diff --git a/utils/searchScope.test.js b/utils/searchScope.test.js new file mode 100644 index 0000000..a42fc44 --- /dev/null +++ b/utils/searchScope.test.js @@ -0,0 +1,43 @@ +const searchScope = require('./searchScope'); + +describe('searchScope', () => { + test.each([ + [undefined, 'FULL'], + ['full', 'FULL'], + ['cna_only', 'CNA_ONLY'], + ['AdP_OnLy', 'ADP_ONLY'] + ])('normalizes %p to %s', (input, expected) => { + expect(searchScope.normalizeScope(input)).toBe(expected); + expect(searchScope.isValidScope(input)).toBe(true); + }); + + test('resolves base fields in every scope and selected container fields only', () => { + expect(searchScope.resolveFields('cveIds', 'ADP_ONLY')).toStrictEqual(['cveMetadata.cveId']); + expect(searchScope.resolveFields('keywordSearch', 'CNA_ONLY')).toStrictEqual([ + 'containers.cna.descriptions.value' + ]); + expect(searchScope.resolveFields('keywordSearch', 'ADP_ONLY')).toStrictEqual([ + 'containers.adp.descriptions.value' + ]); + expect(searchScope.resolveFields('keywordSearch', 'FULL')).toStrictEqual([ + 'containers.cna.descriptions.value', + 'containers.adp.descriptions.value' + ]); + expect(searchScope.resolveFields('referenceURL', 'FULL')).toStrictEqual([ + 'containers.cna.references.url', + 'containers.adp.references.url' + ]); + expect(searchScope.resolveFields('disputed', 'ADP_ONLY')).toStrictEqual(['containers.adp.tags']); + }); + + test('reports CPE applicability parameters as incompatible with ADP_ONLY', () => { + expect( + searchScope.getIncompatibleParams({ + scope: 'adp_only', + cpeName: 'cpe:2.3:a:vendor:product:1.0', + isVulnerable: false, + versionStart: '1.0' + }) + ).toStrictEqual(['cpeName', 'isVulnerable', 'versionStart']); + }); +}); diff --git a/utils/ssvc.js b/utils/ssvc.js new file mode 100644 index 0000000..3b372f3 --- /dev/null +++ b/utils/ssvc.js @@ -0,0 +1,89 @@ +const searchScope = require('./searchScope'); +const searchContainerMetrics = require('./searchContainerMetrics'); + +const EXPLOITATION_VALUES = Object.freeze(['NONE', 'POC', 'ACTIVE']); +const AUTOMATABLE_VALUES = Object.freeze(['YES', 'NO']); +const TECHNICAL_IMPACT_VALUES = Object.freeze(['PARTIAL', 'TOTAL']); +const PARAMS = Object.freeze([ + 'ssvcExploitation', + 'ssvcAutomatable', + 'ssvcTechnicalImpact', + 'ssvcRole', + 'ssvcTimestampStartDate', + 'ssvcTimestampEndDate' +]); + +function getCaseInsensitiveProperty(object, propertyName) { + if (!object || typeof object !== 'object') return undefined; + const key = Object.keys(object).find((candidate) => candidate.toLowerCase() === propertyName.toLowerCase()); + return key === undefined ? undefined : object[key]; +} + +function getOption(content, optionName) { + if (!Array.isArray(content?.options)) return undefined; + + for (const option of content.options) { + const value = getCaseInsensitiveProperty(option, optionName); + if (value !== undefined) return value; + } + + return undefined; +} + +function equalIgnoreCase(actual, expected) { + return String(actual ?? '').toLowerCase() === String(expected ?? '').toLowerCase(); +} + +function timestampMatches(value, startDate, endDate) { + if (!startDate && !endDate) return true; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return false; + if (startDate && timestamp < Date.parse(startDate)) return false; + if (endDate && timestamp > Date.parse(endDate)) return false; + return true; +} + +function entryMatches(other, filter) { + if (!equalIgnoreCase(other?.type, 'ssvc')) return false; + const content = other?.content; + if (!content || typeof content !== 'object') return false; + + if (filter.exploitation && !equalIgnoreCase(getOption(content, 'Exploitation'), filter.exploitation)) return false; + if (filter.automatable && !equalIgnoreCase(getOption(content, 'Automatable'), filter.automatable)) return false; + if (filter.technicalImpact && !equalIgnoreCase(getOption(content, 'Technical Impact'), filter.technicalImpact)) { + return false; + } + if (filter.role && !equalIgnoreCase(content.role, filter.role)) return false; + + return timestampMatches(content.timestamp, filter.timestampStartDate, filter.timestampEndDate); +} + +function buildPostFilter(params) { + if (!PARAMS.some((param) => params?.[param] !== undefined)) return null; + + return { + scope: searchScope.normalizeScope(params.scope), + exploitation: params.ssvcExploitation, + automatable: params.ssvcAutomatable, + technicalImpact: params.ssvcTechnicalImpact, + role: params.ssvcRole, + timestampStartDate: params.ssvcTimestampStartDate, + timestampEndDate: params.ssvcTimestampEndDate + }; +} + +function hitMatches(hit, filter) { + return searchContainerMetrics + .getOtherMetrics(hit?._source, filter.scope) + .some((other) => entryMatches(other, filter)); +} + +module.exports = { + AUTOMATABLE_VALUES, + EXPLOITATION_VALUES, + PARAMS, + TECHNICAL_IMPACT_VALUES, + buildPostFilter, + entryMatches, + hitMatches +}; diff --git a/utils/ssvc.test.js b/utils/ssvc.test.js new file mode 100644 index 0000000..98c4343 --- /dev/null +++ b/utils/ssvc.test.js @@ -0,0 +1,64 @@ +const ssvc = require('./ssvc'); + +function metric(other) { + return { other }; +} + +function hit({ cna = [], adp = [] } = {}) { + return { + _source: { + containers: { + cna: { metrics: cna }, + adp: adp.map((metrics) => ({ metrics })) + } + } + }; +} + +const matchingSsvc = metric({ + type: 'ssvc', + content: { + role: 'CISA Coordinator', + timestamp: '2024-06-15T12:00:00.000Z', + options: [{ Exploitation: 'active' }, { Automatable: 'yes' }, { 'Technical Impact': 'total' }] + } +}); + +describe('SSVC semantic filtering', () => { + test('matches case-insensitive decisions and role with inclusive timestamp bounds', () => { + expect( + ssvc.hitMatches(hit({ adp: [[matchingSsvc]] }), { + scope: 'ADP_ONLY', + exploitation: 'ACTIVE', + automatable: 'YES', + technicalImpact: 'TOTAL', + role: 'cisa coordinator', + timestampStartDate: '2024-06-15T12:00:00.000Z', + timestampEndDate: '2024-06-15T12:00:00.000Z' + }) + ).toBe(true); + }); + + test('does not combine conditions from separate SSVC entries', () => { + const candidate = hit({ + cna: [ + metric({ type: 'ssvc', content: { options: [{ Exploitation: 'active' }] } }), + metric({ type: 'ssvc', content: { options: [{ Automatable: 'yes' }] } }) + ] + }); + + expect( + ssvc.hitMatches(candidate, { + scope: 'CNA_ONLY', + exploitation: 'ACTIVE', + automatable: 'YES' + }) + ).toBe(false); + }); + + test('ignores containers outside the selected scope', () => { + expect(ssvc.hitMatches(hit({ adp: [[matchingSsvc]] }), { scope: 'CNA_ONLY', exploitation: 'ACTIVE' })).toBe( + false + ); + }); +});