diff --git a/.env.example b/.env.example index a6ac790..4afd551 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ OpenSearchDomainEndpoint= SearchOpenSearchRequestTimeoutMs=20000 SearchRequestDeadlineMs=30000 SearchReadinessTimeoutMs=5000 +SearchClientCacheMaxAgeSeconds=60 RequestLogSuccessSampleRate=0.1 RequestLogHealthChecks=false WebSearchEnabled=true diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 95eff65..551ee9b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 24.14.1 + node-version: 24.22.1 cache: npm - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index 8914334..577595d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,6 @@ # syntax=docker/dockerfile:1 -ARG NODE_VERSION=24.14.1 -ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot - -FROM node:${NODE_VERSION} AS deps +FROM node:24.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS deps ENV NODE_ENV=production @@ -16,7 +13,7 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev \ && npm cache clean --force -FROM ${DISTROLESS_NODE_IMAGE} AS runtime +FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:cb606325b7ec4af55462cb0b424a53b452a5b4a894ffe841fcf1048d216fb9f8 AS runtime ENV NODE_ENV=production diff --git a/Dockerfile.local b/Dockerfile.local index 65345fb..fe32040 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,9 +1,6 @@ # syntax=docker/dockerfile:1 -ARG NODE_VERSION=24.14.1 -ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot - -FROM node:${NODE_VERSION} AS deps +FROM node:24.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS deps ENV NODE_ENV=production @@ -21,7 +18,7 @@ RUN --mount=type=secret,id=npm_ca,required=false \ fi \ && npm cache clean --force -FROM ${DISTROLESS_NODE_IMAGE} AS runtime +FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:cb606325b7ec4af55462cb0b424a53b452a5b4a894ffe841fcf1048d216fb9f8 AS runtime ENV NODE_ENV=production diff --git a/README.md b/README.md index d8361ab..b15375a 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,13 @@ - [Virtual Match String Search](#virtual-match-string-search) - [Virtual Match String Version Ranges](#virtual-match-string-version-ranges) - [Raw Query Endpoint](#raw-query-endpoint) +- [Client Result Caching](#client-result-caching) - [Swagger API docs](#swagger-api-docs) - [Testing](#testing) - [Operational Logging](#operational-logging) - [Semantic Post-Filtering](#semantic-post-filtering) - [Docker Runtime Checks](#docker-runtime-checks) + - [Updating Pinned Base Images](#updating-pinned-base-images) ## The CVE Search API Project @@ -38,6 +40,7 @@ Copy `.env.example` to an untracked `.env` and replace or check the following va - `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` +- `SearchClientCacheMaxAgeSeconds` - client result-cache freshness hint in seconds, default `60`; `0` disables reuse with `no-store`. Invalid values also produce `no-store`. - `RequestLogSuccessSampleRate` - optional fraction of successful access logs to retain, default `0.1`; includes health checks when they are enabled for logging - `RequestLogHealthChecks` - optional flag to include successful health checks in access-log sampling, default `false` - `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` @@ -77,7 +80,7 @@ The compose file runs only the Search API with `NODE_ENV=production` and `NODE_C ## Deploying with npm -Use Node.js 24 for the repository's current runtime and development tooling. CI and the Docker dependency-build stage use Node.js 24.14.1; the final runtime image uses Distroless Node.js 24. +Use Node.js 24.22.1 for the repository's current runtime and development tooling. CI and the Docker dependency-build stage use this patch version; the final runtime uses a verified Distroless image containing Node.js 24.22.1. Both Dockerfiles pin their builder and runtime images by digest so separate builds use the same base-image contents for a given architecture. The API depends directly on the official OpenSearch JavaScript client. `utils/searchProvider.js` owns its configuration and reuses one client per process for both endpoints and readiness checks. Query construction remains separate: `utils/searchQueryBuilder.js` implements `/search`, and `utils/webSearchQueryBuilder.js` implements `/webSearch`. @@ -117,23 +120,27 @@ The Search API uses `PORT`, defaulting to `http://localhost:3000`. The tracked P An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior. -When the configured index is an alias spanning multiple indices, every reported mapped type of each required field must be compatible and searchable. CVE ID sorting also requires aggregation support. One compatible type cannot conceal a conflicting type in another index; readiness returns HTTP 503 rather than advertising that configuration as ready. +When the configured index is an alias spanning multiple indices, every reported mapped type of each required field must be compatible and searchable. CVE ID and publication-date sorting also require aggregation support. One compatible type cannot conceal a conflicting type in another index; readiness returns HTTP 503 rather than advertising that configuration as ready. ## API Input Validation JSON request bodies for both search endpoints are limited to 100 KiB (102,400 bytes, measured after decompression). Larger bodies return HTTP 413 `REQUEST_BODY_TOO_LARGE`. Parser errors return sanitized JSON rather than HTML or stack traces: malformed JSON returns HTTP 400 `MALFORMED_JSON`, other unreadable bodies return HTTP 400 `INVALID_REQUEST`, and unsupported encoding returns HTTP 415 (`SEARCH_CONTENT_TYPE` for `/search`, `REQUEST_ENCODING_UNSUPPORTED` for `/webSearch`). Unexpected server errors return a generic `INTERNAL_SERVER_ERROR` response without exception details, including outside production mode. -`POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. +`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. Errors use a single `{ "error": "CODE", "message": "Description" }` object; if multiple validation checks fail, the first validation error is returned. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. Successful `/search` responses use an API-owned response structure rather than exposing search-backend response objects. The top-level fields are `apiVersion`, `status`, `warnings`, `pagination`, and `records`, in that order. `records` contains complete CVE Records directly, without search-backend hit wrappers or infrastructure metadata. `pagination` contains `page`, `pageSize`, `resultsReturned`, `totalResults`, `totalPages`, and `totalIsExact`. Non-fatal conditions are reported in `warnings` before the potentially long `records` array. The API omits the top-level `opensearch_ops` ingestion metadata from each `/search` record, including ingestion timestamps and signatures, while preserving all other record fields and containers. This targeted removal does not validate or repair records against the CVE JSON schema and does not modify the indexed data. +`resultsPerPage` must be an integer from 1 through 500; integer strings are also accepted. Invalid types, fractional values, and nonpositive values return `RESULTS_PER_PAGE_INT`. Positive integers above 500 return `MAX_RESULTS_LIMIT`. + CVE Records do not exist for years before 1999. A syntactically valid pre-1999 value in `cveIds` is therefore a valid search that contributes no matches rather than a request error. The response includes a `CVE_IDS_NOT_RETURNED` warning listing those IDs. When pre-1999 and supported-year IDs are requested together, matching supported-year records are still returned. +Other syntactically valid IDs with no matches contribute zero results and do not produce a missing-ID warning. + 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. +Date filters accept `YYYY-MM-DD` or timestamps with seconds and an explicit timezone (`Z` or `+/-HH:MM`), with optional fractional seconds of 1 to 3 digits. For example, `2024-01-01`, `2024-01-01T00:00:00Z`, and `2024-01-01T01:00:00+01:00` are accepted. Values are normalized to UTC. A date-only start means `00:00:00.000Z`; a date-only end means `23:59:59.999Z` on that day. Explicit timestamps retain their exact instant, including end boundaries. Impossible dates, timestamps without a timezone, leap seconds, and precision finer than milliseconds return HTTP 400. 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 after normalization. All boundaries are inclusive. CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`, case-insensitively. They match the stored `baseSeverity` label in scope-selected CNA/ADP metrics; the API does not recalculate the label from scores. CVSS v2 instead maps `LOW`, `MEDIUM`, and `HIGH` to base-score ranges and does not accept `NONE` or `CRITICAL`. @@ -151,19 +158,21 @@ Scope applies to filter matching as follows: | `cweIds`, 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 | +| `resultsPerPage`, `pageNumber`, `sort` | Response controls; they do not select record data | Missing data in selected containers does not make an otherwise compatible request invalid. A positive filter cannot match a record that lacks the requested data. Exclusion filters behave differently: `knownExploited: false` and `disputed: false` can match records with no selected KEV metric or disputed tag, including records without a selected container. Other supplied filters must still match. The API does not remove unselected containers from a returned record. `keywordSearch` searches only description text selected by `scope`. By default, the API escapes caller-supplied query syntax, adds a trailing prefix wildcard to each whitespace-delimited term, and combines terms with `AND` using `simple_query_string` with wildcard analysis enabled. For example, `remote code` searches for `remote* AND code*`. Each prefix term is processed by the selected field's text analyzer. With the standard analyzer, `use-after-free` becomes required `use` and `after` tokens plus a `free*` prefix; those tokens need not be adjacent or in order. This also allows hyphenated CVE and CWE IDs to match when present in description text; use `cveIds` or `cweId` to search their dedicated fields. With `keywordExactMatch: true`, the API instead uses an analyzed phrase query in the selected description fields. Phrase matching follows the index's text analyzer; it is not a case-sensitive, character-for-character substring comparison. -SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, timestamps use the API's full UTC timestamp format with inclusive boundaries, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. +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, date boundaries follow the date-input rules above, 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 `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries follow the date-input rules above and require `knownExploited: true`. Stored KEV `dateAdded` values are interpreted as midnight UTC; a date-only end boundary includes that entire day. 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. +Use the 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. + +All boolean filters (`isVulnerable`, `keywordExactMatch`, `noRejected`, `knownExploited`, and `disputed`) accept JSON booleans or the lowercase strings `"true"` and `"false"`. Strings are normalized before filter evaluation; numeric values, null, arrays, and other strings are rejected. String inputs are bounded before query construction: @@ -275,7 +284,7 @@ curl -X POST "http://localhost:3000/search" \ Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. Stored intervals with a start after their end, or equal boundaries where either boundary is exclusive, are empty and do not match range searches. -CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. +CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use an API-defined extension of Maven-style qualifier precedence: `dev` < `alpha` < `beta` < `milestone` < `pre` < `preview` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. The short qualifiers `a`, `b`, and `m` are aliases for `alpha`, `beta`, and `milestone` only when immediately followed by a digit, such as `2.0a1`, `1.0b2`, or `1.0-m1`. Bare trailing letters remain literal qualifiers after the release, so `1.0.2 < 1.0.2a < 1.0.2b` and `1.1.1l < 1.1.1m`. A separator between the letter and number, as in `1.0a.1`, does not activate the short alias. The stages `pre`, `preview`, and `rc` are distinct, not aliases: `2.0.0-pre1 < 2.0.0-preview1 < 2.0.0-rc1 < 2.0.0`. Development versions precede alpha versions, so `2.0.0-dev1 < 2.0.0-alpha1`. These additional stages are an API policy rather than strict Maven behavior. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. Qualifier normalization applies to range comparisons; exact criteria versions remain literal matches. Numeric segments used in version range comparisons ignore leading zeros. A stored exclusive upper bound of `10.05.1` therefore includes `10.5.0`, and CVE-2026-33280's exclusive `6.02` upper bound includes `6.1` but excludes `6.2`. This normalization applies only to range comparison. Exact CPE Match Criteria preserve leading zeros, so the CVE-2025-9392 versions `1.0.04.001` and `1.0.4.1` are not equivalent. This comparison rule is not a universal firmware-versioning rule and can differ from a vendor's actual release sequence. @@ -304,12 +313,24 @@ 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 raw-query builder preserves the original expression and uses `query_string` with an `AND` default operator. Standalone calendar dates such as `2024-01-01` and ranges such as `2024-01-01..2024-01-31` search the supported record/container date fields using inclusive boundaries. Range endpoints can also use year or month precision (`2023..2024`, `2024-01..2024-03`); a standalone year or month remains a text query. Timestamps accept UTC or explicit offsets, and timestamps without a zone are interpreted as UTC. Date-query years are limited to 1900 through 2100. Quoted dates remain literal query expressions. This date syntax is specific to `/webSearch`; `/search` retains its explicit timestamp filters. +The raw-query builder validates every token before using `query_string` with an `AND` default operator. Ordinary text (`remote code execution`), quoted phrases, Boolean expressions and parentheses (`apache AND (nginx OR "remote code")`), and `*`/`?` wildcard searches remain supported, including leading wildcards. Notes describe all distinct term types, not only the final token. Recognized URLs containing slashes and simple relative paths are quoted as literal text before execution; URL punctuation does not become a query operator. Other supported expressions retain their syntax. + +Regular-expression operators, including field-qualified regex expressions, are not supported. Unsupported tokens anywhere in the query, unescaped braces, unclosed quotes, unbalanced parentheses, dangling escapes, and reversed date ranges return HTTP `400` with `WEB_SEARCH_QUERY_SYNTAX` in `searchMetadata.errors`, without contacting OpenSearch. For example, `/fre[e]type/`, `/fre[e]type/ apache`, and `apache /fre[e]type/` are rejected. Quote or escape special characters when they are intended as literal text: the JSON query strings `"\"/fre[e]type/\""` and `"\\/fre[e]type\\/"` are accepted literals, not regex queries. Quote absolute paths, for example `"query": "\"/usr/local/bin\""`. Invalid body fields, sorting, and pagination retain their existing validation-error array format. These checks do not implement the complete OpenSearch query grammar or eliminate the cost of allowed wildcard searches; other backend query errors remain sanitized HTTP `502` responses. + +Standalone calendar dates such as `2024-01-01` and ranges such as `2024-01-01..2024-01-31` search the supported record/container date fields using inclusive boundaries. Range endpoints can also use year or month precision (`2023..2024`, `2024-01..2024-03`); a standalone year or month remains a text query. Timestamps accept UTC or explicit offsets, and timestamps without a zone are interpreted as UTC. Date-query years are limited to 1900 through 2100. Quoted dates remain literal query expressions. This date syntax is specific to `/webSearch`; `/search` retains its explicit timestamp filters. Both endpoints use the shared OpenSearch client and reject incomplete upstream responses. A response with failed shards, a timeout, missing hit data, or an invalid total-hit count cannot produce a successful `/webSearch` response. Backend failures return sanitized HTTP 502 responses; transport timeouts return HTTP 504. The `/webSearch` response retains `searchMetadata`, `resultsTotal`, and `data`. Metadata includes `searchStatus`, the response-format `version`, `notes`, `errors`, and `queryTime_ms` when available. The obsolete `cveCore` and `cveMitreCore` package-version fields have been removed. `/search` keeps its existing API-owned response format, warnings, and filter behavior. +## Client Result Caching + +Successful HTTP 200 responses from both `/search` and `/webSearch`, including zero-result searches, include `Cache-Control: private, max-age=60` by default. `SearchClientCacheMaxAgeSeconds` (or `appConfig.clientCache.maxAgeSeconds`) accepts a nonnegative safe integer number of seconds. Setting `0`, or an invalid value, produces `Cache-Control: no-store`. Search errors, disabled-endpoint responses, parser errors, and liveness/readiness responses use `no-store`. These headers do not create a cache inside the API, change rate enforcement, or force callers to cache. + +Both search endpoints use POST. Ordinary browser and proxy HTTP caches do not automatically satisfy repeated POST requests from stored POST responses. Integrators must implement application-level result caching, using the freshness header as a hint. Key cached results by API environment, endpoint, and the complete request body, including filters, scope, sorting, and pagination. Never share results merely because requests use the same `/search` URL. `private` does not authorize shared HTTP proxy caching, and the API does not provide conditional POST/304 revalidation. + +Retain a successful result for no longer than the advertised freshness duration, then issue a fresh request. This permits up to that duration of additional staleness after the result was generated; it does not guarantee indexing freshness or consistency across separately cached pages. A cached `/webSearch` response's `queryTime_ms` describes its original execution, not the current lookup. Clients must not cache errors or treat old rate-limit information as a current quota. Only avoiding an HTTP request reduces traffic counted by the deployment's rate limiter; sending a request still counts even if a future server-side cache answers it. + ## Swagger API docs Swagger API docs are regenerated by `npm run dev` and can be regenerated manually using: @@ -362,7 +383,20 @@ CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` search The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 search candidates may not be represented. When a semantic search reaches that limit, the response sets `pagination.totalIsExact` to `false` and includes a `SEMANTIC_CANDIDATE_LIMIT_REACHED` warning. Semantic searches that remain below the limit and direct searches set `totalIsExact` to `true`. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct query and retains ordinary exact-total behavior. -`/search` orders search results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pagination.pageSize` preserves the requested `resultsPerPage` value, while `pagination.resultsReturned` reports the number of records actually returned and may be smaller for a partial or out-of-range page. Current searches do not use a point-in-time snapshot, so index updates between separate page requests can shift results. Request-scoped PIT traversal would improve consistency within one request, but consistency across separate page requests would additionally require reusing the same snapshot. +`/search` defaults to CVE ID ascending, using textual rather than numeric sequence ordering. For example, `CVE-2024-10000` sorts before `CVE-2024-9999`. Use the optional `sort` object to select either immutable `cveId` or `datePublished`, with `asc` or `desc`. The `property` is required when `sort` is supplied; omitted `order` defaults to `asc`. Values are case-insensitive. Publication-date sorts place missing dates last and use CVE ID ascending to break ties. Mutable `dateUpdated` sorting is not supported. Sort fields use base record metadata independently of `scope`. + +For the most recently published matching records first: + +```json +{ + "cvssV3_1Severity": "CRITICAL", + "sort": { "property": "datePublished", "order": "desc" } +} +``` + +The selected order applies to direct searches and to candidate retrieval before semantic filtering. Semantic filtering preserves that order, and the full-record page fetch restores the selected CVE ID order. Changing the sort can therefore change which candidates fit within the semantic limit and the reported partial total; a descending sort does not remove either 10,000-record control. Narrowing filters, such as publication date ranges, can help divide a larger result set into accessible windows. + +The response `pagination.pageSize` preserves the requested `resultsPerPage` value, while `pagination.resultsReturned` reports the number of records actually returned and may be smaller for a partial or out-of-range page. Immutable sort values prevent records from moving because a sort key changed, but new records, deletions, or changed filter membership can still shift pages. Current searches do not use a point-in-time snapshot. Request-scoped PIT traversal would improve consistency within one request, but consistency across separate page requests would additionally require reusing the same snapshot. The API does not cache, coalesce, queue, rate-limit, or aggregate performance telemetry in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and durable metrics collection must be supplied by the deployment infrastructure. @@ -390,3 +424,18 @@ docker build -f Dockerfile.local --secret id=npm_ca,src=/path/to/ca.crt -t cve-s ``` The final runtime image uses Google Distroless Node, so it does not include a shell, package manager, or command lookup tools. + +### Updating Pinned Base Images + +`Dockerfile` and `Dockerfile.local` use identical, digest-pinned Node.js builder and Distroless runtime images. The digests identify multi-architecture image indexes supporting AMD64 and ARM64. Each platform selects its own fixed image from that index; pinning does not make the images for different architectures identical. + +The builder tag includes its Node.js patch version, but the Distroless tag names only the major version. Verify the runtime's actual patch version rather than inferring it from the builder tag. A digest fixes the entire base image, including its operating-system packages. Rebuilding, even with `--pull`, does not advance a pinned digest or automatically install newer security patches. + +When updating the base images: + +1. Select supported, patched builder and Distroless images and inspect their registry manifests with `docker buildx imagetools inspect `. Use image-index digests that retain the required deployment and development architectures. +2. Run the selected images with `docker run --rm --network none @sha256: --version` to confirm their Node.js patch versions. Verify each architecture used in deployment or development; matching major-version tags alone are insufficient. +3. Update both `FROM` references in both Dockerfiles together. If the Node.js patch version changes, also update `.github/workflows/quality.yml`, the version assertions in `config/runtime-config.test.js`, and this README. +4. Run `npm run quality`, build both Dockerfiles, and confirm the completed images report the intended version with `docker run --rm --network none --version`. Perform the runtime health checks above and scan the production image before deployment. + +Deployments must rebuild and roll out the updated application images to receive the change. Restarting containers from an older application image does not update Node.js. Base-image pinning controls the base-image inputs; it does not guarantee that independently built application images have identical digests. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index c67d91d..3352488 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "1.0.2-beta", + "version": "1.1.0-beta", "title": "CVE Search API", "description": "CVE Search API to retrieve all matching CVE records after applying request inputs as filters.\n\nResponsible Use Guide: [https://cveproject.github.io/search-api-responsible-use](https://cveproject.github.io/search-api-responsible-use \"https://cveproject.github.io/search-api-responsible-use\")", "contact": { @@ -34,6 +34,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/SearchCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -44,16 +47,26 @@ }, "400": { "description": "Malformed JSON, invalid request body, parameter combination, or version range", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "$ref": "#/components/schemas/ApiError" } } } }, "413": { "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -64,6 +77,11 @@ }, "415": { "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -74,6 +92,11 @@ }, "500": { "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -84,6 +107,11 @@ }, "502": { "description": "The search backend returned an invalid or unexpected response", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -102,6 +130,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "Retry-After": { "description": "Seconds before retrying the unavailable search service", "schema": { @@ -112,6 +143,11 @@ }, "504": { "description": "Search exceeded its processing deadline", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -133,6 +169,10 @@ "summary": "All supported request body parameters", "value": { "scope": "FULL", + "sort": { + "property": "datePublished", + "order": "desc" + }, "keywordSearch": "remote code execution", "keywordExactMatch": true, "cveIds": "CVE-2024-1234,CVE-2024-5678", @@ -214,6 +254,9 @@ "200": { "description": "OK", "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -234,6 +277,9 @@ "200": { "description": "API is ready to serve search requests", "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -252,6 +298,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "Retry-After": { "description": "Seconds before retrying the readiness check", "schema": { @@ -275,7 +324,7 @@ "CVE Record" ], "summary": "Raw query search", - "description": "

Expected Behavior

Retrieves matching CVE records using the raw query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration. Standalone dates and date ranges search the supported record/container date fields; other expressions use raw query syntax with an AND default operator.

", + "description": "

Expected Behavior

Retrieves matching CVE records using the validated query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration. Standalone dates and date ranges search the supported record/container date fields; other expressions use query syntax with an AND default operator. Every token is validated before execution. Regular expressions are not supported.

", "operationId": "webSearch", "responses": { "200": { @@ -288,6 +337,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/SearchCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -297,17 +349,34 @@ } }, "400": { - "description": "Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination", + "description": "Malformed JSON, unreadable request body, invalid request fields, sort or pagination, or unsupported query syntax. WEB_SEARCH_QUERY_SYNTAX uses the WebSearchResponse envelope with searchMetadata.errors; request validation retains its existing error format.", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "oneOf": [ + { + "$ref": "#/components/schemas/ApiErrorResponse" + }, + { + "$ref": "#/components/schemas/WebSearchResponse" + } + ] } } } }, "410": { "description": "The endpoint is disabled by service configuration", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -318,6 +387,11 @@ }, "413": { "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -328,6 +402,11 @@ }, "415": { "description": "Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -338,6 +417,11 @@ }, "500": { "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -348,6 +432,11 @@ }, "502": { "description": "The search backend returned an error or invalid response", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -358,6 +447,11 @@ }, "504": { "description": "Search exceeded its OpenSearch request deadline", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -380,7 +474,7 @@ "properties": { "query": { "type": "string", - "description": "Raw search expression. Examples: remote code execution, apache*, 2024-01-01, or 2024-01-01..2024-01-31. Date ranges are inclusive and allow year/month endpoints; standalone years/months and quoted dates remain text queries. Date-query years must be between 1900 and 2100.", + "description": "Search expression. Supports ordinary multi-term text, quoted phrases, Boolean expressions with parentheses, and * or ? wildcards, including leading wildcards. Recognized URLs containing slashes and simple relative paths are treated as literal text. Quote or escape literal special characters, including absolute paths. Regex expressions (including field-qualified regex), unsupported tokens in any position, unescaped braces, unclosed quotes, unbalanced parentheses, dangling escapes, and reversed date ranges return HTTP 400 WEB_SEARCH_QUERY_SYNTAX in searchMetadata.errors before backend execution. These checks do not implement the complete OpenSearch query grammar or bound the cost of allowed wildcard searches. Examples: remote code execution, apache*, 2024-01-01, or 2024-01-01..2024-01-31. Date ranges are inclusive and allow year/month endpoints; standalone years/months and quoted dates remain text queries. Date-query years must be between 1900 and 2100.", "minLength": 1 }, "sort": { @@ -424,6 +518,24 @@ } }, "components": { + "headers": { + "SearchCacheControl": { + "description": "Client application result-cache freshness hint: private, max-age=60 by default. SearchClientCacheMaxAgeSeconds configures the seconds; zero or invalid values produce no-store. POST results are not automatically reused by ordinary HTTP caches. Clients must key application caches by API environment, endpoint, and the complete request body, including pagination, scope, and sorting. No server-side cache is created.", + "schema": { + "type": "string" + }, + "example": "private, max-age=60" + }, + "NoStoreCacheControl": { + "description": "Do not cache error or health/readiness responses.", + "schema": { + "type": "string", + "enum": [ + "no-store" + ] + } + } + }, "schemas": { "ApiError": { "type": "object", @@ -659,6 +771,39 @@ "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." }, + "sort": { + "type": "object", + "additionalProperties": false, + "required": [ + "property" + ], + "properties": { + "property": { + "type": "string", + "enum": [ + "cveId", + "datePublished" + ] + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + } + }, + "default": { + "property": "cveId", + "order": "asc" + }, + "example": { + "property": "datePublished", + "order": "desc" + }, + "description": "Orders matching records using immutable base-record fields, independently of scope. Defaults to CVE ID ascending when omitted. property is required and accepts cveId (textual, not numeric sequence ordering) or datePublished; order accepts asc or desc and defaults to asc. Values are case-insensitive. Publication-date sorts place missing dates last and use CVE ID ascending to break ties. The selected sort is applied before the 10,000-candidate limit for semantic searches, so changing it can change the bounded candidate set and reported partial total. Both 10,000-record controls still apply. New records, deletions, or changed filter membership can shift pages between requests; this option does not provide a shared snapshot. Mutable dateUpdated sorting is unsupported." + }, "keywordSearch": { "type": "string", "maxLength": 512, @@ -685,7 +830,7 @@ "type": "string", "maxLength": 4096, "example": "CVE-2024-1234,CVE-2024-5678", - "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." + "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning (CVE_IDS_NOT_RETURNED) without preventing other requested IDs from matching. Other syntactically valid IDs with no matches do not produce a missing-ID warning. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." }, "noRejected": { "oneOf": [ @@ -796,7 +941,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." }, "isVulnerable": { "oneOf": [ @@ -815,28 +960,60 @@ "description": "When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable set to true; false is allowed. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "lastModStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-01-01T00:00:00.000Z", - "description": "Returns CVE records with dateUpdated timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with dateUpdated timestamps on or after this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "lastModEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-12-31T23:59:59.999Z", - "description": "Returns CVE records with dateUpdated timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with dateUpdated timestamps on or before this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "pubStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-01-01T00:00:00.000Z", - "description": "Returns CVE records with datePublished timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with datePublished timestamps on or after this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "pubEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-12-31T23:59:59.999Z", - "description": "Returns CVE records with datePublished timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with datePublished timestamps on or before this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "resultsPerPage": { "type": "integer", @@ -857,7 +1034,7 @@ "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. 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." + "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 extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -871,7 +1048,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. 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." + "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 extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", @@ -919,32 +1096,75 @@ "description": "Returns records with a scope-selected SSVC metric whose role exactly matches this case-insensitive value." }, "ssvcTimestampStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "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." + "description": "Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "ssvcTimestampEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "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." + "description": "Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "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." + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "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. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "kevDateAddedStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "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." + "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. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "kevDateAddedEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "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." + "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. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "referenceURL": { "type": "string", @@ -954,8 +1174,19 @@ "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." + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "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. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index a88fbf6..445289f 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -100,15 +100,33 @@ describe('OpenAPI contract', () => { 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.cveIds.description).toContain('CVE_IDS_NOT_RETURNED'); + expect(schema.properties.sort.type).toBe('object'); + expect(schema.properties.sort.required).toStrictEqual(['property']); + expect(schema.properties.sort.additionalProperties).toBe(false); + expect(schema.properties.sort.properties.property.enum).toStrictEqual(['cveId', 'datePublished']); + expect(schema.properties.sort.properties.order.enum).toStrictEqual(['asc', 'desc']); + expect(schema.properties.sort.default).toStrictEqual({ property: 'cveId', order: 'asc' }); + expect(schema.properties.sort.description).toContain('before the 10,000-candidate limit'); 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.knownExploited.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); + for (const field of require('../utils/searchDates').DATE_PARAMS) { + expect(schema.properties[field].oneOf).toStrictEqual([ + { type: 'string', format: 'date' }, + { type: 'string', format: 'date-time' } + ]); + expect(schema.properties[field].description).toContain('3 fractional-second digits'); + expect(schema.properties[field].description).toContain( + field.endsWith('EndDate') ? '23:59:59.999' : '00:00:00.000' + ); + } 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.disputed.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); 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'); @@ -159,12 +177,20 @@ describe('OpenAPI contract', () => { const schema = operation.requestBody.content['application/json'].schema; expect(operation.deprecated).toBeUndefined(); - expect(operation.description).toContain('raw query expression'); + expect(operation.description).toContain('validated query expression'); expect(operation.parameters || []).toStrictEqual([]); expect(operation.requestBody.required).toBe(true); expect(schema.required).toStrictEqual(['query']); expect(schema.additionalProperties).toBe(false); expect(schema.properties.query.type).toBe('string'); + expect(schema.properties.query.description).toContain('WEB_SEARCH_QUERY_SYNTAX'); + expect(schema.properties.query.description).toContain('unsupported tokens in any position'); + expect(schema.properties.query.description).toContain('including leading wildcards'); + expect(schema.properties.query.description).toContain('treated as literal text'); + expect(operation.responses['400'].content['application/json'].schema.oneOf).toStrictEqual([ + { $ref: '#/components/schemas/ApiErrorResponse' }, + { $ref: '#/components/schemas/WebSearchResponse' } + ]); 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); @@ -254,6 +280,9 @@ describe('OpenAPI contract', () => { expect(schemas.CveRecord.description).toContain('without search-backend transport metadata'); expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); + expect(openapi.paths['/search'].post.responses['400'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/ApiError' + ); expect(openapi.paths['/search'].post.responses['200'].content['application/json'].schema.$ref).toBe( '#/components/schemas/SearchResponse' ); @@ -262,6 +291,21 @@ describe('OpenAPI contract', () => { ); }); + test('documents the client freshness policy on successes and no-store on errors and health checks', () => { + for (const path of ['/search', '/webSearch', '/api/health-check', '/api/ready-check']) { + const operation = openapi.paths[path][path.startsWith('/api/') ? 'get' : 'post']; + for (const [status, response] of Object.entries(operation.responses)) { + const successfulSearch = status === '200' && !path.startsWith('/api/'); + expect(response.headers['Cache-Control']).toStrictEqual({ + $ref: `#/components/headers/${successfulSearch ? 'SearchCacheControl' : 'NoStoreCacheControl'}` + }); + } + } + expect(openapi.components.headers.SearchCacheControl.example).toBe('private, max-age=60'); + expect(openapi.components.headers.SearchCacheControl.description).toContain('complete request body'); + expect(openapi.components.headers.NoStoreCacheControl.schema.enum).toStrictEqual(['no-store']); + }); + 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 265fc61..c4d97cc 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -12,6 +12,9 @@ "requestDeadlineMs": "SearchRequestDeadlineMs", "readinessTimeoutMs": "SearchReadinessTimeoutMs" }, + "clientCache": { + "maxAgeSeconds": "SearchClientCacheMaxAgeSeconds" + }, "requestLogging": { "successSampleRate": "RequestLogSuccessSampleRate", "healthChecks": "RequestLogHealthChecks" diff --git a/config/default.jsonc b/config/default.jsonc index cceb5e4..32ec4d0 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -18,6 +18,11 @@ // Overall /api/ready-check deadline and per-operation timeout for field capabilities and search. "readinessTimeoutMs": "5000" }, + // Freshness hint for body-aware client result caches, not an in-process cache. + "clientCache": { + // Zero disables reuse; invalid values also result in no-store. + "maxAgeSeconds": "60" + }, // Access logging settings. Error responses are always logged. "requestLogging": { // Fraction of successful requests to log, including health checks when enabled, from 0 through 1. diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index c94173e..1f85b5d 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -6,12 +6,28 @@ function readProjectFile(...segments) { } describe('runtime container and compose configuration', () => { + test.each(['Dockerfile', 'Dockerfile.local'])('%s pins both base images by digest', (filePath) => { + const dockerfile = readProjectFile(filePath); + + expect(dockerfile).toMatch(/^FROM node:24\.22\.1-trixie-slim@sha256:[a-f0-9]{64} AS deps$/m); + expect(dockerfile).toMatch( + /^FROM gcr\.io\/distroless\/nodejs24-debian13:nonroot@sha256:[a-f0-9]{64} AS runtime$/m + ); + expect(dockerfile.match(/^FROM .+$/gm)).toHaveLength(2); + }); + + test('local and production builds use the same base images and CI uses the builder patch version', () => { + const dockerfile = readProjectFile('Dockerfile'); + const localDockerfile = readProjectFile('Dockerfile.local'); + const workflow = readProjectFile('.github/workflows/quality.yml'); + + expect(localDockerfile.match(/^FROM .+$/gm)).toEqual(dockerfile.match(/^FROM .+$/gm)); + expect(workflow).toContain('node-version: 24.22.1'); + }); + test('Dockerfile keeps AWS-compatible production runtime hardening invariants', () => { const dockerfile = readProjectFile('Dockerfile'); - expect(dockerfile).toContain('ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot'); - expect(dockerfile).toContain('FROM node:${NODE_VERSION} AS deps'); - expect(dockerfile).toContain('FROM ${DISTROLESS_NODE_IMAGE} AS runtime'); expect(dockerfile).toMatch(/^RUN npm ci --omit=dev \\/m); expect(dockerfile).toMatch(/^COPY --chown=65532:65532 config \.\/config\//m); expect(dockerfile).toMatch(/^COPY --chown=65532:65532 controllers \.\/controllers\//m); @@ -23,7 +39,6 @@ describe('runtime container and compose configuration', () => { expect(dockerfile).toMatch(/^USER 65532:65532$/m); expect(dockerfile).toContain('CMD ["server.js"]'); expect(dockerfile).not.toContain('COPY . .'); - expect(dockerfile).not.toContain('node:${NODE_VERSION}-bookworm-slim'); expect(dockerfile).not.toContain('apt-get'); expect(dockerfile).not.toContain('docker-entrypoint.sh'); expect(dockerfile).not.toContain('--mount=type=secret'); @@ -33,9 +48,6 @@ describe('runtime container and compose configuration', () => { test('local Dockerfile keeps npm CA secret mount for local builds', () => { const dockerfile = readProjectFile('Dockerfile.local'); - expect(dockerfile).toContain('ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot'); - expect(dockerfile).toContain('FROM node:${NODE_VERSION} AS deps'); - expect(dockerfile).toContain('FROM ${DISTROLESS_NODE_IMAGE} AS runtime'); expect(dockerfile).toMatch(/^RUN --mount=type=secret,id=npm_ca,required=false/m); expect(dockerfile).toContain('if [ -s /run/secrets/npm_ca ]; then'); expect(dockerfile).toContain('NPM_CONFIG_CAFILE=/run/secrets/npm_ca npm ci --omit=dev'); @@ -48,7 +60,6 @@ describe('runtime container and compose configuration', () => { expect(dockerfile).toMatch(/^USER 65532:65532$/m); expect(dockerfile).toContain('CMD ["server.js"]'); expect(dockerfile).not.toContain('COPY . .'); - expect(dockerfile).not.toContain('node:${NODE_VERSION}-bookworm-slim'); expect(dockerfile).not.toContain('apt-get'); expect(dockerfile).not.toContain('docker-entrypoint.sh'); }); @@ -91,6 +102,7 @@ describe('runtime container and compose configuration', () => { ); expect(customEnvironmentVariables).toContain('"requestDeadlineMs": "SearchRequestDeadlineMs"'); expect(customEnvironmentVariables).toContain('"readinessTimeoutMs": "SearchReadinessTimeoutMs"'); + expect(customEnvironmentVariables).toContain('"maxAgeSeconds": "SearchClientCacheMaxAgeSeconds"'); expect(customEnvironmentVariables).toContain('"successSampleRate": "RequestLogSuccessSampleRate"'); expect(customEnvironmentVariables).toContain('"healthChecks": "RequestLogHealthChecks"'); expect(customEnvironmentVariables).toContain('"webSearchEnabled": "WebSearchEnabled"'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 6b11084..c872146 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -4,6 +4,8 @@ const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); const ssvc = require('../utils/ssvc'); const cvss = require('../utils/cvss'); +const searchDates = require('../utils/searchDates'); +const searchSort = require('../utils/searchSort'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -46,26 +48,12 @@ const ssvcEnumValidators = [ const cvssMetricsParams = cvss.PARAMS; -const dateParams = [ - 'lastModStartDate', - 'lastModEndDate', - 'pubStartDate', - 'pubEndDate', - 'ssvcTimestampStartDate', - 'ssvcTimestampEndDate', - 'kevDateAddedStartDate', - 'kevDateAddedEndDate' -]; -const dateRangePairs = [ - ['lastModStartDate', 'lastModEndDate'], - ['pubStartDate', 'pubEndDate'], - ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], - ['kevDateAddedStartDate', 'kevDateAddedEndDate'] -]; +const { DATE_PARAMS: dateParams, DATE_RANGE_PAIRS: dateRangePairs } = searchDates; const versionRangeParams = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; const versionRangeValueParams = ['versionStart', 'versionEnd']; const versionRangeTypeParams = ['versionStartType', 'versionEndType']; const nonStringSearchParams = [ + 'sort', 'keywordExactMatch', 'cveIds', 'cweIds', @@ -79,7 +67,6 @@ const nonStringSearchParams = [ ]; const nonEmptyStringParams = constants.SEARCH_QUERY_PARAMS.filter((param) => !nonStringSearchParams.includes(param)); -const dateTimeFormat = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/; const cveIdFormat = /^CVE-\d{4}-\d{4,}$/i; const cweIdFormat = /^CWE-[1-9][0-9]*$/i; @@ -157,11 +144,7 @@ function exceedsSearchWindow(params) { } function isValidDateTime(value) { - const input = String(value ?? ''); - if (!dateTimeFormat.test(input)) return false; - - const parsed = new Date(input); - return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === input; + return typeof value === 'string' && searchDates.normalizeDateTime(value) === value; } //Middleware function that will be called on the search endpoint. @@ -183,6 +166,10 @@ function validateParams(req, res, next) { } } + if (!searchSort.normalizeSort(params.sort)) { + return res.status(400).json(error.searchSortError()); + } + if (!searchScope.isValidScope(params.scope)) { return res.status(400).json(error.scopeValueError()); } @@ -386,7 +373,7 @@ function validateParams(req, res, next) { } } - //Ensures date params are valid timestamps matching yyyy-MM-ddTHH:mm:ss.SSS'Z' and real calendar dates. + // Date inputs have already been normalized by the route; reject invalid originals left unchanged. for (const param of dateParams) { if (params?.[param] && !isValidDateTime(params[param])) { return res.status(400).json(error.dateTimeError(param)); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 27ea2a5..65a3833 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -447,6 +447,7 @@ describe('middleware validation function', () => { test.each([ ['keywordSearch', 'remote code'], + ['sort', { property: 'DATEPUBLISHED', order: 'DESC' }], ['keywordExactMatch', 'false'], ['cveIds', 'CVE-2024-0001,CVE-2024-0002'], ['noRejected', true], @@ -916,7 +917,7 @@ describe('middleware validation function', () => { expectValidationError( 'DATE_TIME_FORMAT', - `The ${dateParam} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${dateParam} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); } ); @@ -965,14 +966,14 @@ describe('middleware validation function', () => { ['2024-01-01T00:00Z'], ['2024-01-01T00:00:00Z'], ['2024-01-01T00:00:00.000'] - ])('ValidateParams rejects partial dateTime value %s.', (dateValue) => { + ])('ValidateParams rejects date input that has not been normalized by the route: %s.', (dateValue) => { validate({ pubStartDate: dateValue }); expectValidationError( 'DATE_TIME_FORMAT', - "The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'" + 'The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.' ); }); @@ -988,7 +989,7 @@ describe('middleware validation function', () => { expectValidationError( 'DATE_TIME_FORMAT', - `The ${dateParam} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${dateParam} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); }); @@ -1061,7 +1062,7 @@ describe('middleware validation function', () => { 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'` + `The ${param} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); } ); diff --git a/controllers/searchController.js b/controllers/searchController.js index 244e15c..93fdc39 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -6,6 +6,7 @@ const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); const searchResponseBuilder = require('../utils/searchResponseBuilder'); const webSearchQueryBuilder = require('../utils/webSearchQueryBuilder'); +const clientCachePolicy = require('../utils/clientCachePolicy'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); @@ -164,18 +165,15 @@ async function searchHandler(req, res, next) { const pagingOptions = responseOptions ?? searchOptions; const warnings = [...searchResponseBuilder.getRequestWarnings(params), ...searchResult.warnings]; - return res.status(200).json( - searchResponseBuilder.buildSearchResponse({ - ...searchResult, - warnings, - pagingOptions - }) - ); + const body = searchResponseBuilder.buildSearchResponse({ ...searchResult, warnings, pagingOptions }); + clientCachePolicy.setSearchCacheControl(res); + return res.status(200).json(body); } catch (err) { if (searchDeadline.isSearchClientDisconnectedError(err)) { return; } + res.set('Cache-Control', 'no-store'); if (searchDeadline.isSearchTimeoutError(err)) { return res.status(504).json(error.searchTimeoutError()); } @@ -228,8 +226,15 @@ async function webSearchHandler(req, res, next) { body.searchMetadata.notes = request.notes; body.resultsTotal = total; body.data = searchData.hits.hits; + clientCachePolicy.setSearchCacheControl(res); return res.status(200).json(body); } catch (ex) { + res.set('Cache-Control', 'no-store'); + if (ex instanceof webSearchQueryBuilder.WebSearchQueryError) { + body.searchMetadata.errors = [error.webSearchQuerySyntaxError()]; + return res.status(400).json(body); + } + const timedOut = searchDeadline.isSearchTimeoutError(ex); const err = timedOut ? error.searchTimeoutError() : error.upstreamSearchError(); body.searchMetadata.errors = [err]; diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8f08d04..2429cb0 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -1403,6 +1403,8 @@ describe('Search controller WEB_SEARCH handler', () => { test.each([ [new Error('sensitive backend details'), 502, 'UPSTREAM_SEARCH_ERROR'], + [{ name: 'WebSearchQueryError', message: 'sensitive backend details' }, 502, 'UPSTREAM_SEARCH_ERROR'], + [{ statusCode: 400, message: 'sensitive backend parse error' }, 502, 'UPSTREAM_SEARCH_ERROR'], [{ name: 'TimeoutError', message: 'sensitive timeout details' }, 504, 'SEARCH_TIMEOUT'] ])('WEB_SEARCH sanitizes transport errors: %p', async (err, status, code) => { mockApiSearch.mockRejectedValue(err); @@ -1417,10 +1419,30 @@ describe('Search controller WEB_SEARCH handler', () => { expect(console.log).not.toHaveBeenCalled(); }); - test('WEB_SEARCH keeps unsupported expressions sanitized without contacting OpenSearch', async () => { - await controller.WEB_SEARCH({ body: { query: '{invalid}' } }, res, next); - expect(res.status).toHaveBeenCalledWith(502); + test.each([ + '{invalid}', + '{invalid} apache', + 'apache {invalid}', + '/fre[e]type/', + '/fre[e]type/ apache', + 'apache /fre[e]type/', + 'containers.cna.descriptions.value:/fre[e]type/ apache', + '2024-02-01..2024-01-01', + '"unterminated', + '(apache OR nginx' + ])('WEB_SEARCH rejects unsupported expressions before contacting OpenSearch: %s', async (query) => { + await controller.WEB_SEARCH({ body: { query } }, res, next); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store'); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); - expect(res.json.mock.calls[0][0].searchMetadata.errors[0].error).toBe('UPSTREAM_SEARCH_ERROR'); + expect(res.json.mock.calls[0][0]).toMatchObject({ + searchMetadata: { + searchStatus: 'errors', + notes: [], + errors: [{ error: 'WEB_SEARCH_QUERY_SYNTAX' }] + }, + resultsTotal: 0, + data: [] + }); }); }); diff --git a/package-lock.json b/package-lock.json index 1414d2e..5a0327d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cve-search-api", - "version": "1.0.2-beta", + "version": "1.1.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-search-api", - "version": "1.0.2-beta", + "version": "1.1.0-beta", "dependencies": { "@opensearch-project/opensearch": "2.13.0", "config": "4.4.1", @@ -1683,9 +1683,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1696,7 +1696,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -4876,9 +4876,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz", + "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/package.json b/package.json index 2a55f46..b0d4239 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cve-search-api", - "version": "1.0.2-beta", + "version": "1.1.0-beta", "scripts": { "dev": "node routes/swagger.js && nodemon server.js", "coverage": "jest --coverage", @@ -39,6 +39,6 @@ "lodash": "4.18.1", "path-to-regexp": "0.1.13", "picomatch": "2.3.2", - "qs": "6.16.0" + "qs": "6.16.2" } } diff --git a/routes/index.js b/routes/index.js index 0d69c3c..1f77889 100644 --- a/routes/index.js +++ b/routes/index.js @@ -8,12 +8,15 @@ const featureFlags = require('../utils/featureFlags'); const searchDeadline = require('../utils/searchDeadline'); const searchProvider = require('../utils/searchProvider'); const searchScope = require('../utils/searchScope'); +const searchDates = require('../utils/searchDates'); +const searchSort = require('../utils/searchSort'); const error = new errors.errors(); const SEARCH_BODY_FIELDS = new Set(constants.SEARCH_QUERY_PARAMS); const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( (field) => ![ + 'sort', 'keywordExactMatch', 'cveIds', 'cweIds', @@ -61,7 +64,8 @@ function parseError(req, res, next) { return msg; }); if (!err.isEmpty()) { - return res.status(400).json(err.array()); + const validationErrors = err.array(); + return res.status(400).json(req.route.path === '/search' ? validationErrors[0] : validationErrors); } next(); } @@ -110,10 +114,6 @@ 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; @@ -183,6 +183,19 @@ function normalizeSearchParams(req, res, next) { if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toLowerCase(); }); + searchDates.DATE_RANGE_PAIRS.forEach(([start, end]) => { + for (const field of [start, end]) { + if (searchParams[field] === undefined) continue; + const normalized = searchDates.normalizeDateTime(searchParams[field], { endOfDay: field === end }); + if (normalized !== null) searchParams[field] = normalized; + } + }); + + if (searchParams.sort !== undefined) { + const sort = searchSort.normalizeSort(searchParams.sort); + if (sort) searchParams.sort = sort; + } + req.searchParams = searchParams; next(); } @@ -212,6 +225,7 @@ router.post( summary: "All supported request body parameters", value: { scope: "FULL", + sort: { property: "datePublished", order: "desc" }, keywordSearch: "remote code execution", keywordExactMatch: true, cveIds: "CVE-2024-1234,CVE-2024-5678", @@ -292,6 +306,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/SearchCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -302,16 +317,18 @@ headers: { } #swagger.responses[400] = { description: 'Malformed JSON, invalid request body, parameter combination, or version range', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { - $ref: "#/components/schemas/ApiErrorResponse" + $ref: "#/components/schemas/ApiError" } } }, } #swagger.responses[413] = { description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -320,6 +337,7 @@ content: { } #swagger.responses[500] = { description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -328,6 +346,7 @@ content: { } #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -338,6 +357,7 @@ content: { } #swagger.responses[502] = { description: 'The search backend returned an invalid or unexpected response', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -356,6 +376,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "Retry-After": { description: "Seconds before retrying the unavailable search service", schema: { @@ -366,6 +387,7 @@ headers: { } #swagger.responses[504] = { description: 'Search exceeded its processing deadline', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -395,10 +417,24 @@ content: { .custom(isStrictBoolean) .withMessage(error.noRejectedValueError()) .customSanitizer(toBooleanValue), - body('knownExploited').optional().custom(isJsonBoolean).withMessage(error.knownExploitedInputError()), - body('disputed').optional().custom(isJsonBoolean).withMessage(error.disputedInputError()), + body('knownExploited') + .optional() + .custom(isStrictBoolean) + .withMessage(error.knownExploitedInputError()) + .customSanitizer(toBooleanValue), + body('disputed') + .optional() + .custom(isStrictBoolean) + .withMessage(error.disputedInputError()) + .customSanitizer(toBooleanValue), body(['resultsPerPage']) .optional() + .custom((value) => typeof value === 'number' || typeof value === 'string') + .withMessage(error.resultsPerPageError()) + .bail() + .isInt({ min: 1 }) + .withMessage(error.resultsPerPageError()) + .bail() .isInt({ max: constants.MAX_RESULTS_LIMIT }) .withMessage(error.maxResultsError()) .toInt(), @@ -419,6 +455,7 @@ router.get( #swagger.responses[200] = { description: 'OK', headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -441,6 +478,7 @@ router.get( #swagger.responses[200] = { description: 'API is ready to serve search requests', headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -459,6 +497,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "Retry-After": { description: "Seconds before retrying the readiness check", schema: { @@ -485,7 +524,7 @@ router.post( #swagger.summary = "Raw query search" #swagger.description = "

Expected Behavior

-

Retrieves matching CVE records using the raw query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration. Standalone dates and date ranges search the supported record/container date fields; other expressions use raw query syntax with an AND default operator.

+

Retrieves matching CVE records using the validated query expression supplied in the JSON request body. Service operators can disable this endpoint through runtime configuration. Standalone dates and date ranges search the supported record/container date fields; other expressions use query syntax with an AND default operator. Every token is validated before execution. Regular expressions are not supported.

#swagger.requestBody = { required: true, content: { @@ -497,7 +536,7 @@ router.post( properties: { query: { type: "string", - description: "Raw search expression. Examples: remote code execution, apache*, 2024-01-01, or 2024-01-01..2024-01-31. Date ranges are inclusive and allow year/month endpoints; standalone years/months and quoted dates remain text queries. Date-query years must be between 1900 and 2100.", + description: "Search expression. Supports ordinary multi-term text, quoted phrases, Boolean expressions with parentheses, and * or ? wildcards, including leading wildcards. Recognized URLs containing slashes and simple relative paths are treated as literal text. Quote or escape literal special characters, including absolute paths. Regex expressions (including field-qualified regex), unsupported tokens in any position, unescaped braces, unclosed quotes, unbalanced parentheses, dangling escapes, and reversed date ranges return HTTP 400 WEB_SEARCH_QUERY_SYNTAX in searchMetadata.errors before backend execution. These checks do not implement the complete OpenSearch query grammar or bound the cost of allowed wildcard searches. Examples: remote code execution, apache*, 2024-01-01, or 2024-01-01..2024-01-31. Date ranges are inclusive and allow year/month endpoints; standalone years/months and quoted dates remain text queries. Date-query years must be between 1900 and 2100.", minLength: 1 }, sort: { @@ -539,6 +578,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/SearchCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -548,17 +588,22 @@ headers: { } } #swagger.responses[400] = { -description: 'Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination', +description: 'Malformed JSON, unreadable request body, invalid request fields, sort or pagination, or unsupported query syntax. WEB_SEARCH_QUERY_SYNTAX uses the WebSearchResponse envelope with searchMetadata.errors; request validation retains its existing error format.', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { - $ref: "#/components/schemas/ApiErrorResponse" + oneOf: [ + { $ref: "#/components/schemas/ApiErrorResponse" }, + { $ref: "#/components/schemas/WebSearchResponse" } + ] } } }, } #swagger.responses[504] = { description: 'Search exceeded its OpenSearch request deadline', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -569,6 +614,7 @@ content: { } #swagger.responses[413] = { description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -577,6 +623,7 @@ content: { } #swagger.responses[415] = { description: 'Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -585,6 +632,7 @@ content: { } #swagger.responses[500] = { description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -593,6 +641,7 @@ content: { } #swagger.responses[410] = { description: 'The endpoint is disabled by service configuration', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -603,6 +652,7 @@ content: { } #swagger.responses[502] = { description: 'The search backend returned an error or invalid response', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { diff --git a/routes/index.test.js b/routes/index.test.js index 9be9c4d..b682c93 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -34,6 +34,7 @@ const mw = require('../controllers/middleware'); const controller = require('../controllers/searchController'); const featureFlags = require('../utils/featureFlags'); const router = require('./index'); +const { DATE_RANGE_PAIRS } = require('../utils/searchDates'); describe('routes/index router', () => { let app; @@ -106,6 +107,20 @@ describe('routes/index router', () => { }); }); + test('POST /search normalizes a sort object without mutating its JSON body', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { sort: { property: ' DATEPUBLISHED ', order: ' DESC ' } } + }); + expect(response.status).toBe(200); + expect(response.body.params.sort).toStrictEqual({ property: 'datePublished', order: 'desc' }); + expect(mw.validateParams.mock.calls[0][0].body.sort).toStrictEqual({ + property: ' DATEPUBLISHED ', + order: ' DESC ' + }); + }); + test('POST /search permits an omitted request body', async () => { const response = await request(app, { method: 'POST', @@ -123,6 +138,30 @@ describe('routes/index router', () => { }); }); + test.each(DATE_RANGE_PAIRS)('normalizes date-only %s and %s without changing the body', async (start, end) => { + const body = { [start]: '2024-02-29', [end]: '2024-02-29' }; + const response = await request(app, { method: 'POST', path: '/search', body }); + expect(response.status).toBe(200); + expect(response.body.params).toMatchObject({ + [start]: '2024-02-29T00:00:00.000Z', + [end]: '2024-02-29T23:59:59.999Z' + }); + expect(mw.validateParams.mock.calls[0][0].body).toStrictEqual(body); + }); + + test.each(DATE_RANGE_PAIRS)('normalizes offsets for %s and %s', async (start, end) => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { [start]: '2024-01-01T00:00:00+05:30', [end]: '2024-01-01T00:00:00.12Z' } + }); + expect(response.status).toBe(200); + expect(response.body.params).toMatchObject({ + [start]: '2023-12-31T18:30:00.000Z', + [end]: '2024-01-01T00:00:00.120Z' + }); + }); + test('POST /search rejects a non-empty body with an unsupported media type before validation', async () => { const response = await request(app, { method: 'POST', @@ -178,12 +217,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'EXACT_MATCH_BOOLEAN', - message: 'The keywordExactMatch parameter must be a boolean.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'EXACT_MATCH_BOOLEAN', + message: 'The keywordExactMatch parameter must be a boolean.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -200,12 +237,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'SEARCH_FIELD_STRING', - message: 'The keywordSearch field must be a non-empty string.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The keywordSearch field must be a non-empty string.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -220,12 +255,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'SEARCH_FIELD_STRING', - message: 'The cpeName field must be a non-empty string.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The cpeName field must be a non-empty string.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -240,12 +273,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'SEARCH_FIELD_STRING', - message: 'The referenceURL field must be a non-empty string.' - } - ]); + 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(); }); @@ -271,6 +302,24 @@ describe('routes/index router', () => { }); }); + test.each(['/search', '/search/', '/SEARCH'])( + 'POST %s returns one error object when multiple validations fail', + async (path) => { + const response = await request(app, { + method: 'POST', + path, + body: { keywordSearch: [], keywordExactMatch: 'invalid', resultsPerPage: 501 } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The keywordSearch field must be a non-empty string.' + }); + expect(controller.SEARCH).not.toHaveBeenCalled(); + } + ); + test('POST /search normalizes noRejected false as omitted', async () => { const response = await request(app, { method: 'POST', @@ -312,31 +361,26 @@ describe('routes/index router', () => { }); }); - 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(); - }); + describe.each(['isVulnerable', 'keywordExactMatch', 'noRejected', 'knownExploited', 'disputed'])( + '%s boolean input', + (field) => { + test.each([true, false, 'true', 'false'])('normalizes %j', async (value) => { + const response = await request(app, { method: 'POST', path: '/search', body: { [field]: value } }); + expect(response.status).toBe(200); + const expected = value === true || value === 'true'; + if (field === 'noRejected' && !expected) expect(response.body.params).not.toHaveProperty(field); + else expect(response.body.params[field]).toBe(expected); + }); + + test.each([null, 0, 1, [], ['true'], {}, 'TRUE', 'False', 'yes', ''])('rejects %j', async (value) => { + const response = await request(app, { method: 'POST', path: '/search', body: { [field]: value } }); + expect(response.status).toBe(400); + expect(Array.isArray(response.body)).toBe(false); + expect(mw.validateParams).not.toHaveBeenCalled(); + expect(controller.SEARCH).not.toHaveBeenCalled(); + }); + } + ); test('POST /search rejects resultsPerPage above the configured max before custom middleware runs', async () => { const response = await request(app, { @@ -348,16 +392,34 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'MAX_RESULTS_LIMIT', - message: 'The resultsPerPage must be an integer less than or equal to 500.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'MAX_RESULTS_LIMIT', + message: 'The resultsPerPage must be an integer less than or equal to 500.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); + test.each(['abc', 1.5, '1.5', 0, -1, null, true, false, [], [1], {}])( + 'POST /search rejects invalid page size %j with the integer error', + async (resultsPerPage) => { + const response = await request(app, { method: 'POST', path: '/search', body: { resultsPerPage } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('RESULTS_PER_PAGE_INT'); + expect(mw.validateParams).not.toHaveBeenCalled(); + expect(controller.SEARCH).not.toHaveBeenCalled(); + } + ); + + test.each([1, 500, '1', '500'])( + 'POST /search normalizes valid page size %j without truncation', + async (resultsPerPage) => { + const response = await request(app, { method: 'POST', path: '/search', body: { resultsPerPage } }); + expect(response.status).toBe(200); + expect(response.body.params.resultsPerPage).toBe(Number(resultsPerPage)); + } + ); + test('POST /webSearch parses JSON body and reaches controller', async () => { const response = await request(app, { method: 'POST', diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index cd172a7..075677c 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -28,6 +28,57 @@ describe('search route integration', () => { mockSearchOpenSearch.mockReset(); }); + test.each([ + ['/search', { keywordSearch: 'apache' }, 0], + ['/search', { keywordSearch: 'apache' }, 1], + ['/webSearch', { query: 'apache' }, 0], + ['/webSearch', { query: 'apache' }, 1], + ['/SEARCH/', { keywordSearch: 'apache' }, 0], + ['/WEBSEARCH/', { query: 'apache' }, 0] + ])('%s advertises client freshness for successful results, including %s hits', async (path, body, total) => { + const hits = total ? [{ _source: { cveMetadata: { cveId: 'CVE-2024-0001' } } }] : []; + mockSearchOpenSearch.mockResolvedValue(advancedResponse(total, hits).data); + const response = await request(createApp(), { method: 'POST', path, body }); + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('private, max-age=60'); + }); + + test.each([ + ['/search', { resultsPerPage: 0 }], + ['/webSearch', { query: '' }] + ])('%s does not cache validation errors', async (path, body) => { + const response = await request(createApp(), { method: 'POST', path, body }); + expect(response.status).toBe(400); + expect(response.headers['cache-control']).toBe('no-store'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + ['/search', { name: 'TimeoutError' }, 504], + ['/search', { name: 'ConnectionError' }, 503], + ['/search', new Error('backend failure'), 502], + ['/webSearch', { name: 'TimeoutError' }, 504], + ['/webSearch', new Error('backend failure'), 502] + ])('%s does not cache upstream errors: %p', async (path, error, status) => { + mockSearchOpenSearch.mockRejectedValue(error); + const body = path === '/search' ? { keywordSearch: 'apache' } : { query: 'apache' }; + const response = await request(createApp(), { method: 'POST', path, body }); + expect(response.status).toBe(status); + expect(response.headers['cache-control']).toBe('no-store'); + }); + + test.each(['/search', '/webSearch'])('%s does not cache parser errors', async (path) => { + const response = await request(createApp(), { + method: 'POST', + path, + rawBody: '{', + headers: { 'Content-Type': 'application/json' } + }); + expect(response.status).toBe(400); + expect(response.headers['cache-control']).toBe('no-store'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test.each( [ '79', @@ -56,6 +107,36 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test.each( + [ + null, + [], + 'datePublished', + false, + 1, + {}, + { order: 'desc' }, + { property: null }, + { property: [] }, + { property: '' }, + { property: 'dateUpdated' }, + { property: 'relevancy' }, + { property: '_script' }, + { property: 'cveMetadata.cveId.keyword' }, + { property: 'cveId', order: null }, + { property: 'cveId', order: [] }, + { property: 'cveId', order: false }, + { property: 'cveId', order: '' }, + { property: 'cveId', order: 'newest' }, + { property: 'cveId', extra: true } + ].map((value) => [value]) + )('rejects malformed or unsupported /search sort %j before OpenSearch', async (sort) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { sort } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('SEARCH_SORT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test.each([ [ { @@ -79,7 +160,89 @@ describe('search route integration', () => { async (body) => { const response = await request(createApp(), { method: 'POST', path: '/search', body }); expect(response.status).toBe(400); - expect(response.body[0].error).toBe('INVALID_PARAM'); + expect(response.body.error).toBe('INVALID_PARAM'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + + test.each([ + ['CVE-2099-99999999', false, []], + ['cve-2024-0001,CVE-2099-99999999,CVE-2099-99999999', true, []], + [ + 'CVE-1998-0001,cve-2024-0001,CVE-2099-99999999', + true, + [expect.objectContaining({ code: 'CVE_IDS_NOT_RETURNED', cveIds: ['CVE-1998-0001'] })] + ] + ])('returns matches without an availability lookup for %s', async (cveIds, hasMatch, warnings) => { + const record = { cveMetadata: { cveId: 'CVE-2024-0001' } }; + const records = hasMatch ? [record] : []; + mockSearchOpenSearch.mockResolvedValue( + advancedResponse( + records.length, + records.map((_source) => ({ _source })) + ).data + ); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cveIds, resultsPerPage: 1 } + }); + expect(response.status).toBe(200); + expect(response.body.records).toStrictEqual(records); + expect(response.body.pagination.totalResults).toBe(records.length); + expect(response.body.warnings).toStrictEqual(warnings); + expect(Object.keys(response.body).indexOf('warnings')).toBeLessThan( + Object.keys(response.body).indexOf('records') + ); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + }); + + test.each([ + { resultsPerPage: 1, pageNumber: 2 }, + { keywordSearch: 'nonmatching-text', scope: 'ADP_ONLY' }, + { noRejected: true }, + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' } + ])('does not perform an availability lookup when filters or pagination exclude an ID: %p', async (filters) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cveIds: 'CVE-2024-0001', ...filters } + }); + expect(response.status).toBe(200); + expect(response.body.records).toStrictEqual([]); + expect(response.body.warnings).toStrictEqual([]); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + }); + + test('date-only boundaries and offsets reach OpenSearch as inclusive UTC ranges', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + pubStartDate: '2024-02-29', + pubEndDate: '2024-02-29', + lastModStartDate: '2024-03-01T01:00:00+01:00' + } + }); + expect(response.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + { + range: { + 'cveMetadata.datePublished': { gte: '2024-02-29T00:00:00.000Z', lte: '2024-02-29T23:59:59.999Z' } + } + }, + { range: { 'cveMetadata.dateUpdated': { gte: '2024-03-01T00:00:00.000Z', lte: null } } } + ]); + }); + + test.each(['2024-02-30', '2023-02-29T00:00:00+01:00', '2024-01-01T00:00:00.1234Z', '2024-01-01T00:00:00'])( + 'rejects invalid or unsupported date %s before OpenSearch', + async (pubStartDate) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { pubStartDate } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('DATE_TIME_FORMAT'); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); } ); @@ -151,6 +314,169 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('compares date range order after timezone normalization', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { pubStartDate: '2024-01-01T01:00:00-02:00', pubEndDate: '2024-01-01T02:00:00Z' } + }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('DATE_RANGE_ORDER'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + ['ssvc', 'ssvcTimestampStartDate', 'ssvcTimestampEndDate', { timestamp: '2024-02-29T12:00:00Z' }], + ['kev', 'kevDateAddedStartDate', 'kevDateAddedEndDate', { dateAdded: '2024-02-29' }] + ])('date-only %s boundaries include a metric on the requested day', async (type, start, end, content) => { + const hit = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { metrics: [{ other: { type, content } }] } } + } + }; + mockSearchOpenSearch.mockResolvedValue(advancedResponse(1, [hit]).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { [start]: '2024-02-29', [end]: '2024-02-29', ...(type === 'kev' ? { knownExploited: 'true' } : {}) } + }); + expect(response.status).toBe(200); + expect(response.body.pagination.totalResults).toBe(1); + expect(response.body.records[0].cveMetadata.cveId).toBe('CVE-2024-0001'); + const candidate = JSON.stringify(mockSearchOpenSearch.mock.calls[0][0]); + expect(candidate).toContain('2024-02-29T00:00:00.000Z'); + expect(candidate).toContain('2024-02-29T23:59:59.999Z'); + }); + + test.each([ + ['cveId', 'asc'], + ['cveId', 'desc'], + ['datePublished', 'asc'], + ['datePublished', 'desc'] + ])('applies direct %s sorting in %s order with pagination', async (property, order) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'ADP_ONLY', + sort: { property: property.toUpperCase(), order: order.toUpperCase() }, + resultsPerPage: 2, + pageNumber: 2 + } + }); + expect(response.status).toBe(200); + const expectedSort = + property === 'cveId' + ? [{ 'cveMetadata.cveId.keyword': { order } }] + : [ + { 'cveMetadata.datePublished': { order, missing: '_last' } }, + { 'cveMetadata.cveId.keyword': { order: 'asc' } } + ]; + expect(mockSearchOpenSearch.mock.calls[0][0]).toMatchObject({ size: 2, from: 2, sort: expectedSort }); + }); + + test('combines date normalization, immutable sorting, boolean inputs, and client-cache headers', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + pubStartDate: '2024-02-29', + pubEndDate: '2024-03-01T01:00:00+01:00', + sort: { property: 'DATEPUBLISHED', order: 'DESC' }, + noRejected: 'true', + resultsPerPage: '2', + pageNumber: 2 + } + }); + + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('private, max-age=60'); + const query = mockSearchOpenSearch.mock.calls[0][0]; + expect(query).toMatchObject({ + size: 2, + from: 2, + sort: [ + { 'cveMetadata.datePublished': { order: 'desc', missing: '_last' } }, + { 'cveMetadata.cveId.keyword': { order: 'asc' } } + ] + }); + expect(JSON.stringify(query.query)).toContain('2024-02-29T00:00:00.000Z'); + expect(JSON.stringify(query.query)).toContain('2024-03-01T00:00:00.000Z'); + expect(query.query.bool.filter).toContainEqual({ + bool: { + must_not: expect.arrayContaining([ + { terms: { 'cveMetadata.state.keyword': constants.REJECTED_CVE_STATES } } + ]) + } + }); + }); + + test.each(['cveId', 'datePublished'])( + 'preserves %s candidate order through semantic filtering and full record fetches', + async (property) => { + const ids = + property === 'datePublished' ? ['CVE-2020-0002', 'CVE-2024-0001'] : ['CVE-2024-0001', 'CVE-2020-0002']; + const hit = (id, version) => ({ + _id: id, + _source: { + cveMetadata: { cveId: id }, + containers: { + cna: { + cpeApplicability: [ + { + nodes: [ + { + cpeMatch: [ + { + vulnerable: true, + criteria: `cpe:2.3:a:vendor:product:${version}:*:*:*:*:*:*:*` + } + ] + } + ] + } + ] + } + } + } + }); + const matches = ids.map((id) => hit(id, '1.0')); + mockSearchOpenSearch + .mockResolvedValueOnce(advancedResponse(3, [matches[0], hit('CVE-2023-0003', '2.0'), matches[1]]).data) + .mockResolvedValueOnce(advancedResponse(2, [...matches].reverse()).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', + sort: { property, order: 'desc' }, + resultsPerPage: 2 + } + }); + expect(response.status).toBe(200); + expect(response.body.records.map((record) => record.cveMetadata.cveId)).toStrictEqual(ids); + expect(response.body.pagination.totalResults).toBe(2); + const candidate = mockSearchOpenSearch.mock.calls[0][0]; + expect(candidate).toMatchObject({ size: constants.MAX_SEARCH_WINDOW, from: 0 }); + expect(candidate.sort).toStrictEqual( + property === 'cveId' + ? [{ 'cveMetadata.cveId.keyword': { order: 'desc' } }] + : [ + { 'cveMetadata.datePublished': { order: 'desc', missing: '_last' } }, + { 'cveMetadata.cveId.keyword': { order: 'asc' } } + ] + ); + expect(candidate._source).not.toContain('cveMetadata.datePublished'); + expect(mockSearchOpenSearch.mock.calls[1][0].query.bool.filter).toStrictEqual([ + { terms: { 'cveMetadata.cveId.keyword': ids } } + ]); + } + ); + test('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { const record = { dataType: 'CVE_RECORD', @@ -374,7 +700,8 @@ describe('search route integration', () => { expect(response.status).toBe(400); expect(response.body).toStrictEqual({ error: 'DATE_TIME_FORMAT', - message: "The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'" + message: + 'The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.' }); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); @@ -430,6 +757,48 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test.each([ + ['1.0.2', '1.0.2a', 200], + ['1.1.1l', '1.1.1m', 200], + ['2.0a1', '2.0', 200], + ['1.0b2', '1.0', 200], + ['1.0m3', '1.0', 200], + ['2.0.0-dev1', '2.0.0-alpha1', 200], + ['2.0.0-pre1', '2.0.0-preview1', 200], + ['2.0.0-preview1', '2.0.0-rc1', 200], + ['2.0.0-preview1', '2.0.0', 200], + ['1.0.2a', '1.0.2', 400], + ['1.1.1m', '1.1.1l', 400], + ['2.0', '2.0a1', 400], + ['2.0.0', '2.0.0-dev1', 400], + ['2.0.0-preview1', '2.0.0-pre1', 400], + ['2.0.0-rc1', '2.0.0-preview1', 400] + ])( + 'validates qualifier and patch-letter ranges from %s to %s', + async (versionStart, versionEnd, expectedStatus) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + virtualMatchString: 'cpe:2.3:a:openssl:openssl', + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + } + }); + expect(response.status).toBe(expectedStatus); + if (expectedStatus === 200) { + expect(response.body.pagination.totalResults).toBe(0); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + } else { + expect(response.body.error).toBe('VERSION_RANGE_ORDER'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + } + ); + test('POST /search rejects an inverted virtualMatchString version range before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', @@ -513,6 +882,52 @@ describe('search route integration', () => { } ); + test.each([ + '/fre[e]type/', + '/fre[e]type/ apache', + 'apache /fre[e]type/', + '(apache OR /fre[e]type/)', + 'containers.cna.descriptions.value:/fre[e]type/ apache', + '{invalid} apache', + 'apache {invalid}', + 'field:value apache', + '"unterminated', + '(apache OR nginx', + '2024-02-01..2024-01-01' + ])('POST /webSearch rejects unsupported syntax before searching: %s', async (query) => { + const response = await request(createApp(), { method: 'POST', path: '/webSearch', body: { query } }); + + expect(response.status).toBe(400); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.body).toMatchObject({ + searchMetadata: { searchStatus: 'errors', errors: [{ error: 'WEB_SEARCH_QUERY_SYNTAX' }] }, + resultsTotal: 0, + data: [] + }); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + ['remote code execution', 'remote code execution'], + ['apache*', 'apache*'], + ['*etype', '*etype'], + ['ap?che', 'ap?che'], + ['apache AND (nginx OR "remote code")', 'apache AND (nginx OR "remote code")'], + ['"/fre[e]type/"', '"/fre[e]type/"'], + ['\\/fre[e]type\\/', '\\/fre[e]type\\/'], + ['https://example.com/a?b=1', '"https://example.com/a?b=1"'] + ])('POST /webSearch preserves supported query behavior: %s', async (query, executedQuery) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { method: 'POST', path: '/webSearch', body: { query } }); + + expect(response.status).toBe(200); + expect(mockSearchOpenSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: { query_string: { query: executedQuery, default_operator: 'AND' } } + }) + ); + }); + test('POST /webSearch normalizes fields, builds DSL, and formats OpenSearch responses', async () => { mockSearchOpenSearch.mockResolvedValue(advancedResponse(1, [{ _id: 'CVE-2024-0002' }]).data); diff --git a/routes/swagger.js b/routes/swagger.js index 0195355..6b49937 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -35,6 +35,19 @@ function coercibleBooleanSchema(defaultValue = false) { }; } +function searchDateProperty(description, endOfDay) { + return searchRequestProperty( + `${description} Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means ${endOfDay ? '23:59:59.999' : '00:00:00.000'} UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected.`, + { + oneOf: [ + { type: 'string', format: 'date' }, + { type: 'string', format: 'date-time' } + ], + example: endOfDay ? '2024-12-31T23:59:59.999Z' : '2024-01-01T00:00:00.000Z' + } + ); +} + 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.', @@ -44,6 +57,20 @@ const searchRequestProperties = { default: searchScope.SEARCH_SCOPES.FULL } ), + sort: searchRequestProperty( + 'Orders matching records using immutable base-record fields, independently of scope. Defaults to CVE ID ascending when omitted. property is required and accepts cveId (textual, not numeric sequence ordering) or datePublished; order accepts asc or desc and defaults to asc. Values are case-insensitive. Publication-date sorts place missing dates last and use CVE ID ascending to break ties. The selected sort is applied before the 10,000-candidate limit for semantic searches, so changing it can change the bounded candidate set and reported partial total. Both 10,000-record controls still apply. New records, deletions, or changed filter membership can shift pages between requests; this option does not provide a shared snapshot. Mutable dateUpdated sorting is unsupported.', + { + type: 'object', + additionalProperties: false, + required: ['property'], + properties: { + property: { type: 'string', enum: constants.SEARCH_SORT_PROPERTIES }, + order: { type: 'string', enum: constants.SEARCH_SORT_ORDERS, default: 'asc' } + }, + default: { property: 'cveId', order: 'asc' }, + example: { property: 'datePublished', order: 'desc' } + } + ), keywordSearch: searchRequestProperty( `Searches description text selected by scope. Unless keywordExactMatch is true, caller query syntax is escaped, each whitespace-delimited term receives a trailing prefix wildcard, and terms are combined with AND using simple_query_string with wildcard analysis enabled. For example, remote code searches for remote* AND code*. With the standard analyzer, use-after-free requires use and after tokens plus a free* prefix, without requiring adjacency or order. Hyphenated CVE and CWE IDs can match description text; use cveIds or cweId for their dedicated fields. Matching follows the selected field's text analyzer. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, { @@ -57,7 +84,7 @@ const searchRequestProperties = { coercibleBooleanSchema() ), cveIds: searchRequestProperty( - `Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, + `Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning (CVE_IDS_NOT_RETURNED) without preventing other requested IDs from matching. Other syntactically valid IDs with no matches do not produce a missing-ID warning. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cveIds, @@ -152,7 +179,7 @@ const searchRequestProperties = { } ), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -163,38 +190,19 @@ const searchRequestProperties = { 'When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable set to true; false is allowed. JSON booleans and the lowercase strings "true" and "false" are accepted.', coercibleBooleanSchema() ), - lastModStartDate: searchRequestProperty( - 'Returns CVE records with dateUpdated timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-01-01T00:00:00.000Z' - } - ), - lastModEndDate: searchRequestProperty( - 'Returns CVE records with dateUpdated timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-12-31T23:59:59.999Z' - } + lastModStartDate: searchDateProperty( + 'Returns CVE records with dateUpdated timestamps on or after this value.', + false ), - pubStartDate: searchRequestProperty( - 'Returns CVE records with datePublished timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-01-01T00:00:00.000Z' - } + lastModEndDate: searchDateProperty( + 'Returns CVE records with dateUpdated timestamps on or before this value.', + true ), - pubEndDate: searchRequestProperty( - 'Returns CVE records with datePublished timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-12-31T23:59:59.999Z' - } + pubStartDate: searchDateProperty( + 'Returns CVE records with datePublished timestamps on or after this value.', + false ), + pubEndDate: searchDateProperty('Returns CVE records with datePublished timestamps on or before this value.', true), resultsPerPage: searchRequestProperty( `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.`, { @@ -215,7 +223,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. 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.', + '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 extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -230,7 +238,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. 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.', + '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 extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, @@ -281,43 +289,25 @@ const searchRequestProperties = { 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' - } + ssvcTimestampStartDate: searchDateProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value.', + false ), - 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' - } + ssvcTimestampEndDate: searchDateProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value.', + true ), 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' - } + '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. JSON booleans and the lowercase strings "true" and "false" are accepted.', + { oneOf: coercibleBooleanSchema().oneOf } ), - kevDateAddedStartDate: searchRequestProperty( + kevDateAddedStartDate: searchDateProperty( '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' - } + false ), - kevDateAddedEndDate: searchRequestProperty( + kevDateAddedEndDate: searchDateProperty( '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' - } + true ), 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.`, @@ -329,10 +319,8 @@ const searchRequestProperties = { } ), 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' - } + '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. JSON booleans and the lowercase strings "true" and "false" are accepted.', + { oneOf: coercibleBooleanSchema().oneOf } ) }; @@ -354,6 +342,18 @@ const doc = { } ], components: { + headers: { + SearchCacheControl: { + description: + 'Client application result-cache freshness hint: private, max-age=60 by default. SearchClientCacheMaxAgeSeconds configures the seconds; zero or invalid values produce no-store. POST results are not automatically reused by ordinary HTTP caches. Clients must key application caches by API environment, endpoint, and the complete request body, including pagination, scope, and sorting. No server-side cache is created.', + schema: { type: 'string' }, + example: 'private, max-age=60' + }, + NoStoreCacheControl: { + description: 'Do not cache error or health/readiness responses.', + schema: { type: 'string', enum: ['no-store'] } + } + }, '@schemas': { ApiError: { type: 'object', diff --git a/server.js b/server.js index 6cc0233..cca6f97 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const logger = require('morgan'); const router = require('./routes/index'); const errors = require('./utils/errors'); const requestLogging = require('./utils/requestLogging'); +const clientCachePolicy = require('./utils/clientCachePolicy'); const error = new errors.errors(); @@ -63,6 +64,7 @@ function createApp() { app.use(requestLogging.assignRequestId); app.use(createRequestLogger()); + app.use(['/search', '/webSearch', '/api/health-check', '/api/ready-check'], clientCachePolicy.noStore); app.use(cors()); app.use(express.json()); diff --git a/server.test.js b/server.test.js index 7b0bcac..4de72e6 100644 --- a/server.test.js +++ b/server.test.js @@ -1,4 +1,5 @@ const http = require('http'); +const { deflateSync, gzipSync } = require('zlib'); const { request } = require('./test/http'); const mockValidateParams = jest.fn((req, res, next) => next()); @@ -70,6 +71,7 @@ describe('server application bootstrap', () => { expect(response.text).toBe(''); expect(response.headers['access-control-allow-origin']).toBe('*'); expect(response.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/); + expect(response.headers['cache-control']).toBe('no-store'); }); test('createApp preserves valid caller request IDs in response headers', async () => { @@ -83,6 +85,23 @@ describe('server application bootstrap', () => { expect(response.headers['x-request-id']).toBe('caller-request-123'); }); + test.each([true, false])('readiness responses are not cacheable when ready=%s', async (ready) => { + const check = jest.spyOn(require('./utils/searchProvider'), 'checkOpenSearchReadiness'); + if (ready) check.mockResolvedValue(undefined); + else check.mockRejectedValue(new Error('unavailable')); + + const response = await request(createApp(), { path: '/api/ready-check' }); + expect(response.status).toBe(ready ? 200 : 503); + expect(response.headers['cache-control']).toBe('no-store'); + }); + + test('disabled webSearch responses are not cacheable', async () => { + jest.spyOn(require('./utils/featureFlags'), 'isWebSearchEnabled').mockReturnValue(false); + const response = await request(createApp(), { method: 'POST', path: '/webSearch', body: {} }); + expect(response.status).toBe(410); + expect(response.headers['cache-control']).toBe('no-store'); + }); + test('createApp mounts the OpenAPI JSON endpoint', async () => { const response = await request(createApp(), { path: '/api-docs/openapi.json' @@ -146,6 +165,76 @@ describe('server application bootstrap', () => { }); }); + test.each([ + ['/search', 'gzip', gzipSync], + ['/search', 'deflate', deflateSync], + ['/webSearch', 'gzip', gzipSync], + ['/webSearch', 'deflate', deflateSync] + ])('parses valid compressed JSON at %s with %s encoding', async (path, encoding, compress) => { + const body = path === '/search' ? { keywordSearch: 'apache' } : { query: 'apache' }; + const response = await request(createApp(), { + method: 'POST', + path, + rawBody: compress(JSON.stringify(body)), + headers: { 'Content-Type': 'application/json', 'Content-Encoding': encoding } + }); + + expect(response.status).toBe(200); + expect(path === '/search' ? response.body.params : response.body.body).toEqual(expect.objectContaining(body)); + }); + + test.each(['/search', '/webSearch'])( + 'does not crash when trailing bytes arrive after decoding a compressed body at %s', + async (path) => { + const app = createApp(); + const body = path === '/search' ? { keywordSearch: 'apache' } : { query: 'apache' }; + const first = Buffer.concat([gzipSync(JSON.stringify(body)), Buffer.from([0])]); + const trailing = Buffer.alloc(100 * 1024, 0x58); + let client; + + const controller = path === '/search' ? mockSearch : mockWebSearch; + controller.mockImplementationOnce((req, res) => { + req.once('end', () => res.json(req.body)); + // Send the remaining bytes only after the parser hands off to the route. + client.end(trailing); + }); + + const response = await new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + client = http.request( + { + hostname: '127.0.0.1', + port: server.address().port, + method: 'POST', + path, + agent: false, + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': first.length + trailing.length + } + }, + (res) => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (text += chunk)); + res.on('end', () => { + server.close(() => resolve({ status: res.statusCode, text })); + }); + } + ); + client.on('error', (err) => server.close(() => reject(err))); + client.setTimeout(2000, () => client.destroy(new Error('Compressed request did not finish.'))); + client.write(first); + }); + }); + + expect(response.status).toBe(200); + expect(JSON.parse(response.text)).toStrictEqual(body); + expect((await request(app, { path: '/api/health-check' })).status).toBe(200); + } + ); + test('createApp returns a sanitized error for malformed JSON', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/test/http.js b/test/http.js index 716afe9..44a0386 100644 --- a/test/http.js +++ b/test/http.js @@ -15,8 +15,12 @@ function hasHeader(headers, name) { function request(app, { method = 'GET', path, body, rawBody, headers = {} }) { return new Promise((resolve, reject) => { const server = app.listen(0, () => { - const payload = - rawBody !== undefined ? String(rawBody) : body !== undefined ? JSON.stringify(body) : undefined; + let payload; + if (rawBody !== undefined) { + payload = Buffer.isBuffer(rawBody) ? rawBody : String(rawBody); + } else if (body !== undefined) { + payload = JSON.stringify(body); + } const requestHeaders = { ...headers }; if (payload !== undefined) { if (rawBody === undefined && !hasHeader(requestHeaders, 'content-type')) { diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 3000add..04e478a 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -428,7 +428,7 @@ "response": [] }, { - "name": "Second valid page can be empty", + "name": "knownExploited accepts string true", "event": [ { "listen": "test", @@ -458,8 +458,6 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -476,14 +474,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 2\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": \"true\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Maximum valid search window boundary", + "name": "knownExploited accepts string false", "event": [ { "listen": "test", @@ -513,8 +511,6 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -531,20 +527,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 500,\n \"pageNumber\": 20\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": \"false\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "02 - Scope coverage", - "description": "Base, CNA, and ADP matching behavior.", - "item": [ + }, { - "name": "FULL scope accepts lowercase input", + "name": "disputed accepts string true", "event": [ { "listen": "test", @@ -574,9 +564,6 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -593,14 +580,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"full\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": \"true\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CNA_ONLY keyword match", + "name": "disputed accepts string false", "event": [ { "listen": "test", @@ -630,9 +617,6 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -649,14 +633,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"cna_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": \"false\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY excludes a CNA-only keyword match", + "name": "Second valid page can be empty", "event": [ { "listen": "test", @@ -686,7 +670,7 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] @@ -704,14 +688,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"adp_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 2\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Base CVE ID participates in ADP_ONLY", + "name": "Maximum valid search window boundary", "event": [ { "listen": "test", @@ -742,8 +726,7 @@ " pm.expect(payload).not.to.have.property('data');", "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -760,14 +743,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 500,\n \"pageNumber\": 20\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CNA_ONLY CWE match", + "name": "Direct cveId asc sorting", "event": [ { "listen": "test", @@ -797,9 +780,30 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 6', () => pm.expect(payload.pagination.totalResults).to.eql(6));", + "pm.test('returned record count is 6', () => pm.expect(payload.records.length).to.eql(6));", + "const requestedSort = {\"property\":\"cveId\",\"order\":\"asc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -816,14 +820,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31640,CVE-2022-31279,CVE-2020-1472,CVE-2025-23022,CVE-2022-38625\",\n \"resultsPerPage\": 6,\n \"sort\": {\n \"property\": \"CVEID\",\n \"order\": \"ASC\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY CWE match", + "name": "Direct cveId desc sorting", "event": [ { "listen": "test", @@ -853,9 +857,30 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "pm.test('total is 6', () => pm.expect(payload.pagination.totalResults).to.eql(6));", + "pm.test('returned record count is 6', () => pm.expect(payload.records.length).to.eql(6));", + "const requestedSort = {\"property\":\"cveId\",\"order\":\"desc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -872,20 +897,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"CWE-367\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31640,CVE-2022-31279,CVE-2020-1472,CVE-2025-23022,CVE-2022-38625\",\n \"resultsPerPage\": 6,\n \"sort\": {\n \"property\": \"CVEID\",\n \"order\": \"DESC\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "03 - Keyword and record filters", - "description": "Description, ID, and record-state filters.", - "item": [ + }, { - "name": "keywordSearch", + "name": "Direct datePublished asc sorting", "event": [ { "listen": "test", @@ -915,9 +934,30 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 6', () => pm.expect(payload.pagination.totalResults).to.eql(6));", + "pm.test('returned record count is 6', () => pm.expect(payload.records.length).to.eql(6));", + "const requestedSort = {\"property\":\"datePublished\",\"order\":\"asc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -934,14 +974,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31640,CVE-2022-31279,CVE-2020-1472,CVE-2025-23022,CVE-2022-38625\",\n \"resultsPerPage\": 6,\n \"sort\": {\n \"property\": \"DATEPUBLISHED\",\n \"order\": \"ASC\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "keywordExactMatch true", + "name": "Direct datePublished desc sorting", "event": [ { "listen": "test", @@ -971,9 +1011,30 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 6', () => pm.expect(payload.pagination.totalResults).to.eql(6));", + "pm.test('returned record count is 6', () => pm.expect(payload.records.length).to.eql(6));", + "const requestedSort = {\"property\":\"datePublished\",\"order\":\"desc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -990,14 +1051,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31640,CVE-2022-31279,CVE-2020-1472,CVE-2025-23022,CVE-2022-38625\",\n \"resultsPerPage\": 6,\n \"sort\": {\n \"property\": \"DATEPUBLISHED\",\n \"order\": \"DESC\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "keywordExactMatch false without keyword", + "name": "Publication sort defaults omitted order to ascending", "event": [ { "listen": "test", @@ -1027,9 +1088,30 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 6', () => pm.expect(payload.pagination.totalResults).to.eql(6));", + "pm.test('returned record count is 6', () => pm.expect(payload.records.length).to.eql(6));", + "const requestedSort = {\"property\":\"datePublished\",\"order\":\"asc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -1046,14 +1128,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordExactMatch\": false\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31640,CVE-2022-31279,CVE-2020-1472,CVE-2025-23022,CVE-2022-38625\",\n \"sort\": {\n \"property\": \"datePublished\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "noRejected true excludes rejected records", + "name": "Semantic cveId asc sorting", "event": [ { "listen": "test", @@ -1083,9 +1165,29 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is at least 1', () => pm.expect(payload.pagination.totalResults).to.be.at.least(1));", + "const requestedSort = {\"property\":\"cveId\",\"order\":\"asc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -1102,14 +1204,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": true,\n \"resultsPerPage\": 2\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"resultsPerPage\": 5,\n \"sort\": {\n \"property\": \"cveId\",\n \"order\": \"asc\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "noRejected false does not filter", + "name": "Semantic cveId desc sorting", "event": [ { "listen": "test", @@ -1139,9 +1241,29 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 2', () => pm.expect(payload.pagination.totalResults).to.eql(2));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "pm.test('total is at least 1', () => pm.expect(payload.pagination.totalResults).to.be.at.least(1));", + "const requestedSort = {\"property\":\"cveId\",\"order\":\"desc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -1158,14 +1280,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": false,\n \"resultsPerPage\": 2\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"resultsPerPage\": 5,\n \"sort\": {\n \"property\": \"cveId\",\n \"order\": \"desc\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cweIds", + "name": "Semantic datePublished asc sorting", "event": [ { "listen": "test", @@ -1195,9 +1317,29 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is at least 1', () => pm.expect(payload.pagination.totalResults).to.be.at.least(1));", + "const requestedSort = {\"property\":\"datePublished\",\"order\":\"asc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -1214,14 +1356,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"resultsPerPage\": 5,\n \"sort\": {\n \"property\": \"datePublished\",\n \"order\": \"asc\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Multiple CWE IDs use OR and accept case and whitespace", + "name": "Semantic datePublished desc sorting", "event": [ { "listen": "test", @@ -1251,9 +1393,29 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is at least 1', () => pm.expect(payload.pagination.totalResults).to.be.at.least(1));", + "const requestedSort = {\"property\":\"datePublished\",\"order\":\"desc\"};", + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + " const records = payload.records;", + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + " const value = record.cveMetadata.datePublished;", + " if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);", + " });", + " for (let i = 1; i < records.length; i++) {", + " const left = records[i - 1].cveMetadata;", + " const right = records[i].cveMetadata;", + " let comparison;", + " if (requestedSort.property === 'cveId') {", + " comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;", + " } else {", + " if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }", + " if (right.datePublished == null) continue;", + " comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));", + " if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }", + " }", + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + " }", + "});", "}" ] } @@ -1270,14 +1432,20 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \" CWE-79, cwe-89, CWE-89 \"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"resultsPerPage\": 5,\n \"sort\": {\n \"property\": \"datePublished\",\n \"order\": \"desc\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - }, + } + ] + }, + { + "name": "02 - Scope coverage", + "description": "Base, CNA, and ADP matching behavior.", + "item": [ { - "name": "An unmatched CWE ID does not discard another match", + "name": "FULL scope accepts lowercase input", "event": [ { "listen": "test", @@ -1326,20 +1494,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-99999999,CWE-89\"\n}", + "raw": "{\n \"scope\": \"full\",\n \"cveIds\": \"CVE-2024-5981\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "04 - CVSS filters", - "description": "Every supported CVSS severity and vector filter.", - "item": [ + }, { - "name": "cvssV2Severity is case-insensitive", + "name": "CNA_ONLY keyword match", "event": [ { "listen": "test", @@ -1388,14 +1550,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Severity\": \"medium\"\n}", + "raw": "{\n \"scope\": \"cna_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV2Metrics", + "name": "ADP_ONLY excludes a CNA-only keyword match", "event": [ { "listen": "test", @@ -1425,9 +1587,8 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -1444,14 +1605,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Metrics\": \"AV:N/AC:L/Au:S\"\n}", + "raw": "{\n \"scope\": \"adp_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV3Severity", + "name": "Base CVE ID participates in ADP_ONLY", "event": [ { "listen": "test", @@ -1500,14 +1661,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Severity\": \"medium\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV3Metrics", + "name": "CNA_ONLY CWE match", "event": [ { "listen": "test", @@ -1556,14 +1717,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Metrics\": \"AV:N/AC:L/PR:L\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV3_0Severity", + "name": "ADP_ONLY CWE match", "event": [ { "listen": "test", @@ -1595,7 +1756,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", "}" ] } @@ -1612,14 +1773,20 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Severity\": \"medium\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"CWE-367\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - }, + } + ] + }, + { + "name": "03 - Keyword and record filters", + "description": "Description, ID, and record-state filters.", + "item": [ { - "name": "cvssV3_0Metrics", + "name": "keywordSearch", "event": [ { "listen": "test", @@ -1668,14 +1835,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV3_1Severity", + "name": "keywordExactMatch true", "event": [ { "listen": "test", @@ -1724,14 +1891,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Severity\": \"medium\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV3_1Metrics", + "name": "keywordExactMatch false without keyword", "event": [ { "listen": "test", @@ -1780,14 +1947,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordExactMatch\": false\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV4Severity", + "name": "noRejected true excludes rejected records", "event": [ { "listen": "test", @@ -1836,14 +2003,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Severity\": \"medium\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": true,\n \"resultsPerPage\": 2\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cvssV4Metrics", + "name": "noRejected false does not filter", "event": [ { "listen": "test", @@ -1873,9 +2040,9 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + "pm.test('total is 2', () => pm.expect(payload.pagination.totalResults).to.eql(2));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", "}" ] } @@ -1892,14 +2059,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": false,\n \"resultsPerPage\": 2\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP CVSS and SSVC must match selected containers", + "name": "cweIds", "event": [ { "listen": "test", @@ -1931,7 +2098,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -1948,20 +2115,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "05 - Record date filters", - "description": "Publication and modification boundaries.", - "item": [ + }, { - "name": "pubStartDate", + "name": "Multiple CWE IDs use OR and accept case and whitespace", "event": [ { "listen": "test", @@ -2010,14 +2171,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \" CWE-79, cwe-89, CWE-89 \"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "pubEndDate", + "name": "An unmatched CWE ID does not discard another match", "event": [ { "listen": "test", @@ -2066,14 +2227,20 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-99999999,CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - }, + } + ] + }, + { + "name": "04 - CVSS filters", + "description": "Every supported CVSS severity and vector filter.", + "item": [ { - "name": "Publication date range", + "name": "cvssV2Severity is case-insensitive", "event": [ { "listen": "test", @@ -2122,14 +2289,14 @@ }, "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}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Severity\": \"medium\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "lastModStartDate", + "name": "cvssV2Metrics", "event": [ { "listen": "test", @@ -2178,14 +2345,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "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": "lastModEndDate", + "name": "cvssV3Severity", "event": [ { "listen": "test", @@ -2234,14 +2401,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Severity\": \"medium\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Modification date range", + "name": "cvssV3Metrics", "event": [ { "listen": "test", @@ -2290,20 +2457,14 @@ }, "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}", + "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": "06 - CPE filters", - "description": "Concrete CPE and virtual match-string behavior.", - "item": [ + }, { - "name": "cpeName", + "name": "cvssV3_0Severity", "event": [ { "listen": "test", @@ -2335,7 +2496,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -2352,14 +2513,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Severity\": \"medium\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cpeName with isVulnerable true", + "name": "cvssV3_0Metrics", "event": [ { "listen": "test", @@ -2391,7 +2552,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -2408,14 +2569,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "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": "isVulnerable false without cpeName", + "name": "cvssV3_1Severity", "event": [ { "listen": "test", @@ -2464,14 +2625,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"isVulnerable\": false\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Severity\": \"medium\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "virtualMatchString", + "name": "cvssV3_1Metrics", "event": [ { "listen": "test", @@ -2503,7 +2664,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -2520,14 +2681,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\"\n}", + "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": "Broad virtualMatchString returns an interpretation warning", + "name": "cvssV4Severity", "event": [ { "listen": "test", @@ -2559,9 +2720,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "const expectedWarnings = [{\"code\":\"BROAD_VIRTUAL_MATCH_STRING\",\"message\":\"Wildcard or omitted leading CPE components broaden this search and may cause results to reach the semantic candidate limit.\",\"components\":[\"vendor\",\"product\"]}];", - "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -2578,14 +2737,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:*\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Severity\": \"medium\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Abbreviated cpeName returns a defaulted-components warning", + "name": "cvssV4Metrics", "event": [ { "listen": "test", @@ -2617,9 +2776,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "const expectedWarnings = [{\"code\":\"CPE_COMPONENTS_DEFAULTED\",\"message\":\"Omitted trailing cpeName components were treated as wildcard components.\",\"components\":[\"update\",\"edition\",\"language\",\"sw_edition\",\"target_sw\",\"target_hw\",\"other\"]}];", - "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -2636,14 +2793,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1\"\n}", + "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": "virtualMatchString with versionStart pair", + "name": "ADP CVSS and SSVC must match selected containers", "event": [ { "listen": "test", @@ -2675,7 +2832,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", "}" ] } @@ -2692,14 +2849,20 @@ }, "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}", + "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": "virtualMatchString with versionEnd pair", + "name": "Date-only publication boundaries", "event": [ { "listen": "test", @@ -2730,8 +2893,6 @@ " pm.expect(payload).not.to.have.property('data');", "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -2748,14 +2909,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01\",\n \"pubEndDate\": \"2024-12-31\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "virtualMatchString with complete range", + "name": "Publication timestamp with explicit offset", "event": [ { "listen": "test", @@ -2786,8 +2947,6 @@ " pm.expect(payload).not.to.have.property('data');", "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -2804,20 +2963,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T01:00:00+01:00\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "07 - SSVC filters", - "description": "Individual and same-entry SSVC matching.", - "item": [ + }, { - "name": "ssvcExploitation", + "name": "Publication timestamp without fractional seconds", "event": [ { "listen": "test", @@ -2848,8 +3001,6 @@ " pm.expect(payload).not.to.have.property('data');", "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", "}" ] } @@ -2866,14 +3017,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"none\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00Z\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ssvcAutomatable", + "name": "pubStartDate", "event": [ { "listen": "test", @@ -2922,14 +3073,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcAutomatable\": \"no\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ssvcTechnicalImpact", + "name": "pubEndDate", "event": [ { "listen": "test", @@ -2978,14 +3129,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTechnicalImpact\": \"partial\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ssvcRole is case-insensitive", + "name": "Publication date range", "event": [ { "listen": "test", @@ -3034,14 +3185,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcRole\": \"cisa coordinator\"\n}", + "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": "ssvcTimestampStartDate", + "name": "lastModStartDate", "event": [ { "listen": "test", @@ -3090,14 +3241,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ssvcTimestampEndDate", + "name": "lastModEndDate", "event": [ { "listen": "test", @@ -3146,14 +3297,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Complete SSVC same-entry combination", + "name": "Modification date range", "event": [ { "listen": "test", @@ -3202,7 +3353,7 @@ }, "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}", + "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" } } } }, @@ -3211,11 +3362,11 @@ ] }, { - "name": "08 - KEV filters", - "description": "Structured KEV presence and inclusive date filtering.", + "name": "06 - CPE filters", + "description": "Concrete CPE and virtual match-string behavior.", "item": [ { - "name": "knownExploited true", + "name": "cpeName", "event": [ { "listen": "test", @@ -3247,7 +3398,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -3264,14 +3415,70 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true\n}", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "knownExploited false", + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "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", @@ -3320,14 +3527,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": false\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"isVulnerable\": false\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "kevDateAddedStartDate", + "name": "virtualMatchString", "event": [ { "listen": "test", @@ -3359,7 +3566,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -3376,14 +3583,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "kevDateAddedEndDate", + "name": "Broad virtualMatchString returns an interpretation warning", "event": [ { "listen": "test", @@ -3415,7 +3622,9 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "const expectedWarnings = [{\"code\":\"BROAD_VIRTUAL_MATCH_STRING\",\"message\":\"Wildcard or omitted leading CPE components broaden this search and may cause results to reach the semantic candidate limit.\",\"components\":[\"vendor\",\"product\"]}];", + "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", "}" ] } @@ -3432,14 +3641,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:*\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Inclusive KEV date range", + "name": "Abbreviated cpeName returns a defaulted-components warning", "event": [ { "listen": "test", @@ -3471,7 +3680,9 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "const expectedWarnings = [{\"code\":\"CPE_COMPONENTS_DEFAULTED\",\"message\":\"Omitted trailing cpeName components were treated as wildcard components.\",\"components\":[\"update\",\"edition\",\"language\",\"sw_edition\",\"target_sw\",\"target_hw\",\"other\"]}];", + "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", "}" ] } @@ -3488,20 +3699,14 @@ }, "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}", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "09 - Reference and dispute filters", - "description": "Reference URL substring and disputed-tag behavior.", - "item": [ + }, { - "name": "referenceURL FULL", + "name": "virtualMatchString with versionStart pair", "event": [ { "listen": "test", @@ -3533,7 +3738,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -3550,14 +3755,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "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": "referenceURL CNA_ONLY", + "name": "virtualMatchString with versionEnd pair", "event": [ { "listen": "test", @@ -3589,7 +3794,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -3606,14 +3811,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1\"\n}", + "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": "referenceURL ADP_ONLY", + "name": "virtualMatchString with complete range", "event": [ { "listen": "test", @@ -3645,7 +3850,7 @@ "});", "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", "}" ] } @@ -3662,14 +3867,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "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": "referenceURL treats wildcard characters literally", + "name": "Valid qualifier or patch-letter range 1.0.2 to 1.0.2a", "event": [ { "listen": "test", @@ -3715,16 +3920,17 @@ "path": ["search"], "query": [] }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/*/cve/issues/1\"\n}", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0.2\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0.2a\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "disputed true", + "name": "Valid qualifier or patch-letter range 1.1.1l to 1.1.1m", "event": [ { "listen": "test", @@ -3754,9 +3960,8 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-38625\"]));", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -3771,16 +3976,17 @@ "path": ["search"], "query": [] }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2022-38625\",\n \"disputed\": true\n}", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.1.1l\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.1.1m\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "disputed false", + "name": "Valid qualifier or patch-letter range 2.0a1 to 2.0", "event": [ { "listen": "test", @@ -3810,9 +4016,8 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -3827,22 +4032,17 @@ "path": ["search"], "query": [] }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": false\n}", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0a1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0\",\n \"versionEndType\": \"including\"\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", + "name": "Valid qualifier or patch-letter range 1.0b2 to 1.0", "event": [ { "listen": "test", @@ -3872,9 +4072,8 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -3889,16 +4088,17 @@ "path": ["search"], "query": [] }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweIds\": \"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}", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0b2\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP CWE, CVSS, SSVC, and reference", + "name": "Valid qualifier or patch-letter range 1.0m3 to 1.0", "event": [ { "listen": "test", @@ -3928,9 +4128,8 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", "}" ] } @@ -3945,16 +4144,17 @@ "path": ["search"], "query": [] }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"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}", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0m3\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "FULL cross-container KEV, SSVC, CVSS, and reference", + "name": "Valid qualifier or patch-letter range 2.0.0-dev1 to 2.0.0-alpha1", "event": [ { "listen": "test", @@ -3984,10 +4184,2408 @@ " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", " pm.expect(payload).not.to.have.property('data');", "});", - "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", - " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", - "}" + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-dev1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-alpha1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-pre1 to 2.0.0-preview1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-pre1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-preview1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-preview1 to 2.0.0-rc1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-preview1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-rc1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-preview1 to 2.0.0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-preview1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0\",\n \"versionEndType\": \"including\"\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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "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 \"cweIds\": \"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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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 \"cweIds\": \"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.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "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": "Invalid CWE IDs 79", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-0", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-079", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-079\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,,CWE-89", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-79,,CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-*", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,invalid", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-79,invalid\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": [\n \"CWE-79\"\n ]\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-79,\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Former cweId field is unsupported", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"cweId 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 \"cweId\": \"CWE-79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CWE ID item 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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_ID_LENGTH\",\"message\":\"Each CWE ID in the cweIds field 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 \"cweIds\": \"CWE-11111111111111111111111111111\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CWE 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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CWE_IDS_LIMIT\",\"message\":\"The cweIds parameter cannot contain more than 100 CWE 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);", + "});" ] } } @@ -4003,20 +6601,14 @@ }, "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}", + "raw": "{\n \"cweIds\": \"CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "11 - Expected failures - request contract", - "description": "Transport and JSON body failures.", - "item": [ + }, { - "name": "Query parameters are rejected", + "name": "CVE ID length", "event": [ { "listen": "test", @@ -4029,8 +6621,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -4046,31 +6639,36 @@ "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], "url": { - "raw": "{{baseUrl}}/search?keywordSearch=apache", + "raw": "{{baseUrl}}/search", "host": ["{{baseUrl}}"], "path": ["search"], - "query": [{ "key": "keywordSearch", "value": "apache" }] + "query": [] }, - "body": { "mode": "raw", "raw": "{}", "options": { "raw": { "language": "json" } } } + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "options": { "raw": { "language": "json" } } + } }, "response": [] }, { - "name": "Non-JSON content type is rejected", + "name": "CVE ID count limit", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ - "pm.test('status is 415', () => pm.response.to.have.status(415));", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -4084,19 +6682,23 @@ ], "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "text/plain", "type": "text" }], + "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": "text" } } } + "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": "Malformed JSON is rejected", + "name": "resultsPerPage must be positive", "event": [ { "listen": "test", @@ -4109,8 +6711,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "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);", "});", @@ -4133,14 +6736,14 @@ }, "body": { "mode": "raw", - "raw": "{\"keywordSearch\":", + "raw": "{\n \"resultsPerPage\": -1\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Array body is rejected", + "name": "Invalid page size \"abc\"", "event": [ { "listen": "test", @@ -4153,8 +6756,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", + "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);", "});", @@ -4175,12 +6779,16 @@ "path": ["search"], "query": [] }, - "body": { "mode": "raw", "raw": "[]", "options": { "raw": { "language": "json" } } } + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": \"abc\"\n}", + "options": { "raw": { "language": "json" } } + } }, "response": [] }, { - "name": "Top-level JSON string is rejected by the parser", + "name": "Invalid page size 1.5", "event": [ { "listen": "test", @@ -4193,8 +6801,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "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);", "});", @@ -4215,12 +6824,16 @@ "path": ["search"], "query": [] }, - "body": { "mode": "raw", "raw": "\"search\"", "options": { "raw": { "language": "json" } } } + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 1.5\n}", + "options": { "raw": { "language": "json" } } + } }, "response": [] }, { - "name": "Unsupported body field is rejected", + "name": "Invalid page size 0", "event": [ { "listen": "test", @@ -4233,8 +6846,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", + "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);", "});", @@ -4257,14 +6871,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"unsupportedFilter\": true\n}", + "raw": "{\n \"resultsPerPage\": 0\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Empty string field is rejected", + "name": "Invalid page size true", "event": [ { "listen": "test", @@ -4277,8 +6891,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", + "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);", "});", @@ -4301,14 +6916,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"keywordSearch\": \" \"\n}", + "raw": "{\n \"resultsPerPage\": true\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Array-valued enum is rejected", + "name": "Invalid page size [1]", "event": [ { "listen": "test", @@ -4321,8 +6936,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", + "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);", "});", @@ -4345,20 +6961,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": [\n \"FULL\"\n ]\n}", + "raw": "{\n \"resultsPerPage\": [\n 1\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", + "name": "resultsPerPage maximum", "event": [ { "listen": "test", @@ -4371,8 +6981,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", + "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);", "});", @@ -4395,14 +7006,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"keywordSearch\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}", + "raw": "{\n \"resultsPerPage\": 501\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Keyword token limit", + "name": "pageNumber must be positive", "event": [ { "listen": "test", @@ -4415,8 +7026,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", + "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);", "});", @@ -4439,14 +7051,14 @@ }, "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}", + "raw": "{\n \"pageNumber\": 0\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Reference URL minimum length", + "name": "Pagination window limit", "event": [ { "listen": "test", @@ -4459,8 +7071,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", + "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);", "});", @@ -4483,14 +7096,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"referenceURL\": \"ab\"\n}", + "raw": "{\n \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVE ID format", + "name": "CVSS vector total length", "event": [ { "listen": "test", @@ -4503,8 +7116,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -4527,14 +7141,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"not-a-cve\"\n}", + "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE IDs 79", + "name": "CVSS vector token count", "event": [ { "listen": "test", @@ -4547,8 +7161,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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);", "});", @@ -4571,14 +7186,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"79\"\n}", + "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": "Invalid CWE IDs CWE-0", + "name": "CVSS vector token length", "event": [ { "listen": "test", @@ -4591,8 +7206,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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);", "});", @@ -4615,14 +7231,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-0\"\n}", + "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE IDs CWE-079", + "name": "CVSS vector version mismatch", "event": [ { "listen": "test", @@ -4635,8 +7251,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3Metrics parameter is invalid. The CVSS version prefix must match the requested filter.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4659,14 +7276,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-079\"\n}", + "raw": "{\n \"cvssV3Metrics\": \"CVSS:4.0/AV:N\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE IDs CWE-79,,CWE-89", + "name": "CVSS vector unknown metric", "event": [ { "listen": "test", @@ -4679,8 +7296,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV4Metrics parameter is invalid. A metric name or value is not supported by the requested CVSS version.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4703,14 +7321,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-79,,CWE-89\"\n}", + "raw": "{\n \"cvssV4Metrics\": \"FOO:N\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE IDs CWE-*", + "name": "CVSS vector empty segment", "event": [ { "listen": "test", @@ -4723,8 +7341,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3_1Metrics parameter is invalid. Supply one or more slash-delimited metric:value tokens without empty segments.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4747,14 +7366,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-*\"\n}", + "raw": "{\n \"cvssV3_1Metrics\": \"AV:N/\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE IDs CWE-79,invalid", + "name": "CVSS vector conflicting metric values", "event": [ { "listen": "test", @@ -4767,8 +7386,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV2Metrics parameter is invalid. A metric cannot have conflicting values in the same filter.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4791,14 +7411,20 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-79,invalid\"\n}", + "raw": "{\n \"cvssV2Metrics\": \"AV:N/AV:L\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - }, + } + ] + }, + { + "name": "13 - Expected failures - booleans and enums", + "description": "Strict booleans and enum domains.", + "item": [ { - "name": "Invalid CWE IDs CWE-79", + "name": "isVulnerable boolean", "event": [ { "listen": "test", @@ -4811,8 +7437,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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);", "});", @@ -4835,14 +7462,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": [\n \"CWE-79\"\n ]\n}", + "raw": "{\n \"isVulnerable\": \"yes\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE IDs CWE-79,", + "name": "keywordExactMatch boolean", "event": [ { "listen": "test", @@ -4855,8 +7482,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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);", "});", @@ -4879,14 +7507,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-79,\"\n}", + "raw": "{\n \"keywordExactMatch\": \"yes\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Former cweId field is unsupported", + "name": "noRejected boolean", "event": [ { "listen": "test", @@ -4899,8 +7527,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"cweId is not supported by the Search API.\"};", + "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);", "});", @@ -4923,14 +7552,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-79\"\n}", + "raw": "{\n \"noRejected\": \"yes\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CWE ID item length", + "name": "knownExploited boolean", "event": [ { "listen": "test", @@ -4943,8 +7572,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_ID_LENGTH\",\"message\":\"Each CWE ID in the cweIds field must be 32 characters or fewer.\"};", + "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);", "});", @@ -4967,14 +7597,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-11111111111111111111111111111\"\n}", + "raw": "{\n \"knownExploited\": \"yes\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CWE ID count limit", + "name": "disputed boolean", "event": [ { "listen": "test", @@ -4987,8 +7617,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CWE_IDS_LIMIT\",\"message\":\"The cweIds parameter cannot contain more than 100 CWE IDs.\"};", + "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);", "});", @@ -5011,14 +7642,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweIds\": \"CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79\"\n}", + "raw": "{\n \"disputed\": \"no\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVE ID length", + "name": "scope enum", "event": [ { "listen": "test", @@ -5031,8 +7662,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -5055,14 +7687,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "raw": "{\n \"scope\": \"CNA_AND_ADP\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVE ID count limit", + "name": "Invalid search sort null", "event": [ { "listen": "test", @@ -5075,8 +7707,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5099,14 +7732,14 @@ }, "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}", + "raw": "{\n \"sort\": null\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "resultsPerPage must be positive", + "name": "Invalid search sort []", "event": [ { "listen": "test", @@ -5119,8 +7752,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5143,14 +7777,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"resultsPerPage\": -1\n}", + "raw": "{\n \"sort\": []\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "resultsPerPage maximum", + "name": "Invalid search sort \"datePublished\"", "event": [ { "listen": "test", @@ -5163,8 +7797,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5187,14 +7822,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"resultsPerPage\": 501\n}", + "raw": "{\n \"sort\": \"datePublished\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "pageNumber must be positive", + "name": "Invalid search sort {}", "event": [ { "listen": "test", @@ -5207,8 +7842,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5231,14 +7867,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"pageNumber\": 0\n}", + "raw": "{\n \"sort\": {}\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Pagination window limit", + "name": "Invalid search sort {\"order\":\"desc\"}", "event": [ { "listen": "test", @@ -5251,8 +7887,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5275,14 +7912,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", + "raw": "{\n \"sort\": {\n \"order\": \"desc\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector total length", + "name": "Invalid search sort {\"property\":\"dateUpdated\"}", "event": [ { "listen": "test", @@ -5295,8 +7932,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5319,14 +7957,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "raw": "{\n \"sort\": {\n \"property\": \"dateUpdated\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector token count", + "name": "Invalid search sort {\"property\":\"relevancy\"}", "event": [ { "listen": "test", @@ -5339,8 +7977,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5363,14 +8002,14 @@ }, "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}", + "raw": "{\n \"sort\": {\n \"property\": \"relevancy\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector token length", + "name": "Invalid search sort {\"property\":\"_script\"}", "event": [ { "listen": "test", @@ -5383,8 +8022,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5407,14 +8047,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "raw": "{\n \"sort\": {\n \"property\": \"_script\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector version mismatch", + "name": "Invalid search sort {\"property\":\"cveId\",\"order\":\"newest\"}", "event": [ { "listen": "test", @@ -5427,8 +8067,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3Metrics parameter is invalid. The CVSS version prefix must match the requested filter.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5451,14 +8092,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3Metrics\": \"CVSS:4.0/AV:N\"\n}", + "raw": "{\n \"sort\": {\n \"property\": \"cveId\",\n \"order\": \"newest\"\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector unknown metric", + "name": "Invalid search sort {\"property\":\"cveId\",\"order\":null}", "event": [ { "listen": "test", @@ -5471,8 +8112,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV4Metrics parameter is invalid. A metric name or value is not supported by the requested CVSS version.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5495,14 +8137,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV4Metrics\": \"FOO:N\"\n}", + "raw": "{\n \"sort\": {\n \"property\": \"cveId\",\n \"order\": null\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector empty segment", + "name": "Invalid search sort {\"property\":\"cveId\",\"extra\":true}", "event": [ { "listen": "test", @@ -5515,8 +8157,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3_1Metrics parameter is invalid. Supply one or more slash-delimited metric:value tokens without empty segments.\"};", + "const expectedError = {\"error\":\"SEARCH_SORT\",\"message\":\"The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5539,14 +8182,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3_1Metrics\": \"AV:N/\"\n}", + "raw": "{\n \"sort\": {\n \"property\": \"cveId\",\n \"extra\": true\n }\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector conflicting metric values", + "name": "CVSS v2 severity enum", "event": [ { "listen": "test", @@ -5559,8 +8202,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV2Metrics parameter is invalid. A metric cannot have conflicting values in the same filter.\"};", + "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);", "});", @@ -5583,20 +8227,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV2Metrics\": \"AV:N/AV:L\"\n}", + "raw": "{\n \"cvssV2Severity\": \"CRITICAL\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "13 - Expected failures - booleans and enums", - "description": "Strict booleans and enum domains.", - "item": [ + }, { - "name": "isVulnerable boolean", + "name": "CVSS v3 severity enum", "event": [ { "listen": "test", @@ -5609,8 +8247,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5633,14 +8272,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"isVulnerable\": \"yes\"\n}", + "raw": "{\n \"cvssV3Severity\": \"UNKNOWN\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "keywordExactMatch boolean", + "name": "CVSS v3.0 severity enum", "event": [ { "listen": "test", @@ -5653,8 +8292,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5677,14 +8317,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"keywordExactMatch\": \"yes\"\n}", + "raw": "{\n \"cvssV3_0Severity\": \"UNKNOWN\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "noRejected boolean", + "name": "CVSS v3.1 severity enum", "event": [ { "listen": "test", @@ -5697,8 +8337,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5721,14 +8362,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"noRejected\": \"yes\"\n}", + "raw": "{\n \"cvssV3_1Severity\": \"UNKNOWN\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "knownExploited boolean", + "name": "CVSS v4 severity enum", "event": [ { "listen": "test", @@ -5741,8 +8382,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5765,14 +8407,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"knownExploited\": \"true\"\n}", + "raw": "{\n \"cvssV4Severity\": \"UNKNOWN\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "disputed boolean", + "name": "SSVC exploitation enum", "event": [ { "listen": "test", @@ -5785,8 +8427,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", + "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);", "});", @@ -5809,14 +8452,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"disputed\": \"false\"\n}", + "raw": "{\n \"ssvcExploitation\": \"UNKNOWN\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "scope enum", + "name": "SSVC automatable enum", "event": [ { "listen": "test", @@ -5829,8 +8472,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", + "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);", "});", @@ -5853,14 +8497,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_AND_ADP\"\n}", + "raw": "{\n \"ssvcAutomatable\": \"MAYBE\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS v2 severity enum", + "name": "SSVC technical impact enum", "event": [ { "listen": "test", @@ -5873,8 +8517,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -5897,14 +8542,20 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV2Severity\": \"CRITICAL\"\n}", + "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": "CVSS v3 severity enum", + "name": "keywordExactMatch requires keywordSearch", "event": [ { "listen": "test", @@ -5917,8 +8568,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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 NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "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);", "});", @@ -5941,14 +8593,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3Severity\": \"UNKNOWN\"\n}", + "raw": "{\n \"keywordExactMatch\": true\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS v3.0 severity enum", + "name": "isVulnerable requires cpeName", "event": [ { "listen": "test", @@ -5961,8 +8613,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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 NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "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);", "});", @@ -5985,14 +8638,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3_0Severity\": \"UNKNOWN\"\n}", + "raw": "{\n \"isVulnerable\": true\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS v3.1 severity enum", + "name": "isVulnerable conflicts with virtualMatchString", "event": [ { "listen": "test", @@ -6005,8 +8658,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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 NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "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);", "});", @@ -6029,14 +8683,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3_1Severity\": \"UNKNOWN\"\n}", + "raw": "{\n \"isVulnerable\": true,\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS v4 severity enum", + "name": "KEV start date requires knownExploited true", "event": [ { "listen": "test", @@ -6049,8 +8703,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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 NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "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);", "});", @@ -6073,14 +8728,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV4Severity\": \"UNKNOWN\"\n}", + "raw": "{\n \"knownExploited\": false,\n \"kevDateAddedStartDate\": \"2022-01-01T00:00:00.000Z\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "SSVC exploitation enum", + "name": "KEV end date requires knownExploited true", "event": [ { "listen": "test", @@ -6093,8 +8748,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", + "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);", "});", @@ -6117,14 +8773,20 @@ }, "body": { "mode": "raw", - "raw": "{\n \"ssvcExploitation\": \"UNKNOWN\"\n}", + "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": "SSVC automatable enum", + "name": "ADP_ONLY rejects cpeName", "event": [ { "listen": "test", @@ -6137,8 +8799,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", + "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);", "});", @@ -6161,14 +8824,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"ssvcAutomatable\": \"MAYBE\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "SSVC technical impact enum", + "name": "ADP_ONLY rejects isVulnerable", "event": [ { "listen": "test", @@ -6181,8 +8844,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", + "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);", "});", @@ -6205,20 +8869,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"ssvcTechnicalImpact\": \"UNKNOWN\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"isVulnerable\": false\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", + "name": "ADP_ONLY rejects virtualMatchString", "event": [ { "listen": "test", @@ -6231,8 +8889,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -6255,14 +8914,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"keywordExactMatch\": true\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "isVulnerable requires cpeName", + "name": "ADP_ONLY rejects versionStart", "event": [ { "listen": "test", @@ -6275,8 +8934,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -6299,14 +8959,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"isVulnerable\": true\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStart\": \"2.2\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "isVulnerable conflicts with virtualMatchString", + "name": "ADP_ONLY rejects versionStartType", "event": [ { "listen": "test", @@ -6319,8 +8979,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -6343,14 +9004,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"isVulnerable\": true,\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStartType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "KEV start date requires knownExploited true", + "name": "ADP_ONLY rejects versionEnd", "event": [ { "listen": "test", @@ -6363,8 +9024,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -6387,14 +9049,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"knownExploited\": false,\n \"kevDateAddedStartDate\": \"2022-01-01T00:00:00.000Z\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEnd\": \"2.6\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "KEV end date requires knownExploited true", + "name": "ADP_ONLY rejects versionEndType", "event": [ { "listen": "test", @@ -6407,8 +9069,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -6431,20 +9094,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"kevDateAddedEndDate\": \"2022-12-31T23:59:59.999Z\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEndType\": \"excluding\"\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", + "name": "Invalid cpeName", "event": [ { "listen": "test", @@ -6457,8 +9114,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", + "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);", "});", @@ -6481,14 +9139,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor:product:*\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY rejects isVulnerable", + "name": "Invalid virtualMatchString", "event": [ { "listen": "test", @@ -6501,8 +9159,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -6525,14 +9184,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"isVulnerable\": false\n}", + "raw": "{\n \"virtualMatchString\": \"not-a-cpe\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY rejects virtualMatchString", + "name": "virtualMatchString requires a part component", "event": [ { "listen": "test", @@ -6545,8 +9204,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -6569,14 +9229,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY rejects versionStart", + "name": "virtualMatchString rejects an unsupported part", "event": [ { "listen": "test", @@ -6589,8 +9249,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString part component must be one of a, h, o, *, or -.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -6613,14 +9274,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStart\": \"2.2\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:x:vendor:product\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY rejects versionStartType", + "name": "cpeName rejects an empty component", "event": [ { "listen": "test", @@ -6633,8 +9294,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", + "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -6657,14 +9319,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStartType\": \"including\"\n}", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor::1.0\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY rejects versionEnd", + "name": "Version range requires virtualMatchString", "event": [ { "listen": "test", @@ -6677,8 +9339,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", + "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);", "});", @@ -6701,14 +9364,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEnd\": \"2.6\"\n}", + "raw": "{\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "ADP_ONLY rejects versionEndType", + "name": "versionStart requires versionStartType", "event": [ { "listen": "test", @@ -6721,8 +9384,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", + "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);", "});", @@ -6745,14 +9409,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEndType\": \"excluding\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid cpeName", + "name": "versionEnd requires versionEndType", "event": [ { "listen": "test", @@ -6765,8 +9429,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -6789,14 +9454,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor:product:*\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionEnd\": \"2.0\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid virtualMatchString", + "name": "Invalid version range type", "event": [ { "listen": "test", @@ -6809,8 +9474,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", + "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);", "});", @@ -6833,14 +9499,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"not-a-cpe\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"before\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "virtualMatchString requires a part component", + "name": "Negative numeric version range boundary", "event": [ { "listen": "test", @@ -6853,8 +9519,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.\"};", + "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);", "});", @@ -6877,14 +9544,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\",\n \"versionEnd\": \"-1\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "virtualMatchString rejects an unsupported part", + "name": "Invalid ranged virtualMatchString format", "event": [ { "listen": "test", @@ -6897,8 +9564,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString part component must be one of a, h, o, *, or -.\"};", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -6921,14 +9589,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:x:vendor:product\"\n}", + "raw": "{\n \"virtualMatchString\": \"not-a-cpe\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "cpeName rejects an empty component", + "name": "Ranged virtualMatchString cannot contain version", "event": [ { "listen": "test", @@ -6941,8 +9609,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.\"};", + "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);", "});", @@ -6965,14 +9634,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor::1.0\"\n}", + "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": "Version range requires virtualMatchString", + "name": "Inverted version range", "event": [ { "listen": "test", @@ -6985,8 +9654,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -7009,14 +9679,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "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": "versionStart requires versionStartType", + "name": "Empty equal exclusive version range", "event": [ { "listen": "test", @@ -7029,8 +9699,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", + "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);", "});", @@ -7053,14 +9724,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\"\n}", + "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": "versionEnd requires versionEndType", + "name": "Inverted qualifier or patch-letter range 1.0.2a to 1.0.2", "event": [ { "listen": "test", @@ -7073,8 +9744,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", + "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);", "});", @@ -7097,14 +9769,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionEnd\": \"2.0\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0.2a\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0.2\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid version range type", + "name": "Inverted qualifier or patch-letter range 1.1.1m to 1.1.1l", "event": [ { "listen": "test", @@ -7117,8 +9789,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_RANGE_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", + "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);", "});", @@ -7141,14 +9814,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"before\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.1.1m\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.1.1l\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Negative numeric version range boundary", + "name": "Inverted qualifier or patch-letter range 2.0 to 2.0a1", "event": [ { "listen": "test", @@ -7161,8 +9834,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -7185,14 +9859,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\",\n \"versionEnd\": \"-1\",\n \"versionEndType\": \"including\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0a1\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid ranged virtualMatchString format", + "name": "Inverted qualifier or patch-letter range 2.0.0 to 2.0.0-dev1", "event": [ { "listen": "test", @@ -7205,8 +9879,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", + "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);", "});", @@ -7229,14 +9904,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"not-a-cpe\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-dev1\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Ranged virtualMatchString cannot contain version", + "name": "Inverted qualifier or patch-letter range 2.0.0-preview1 to 2.0.0-pre1", "event": [ { "listen": "test", @@ -7249,8 +9924,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "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);", "});", @@ -7273,14 +9949,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product:1.0\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-preview1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-pre1\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Inverted version range", + "name": "Inverted qualifier or patch-letter range 2.0.0-rc1 to 2.0.0-preview1", "event": [ { "listen": "test", @@ -7293,6 +9969,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7317,14 +9994,20 @@ }, "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}", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-rc1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-preview1\",\n \"versionEndType\": \"including\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - }, + } + ] + }, + { + "name": "16 - Expected failures - dates", + "description": "Timestamp format, calendar validity, and range order.", + "item": [ { - "name": "Empty equal exclusive version range", + "name": "Timestamp without timezone", "event": [ { "listen": "test", @@ -7337,8 +10020,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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.\"};", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -7361,20 +10045,14 @@ }, "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}", + "raw": "{\n \"pubStartDate\": \"2024-01-01T00:00:00\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] - } - ] - }, - { - "name": "16 - Expected failures - dates", - "description": "Timestamp format, calendar validity, and range order.", - "item": [ + }, { - "name": "Partial timestamp", + "name": "Timestamp beyond millisecond precision", "event": [ { "listen": "test", @@ -7387,8 +10065,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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'\"};", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -7411,7 +10090,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"pubStartDate\": \"2024-01-01\"\n}", + "raw": "{\n \"pubStartDate\": \"2024-01-01T00:00:00.1234Z\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -7431,8 +10110,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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'\"};", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -7475,6 +10155,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7519,6 +10200,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7563,6 +10245,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7607,6 +10290,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7671,6 +10355,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7725,6 +10410,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_UNAVAILABLE\",\"message\":\"Search service is temporarily unavailable. Please try again later.\"};", "pm.test('expected error code is returned', () => {", @@ -7784,6 +10470,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7843,6 +10530,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 2a7e0ad..81506a8 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -4,6 +4,7 @@ const prettier = require('prettier'); const constants = require('../../utils/constants'); const { errors: SearchErrors } = require('../../utils/errors'); const openApi = require('../../api-docs/openapi.json'); +const searchSort = require('../../utils/searchSort'); const error = new SearchErrors(); const outputDirectory = __dirname; @@ -55,7 +56,8 @@ function successTests({ minimumTotal, expectedIds = [], expectedHitCount, - expectedWarnings = [] + expectedWarnings = [], + sort } = {}) { const tests = [ `pm.test('status is ${status}', () => pm.response.to.have.status(${status}));`, @@ -111,6 +113,33 @@ function successTests({ ); } + if (sort) { + tests.push( + `const requestedSort = ${JSON.stringify(sort)};`, + "pm.test('records follow the requested sort, tie-breaker, and missing-date order', () => {", + ' const records = payload.records;', + " if (requestedSort.property === 'datePublished') records.forEach(record => {", + ' const value = record.cveMetadata.datePublished;', + ' if (value !== undefined && value !== null) pm.expect(Number.isFinite(Date.parse(value))).to.eql(true);', + ' });', + ' for (let i = 1; i < records.length; i++) {', + ' const left = records[i - 1].cveMetadata;', + ' const right = records[i].cveMetadata;', + ' let comparison;', + " if (requestedSort.property === 'cveId') {", + ' comparison = left.cveId === right.cveId ? 0 : left.cveId < right.cveId ? -1 : 1;', + ' } else {', + ' if (left.datePublished == null) { pm.expect(right.datePublished == null).to.eql(true); continue; }', + ' if (right.datePublished == null) continue;', + ' comparison = Math.sign(Date.parse(left.datePublished) - Date.parse(right.datePublished));', + ' if (comparison === 0) { pm.expect(left.cveId <= right.cveId).to.eql(true); continue; }', + ' }', + " pm.expect(requestedSort.order === 'asc' ? comparison <= 0 : comparison >= 0).to.eql(true);", + ' }', + '});' + ); + } + tests.push('}'); return tests; } @@ -121,6 +150,7 @@ function errorTests(expectedStatus, expectedError) { ...requestIdTests(), "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", 'const payload = pm.response.json();', + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", 'const errors = Array.isArray(payload) ? payload : [payload];', `const expectedError = ${JSON.stringify(expectedError)};`, "pm.test('expected error code is returned', () => {", @@ -206,7 +236,10 @@ function positive(name, body, options = {}) { name, description: options.description, body, - tests: successTests(options) + tests: successTests({ + ...options, + ...(body?.sort !== undefined ? { sort: searchSort.normalizeSort(body.sort) } : {}) + }) }); } @@ -301,6 +334,11 @@ const baselineAndPagination = folder( { cveIds: `${ids.primary},${ids.rejected}`, resultsPerPage: 2, pageNumber: 1 }, { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } ), + ...['knownExploited', 'disputed'].flatMap((field) => + ['true', 'false'].map((value) => + positive(`${field} accepts string ${value}`, { cveIds: ids.primary, [field]: value }) + ) + ), positive( 'Second valid page can be empty', { cveIds: ids.primary, resultsPerPage: 1, pageNumber: 2 }, @@ -313,6 +351,33 @@ const baselineAndPagination = folder( 'Maximum valid search window boundary', { cveIds: ids.primary, resultsPerPage: 500, pageNumber: 20 }, { exactTotal: 1, expectedHitCount: 0 } + ), + ...constants.SEARCH_SORT_PROPERTIES.flatMap((property) => + constants.SEARCH_SORT_ORDERS.map((order) => + positive( + `Direct ${property} ${order} sorting`, + { + cveIds: Object.values(ids).join(','), + resultsPerPage: 6, + sort: { property: property.toUpperCase(), order: order.toUpperCase() } + }, + { exactTotal: 6, expectedHitCount: 6 } + ) + ) + ), + positive( + 'Publication sort defaults omitted order to ascending', + { cveIds: Object.values(ids).join(','), sort: { property: 'datePublished' } }, + { exactTotal: 6, expectedHitCount: 6 } + ), + ...constants.SEARCH_SORT_PROPERTIES.flatMap((property) => + constants.SEARCH_SORT_ORDERS.map((order) => + positive( + `Semantic ${property} ${order} sorting`, + { virtualMatchString: 'cpe:2.3:a:freetype:freetype', resultsPerPage: 5, sort: { property, order } }, + { minimumTotal: 1 } + ) + ) ) ] ); @@ -509,6 +574,21 @@ const cvssCoverage = folder('04 - CVSS filters', 'Every supported CVSS severity ]); const recordDates = folder('05 - Record date filters', 'Publication and modification boundaries.', [ + positive( + 'Date-only publication boundaries', + { cveIds: ids.primary, pubStartDate: '2024-01-01', pubEndDate: '2024-12-31' }, + { exactTotal: 1 } + ), + positive( + 'Publication timestamp with explicit offset', + { cveIds: ids.primary, pubStartDate: '2024-01-01T01:00:00+01:00' }, + { exactTotal: 1 } + ), + positive( + 'Publication timestamp without fractional seconds', + { cveIds: ids.primary, pubStartDate: '2024-01-01T00:00:00Z' }, + { exactTotal: 1 } + ), positive( 'pubStartDate', { cveIds: ids.primary, pubStartDate: '2024-01-01T00:00:00.000Z' }, @@ -654,6 +734,35 @@ const cpeCoverage = folder('06 - CPE filters', 'Concrete CPE and virtual match-s versionEndType: 'excluding' }, { exactTotal: 1, expectedIds: [ids.cpe] } + ), + ...[ + ['1.0.2', '1.0.2a'], + ['1.1.1l', '1.1.1m'], + ['2.0a1', '2.0'], + ['1.0b2', '1.0'], + ['1.0m3', '1.0'], + ['2.0.0-dev1', '2.0.0-alpha1'], + ['2.0.0-pre1', '2.0.0-preview1'], + ['2.0.0-preview1', '2.0.0-rc1'], + ['2.0.0-preview1', '2.0.0'] + ].map(([versionStart, versionEnd]) => + positive( + `Valid qualifier or patch-letter range ${versionStart} to ${versionEnd}`, + { + cveIds: 'CVE-1998-0001', + virtualMatchString: 'cpe:2.3:a:openssl:openssl', + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + }, + { + exactTotal: 0, + expectedHitCount: 0, + description: + 'Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.' + } + ) ) ]); @@ -955,6 +1064,13 @@ const limitFailures = folder( error.cveIdsLimitError() ), negative('resultsPerPage must be positive', { resultsPerPage: -1 }, error.resultsPerPageError()), + ...['abc', 1.5, 0, true, [1]].map((resultsPerPage) => + negative( + `Invalid page size ${JSON.stringify(resultsPerPage)}`, + { resultsPerPage }, + error.resultsPerPageError() + ) + ), negative( 'resultsPerPage maximum', { resultsPerPage: constants.MAX_RESULTS_LIMIT + 1 }, @@ -1018,9 +1134,22 @@ const booleanAndEnumFailures = folder( negative('isVulnerable boolean', { isVulnerable: 'yes' }, error.isVulnInputError()), negative('keywordExactMatch boolean', { keywordExactMatch: 'yes' }, error.exactMatchInputError()), negative('noRejected boolean', { noRejected: 'yes' }, error.noRejectedValueError()), - negative('knownExploited boolean', { knownExploited: 'true' }, error.knownExploitedInputError()), - negative('disputed boolean', { disputed: 'false' }, error.disputedInputError()), + negative('knownExploited boolean', { knownExploited: 'yes' }, error.knownExploitedInputError()), + negative('disputed boolean', { disputed: 'no' }, error.disputedInputError()), negative('scope enum', { scope: 'CNA_AND_ADP' }, error.scopeValueError()), + ...[ + null, + [], + 'datePublished', + {}, + { order: 'desc' }, + { property: 'dateUpdated' }, + { property: 'relevancy' }, + { property: '_script' }, + { property: 'cveId', order: 'newest' }, + { property: 'cveId', order: null }, + { property: 'cveId', extra: true } + ].map((sort) => negative(`Invalid search sort ${JSON.stringify(sort)}`, { sort }, error.searchSortError())), negative('CVSS v2 severity enum', { cvssV2Severity: 'CRITICAL' }, error.cvssV2SeverityError('CRITICAL')), negative('CVSS v3 severity enum', { cvssV3Severity: 'UNKNOWN' }, error.cvssV3SeverityError('UNKNOWN')), negative('CVSS v3.0 severity enum', { cvssV3_0Severity: 'UNKNOWN' }, error.cvssV3_0SeverityError('UNKNOWN')), @@ -1182,12 +1311,41 @@ const cpeAndVersionFailures = folder( versionEndType: 'including' }, error.versionRangeOrderError() + ), + ...[ + ['1.0.2a', '1.0.2'], + ['1.1.1m', '1.1.1l'], + ['2.0', '2.0a1'], + ['2.0.0', '2.0.0-dev1'], + ['2.0.0-preview1', '2.0.0-pre1'], + ['2.0.0-rc1', '2.0.0-preview1'] + ].map(([versionStart, versionEnd]) => + negative( + `Inverted qualifier or patch-letter range ${versionStart} to ${versionEnd}`, + { + virtualMatchString: 'cpe:2.3:a:openssl:openssl', + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + }, + error.versionRangeOrderError() + ) ) ] ); const dateFailures = folder('16 - Expected failures - dates', 'Timestamp format, calendar validity, and range order.', [ - negative('Partial timestamp', { pubStartDate: '2024-01-01' }, error.dateTimeError('pubStartDate')), + negative( + 'Timestamp without timezone', + { pubStartDate: '2024-01-01T00:00:00' }, + error.dateTimeError('pubStartDate') + ), + negative( + 'Timestamp beyond millisecond precision', + { pubStartDate: '2024-01-01T00:00:00.1234Z' }, + error.dateTimeError('pubStartDate') + ), negative( 'Impossible calendar date', { lastModStartDate: '2024-02-30T00:00:00.000Z' }, diff --git a/utils/appConfig.test.js b/utils/appConfig.test.js index 4740419..40e7087 100644 --- a/utils/appConfig.test.js +++ b/utils/appConfig.test.js @@ -12,6 +12,7 @@ function readConfig(env = {}) { const provider = require(${JSON.stringify(path.join(__dirname, 'searchProvider'))}); const flags = require(${JSON.stringify(path.join(__dirname, 'featureFlags'))}); const logging = require(${JSON.stringify(path.join(__dirname, 'requestLogging'))}); + const clientCachePolicy = require(${JSON.stringify(path.join(__dirname, 'clientCachePolicy'))}); console.log(JSON.stringify({ endpoint: config.has('search.providerEndpoint') ? config.get('search.providerEndpoint') : null, index: config.has('search.index') ? config.get('search.index') : null, @@ -19,6 +20,7 @@ function readConfig(env = {}) { timeout: provider.getOpenSearchRequestTimeoutMs(), enabled: flags.isWebSearchEnabled(), sampleRate: logging.getSuccessSampleRate(), + clientCacheMaxAge: clientCachePolicy.getMaxAgeSeconds(), missing: config.has('missing') })); `; @@ -50,6 +52,7 @@ test('default settings load without a dotenv file or endpoint', () => { timeout: 20000, enabled: true, sampleRate: 0.1, + clientCacheMaxAge: 60, missing: false }); }); @@ -64,7 +67,8 @@ test('dotenv is loaded before deployment selection and custom environment mappin 'OpenSearchAllowUnknownSslCerts=FALSE', 'SearchOpenSearchRequestTimeoutMs=25000', 'WebSearchEnabled=false', - 'RequestLogSuccessSampleRate=0.25' + 'RequestLogSuccessSampleRate=0.25', + 'SearchClientCacheMaxAgeSeconds=120' ].join('\n') ); expect(readConfig()).toStrictEqual({ @@ -74,6 +78,7 @@ test('dotenv is loaded before deployment selection and custom environment mappin timeout: 25000, enabled: false, sampleRate: 0.25, + clientCacheMaxAge: 120, missing: false }); }); diff --git a/utils/clientCachePolicy.js b/utils/clientCachePolicy.js new file mode 100644 index 0000000..807846c --- /dev/null +++ b/utils/clientCachePolicy.js @@ -0,0 +1,25 @@ +const AppConfig = require('./appConfig'); + +const DEFAULT_MAX_AGE_SECONDS = 60; + +function getMaxAgeSeconds() { + if (!AppConfig.has('clientCache.maxAgeSeconds')) return DEFAULT_MAX_AGE_SECONDS; + + const value = AppConfig.get('clientCache.maxAgeSeconds'); + if (!['string', 'number'].includes(typeof value) || !/^\d+$/.test(String(value))) return 0; + + const seconds = Number(value); + return Number.isSafeInteger(seconds) ? seconds : 0; +} + +function noStore(req, res, next) { + res.set('Cache-Control', 'no-store'); + next(); +} + +function setSearchCacheControl(res) { + const seconds = getMaxAgeSeconds(); + res.set('Cache-Control', seconds > 0 ? `private, max-age=${seconds}` : 'no-store'); +} + +module.exports = { getMaxAgeSeconds, noStore, setSearchCacheControl }; diff --git a/utils/clientCachePolicy.test.js b/utils/clientCachePolicy.test.js new file mode 100644 index 0000000..0fa1215 --- /dev/null +++ b/utils/clientCachePolicy.test.js @@ -0,0 +1,52 @@ +const mockHas = jest.fn(); +const mockGet = jest.fn(); + +jest.mock('./appConfig', () => ({ has: mockHas, get: mockGet })); + +const clientCachePolicy = require('./clientCachePolicy'); + +beforeEach(() => { + mockHas.mockReturnValue(false); + mockGet.mockReset(); +}); + +test('defaults to a 60-second private client-cache policy', () => { + const res = { set: jest.fn() }; + expect(clientCachePolicy.getMaxAgeSeconds()).toBe(60); + clientCachePolicy.setSearchCacheControl(res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=60'); +}); + +test.each([ + ['120', 120], + [30, 30], + ['0', 0], + [0, 0] +])('accepts configured seconds %p', (value, seconds) => { + mockHas.mockReturnValue(true); + mockGet.mockReturnValue(value); + const res = { set: jest.fn() }; + expect(clientCachePolicy.getMaxAgeSeconds()).toBe(seconds); + clientCachePolicy.setSearchCacheControl(res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', seconds > 0 ? `private, max-age=${seconds}` : 'no-store'); +}); + +test.each( + ['', ' ', 'invalid', '-1', '1.5', 'Infinity', '9007199254740992', -1, Infinity, null, true, {}, []].map((value) => [ + value + ]) +)('invalid configuration disables caching rather than emitting an invalid header: %p', (value) => { + mockHas.mockReturnValue(true); + mockGet.mockReturnValue(value); + const res = { set: jest.fn() }; + clientCachePolicy.setSearchCacheControl(res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store'); +}); + +test('sets no-store before request processing continues', () => { + const res = { set: jest.fn() }; + const next = jest.fn(); + clientCachePolicy.noStore({}, res, next); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(next).toHaveBeenCalledTimes(1); +}); diff --git a/utils/constants.js b/utils/constants.js index 72cf6ee..a2e511a 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -12,6 +12,8 @@ const MAX_CWE_ID_LENGTH = 32; const FIRST_CVE_RECORD_YEAR = 1999; const MAX_KEYWORD_SEARCH_TOKENS = 64; const MIN_REFERENCE_URL_LENGTH = 3; +const SEARCH_SORT_PROPERTIES = Object.freeze(['cveId', 'datePublished']); +const SEARCH_SORT_ORDERS = Object.freeze(['asc', 'desc']); const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, @@ -69,6 +71,7 @@ const fields = searchScope.getFullFields(); const searchControlParams = [ 'scope', + 'sort', 'keywordExactMatch', 'resultsPerPage', 'pageNumber', @@ -84,6 +87,7 @@ const searchControlParams = [ const searchQueryParams = [ 'scope', + 'sort', 'keywordSearch', 'keywordExactMatch', 'cveIds', @@ -138,6 +142,8 @@ module.exports = { FIRST_CVE_RECORD_YEAR, MAX_KEYWORD_SEARCH_TOKENS, MIN_REFERENCE_URL_LENGTH, + SEARCH_SORT_PROPERTIES, + SEARCH_SORT_ORDERS, SEARCH_STRING_LENGTH_LIMITS, SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, SEARCH_REQUEST_DEADLINE_MS, diff --git a/utils/cpe.js b/utils/cpe.js index 26548a4..527ab59 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -17,14 +17,13 @@ const CPE_NOT_APPLICABLE = '-'; const REQUIRED_CPE_COMPONENT_INDEXES = [0, 1, 2, 3]; const CPE_PART_VALUES = new Set(['a', 'h', 'o', '*', CPE_NOT_APPLICABLE]); const CONCRETE_CPE_PART_VALUES = new Set(['a', 'h', 'o']); -const VERSION_QUALIFIERS = ['alpha', 'beta', 'milestone', 'rc', 'snapshot', '', 'sp']; +// API policy extends Maven-style precedence with distinct dev, pre, and preview stages. +const VERSION_QUALIFIERS = ['dev', 'alpha', 'beta', 'milestone', 'pre', 'preview', 'rc', 'snapshot', '', 'sp']; +const NUMBERED_QUALIFIER_ALIASES = { a: 'alpha', b: 'beta', m: 'milestone' }; const VERSION_QUALIFIER_ALIASES = { - a: 'alpha', - b: 'beta', cr: 'rc', final: '', ga: '', - m: 'milestone', release: '' }; @@ -295,10 +294,20 @@ function buildVirtualMatchStringCriteriaRegexp(input) { } function tokenizeVersion(value) { - return String(value ?? '') + const input = String(value ?? '') .trim() - .toLowerCase() - .match(/\d+|[a-z]+/g); + .toLowerCase(); + const matches = [...input.matchAll(/\d+|[a-z]+/g)]; + if (!matches.length) return null; + + return matches.map((match) => { + const token = match[0]; + // Token positions distinguish a1 from a.1 or a-1 before separators are discarded. + const followedByDigit = /[0-9]/.test(input.charAt(match.index + token.length)); + return followedByDigit && Object.hasOwn(NUMBERED_QUALIFIER_ALIASES, token) + ? NUMBERED_QUALIFIER_ALIASES[token] + : token; + }); } function isNegativeNumericVersion(value) { diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 17e668c..489b35a 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -247,12 +247,58 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions('-1.0', '-1')).toBe(0); }); - test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { + test.each([ + ['1.0.2', '1.0.2a'], + ['1.0.2a', '1.0.2b'], + ['1.0.2b', '1.0.2c'], + ['1.1.1l', '1.1.1m'], + ['1.0.2m', '1.0.2zh'], + ['1.0.2', '1.0.2m'], + ['2.0a1', '2.0'], + ['1.0b2', '1.0'], + ['1.0m3', '1.0'], + ['1.0', '1.0a.1'], + ['1.0', '1.0b-1'], + ['1.0', '1.0m.3'] + ])('orders %s before %s without treating bare letters as prerelease aliases', (earlier, later) => { + expect(cpe.compareCpeVersions(earlier, later)).toBeLessThan(0); + expect(cpe.compareCpeVersions(later, earlier)).toBeGreaterThan(0); + }); + + test('orders the full patch-letter sequence consistently', () => { + const versions = [ + '1.0.2', + ...'abcdefghijklmnopqrstuvwxyz'.split('').map((letter) => `1.0.2${letter}`), + '1.0.2zh' + ]; + for (let left = 0; left < versions.length; left++) { + expect(cpe.compareCpeVersions(versions[left], versions[left].toUpperCase())).toBe(0); + for (let right = left + 1; right < versions.length; right++) { + expect(cpe.compareCpeVersions(versions[left], versions[right])).toBeLessThan(0); + expect(cpe.compareCpeVersions(versions[right], versions[left])).toBeGreaterThan(0); + } + } + }); + + test.each(['dev', 'pre', 'preview'])('orders %s before release and compares its numeric suffix', (qualifier) => { + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}`, '2.0.0')).toBeLessThan(0); + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}2`, `2.0.0-${qualifier}10`)).toBeLessThan(0); + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}1`, `2.0.0-${qualifier.toUpperCase()}1`)).toBe(0); + }); + + test.each(['devtools', 'prerelease', 'previewish'])('keeps the unknown qualifier %s after release', (qualifier) => { + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}`, '2.0.0')).toBeGreaterThan(0); + }); + + test('compareCpeVersions orders distinct prerelease stages under the API policy', () => { const orderedVersions = [ + '1.0.0-dev1', '1.0.0-alpha1', '1.0.0-alpha2', '1.0.0-beta1', '1.0.0-milestone1', + '1.0.0-pre1', + '1.0.0-preview1', '1.0.0-rc1', '1.0.0-snapshot', '1.0.0', @@ -261,8 +307,11 @@ describe('CPE helpers', () => { '1.0.0-zz1' ]; - for (let index = 1; index < orderedVersions.length; index++) { - expect(cpe.compareCpeVersions(orderedVersions[index - 1], orderedVersions[index])).toBeLessThan(0); + for (let left = 0; left < orderedVersions.length; left++) { + for (let right = left + 1; right < orderedVersions.length; right++) { + expect(cpe.compareCpeVersions(orderedVersions[left], orderedVersions[right])).toBeLessThan(0); + expect(cpe.compareCpeVersions(orderedVersions[right], orderedVersions[left])).toBeGreaterThan(0); + } } }); @@ -363,6 +412,53 @@ describe('CPE helpers', () => { ); }); + test.each([ + ['1.0.2a', { versionStartIncluding: '1.0.2' }, true], + ['1.0.2b', { versionStartIncluding: '1.0.2' }, true], + ['1.0.2m', { versionStartIncluding: '1.0.2' }, true], + ['1.1.1l', { versionEndExcluding: '1.1.1m' }, true], + ['1.1.1m', { versionEndExcluding: '1.1.1m' }, false], + ['1.1.1m', { versionEndIncluding: '1.1.1m' }, true], + ['1.1.1m', { versionStartExcluding: '1.1.1m' }, false], + ['1.1.1n', { versionStartExcluding: '1.1.1m' }, true], + ['2.0.0-dev1', { versionEndExcluding: '2.0.0' }, true], + ['2.0.0-pre1', { versionEndExcluding: '2.0.0-preview1' }, true], + ['2.0.0-preview1', { versionStartIncluding: '2.0.0-pre1', versionEndExcluding: '2.0.0-rc1' }, true], + ['2.0.0-rc1', { versionEndExcluding: '2.0.0-preview1' }, false] + ])('applies version bounds to asset %s with %j', (version, bounds, expected) => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*', + ...bounds + }, + `cpe:2.3:a:openssl:openssl:${version}:*:*:*:*:*:*:*` + ) + ).toBe(expected); + }); + + test('keeps exact criteria versions literal when a numbered qualifier is an alias', () => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:vendor:product:2.0a1:*:*:*:*:*:*:*' + }, + 'cpe:2.3:a:vendor:product:2.0alpha1:*:*:*:*:*:*:*' + ) + ).toBe(false); + }); + + test.each(['2.0.0-preview1', '2.0.0-rc1'])('does not equate exact pre1 criteria with %s', (version) => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:vendor:product:2.0.0-pre1:*:*:*:*:*:*:*' + }, + `cpe:2.3:a:vendor:product:${version}:*:*:*:*:*:*:*` + ) + ).toBe(false); + }); + test('cpeMatchAppliesToCpeName requires every concrete non-version component to match', () => { const cpeName = 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*'; const androidRange = { @@ -657,6 +753,57 @@ describe('CPE helpers', () => { ).toBe(false); }); + test.each([ + ['2.0.0-dev1', '2.0.0-alpha1', false], + ['2.0.0-pre1', '2.0.0-preview1', true], + ['2.0.0-preview1', '2.0.0-rc1', true], + ['2.0.0-rc1', '2.0.0', false] + ])('evaluates distinct prerelease interval %s through %s', (versionStart, versionEnd, expected) => { + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + versionStartIncluding: '2.0.0-pre1', + versionEndExcluding: '2.0.0-rc1' + }, + { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'), + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + } + ) + ).toBe(expected); + }); + + test.each([ + ['1.1.1l', 'including', true], + ['1.1.1m', 'including', false], + ['1.1.1m', 'excluding', false], + ['1.1.1n', 'including', false] + ])( + 'compares a requested %s %s start against an exclusive patch-letter upper bound', + (versionStart, versionStartType, expected) => { + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*', + versionStartIncluding: '1.1.1', + versionEndExcluding: '1.1.1m' + }, + { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:openssl:openssl'), + versionStart, + versionStartType, + versionEnd: '1.1.1n', + versionEndType: 'including' + } + ) + ).toBe(expected); + } + ); + test('cpeMatchOverlapsVirtualMatchStringRange matches upper-bounded wildcard criteria before the excluded boundary', () => { const cpeMatch = { criteria: 'cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*', diff --git a/utils/errors.js b/utils/errors.js index 6710d26..4d823ce 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -218,6 +218,13 @@ class errors { ); } + searchSortError() { + return makeError( + 'SEARCH_SORT', + 'The sort field must be an object containing property (cveId or datePublished) and optional order (asc or desc). Values are case-insensitive; order defaults to asc. No other keys are supported.' + ); + } + searchFieldStringError(field) { return makeError('SEARCH_FIELD_STRING', `The ${field} field must be a non-empty string.`); } @@ -329,7 +336,7 @@ class errors { dateTimeError(dateTime) { return makeError( 'DATE_TIME_FORMAT', - `The ${dateTime} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${dateTime} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); } @@ -337,6 +344,13 @@ class errors { return makeError('WEB_SEARCH_QUERY_REQUIRED', 'The query field is required and must be a non-empty string.'); } + webSearchQuerySyntaxError() { + return makeError( + 'WEB_SEARCH_QUERY_SYNTAX', + 'The query contains unsupported or malformed syntax. Regular expressions and unescaped braces are not supported. Quote or escape literal special characters.' + ); + } + webSearchSortPropertyError() { return makeError('WEB_SEARCH_SORT_PROPERTY', 'The sort.property field must be cveId or relevancy.'); } diff --git a/utils/errors.test.js b/utils/errors.test.js index 9e1b0c2..79fabf8 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -312,7 +312,8 @@ describe('error response helpers', () => { ['pubStartDate'], { error: 'DATE_TIME_FORMAT', - message: "The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'" + message: + 'The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.' } ], [ @@ -323,6 +324,15 @@ describe('error response helpers', () => { message: 'The query field is required and must be a non-empty string.' } ], + [ + 'webSearchQuerySyntaxError', + [], + { + error: 'WEB_SEARCH_QUERY_SYNTAX', + message: + 'The query contains unsupported or malformed syntax. Regular expressions and unescaped braces are not supported. Quote or escape literal special characters.' + } + ], [ 'webSearchSortPropertyError', [], diff --git a/utils/searchDates.js b/utils/searchDates.js new file mode 100644 index 0000000..b90fb13 --- /dev/null +++ b/utils/searchDates.js @@ -0,0 +1,33 @@ +const DATE_RANGE_PAIRS = Object.freeze([ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] +]); +const DATE_PARAMS = Object.freeze(DATE_RANGE_PAIRS.flat()); +const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/; +const timestampPattern = + /^(\d{4}-\d{2}-\d{2})[Tt]([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?([Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + +function normalizeDateTime(value, { endOfDay = false } = {}) { + if (typeof value !== 'string') return null; + if (dateOnlyPattern.test(value)) { + const canonical = `${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`; + const date = new Date(canonical); + return Number.isFinite(date.getTime()) && date.toISOString() === canonical ? canonical : null; + } + + const match = timestampPattern.exec(value); + if (!match) return null; + const [, day, hour, minute, second, fraction = '', zone] = match; + const time = `${day}T${hour}:${minute}:${second}.${fraction.padEnd(3, '0')}`; + // Validate the original local calendar date before an offset can normalize it. + const local = new Date(`${time}Z`); + if (!Number.isFinite(local.getTime()) || local.toISOString() !== `${time}Z`) return null; + const date = new Date(`${time}${zone.toUpperCase()}`); + if (!Number.isFinite(date.getTime())) return null; + const canonical = date.toISOString(); + return /^\d{4}-/.test(canonical) ? canonical : null; +} + +module.exports = { DATE_PARAMS, DATE_RANGE_PAIRS, normalizeDateTime }; diff --git a/utils/searchDates.test.js b/utils/searchDates.test.js new file mode 100644 index 0000000..fd066b5 --- /dev/null +++ b/utils/searchDates.test.js @@ -0,0 +1,55 @@ +const { normalizeDateTime } = require('./searchDates'); + +describe('search date normalization', () => { + test.each([ + ['2024-02-29', '2024-02-29T00:00:00.000Z'], + ['2024-01-01T00:00:00Z', '2024-01-01T00:00:00.000Z'], + ['2024-01-01t00:00:00z', '2024-01-01T00:00:00.000Z'], + ['2024-01-01T00:00:00.1Z', '2024-01-01T00:00:00.100Z'], + ['2024-01-01T00:00:00.12Z', '2024-01-01T00:00:00.120Z'], + ['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z'], + ['2024-01-01T00:00:00+00:00', '2024-01-01T00:00:00.000Z'], + ['2024-01-01T00:00:00+05:30', '2023-12-31T18:30:00.000Z'], + ['2024-12-31T23:00:00-02:00', '2025-01-01T01:00:00.000Z'], + ['0000-01-01', '0000-01-01T00:00:00.000Z'] + ])('normalizes %s to UTC', (input, expected) => { + expect(normalizeDateTime(input)).toBe(expected); + }); + + test('only date-only end boundaries expand to the end of the day', () => { + expect(normalizeDateTime('2024-02-29', { endOfDay: true })).toBe('2024-02-29T23:59:59.999Z'); + expect(normalizeDateTime('2024-02-29T00:00:00Z', { endOfDay: true })).toBe('2024-02-29T00:00:00.000Z'); + }); + + test.each( + [ + undefined, + null, + 2024, + [], + {}, + '', + '2024', + '2024-01', + '2023-02-29', + '2024-02-30', + '2024-13-01', + '2024-01-00', + '2024-01-01T00:00Z', + '2024-01-01T00:00:00', + '2024-02-30T00:00:00+05:30', + '2024-01-01T24:00:00Z', + '2024-01-01T00:60:00Z', + '2024-01-01T00:00:60Z', + '2024-01-01T00:00:00.1234Z', + '2024-01-01T00:00:00+24:00', + '2024-01-01T00:00:00+00:60', + '9999-12-31T23:59:59-01:00', + '0000-01-01T00:00:00+01:00', + ' 2024-01-01 ', + '2024-01-01 00:00:00Z' + ].map((value) => [value]) + )('rejects unsupported or impossible input %j', (value) => { + expect(normalizeDateTime(value)).toBeNull(); + }); +}); diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index ee8cba1..686a45c 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -5,6 +5,7 @@ const searchScope = require('./searchScope'); const searchPostFilters = require('./searchPostFilters'); const ssvc = require('./ssvc'); const kev = require('./kev'); +const searchSort = require('./searchSort'); const { MAX_RESULTS_LIMIT, RESULTS_PER_PAGE } = constants; @@ -32,6 +33,8 @@ function buildSearchRequest(params) { queryStringObjects: buildQueryStrings(processedParams.queryStrings, scope) }; + if (params.sort !== undefined) searchOptions.sort = requireSearchSort(params.sort); + if (Object.keys(postFilters).length) { request.responseOptions = { resultsPerPage, @@ -60,13 +63,7 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc track_total_hits: trackTotalHits, size: searchOptions.resultsPerPage, from: searchOptions.from, - sort: [ - { - 'cveMetadata.cveId.keyword': { - order: 'asc' - } - } - ] + sort: buildSearchSort(searchOptions.sort) }; if (sourceIncludes?.length) { @@ -76,6 +73,20 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc return body; } +function requireSearchSort(value) { + const sort = searchSort.normalizeSort(value); + if (!sort) throw new TypeError('Invalid search sort.'); + return sort; +} + +function buildSearchSort(value) { + const sort = requireSearchSort(value); + const idSort = { 'cveMetadata.cveId.keyword': { order: 'asc' } }; + if (sort.property === 'cveId') return [{ 'cveMetadata.cveId.keyword': { order: sort.order } }]; + + return [{ [pubDatePath]: { order: sort.order, missing: '_last' } }, idSort]; +} + function buildPostFilterCandidateSearchBody(searchOptions, queryStringObjects = [], postFilters = {}) { return buildOpenSearchBody(searchOptions, queryStringObjects, { sourceIncludes: searchPostFilters.getCandidateSourceFields(postFilters), diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 6bde46f..9ea1f8e 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -32,6 +32,24 @@ function cvssTokenRegexp(field, token) { } describe('searchQueryBuilder buildSearchRequest()', () => { + test.each(['cveId', 'datePublished'])('defaults an omitted %s direction to asc', (property) => { + const request = queryBuilder.buildSearchRequest({ sort: { property: property.toUpperCase() } }); + expect(request.searchOptions.sort).toStrictEqual({ property, order: 'asc' }); + const expected = + property === 'cveId' + ? [{ 'cveMetadata.cveId.keyword': { order: 'asc' } }] + : [ + { 'cveMetadata.datePublished': { order: 'asc', missing: '_last' } }, + { 'cveMetadata.cveId.keyword': { order: 'asc' } } + ]; + expect(queryBuilder.buildOpenSearchBody(request.searchOptions).sort).toStrictEqual(expected); + }); + + test('rejects invalid sort values when called without the HTTP validators', () => { + expect(() => queryBuilder.buildSearchRequest({ sort: { property: '_script' } })).toThrow(TypeError); + expect(() => queryBuilder.buildOpenSearchBody({ sort: { property: 'dateUpdated' } })).toThrow(TypeError); + }); + test('buildSearchRequest builds filters, query strings, date ranges, and pagination options', () => { const output = queryBuilder.buildSearchRequest({ scope: 'CNA_ONLY', diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js index fb28d56..90e429a 100644 --- a/utils/searchReadiness.js +++ b/utils/searchReadiness.js @@ -51,7 +51,7 @@ const FIELD_CAPABILITY_GROUPS = Object.freeze([ aggregatable: true } ]), - capabilityGroup('datePublished', [{ field: 'cveMetadata.datePublished', types: ['date'] }]), + capabilityGroup('datePublished', [{ field: 'cveMetadata.datePublished', types: ['date'], aggregatable: true }]), capabilityGroup('dateUpdated', [{ field: 'cveMetadata.dateUpdated', types: ['date'] }]), capabilityGroup('noRejected', keywordRequirements(['noRejected'])), capabilityGroup('cvssV3_0Severity', keywordRequirements(['cvssV3_0Severity'])), diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js index 63b8891..5b0386d 100644 --- a/utils/searchReadiness.test.js +++ b/utils/searchReadiness.test.js @@ -66,9 +66,12 @@ describe('search readiness mapping contract', () => { } }); - test('requires aggregation support for the CVE ID sort field', () => { + test.each([ + ['cveMetadata.cveId.keyword', 'keyword'], + ['cveMetadata.datePublished', 'date'] + ])('requires aggregation support for sort field %s', (field, type) => { const body = validCapabilities(); - body.fields['cveMetadata.cveId.keyword'].keyword.aggregatable = false; + body.fields[field][type].aggregatable = false; expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( searchReadiness.SearchMappingContractError diff --git a/utils/searchSort.js b/utils/searchSort.js new file mode 100644 index 0000000..363a3dc --- /dev/null +++ b/utils/searchSort.js @@ -0,0 +1,20 @@ +const constants = require('./constants'); + +function normalizeSort(value) { + if (value === undefined) return { property: 'cveId', order: 'asc' }; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + if (Object.keys(value).some((key) => !['property', 'order'].includes(key))) return null; + if (typeof value.property !== 'string') return null; + + const property = constants.SEARCH_SORT_PROPERTIES.find( + (field) => field.toLowerCase() === value.property.trim().toLowerCase() + ); + const order = value.order === undefined ? 'asc' : value.order; + if (!property || typeof order !== 'string') return null; + const normalizedOrder = order.trim().toLowerCase(); + if (!constants.SEARCH_SORT_ORDERS.includes(normalizedOrder)) return null; + + return { property, order: normalizedOrder }; +} + +module.exports = { normalizeSort }; diff --git a/utils/webSearchQueryBuilder.js b/utils/webSearchQueryBuilder.js index ecf8a3b..c475c90 100644 --- a/utils/webSearchQueryBuilder.js +++ b/utils/webSearchQueryBuilder.js @@ -16,6 +16,13 @@ const DATE_FIELDS = Object.freeze([ ]); const URL_OPTIONS = { protocols: ['http', 'https', 'ftp', 'app', 'wss', 'ws', 'file'] }; +class WebSearchQueryError extends Error { + constructor() { + super('Unsupported or malformed web search expression.'); + this.name = 'WebSearchQueryError'; + } +} + function parseDate(value, allowPartial = false) { const text = value.trim(); const match = @@ -59,7 +66,7 @@ function parseDateRange(text) { if (parts.length !== 2) return null; const [start, end] = parts.map((part) => parseDate(part, true)); if (!start || !end) return null; - if (start.timestamp > end.timestamp) throw new Error('Invalid web search date range.'); + if (start.timestamp > end.timestamp) throw new WebSearchQueryError(); return [start, end]; } @@ -73,7 +80,6 @@ function isUrl(text) { } function describeToken(token) { - if (token.includes('{')) return null; if (/"[^"]*"/.test(token)) return 'search text is a phrase (surrounded by double quotes)'; if (parseDate(token)) return 'search text is a date (ISO 8601)'; if (parseDateRange(token)) return 'search text is a date range (ISO 8601)'; @@ -96,25 +102,95 @@ function describeToken(token) { return 'general search text string'; } -function describeQuery(query) { - const cleaned = query.trim().replace(/([^\p{L}0-9.-])\1{2,}/gu, ''); - const tokens = (cleaned.match(/"[^"]*"|[\S]+/g) ?? []).filter((token) => token !== ','); - let note; - for (let token of tokens) { - if (token.startsWith('"') && token.endsWith('"')) { - token = token.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +function validateToken(token) { + // URLs and relative paths are literal text, not slash-delimited query operators. + const url = token.includes('/') && isUrl(token) && !/["\\]/.test(token); + const relativePath = /^[\p{L}\p{N}_.-]+(?:\/[\p{L}\p{N}_.-]+)+$/u.test(token); + if (url || relativePath) { + return { + query: `"${token}"`, + note: url ? 'search text is a URL' : 'search text is a literal path' + }; + } + + let quoted = false; + let escaped = false; + let syntax = ''; + for (const character of token) { + if (escaped) { + syntax += 'x'; + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + quoted = !quoted; + syntax += '"'; + } else if (quoted) { + syntax += 'x'; + } else { + if ('/{}'.includes(character)) throw new WebSearchQueryError(); + syntax += character; + } + } + if (quoted || escaped) throw new WebSearchQueryError(); + + const note = describeToken(syntax); + if (!note) throw new WebSearchQueryError(); + return { query: token, note }; +} + +function validateQuery(query) { + const parts = []; + const notes = new Set(); + let token = ''; + let quoted = false; + let escaped = false; + let depth = 0; + function flushToken() { + if (!token) return; + const validated = validateToken(token); + parts.push(validated.query); + if (!['AND', 'OR', 'NOT', '&&', '||'].includes(token)) notes.add(validated.note); + token = ''; + } + + // Preserve quoted and escaped characters while checking every unquoted token. + for (const character of query) { + if (escaped) { + token += character; + escaped = false; + } else if (character === '\\') { + token += character; + escaped = true; + } else if (character === '"') { + token += character; + quoted = !quoted; + } else if (!quoted && /\s/.test(character)) { + flushToken(); + parts.push(character); + } else if (!quoted && '()'.includes(character)) { + flushToken(); + depth += character === '(' ? 1 : -1; + if (depth < 0) throw new WebSearchQueryError(); + parts.push(character); + } else { + token += character; } - // Preserve the legacy final-token note/acceptance behavior; the DSL uses the original query. - note = describeToken(token); } - if (!note) throw new Error('Unsupported web search expression.'); - return [note]; + if (quoted || escaped || depth !== 0) throw new WebSearchQueryError(); + flushToken(); + if (!notes.size || parts.every((part) => /^[\s,()]*$/.test(part))) throw new WebSearchQueryError(); + return { query: parts.join(''), notes: [...notes] }; } function buildWebSearchRequest(query, { sort, from = 0, size = WEB_SEARCH_RESULTS_PER_PAGE } = {}) { - const notes = describeQuery(query); + if (typeof query !== 'string' || !query.trim()) throw new WebSearchQueryError(); + query = query.trim(); const date = parseDate(query); const range = date ? [date, date] : parseDateRange(query); + const validated = range + ? { notes: [date ? 'search text is a date (ISO 8601)' : 'search text is a date range (ISO 8601)'] } + : validateQuery(query); const searchQuery = range ? { bool: { @@ -124,7 +200,7 @@ function buildWebSearchRequest(query, { sort, from = 0, size = WEB_SEARCH_RESULT minimum_should_match: 1 } } - : { query_string: { query, default_operator: 'AND' } }; + : { query_string: { query: validated.query, default_operator: 'AND' } }; const offset = Math.max(0, from); return { @@ -135,8 +211,8 @@ function buildWebSearchRequest(query, { sort, from = 0, size = WEB_SEARCH_RESULT from: offset, size }, - notes + notes: validated.notes }; } -module.exports = { buildWebSearchRequest }; +module.exports = { buildWebSearchRequest, WebSearchQueryError }; diff --git a/utils/webSearchQueryBuilder.test.js b/utils/webSearchQueryBuilder.test.js index 5c85777..7dd6086 100644 --- a/utils/webSearchQueryBuilder.test.js +++ b/utils/webSearchQueryBuilder.test.js @@ -1,4 +1,4 @@ -const { buildWebSearchRequest } = require('./webSearchQueryBuilder'); +const { buildWebSearchRequest, WebSearchQueryError } = require('./webSearchQueryBuilder'); const dateFields = [ 'cveMetadata.datePublished', @@ -20,18 +20,28 @@ describe('raw web search compatibility', () => { 'remote code execution', '"remote code execution"', 'apache*', + '*etype', 'ap?che', 'apache AND nginx', '(apache OR nginx)', 'cveMetadata.cveId.keyword:CVE-2024-0001', 'CVE-2024-0001', - 'https://example.com/a?b=1', '192.0.2.1', '2001:db8::1', '2024', '2024-01', '2024-02-30', - 'prefix\\:value apache' + 'prefix\\:value apache', + '"/fre[e]type/"', + 'containers.cna.descriptions.value:"/fre[e]type/"', + '\\/fre[e]type\\/', + '"/usr/local/bin"', + 'src\\/components\\/search.js', + '"{reserved}" apache', + '\\{reserved\\} apache', + '"a \\"quoted\\" phrase"', + '"(apache OR nginx)"', + 'apache AND (nginx OR "remote code")' ])('preserves the original query expression: %s', (query) => { expect(buildWebSearchRequest(query).body).toStrictEqual({ query: { query_string: { query, default_operator: 'AND' } }, @@ -44,7 +54,7 @@ describe('raw web search compatibility', () => { test.each([ ['apache', 'general search text string'], - ['"remote code"', 'general search text string'], + ['"remote code"', 'search text is a phrase (surrounded by double quotes)'], ['CVE-2024-0001', 'search text is a CVE ID'], ['CVE-2024', 'search text is a CVE ID year series'], ['CWE-79', 'search text is a CWE ID'], @@ -59,12 +69,37 @@ describe('raw web search compatibility', () => { ['some-file', 'search text is a version string'], ['file.txt', 'search text is a URL'], ['a&b', 'search text is a phrase (surrounded by double quotes)'], - ['"a\\\\b"', 'general search text string'], - ['{reserved} apache', 'general search text string'] - ])('preserves the legacy description for %s', (query, note) => { + ['"a\\\\b"', 'search text is a phrase (surrounded by double quotes)'] + ])('describes supported input: %s', (query, note) => { expect(buildWebSearchRequest(query).notes).toStrictEqual([note]); }); + test('describes every query term rather than just the final token', () => { + expect(buildWebSearchRequest('apache* AND "remote code" OR CVE-2024-0001 apache').notes).toStrictEqual([ + "search as wildcard ('*')", + 'search text is a phrase (surrounded by double quotes)', + 'search text is a CVE ID', + 'general search text string' + ]); + }); + + test.each([ + 'https://example.com/a?b=1', + 'https://example.com/fre[e]type/', + 'https://example.com/*', + 'src/components/search.js' + ])('serializes URLs and relative paths as literal text: %s', (query) => { + expect(buildWebSearchRequest(query).body.query).toStrictEqual({ + query_string: { query: `"${query}"`, default_operator: 'AND' } + }); + }); + + test('executes the validated URL representation within a multi-term query', () => { + expect(buildWebSearchRequest('apache https://example.com/a?b=1').body.query).toStrictEqual({ + query_string: { query: 'apache "https://example.com/a?b=1"', default_operator: 'AND' } + }); + }); + test.each([ ['2024-02-29', '2024-02-29||/d', '2024-02-29||/d'], ['2024-01-01T12:30:00Z', '2024-01-01T12:30:00Z', '2024-01-01T12:30:00Z'], @@ -83,12 +118,52 @@ describe('raw web search compatibility', () => { }); }); - test.each(['', ' ', ',', '{reserved}', 'field:value', '2024-02-01..2024-01-01'])( - 'rejects unsupported input without sending a query: %s', - (query) => { - expect(() => buildWebSearchRequest(query)).toThrow(); - } - ); + test.each([ + '', + ' ', + ',', + '()', + 'AND', + '{reserved}', + '{reserved} apache', + 'apache {reserved}', + '***{reserved} apache', + '"safe"{reserved} apache', + 'apache }', + 'field:value', + 'field:value apache', + 'apache field:value', + '2024-02-01..2024-01-01', + '/fre[e]type/', + '/fre[e]type/ apache', + 'apache /fre[e]type/', + 'apache OR /fre[e]type/ nginx', + 'apache\n/fre[e]type/\tnginx', + '(/fre[e]type/ OR apache)', + 'containers.cna.descriptions.value:/fre[e]type/', + 'containers.cna.descriptions.value:/fre[e]type/ apache', + '*/fre[e]type/ apache', + '"safe"/fre[e]type/ apache', + '+/fre[e]type/ apache', + '/apache/', + '/apache', + '\\\\/apache/ nginx', + '\\/apache/ nginx', + '"unterminated apache', + 'apache\\', + '(apache OR nginx', + 'apache) nginx' + ])('rejects unsupported input without sending a query: %s', (query) => { + expect(() => buildWebSearchRequest(query)).toThrow(WebSearchQueryError); + }); + + test.each([null, undefined, 1, [], {}])('rejects non-string input: %j', (query) => { + expect(() => buildWebSearchRequest(query)).toThrow(WebSearchQueryError); + }); + + test('uses the same trimmed query for validation and execution', () => { + expect(buildWebSearchRequest(' apache* ').body.query.query_string.query).toBe('apache*'); + }); test('quoted dates remain literal query expressions', () => { expect(buildWebSearchRequest('"2024-01-01"').body.query).toStrictEqual({