diff --git a/README.md b/README.md index b03ba5c..eb8166e 100644 --- a/README.md +++ b/README.md @@ -123,17 +123,23 @@ When the configured index is an alias spanning multiple indices, every reported 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. +For other requested IDs not already present in the returned page, the API performs at most one additional, ID-only lookup against the configured index. This lookup ignores other filters and pagination, fetches no full records, and handles duplicate documents without omitting another requested ID. `CVE_IDS_NOT_FOUND` lists IDs absent from the searchable index at lookup time; it does not mean those IDs do not exist globally. Existing IDs excluded by another filter or a different page do not receive this warning. Requests without `cveIds`, requests containing only pre-1999 IDs, and requests whose supported-year IDs all appear in the returned page need no additional lookup. + +The lookup adds search work and can add latency. Index refresh delays or changes between the main search and lookup can affect visibility; the two operations do not share a snapshot. If lookup verification fails, `CVE_ID_AVAILABILITY_UNVERIFIED` identifies the unchecked IDs while preserving an otherwise successful search, without reporting those IDs as missing. The overall search deadline and client cancellation still apply to the entire request. + 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. Scope applies to filter matching as follows: @@ -151,13 +157,15 @@ Missing data in selected containers does not make an otherwise compatible reques `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: diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 994a3f3..32ff091 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -47,7 +47,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "$ref": "#/components/schemas/ApiError" } } } @@ -685,7 +685,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 IDs absent from the returned page are verified through at most one additional ID-only lookup independent of scope, other filters, and pagination. CVE_IDS_NOT_FOUND lists IDs absent from the searchable configured index at lookup time, not necessarily nonexistent globally. Existing IDs excluded by filters or pagination do not receive this warning. Index refresh delays and changes between operations can affect visibility. CVE_ID_AVAILABILITY_UNVERIFIED warns when verification fails without discarding successful search results; overall deadlines and cancellation still apply. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." }, "noRejected": { "oneOf": [ @@ -811,28 +811,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", @@ -915,32 +947,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", @@ -950,8 +1025,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 be8da04..ff83a6b 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -94,15 +94,28 @@ 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_FOUND'); + expect(schema.properties.cveIds.description).toContain('CVE_ID_AVAILABILITY_UNVERIFIED'); + expect(schema.properties.cveIds.description).toContain('independent of scope, other filters, and pagination'); 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'); @@ -248,6 +261,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' ); diff --git a/controllers/middleware.js b/controllers/middleware.js index bc06ade..5dddad6 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -3,6 +3,7 @@ const constants = require('../utils/constants'); const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); const ssvc = require('../utils/ssvc'); +const searchDates = require('../utils/searchDates'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -45,22 +46,7 @@ const ssvcEnumValidators = [ const cvssMetricsParams = ['cvssV2Metrics', 'cvssV3Metrics', 'cvssV3_0Metrics', 'cvssV3_1Metrics', 'cvssV4Metrics']; -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']; @@ -77,7 +63,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; function isTrueParam(value) { @@ -154,11 +139,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. @@ -370,7 +351,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 037e98a..c5f02cf 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -884,7 +884,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.` ); } ); @@ -933,14 +933,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.' ); }); @@ -956,7 +956,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.` ); }); @@ -1029,7 +1029,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..b932413 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -5,6 +5,7 @@ const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); const searchResponseBuilder = require('../utils/searchResponseBuilder'); +const searchCveIdWarnings = require('../utils/searchCveIdWarnings'); const webSearchQueryBuilder = require('../utils/webSearchQueryBuilder'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); @@ -162,7 +163,19 @@ async function searchHandler(req, res, next) { ); lifecycle.throwIfAborted(); const pagingOptions = responseOptions ?? searchOptions; - const warnings = [...searchResponseBuilder.getRequestWarnings(params), ...searchResult.warnings]; + const availabilityWarnings = await searchCveIdWarnings.getAvailabilityWarnings( + params.cveIds, + searchResult.hits, + { + signal: lifecycle.signal + } + ); + lifecycle.throwIfAborted(); + const warnings = [ + ...searchResponseBuilder.getRequestWarnings(params), + ...availabilityWarnings, + ...searchResult.warnings + ]; return res.status(200).json( searchResponseBuilder.buildSearchResponse({ diff --git a/routes/index.js b/routes/index.js index fc546cf..cbef9bf 100644 --- a/routes/index.js +++ b/routes/index.js @@ -8,6 +8,7 @@ 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 error = new errors.errors(); const SEARCH_BODY_FIELDS = new Set(constants.SEARCH_QUERY_PARAMS); @@ -60,7 +61,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(); } @@ -109,10 +111,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; @@ -182,6 +180,14 @@ 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; + } + }); + req.searchParams = searchParams; next(); } @@ -304,7 +310,7 @@ description: 'Malformed JSON, invalid request body, parameter combination, or ve content: { "application/json": { schema: { - $ref: "#/components/schemas/ApiErrorResponse" + $ref: "#/components/schemas/ApiError" } } }, @@ -394,10 +400,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(), diff --git a/routes/index.test.js b/routes/index.test.js index 9be9c4d..065f4eb 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; @@ -123,6 +124,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 +203,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 +223,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 +241,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 +259,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 +288,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 +347,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 +378,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 5ae6df0..8386dc1 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -28,6 +28,141 @@ describe('search route integration', () => { mockSearchOpenSearch.mockReset(); }); + test('returns matching IDs and warns only about IDs absent from the index', async () => { + const record = { cveMetadata: { cveId: 'CVE-2024-0001' } }; + mockSearchOpenSearch + .mockResolvedValueOnce(advancedResponse(1, [{ _source: record }]).data) + .mockResolvedValueOnce({ + aggregations: { requested_ids: { buckets: { 'CVE-2099-99999999': { doc_count: 0 } } } } + }); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cveIds: 'CVE-1998-0001,cve-2024-0001,CVE-2099-99999999,CVE-2099-99999999', resultsPerPage: 1 } + }); + expect(response.status).toBe(200); + expect(response.body.records).toStrictEqual([record]); + expect(response.body.pagination.totalResults).toBe(1); + expect(response.body.warnings).toStrictEqual([ + expect.objectContaining({ code: 'CVE_IDS_NOT_RETURNED', cveIds: ['CVE-1998-0001'] }), + expect.objectContaining({ code: 'CVE_IDS_NOT_FOUND', cveIds: ['CVE-2099-99999999'] }) + ]); + expect(Object.keys(response.body).indexOf('warnings')).toBeLessThan( + Object.keys(response.body).indexOf('records') + ); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); + }); + + test.each([ + { resultsPerPage: 1, pageNumber: 2 }, + { keywordSearch: 'nonmatching-text', scope: 'ADP_ONLY' }, + { noRejected: true }, + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' } + ])('does not report an existing ID as missing when excluded by filters or pagination: %p', async (filters) => { + mockSearchOpenSearch.mockResolvedValueOnce(advancedResponse(0).data).mockResolvedValueOnce({ + aggregations: { requested_ids: { buckets: { 'CVE-2024-0001': { doc_count: 1 } } } } + }); + 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.mock.calls[1][0]).toMatchObject({ + size: 0, + _source: false, + query: { bool: { filter: [{ terms: { 'cveMetadata.cveId.keyword': ['CVE-2024-0001'] } }] } } + }); + }); + + test('returns a successful search with a sanitized warning when only the ID lookup fails', async () => { + const record = { cveMetadata: { cveId: 'CVE-2024-0002' } }; + mockSearchOpenSearch + .mockResolvedValueOnce(advancedResponse(1, [{ _source: record }]).data) + .mockRejectedValueOnce(new Error('private backend credentials')); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cveIds: 'CVE-2024-0001,CVE-2024-0002' } + }); + expect(response.status).toBe(200); + expect(response.body.records).toStrictEqual([record]); + expect(response.body.warnings).toStrictEqual([ + expect.objectContaining({ code: 'CVE_ID_AVAILABILITY_UNVERIFIED', cveIds: ['CVE-2024-0001'] }) + ]); + expect(response.text).not.toContain('private'); + }); + + 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(); + } + ); + + 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('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { const record = { dataType: 'CVE_RECORD', @@ -210,7 +345,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(); }); diff --git a/routes/swagger.js b/routes/swagger.js index 069e05e..44fd10a 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -34,6 +34,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.', @@ -56,7 +69,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 IDs absent from the returned page are verified through at most one additional ID-only lookup independent of scope, other filters, and pagination. CVE_IDS_NOT_FOUND lists IDs absent from the searchable configured index at lookup time, not necessarily nonexistent globally. Existing IDs excluded by filters or pagination do not receive this warning. Index refresh delays and changes between operations can affect visibility. CVE_ID_AVAILABILITY_UNVERIFIED warns when verification fails without discarding successful search results; overall deadlines and cancellation still apply. 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, @@ -162,38 +175,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' - } + lastModStartDate: searchDateProperty( + 'Returns CVE records with dateUpdated timestamps on or after this value.', + false ), - 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' - } - ), - 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.`, { @@ -280,43 +274,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.`, @@ -328,10 +304,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 } ) }; diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index c87b1c5..1d503b2 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -427,6 +427,335 @@ }, "response": [] }, + { + "name": "Missing indexed CVE ID warns without discarding matching records", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "const expectedWarnings = [{\"code\":\"CVE_IDS_NOT_FOUND\",\"message\":\"The listed requested CVE IDs were not found in the searchable data of the configured index at lookup time. This does not establish that the IDs do not exist globally.\",\"cveIds\":[\"CVE-2099-99999999\"]}];", + "pm.test('expected warnings are returned', () => pm.expect(payload.warnings).to.deep.include.members(expectedWarnings));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2099-99999999\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Existing rejected ID excluded by a filter is not missing", + "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));", + "const excludedWarningCodes = [\"CVE_IDS_NOT_FOUND\",\"CVE_ID_AVAILABILITY_UNVERIFIED\"];", + "pm.test('present IDs are not reported as missing or unverified', () => {", + " const codes = payload.warnings.map(warning => warning.code);", + " excludedWarningCodes.forEach(code => pm.expect(codes).not.to.include(code));", + "});", + "}" + ] + } + } + ], + "request": { + "method": "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-31279\",\n \"noRejected\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited accepts string 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');", + "});", + "}" + ] + } + } + ], + "request": { + "method": "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 \"knownExploited\": \"true\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited accepts string 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');", + "});", + "}" + ] + } + } + ], + "request": { + "method": "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 \"knownExploited\": \"false\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed accepts string 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');", + "});", + "}" + ] + } + } + ], + "request": { + "method": "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\": \"true\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed accepts string 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');", + "});", + "}" + ] + } + } + ], + "request": { + "method": "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": "Second valid page can be empty", "event": [ @@ -460,6 +789,11 @@ "});", "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));", + "const excludedWarningCodes = [\"CVE_IDS_NOT_FOUND\",\"CVE_ID_AVAILABILITY_UNVERIFIED\"];", + "pm.test('present IDs are not reported as missing or unverified', () => {", + " const codes = payload.warnings.map(warning => warning.code);", + " excludedWarningCodes.forEach(code => pm.expect(codes).not.to.include(code));", + "});", "}" ] } @@ -1848,6 +2182,168 @@ "name": "05 - Record date filters", "description": "Publication and modification boundaries.", "item": [ + { + "name": "Date-only publication boundaries", + "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));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01\",\n \"pubEndDate\": \"2024-12-31\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Publication timestamp with explicit offset", + "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));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T01:00:00+01:00\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Publication timestamp without fractional seconds", + "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));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "pubStartDate", "event": [ @@ -3917,6 +4413,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_QUERY_UNSUPPORTED\",\"message\":\"The /search endpoint accepts JSON request body fields, not query parameters.\"};", "pm.test('expected error code is returned', () => {", @@ -3957,6 +4454,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_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', () => {", @@ -3997,6 +4495,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\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", "pm.test('expected error code is returned', () => {", @@ -4041,6 +4540,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_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", "pm.test('expected error code is returned', () => {", @@ -4081,6 +4581,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\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", "pm.test('expected error code is returned', () => {", @@ -4121,6 +4622,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\":\"INVALID_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", "pm.test('expected error code is returned', () => {", @@ -4165,6 +4667,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_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", "pm.test('expected error code is returned', () => {", @@ -4209,6 +4712,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_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", "pm.test('expected error code is returned', () => {", @@ -4259,6 +4763,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_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -4303,6 +4808,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_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", "pm.test('expected error code is returned', () => {", @@ -4347,6 +4853,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_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", "pm.test('expected error code is returned', () => {", @@ -4391,6 +4898,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\":\"CVE_IDS_FORMAT\",\"message\":\"The cveIds parameter must be a comma-separated list of CVE IDs.\"};", "pm.test('expected error code is returned', () => {", @@ -4435,6 +4943,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\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -4479,6 +4988,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\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", "pm.test('expected error code is returned', () => {", @@ -4523,6 +5033,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\":\"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', () => {", @@ -4553,6 +5064,231 @@ }, "response": [] }, + { + "name": "Invalid page size \"abc\"", + "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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": \"abc\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size 1.5", + "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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 1.5\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size 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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 0\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "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.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size [1]", + "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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": [\n 1\n ]\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "resultsPerPage maximum", "event": [ @@ -4567,6 +5303,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\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", "pm.test('expected error code is returned', () => {", @@ -4611,6 +5348,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\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", "pm.test('expected error code is returned', () => {", @@ -4655,6 +5393,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_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', () => {", @@ -4699,6 +5438,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\":\"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', () => {", @@ -4743,6 +5483,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\":\"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', () => {", @@ -4787,6 +5528,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\":\"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', () => {", @@ -4837,6 +5579,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\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -4881,6 +5624,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\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -4925,6 +5669,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\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -4969,6 +5714,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\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -4993,7 +5739,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"knownExploited\": \"true\"\n}", + "raw": "{\n \"knownExploited\": \"yes\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -5013,6 +5759,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\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -5037,7 +5784,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"disputed\": \"false\"\n}", + "raw": "{\n \"disputed\": \"no\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -5057,6 +5804,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\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5101,6 +5849,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\":\"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', () => {", @@ -5145,6 +5894,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\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -5189,6 +5939,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\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -5233,6 +5984,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\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -5277,6 +6029,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\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -5321,6 +6074,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\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", "pm.test('expected error code is returned', () => {", @@ -5365,6 +6119,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\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", "pm.test('expected error code is returned', () => {", @@ -5409,6 +6164,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\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", "pm.test('expected error code is returned', () => {", @@ -5459,6 +6215,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\":\"MISSING_KEYWORD_SEARCH_PARAM\",\"message\":\"The keywordSearch parameter is required when the keywordExactMatch parameter is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -5503,6 +6260,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\":\"MISSING_CPE_NAME_PARAM\",\"message\":\"The cpeName parameter is required when the isVulnerable parameter is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -5547,6 +6305,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\":\"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', () => {", @@ -5591,6 +6350,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\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -5635,6 +6395,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\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -5685,6 +6446,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5729,6 +6491,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5773,6 +6536,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5817,6 +6581,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5861,6 +6626,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5905,6 +6671,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5949,6 +6716,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\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5993,6 +6761,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\":\"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', () => {", @@ -6037,6 +6806,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\":\"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', () => {", @@ -6081,6 +6851,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\":\"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', () => {", @@ -6125,6 +6896,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\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString part component must be one of a, h, o, *, or -.\"};", "pm.test('expected error code is returned', () => {", @@ -6169,6 +6941,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\":\"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', () => {", @@ -6213,6 +6986,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_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', () => {", @@ -6257,6 +7031,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_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", "pm.test('expected error code is returned', () => {", @@ -6301,6 +7076,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_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", "pm.test('expected error code is returned', () => {", @@ -6345,6 +7121,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_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", "pm.test('expected error code is returned', () => {", @@ -6389,6 +7166,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_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', () => {", @@ -6433,6 +7211,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\":\"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', () => {", @@ -6477,6 +7256,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\":\"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', () => {", @@ -6521,6 +7301,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', () => {", @@ -6565,6 +7346,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', () => {", @@ -6602,7 +7384,52 @@ "description": "Timestamp format, calendar validity, and range order.", "item": [ { - "name": "Partial timestamp", + "name": "Timestamp without timezone", + "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\":\"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);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pubStartDate\": \"2024-01-01T00:00:00\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Timestamp beyond millisecond precision", "event": [ { "listen": "test", @@ -6615,8 +7442,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);", "});", @@ -6639,7 +7467,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" } } } }, @@ -6659,8 +7487,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);", "});", @@ -6703,6 +7532,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', () => {", @@ -6747,6 +7577,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', () => {", @@ -6791,6 +7622,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', () => {", @@ -6835,6 +7667,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', () => {", @@ -6899,6 +7732,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', () => {", @@ -6953,6 +7787,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', () => {", @@ -7012,6 +7847,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', () => {", @@ -7071,6 +7907,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/README.md b/test/postman/README.md index c4883b6..91f6539 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -60,6 +60,8 @@ Review and approve tests before targeting a shared or production deployment. The Index-wide searches assert only that at least one result exists. Exact totals are limited to requests constrained by one or more fixture `cveIds`, so different index sizes do not cause false failures. Constrained zero-result requests intentionally verify scope exclusion or literal substring behavior. +The collection also checks missing-ID warnings, including a mixed request for a fixture and a future-year ID absent from the index. An existing ID excluded by a filter or pagination must not produce `CVE_IDS_NOT_FOUND`. These checks require successful access to the API's auxiliary ID-only lookup. An incomplete local index may legitimately produce missing-ID warnings for the fixtures; a failed verification produces `CVE_ID_AVAILABILITY_UNVERIFIED` instead of declaring IDs missing. + Because CVE records can be updated, refresh a fixture when a positive request stops satisfying its documented filter rather than weakening its assertions without investigating the data change. Before treating a local failure as an API defect, confirm that the local index contains the fixture record and the specific CNA, ADP, CPE, CVSS, SSVC, KEV, reference, or tag data the request expects. A local index must include the fixtures above and their relevant nested fields to run every positive folder unchanged; validation-error folders do not depend on index contents. ## Fault-Injection Folder diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 60feeb1..8bc184c 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -55,7 +55,8 @@ function successTests({ minimumTotal, expectedIds = [], expectedHitCount, - expectedWarnings = [] + expectedWarnings = [], + excludedWarningCodes = [] } = {}) { const tests = [ `pm.test('status is ${status}', () => pm.response.to.have.status(${status}));`, @@ -111,6 +112,16 @@ function successTests({ ); } + if (excludedWarningCodes.length) { + tests.push( + `const excludedWarningCodes = ${JSON.stringify(excludedWarningCodes)};`, + "pm.test('present IDs are not reported as missing or unverified', () => {", + ' const codes = payload.warnings.map(warning => warning.code);', + ' excludedWarningCodes.forEach(code => pm.expect(codes).not.to.include(code));', + '});' + ); + } + tests.push('}'); return tests; } @@ -121,6 +132,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', () => {", @@ -301,12 +313,39 @@ const baselineAndPagination = folder( { cveIds: `${ids.primary},${ids.rejected}`, resultsPerPage: 2, pageNumber: 1 }, { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } ), + positive( + 'Missing indexed CVE ID warns without discarding matching records', + { cveIds: `${ids.primary},CVE-2099-99999999` }, + { + exactTotal: 1, + expectedIds: [ids.primary], + expectedWarnings: [ + { + code: 'CVE_IDS_NOT_FOUND', + message: + 'The listed requested CVE IDs were not found in the searchable data of the configured index at lookup time. This does not establish that the IDs do not exist globally.', + cveIds: ['CVE-2099-99999999'] + } + ] + } + ), + positive( + 'Existing rejected ID excluded by a filter is not missing', + { cveIds: ids.rejected, noRejected: true }, + { exactTotal: 0, excludedWarningCodes: ['CVE_IDS_NOT_FOUND', 'CVE_ID_AVAILABILITY_UNVERIFIED'] } + ), + ...['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 }, { exactTotal: 1, - expectedHitCount: 0 + expectedHitCount: 0, + excludedWarningCodes: ['CVE_IDS_NOT_FOUND', 'CVE_ID_AVAILABILITY_UNVERIFIED'] } ), positive( @@ -499,6 +538,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' }, @@ -927,6 +981,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 }, @@ -964,8 +1025,8 @@ 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()), negative('CVSS v2 severity enum', { cvssV2Severity: 'CRITICAL' }, error.cvssV2SeverityError('CRITICAL')), negative('CVSS v3 severity enum', { cvssV3Severity: 'UNKNOWN' }, error.cvssV3SeverityError('UNKNOWN')), @@ -1133,7 +1194,16 @@ const cpeAndVersionFailures = folder( ); 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/errors.js b/utils/errors.js index cdbaa2e..febd3f9 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -304,7 +304,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.` ); } diff --git a/utils/errors.test.js b/utils/errors.test.js index 6c7bfbb..e011b9f 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.' } ], [ diff --git a/utils/searchCveIdWarnings.js b/utils/searchCveIdWarnings.js new file mode 100644 index 0000000..d9b5eef --- /dev/null +++ b/utils/searchCveIdWarnings.js @@ -0,0 +1,67 @@ +const constants = require('./constants'); +const searchDeadline = require('./searchDeadline'); +const searchProvider = require('./searchProvider'); +const searchQueryBuilder = require('./searchQueryBuilder'); +const { InvalidOpenSearchResponseError } = require('./searchResponseValidator'); + +function unverifiedCveIds(value, hits) { + if (typeof value !== 'string') return []; + const returnedIds = new Set(hits.map((hit) => String(hit._source?.cveMetadata?.cveId ?? hit._id).toUpperCase())); + return [...new Set(value.split(',').map((id) => id.trim().toUpperCase()))].filter((id) => { + const match = /^CVE-(\d{4})-\d{4,}$/.exec(id); + return match && Number(match[1]) >= constants.FIRST_CVE_RECORD_YEAR && !returnedIds.has(id); + }); +} + +async function getAvailabilityWarnings(value, hits, { signal } = {}) { + const cveIds = unverifiedCveIds(value, hits); + if (!cveIds.length) return []; + + try { + searchDeadline.throwIfAborted(signal); + const body = searchQueryBuilder.buildCveIdExistenceSearchBody(cveIds); + const response = await searchProvider.searchOpenSearch(body, { signal }); + searchDeadline.throwIfAborted(signal); + // The provider rejects partial/timed-out searches; additionally require a count for every requested ID. + const buckets = response?.aggregations?.requested_ids?.buckets; + if ( + response.terminated_early === true || + !buckets || + typeof buckets !== 'object' || + Array.isArray(buckets) || + Object.keys(buckets).length !== cveIds.length || + cveIds.some( + (id) => + !Object.hasOwn(buckets, id) || + !Number.isSafeInteger(buckets[id]?.doc_count) || + buckets[id].doc_count < 0 + ) + ) { + throw new InvalidOpenSearchResponseError('CVE ID availability lookup is incomplete.'); + } + const missingIds = cveIds.filter((id) => buckets[id].doc_count === 0); + return missingIds.length + ? [ + { + code: 'CVE_IDS_NOT_FOUND', + message: + 'The listed requested CVE IDs were not found in the searchable data of the configured index at lookup time. This does not establish that the IDs do not exist globally.', + cveIds: missingIds + } + ] + : []; + } catch (err) { + // Cancellation and the overall deadline still govern the entire HTTP request. + searchDeadline.throwIfAborted(signal); + return [ + { + code: 'CVE_ID_AVAILABILITY_UNVERIFIED', + message: + 'Availability of the listed requested CVE IDs could not be verified. Search results are still returned; no missing-ID conclusion was made.', + cveIds + } + ]; + } +} + +module.exports = { getAvailabilityWarnings }; diff --git a/utils/searchCveIdWarnings.test.js b/utils/searchCveIdWarnings.test.js new file mode 100644 index 0000000..cb74e80 --- /dev/null +++ b/utils/searchCveIdWarnings.test.js @@ -0,0 +1,131 @@ +const mockSearchOpenSearch = jest.fn(); +jest.mock('./searchProvider', () => ({ searchOpenSearch: mockSearchOpenSearch })); + +const { getAvailabilityWarnings } = require('./searchCveIdWarnings'); +const { SearchClientDisconnectedError, SearchDeadlineExceededError } = require('./searchDeadline'); +const first = 'CVE-2024-0001'; +const second = 'CVE-2099-99999999'; +const hit = (id) => ({ _source: { cveMetadata: { cveId: id } } }); +const response = (counts) => ({ + aggregations: { + requested_ids: { + buckets: Object.fromEntries(Object.entries(counts).map(([id, count]) => [id, { doc_count: count }])) + } + } +}); + +describe('requested CVE ID availability warnings', () => { + beforeEach(() => mockSearchOpenSearch.mockReset()); + + test.each([undefined, null, '', 'invalid', 'CVE-1998-0001', first])( + 'skips lookup for absent, unsupported-year, or already returned IDs: %p', + async (value) => { + expect(await getAvailabilityWarnings(value, [hit(first)])).toStrictEqual([]); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + + test('normalizes and deduplicates only unverified supported-year IDs', async () => { + mockSearchOpenSearch.mockResolvedValue(response({ [second]: 0 })); + const signal = new AbortController().signal; + const warnings = await getAvailabilityWarnings( + `cve-1998-0001,${first}, cve-2099-99999999,${second}`, + [hit(first)], + { signal } + ); + expect(warnings).toStrictEqual([ + { + code: 'CVE_IDS_NOT_FOUND', + message: expect.stringContaining('at lookup time'), + cveIds: [second] + } + ]); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + expect(mockSearchOpenSearch).toHaveBeenCalledWith( + expect.objectContaining({ + size: 0, + _source: false, + track_total_hits: false, + query: { bool: { filter: [{ terms: { 'cveMetadata.cveId.keyword': [second] } }] } } + }), + { signal } + ); + }); + + test('treats duplicate documents as presence without hiding another missing ID', async () => { + mockSearchOpenSearch.mockResolvedValue(response({ [first]: 100, [second]: 0 })); + expect((await getAvailabilityWarnings(`${first},${second}`, []))[0].cveIds).toStrictEqual([second]); + }); + + test('does not warn for an existing ID omitted from the search page', async () => { + mockSearchOpenSearch.mockResolvedValue(response({ [first]: 1 })); + expect(await getAvailabilityWarnings(first, [])).toStrictEqual([]); + }); + + test('supports IDs returned in hit metadata', async () => { + expect(await getAvailabilityWarnings(first, [{ _id: first }])).toStrictEqual([]); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test('batches the maximum 100 requested IDs in one lookup', async () => { + const ids = Array.from({ length: 100 }, (_, index) => `CVE-2024-${index + 1000}`); + mockSearchOpenSearch.mockResolvedValue(response(Object.fromEntries(ids.map((id) => [id, 0])))); + expect((await getAvailabilityWarnings(ids.join(','), []))[0].cveIds).toStrictEqual(ids); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + }); + + test.each( + [ + undefined, + {}, + { aggregations: {} }, + { aggregations: { requested_ids: { buckets: [] } } }, + response({}), + response({ [first]: -1 }), + response({ [first]: '0' }), + response({ [first]: 0.5 }), + response({ [first]: Number.MAX_SAFE_INTEGER + 1 }), + response({ [second]: 0 }), + response({ [first]: 0, [second]: 0 }), + { ...response({ [first]: 0 }), terminated_early: true } + ].map((value) => [value]) + )('never reports absence from an incomplete lookup: %p', async (value) => { + mockSearchOpenSearch.mockResolvedValue(value); + expect(await getAvailabilityWarnings(first, [])).toStrictEqual([ + expect.objectContaining({ + code: 'CVE_ID_AVAILABILITY_UNVERIFIED', + cveIds: [first] + }) + ]); + }); + + test.each(['ConnectionError', 'TimeoutError', 'InvalidOpenSearchResponseError'])( + 'sanitizes auxiliary %s failures instead of claiming IDs are missing', + async (name) => { + mockSearchOpenSearch.mockRejectedValue({ name, message: 'secret upstream URL and credentials' }); + const warnings = await getAvailabilityWarnings(first, []); + expect(warnings[0].code).toBe('CVE_ID_AVAILABILITY_UNVERIFIED'); + expect(JSON.stringify(warnings)).not.toContain('secret'); + } + ); + + test.each([new SearchClientDisconnectedError(), new SearchDeadlineExceededError()])( + 'respects cancellation before the lookup: %p', + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + await expect(getAvailabilityWarnings(first, [], { signal: controller.signal })).rejects.toBe(reason); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + + test('propagates cancellation during a lookup', async () => { + const controller = new AbortController(); + const reason = new SearchClientDisconnectedError(); + mockSearchOpenSearch.mockImplementation(() => { + controller.abort(reason); + return response({ [first]: 0 }); + }); + await expect(getAvailabilityWarnings(first, [], { signal: controller.signal })).rejects.toBe(reason); + }); +}); 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 0de35f6..c145f05 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -97,6 +97,26 @@ function buildCveIdsSearchRequest(cveIds) { }; } +function buildCveIdExistenceSearchBody(cveIds) { + if (!Array.isArray(cveIds) || !cveIds.length || cveIds.length > constants.MAX_CVE_IDS) { + throw new TypeError('CVE ID existence lookup requires a bounded nonempty ID list.'); + } + const field = 'cveMetadata.cveId.keyword'; + return { + size: 0, + _source: false, + track_total_hits: false, + query: { bool: { filter: [{ terms: { [field]: cveIds } }] } }, + aggs: { + requested_ids: { + filters: { + filters: Object.fromEntries(cveIds.map((id) => [id, { term: { [field]: id } }])) + } + } + } + }; +} + function buildPostFilters(params) { const postFilters = {}; const cpeNamePostFilter = getCpeNamePostFilter(params); @@ -695,6 +715,7 @@ function cvssV2SeverityToRange(sev) { } module.exports = { + buildCveIdExistenceSearchBody, buildCveIdsSearchRequest, buildOpenSearchBody, buildPostFilterCandidateSearchBody, diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 70df908..9c201a1 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -32,6 +32,33 @@ function cvssTokenRegexp(field, token) { } describe('searchQueryBuilder buildSearchRequest()', () => { + test('builds a complete, duplicate-safe ID-only existence lookup without sorting or record retrieval', () => { + const ids = ['CVE-2024-0001', 'CVE-2024-0002']; + expect(queryBuilder.buildCveIdExistenceSearchBody(ids)).toStrictEqual({ + size: 0, + _source: false, + track_total_hits: false, + query: { bool: { filter: [{ terms: { 'cveMetadata.cveId.keyword': ids } }] } }, + aggs: { + requested_ids: { + filters: { + filters: { + 'CVE-2024-0001': { term: { 'cveMetadata.cveId.keyword': ids[0] } }, + 'CVE-2024-0002': { term: { 'cveMetadata.cveId.keyword': ids[1] } } + } + } + } + } + }); + }); + + test.each([undefined, null, [], 'CVE-2024-0001', Array(101).fill('CVE-2024-0001')].map((value) => [value]))( + 'rejects unbounded or empty internal ID lookup input: %p', + (value) => { + expect(() => queryBuilder.buildCveIdExistenceSearchBody(value)).toThrow(TypeError); + } + ); + test('buildSearchRequest builds filters, query strings, date ranges, and pagination options', () => { const output = queryBuilder.buildSearchRequest({ scope: 'CNA_ONLY',