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": "
Retrieves all matching CVE records after applying the JSON request body as filters
", + "description": "Retrieves all matching CVE records after applying the JSON request body as filters
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": "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": "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 = "Retrieves all matching CVE records after applying the JSON request body as filters
+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 = "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 -