From 4c98cc9deb9f63951e4a0cdd7395cc7b22533733 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 17:13:46 -0400 Subject: [PATCH 01/27] feat: add scoped search field selection --- README.md | 4 +- api-docs/openapi.json | 17 +++- api-docs/openapi.test.js | 3 + controllers/middleware.js | 12 +++ controllers/middleware.test.js | 24 +++++ controllers/searchController.test.js | 9 ++ routes/cpeName.test.js | 1 + routes/index.js | 19 ++++ routes/index.test.js | 29 +++++- routes/search.integration.test.js | 20 +++- routes/swagger.js | 27 ++++-- server.test.js | 5 +- utils/constants.js | 24 +---- utils/errors.js | 11 +++ utils/searchQueryBuilder.js | 34 ++++--- utils/searchQueryBuilder.test.js | 32 +++++++ utils/searchScope.js | 131 +++++++++++++++++++++++++++ utils/searchScope.test.js | 38 ++++++++ 18 files changed, 393 insertions(+), 47 deletions(-) create mode 100644 utils/searchScope.js create mode 100644 utils/searchScope.test.js diff --git a/README.md b/README.md index 6201913..37d969a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,9 @@ An AWS load balancer can use `/api/ready-check` to stop routing search traffic t ## API Input Validation -`POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. +`POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. + +Use the optional `scope` field to control which parts of each record participate in matching. Values are case-insensitive: `FULL` (the default) searches base record fields, CNA data, and every ADP provider; `CNA_ONLY` searches base record fields and CNA data; and `ADP_ONLY` searches base record fields and every ADP provider. Scope changes matching only, so successful responses always contain the complete CVE record. CPE applicability filters use CNA data and return HTTP 400 when combined with `ADP_ONLY`. String inputs are bounded before query construction: diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 61279e4..3ef710b 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -93,6 +93,7 @@ "allParameters": { "summary": "All supported request body parameters", "value": { + "scope": "FULL", "keywordSearch": "remote code execution", "keywordExactMatch": true, "cveIds": "CVE-2024-1234,CVE-2024-5678", @@ -298,14 +299,24 @@ "schemas": { "SearchRequest": { "type": "object", - "description": "Optional filters and pagination controls for a CVE record search.", + "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values.", "additionalProperties": false, "properties": { + "scope": { + "type": "string", + "enum": [ + "FULL", + "CNA_ONLY", + "ADP_ONLY" + ], + "default": "FULL", + "description": "Controls which containers participate in filter matching. FULL searches base record fields, CNA data, and all ADP providers. CNA_ONLY searches base record fields and CNA data. ADP_ONLY searches base record fields and all ADP providers. Scope does not remove data from returned records. Values are case-insensitive. CPE applicability filters are not available with ADP_ONLY." + }, "keywordSearch": { "type": "string", "maxLength": 512, "example": "remote code execution", - "description": "Returns CVE records where a word or phrase is included in the record's description. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to 512 characters and 64 whitespace-delimited terms." + "description": "Returns CVE records where a word or phrase is included in a description selected by scope. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to 512 characters and 64 whitespace-delimited terms." }, "keywordExactMatch": { "type": "boolean", @@ -327,7 +338,7 @@ "type": "string", "maxLength": 32, "example": "CWE-79", - "description": "Returns CVE records that include the provided Common Weakness Enumeration ID." + "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID." }, "cvssV2Severity": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 0b49ee4..9885b97 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -75,6 +75,9 @@ describe('OpenAPI contract', () => { }); expect(schema.properties.noRejected.type).toBe('boolean'); expect(schema.properties.noRejected.description).toContain('When true'); + expect(schema.properties.scope.enum).toStrictEqual(['FULL', 'CNA_ONLY', 'ADP_ONLY']); + expect(schema.properties.scope.default).toBe('FULL'); + expect(schema.properties.scope.description).toContain('does not remove data'); expect(schema.properties.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'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 8eedf02..a029361 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -1,6 +1,7 @@ const errors = require('../utils/errors'); const constants = require('../utils/constants'); const cpe = require('../utils/cpe'); +const searchScope = require('../utils/searchScope'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -146,6 +147,17 @@ function validateParams(req, res, next) { } } + if (!searchScope.isValidScope(params.scope)) { + return res.status(400).json(error.scopeValueError()); + } + + const incompatibleParams = searchScope.getIncompatibleParams(params); + if (incompatibleParams.length) { + return res + .status(400) + .json(error.scopeCompatibilityError(searchScope.normalizeScope(params.scope), incompatibleParams)); + } + for (const [param, maxLength] of Object.entries(constants.SEARCH_STRING_LENGTH_LIMITS)) { if (typeof params[param] === 'string' && params[param].length > maxLength) { return res.status(400).json(error.searchFieldLengthError(param, maxLength)); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 5079be4..eec066e 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -872,6 +872,30 @@ describe('middleware validation function', () => { } ); + test.each(['FULL', 'full', 'CNA_ONLY', 'adp_only'])( + 'ValidateParams accepts case-insensitive scope value %s.', + (scope) => { + validate({ scope }); + expectValidationSuccess(); + } + ); + + test('ValidateParams rejects unsupported scope values', () => { + validate({ scope: 'CNA_AND_ADP' }); + expectValidationError('SCOPE_VALUE', 'The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.'); + }); + + test('ValidateParams rejects CNA-only CPE filters with ADP_ONLY', () => { + validate({ + scope: 'ADP_ONLY', + virtualMatchString: 'cpe:2.3:a:vendor:product' + }); + expectValidationError( + 'SCOPE_FILTER_INCOMPATIBLE', + 'The virtualMatchString parameter is not supported with scope ADP_ONLY.' + ); + }); + test('ValidateParams delegates unexpected errors to next()', () => { const err = new Error('query failure'); Object.defineProperty(req, 'query', { diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 2a116f5..0ffca61 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -185,6 +185,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: 'remote code', cveIds: 'CVE-2024-0001', pubStartDate: '2024-01-01T00:00:00.000Z', @@ -263,6 +264,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: 'remote code', keywordExactMatch: 'true' } @@ -312,6 +314,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', noRejected: true } }, @@ -399,6 +402,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cweId: 'CWE-79', cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', isVulnerable: 'true' @@ -1212,6 +1216,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cvssV3_0Severity: 'high', cvssV3_1Severity: 'critical' } @@ -1240,6 +1245,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cvssV2Metrics: 'AV:N/AC:L/Au:N' } }, @@ -1284,6 +1290,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', cvssV3_0Metrics: 'CVSS:3.0/AV:N/AC:L', cvssV3_1Metrics: 'CVSS:3.1/PR:N/UI:R' } @@ -1342,6 +1349,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: 'c++ path:/tmp (test)' } }, @@ -1366,6 +1374,7 @@ describe('Search controller SEARCH handler', () => { await controller.SEARCH( { query: { + scope: 'CNA_ONLY', keywordSearch: ' ' } }, diff --git a/routes/cpeName.test.js b/routes/cpeName.test.js index 89cc369..25bd736 100644 --- a/routes/cpeName.test.js +++ b/routes/cpeName.test.js @@ -55,6 +55,7 @@ describe('POST /search cpeName route behavior', () => { expect(response.body).toStrictEqual({ route: 'search', params: { + scope: 'FULL', cpeName } }); diff --git a/routes/index.js b/routes/index.js index adca945..f0fd183 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 searchRateLimiter = require('../utils/searchRateLimiter'); +const searchScope = require('../utils/searchScope'); const error = new errors.errors(); const limitSearchRequests = searchRateLimiter.createSearchRateLimiter(); @@ -145,11 +146,28 @@ function requireWebSearchEnabled(req, res, next) { function normalizeSearchParams(req, res, next) { const searchParams = { ...(req.body ?? {}) }; + const upperCaseEnums = [ + 'scope', + 'cvssV2Severity', + 'cvssV3Severity', + 'cvssV3_0Severity', + 'cvssV3_1Severity', + 'cvssV4Severity' + ]; + const lowerCaseEnums = ['versionStartType', 'versionEndType']; if (searchParams.noRejected === false) { delete searchParams.noRejected; } + searchParams.scope = searchScope.normalizeScope(searchParams.scope); + upperCaseEnums.forEach((param) => { + if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toUpperCase(); + }); + lowerCaseEnums.forEach((param) => { + if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toLowerCase(); + }); + req.searchParams = searchParams; next(); } @@ -175,6 +193,7 @@ router.post( allParameters: { summary: "All supported request body parameters", value: { + scope: "FULL", keywordSearch: "remote code execution", keywordExactMatch: true, cveIds: "CVE-2024-1234,CVE-2024-5678", diff --git a/routes/index.test.js b/routes/index.test.js index c1cd095..094a562 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -106,6 +106,7 @@ describe('routes/index router', () => { expect(response.body).toStrictEqual({ route: 'search', params: { + scope: 'FULL', keywordSearch: 'test', keywordExactMatch: false } @@ -124,7 +125,9 @@ describe('routes/index router', () => { expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', - params: {} + params: { + scope: 'FULL' + } }); }); @@ -273,6 +276,7 @@ describe('routes/index router', () => { expect(response.body).toStrictEqual({ route: 'search', params: { + scope: 'FULL', cveIds: ['CVE-2024-0001', 'CVE-2024-0002'] } }); @@ -292,7 +296,28 @@ describe('routes/index router', () => { expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', - params: {} + params: { + scope: 'FULL' + } + }); + }); + + test('POST /search normalizes case-insensitive enums before custom middleware runs', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + cvssV3Severity: 'critical', + versionStartType: 'INCLUDING' + } + }); + + expect(response.status).toBe(200); + expect(response.body.params).toStrictEqual({ + scope: 'ADP_ONLY', + cvssV3Severity: 'CRITICAL', + versionStartType: 'including' }); }); diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 5319044..30b28f1 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -74,7 +74,7 @@ describe('search route integration', () => { simple_query_string: { query: 'remote* code*', default_operator: 'AND', - fields: ['containers.cna.descriptions.value'] + fields: ['containers.cna.descriptions.value', 'containers.adp.descriptions.value'] } } ], @@ -132,6 +132,24 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('POST /search rejects CPE applicability filters with ADP_ONLY scope', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + virtualMatchString: 'cpe:2.3:a:vendor:product' + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'SCOPE_FILTER_INCOMPATIBLE', + message: 'The virtualMatchString parameter is not supported with scope ADP_ONLY.' + }); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /search rejects an inverted virtualMatchString version range before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/routes/swagger.js b/routes/swagger.js index 1cf65db..7c6638d 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -2,6 +2,7 @@ const path = require('path'); const swaggerAutogen = require('swagger-autogen'); const packageInfo = require('../package.json'); const constants = require('../utils/constants'); +const searchScope = require('../utils/searchScope'); const projectRoot = path.join(__dirname, '..'); const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); @@ -15,8 +16,16 @@ function searchRequestProperty(description, schema) { } const searchRequestProperties = { + scope: searchRequestProperty( + 'Controls which containers participate in filter matching. FULL searches base record fields, CNA data, and all ADP providers. CNA_ONLY searches base record fields and CNA data. ADP_ONLY searches base record fields and all ADP providers. Scope does not remove data from returned records. Values are case-insensitive. CPE applicability filters are not available with ADP_ONLY.', + { + type: 'string', + enum: searchScope.SEARCH_SCOPE_VALUES, + default: searchScope.SEARCH_SCOPES.FULL + } + ), keywordSearch: searchRequestProperty( - `Returns CVE records where a word or phrase is included in the record's description. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, + `Returns CVE records where a word or phrase is included in a description selected by scope. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to ${constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch} characters and ${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch, @@ -42,11 +51,14 @@ const searchRequestProperties = { type: 'boolean', default: false }), - cweId: searchRequestProperty('Returns CVE records that include the provided Common Weakness Enumeration ID.', { - type: 'string', - maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, - example: 'CWE-79' - }), + cweId: searchRequestProperty( + 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID.', + { + type: 'string', + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, + example: 'CWE-79' + } + ), cvssV2Severity: searchRequestProperty( 'Returns CVE records that contain the provided CVSS v2 qualitative severity rating.', { @@ -248,7 +260,8 @@ const doc = { '@schemas': { SearchRequest: { type: 'object', - description: 'Optional filters and pagination controls for a CVE record search.', + description: + 'Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values.', additionalProperties: false, properties: searchRequestProperties } diff --git a/server.test.js b/server.test.js index 51ce3c8..be14e86 100644 --- a/server.test.js +++ b/server.test.js @@ -138,7 +138,10 @@ describe('server application bootstrap', () => { expect(mockSearch).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'search', - params: body + params: { + scope: 'FULL', + ...body + } }); }); diff --git a/utils/constants.js b/utils/constants.js index 85ae9f3..9a76df6 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -7,6 +7,7 @@ const MAX_CVSS_METRIC_TOKEN_LENGTH = 32; const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; const MAX_KEYWORD_SEARCH_TOKENS = 64; +const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, cveIds: 4096, @@ -26,7 +27,6 @@ const SEARCH_REQUEST_DEADLINE_MS = 30 * 1000; const SEARCH_READINESS_TIMEOUT_MS = 5 * 1000; const rejectedCveStates = ['REJECT', 'REJECTED', 'Rejected']; -const rejectedCveStateFields = ['cveMetadata.state', 'containers.cna.x_legacyV4Record.CVE_data_meta.STATE']; const cpeVersionRangeTypes = ['including', 'excluding']; const cpeMatchFields = { @@ -62,27 +62,10 @@ const cpeMatchFields = { * versionEnd: string, upper version bound used with virtualMatchString * versionEndType: including or excluding */ -const fields = { - keywordSearch: 'containers.cna.descriptions.value', - cveIds: 'cveMetadata.cveId', - noRejected: rejectedCveStateFields, - cweId: 'containers.cna.problemTypes.descriptions.cweId', - cvssV2Severity: 'containers.cna.metrics.cvssV2_0.baseScore', - cvssV2Metrics: 'containers.cna.metrics.cvssV2_0.vectorString', - cvssV3_0Severity: 'containers.cna.metrics.cvssV3_0.baseSeverity', - cvssV3_0Metrics: 'containers.cna.metrics.cvssV3_0.vectorString', - cvssV3_1Severity: 'containers.cna.metrics.cvssV3_1.baseSeverity', - cvssV3_1Metrics: 'containers.cna.metrics.cvssV3_1.vectorString', - cvssV3Severity: ['containers.cna.metrics.cvssV3_0.baseSeverity', 'containers.cna.metrics.cvssV3_1.baseSeverity'], - cvssV3Metrics: ['containers.cna.metrics.cvssV3_0.vectorString', 'containers.cna.metrics.cvssV3_1.vectorString'], - cvssV4Severity: 'containers.cna.metrics.cvssV4_0.baseSeverity', - cvssV4Metrics: 'containers.cna.metrics.cvssV4_0.vectorString', - cpeName: cpeMatchFields.criteria, - isVulnerable: cpeMatchFields.vulnerable, - virtualMatchString: cpeMatchFields.criteria -}; +const fields = searchScope.getFullFields(); const searchControlParams = [ + 'scope', 'keywordExactMatch', 'resultsPerPage', 'pageNumber', @@ -97,6 +80,7 @@ const searchControlParams = [ ]; const searchQueryParams = [ + 'scope', 'keywordSearch', 'keywordExactMatch', 'cveIds', diff --git a/utils/errors.js b/utils/errors.js index 4c126e0..aff3c60 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -5,6 +5,17 @@ function makeError(error, message) { } class errors { + scopeValueError() { + return makeError('SCOPE_VALUE', 'The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.'); + } + + scopeCompatibilityError(scope, params) { + return makeError( + 'SCOPE_FILTER_INCOMPATIBLE', + `The ${params.join(', ')} parameter${params.length === 1 ? ' is' : 's are'} not supported with scope ${scope}.` + ); + } + maxResultsError() { return makeError( 'MAX_RESULTS_LIMIT', diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 07894bb..0c8f7f6 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -1,5 +1,6 @@ const constants = require('./constants'); const cpe = require('./cpe'); +const searchScope = require('./searchScope'); const { MAX_RESULTS_LIMIT, RESULTS_PER_PAGE } = constants; @@ -18,19 +19,20 @@ const cpeCandidateSourceFields = [ ]; function buildSearchRequest(params) { + const scope = searchScope.normalizeScope(params?.scope); const processedParams = processParams(params); const resultsPerPage = getResultsPerPage(params); const from = getFrom(params, resultsPerPage); const postFilters = buildPostFilters(params); const searchOptions = { - filters: getFilterFields(processedParams.matchPhrases), + filters: getFilterFields(processedParams.matchPhrases, scope), rangeObjects: buildDateRangeObjects(params), resultsPerPage, from }; const request = { searchOptions, - queryStringObjects: buildQueryStrings(processedParams.queryStrings) + queryStringObjects: buildQueryStrings(processedParams.queryStrings, scope) }; if (Object.keys(postFilters).length) { @@ -228,14 +230,15 @@ function escapeQueryStringTerm(term, { escapeWildcards = false } = {}) { return s.replace(re, '\\$1'); } -function buildQueryStrings(params) { +function buildQueryStrings(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { const queryStringObjects = []; Object.keys(params).forEach((param) => { if (param === 'virtualMatchString') return; - const fieldSpec = constants.FIELDS[param]; + const fieldSpec = searchScope.resolveFields(param, scope); const fieldList = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + if (!fieldList.length) return; if (param === 'keywordSearch') { queryStringObjects.push({ @@ -260,7 +263,7 @@ function buildQueryStrings(params) { return queryStringObjects; } -function getFilterFields(params) { +function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { let fieldsToReturn = []; const validFields = Object.keys(constants.FIELDS); @@ -277,8 +280,8 @@ function getFilterFields(params) { Object.keys(params).forEach((param) => { if (!validFields.includes(param)) return; - const fieldSpec = constants.FIELDS[param]; - if (!fieldSpec) return; + const fieldSpec = searchScope.resolveFields(param, scope); + if (!fieldSpec.length) return; if (param === 'noRejected') { if (params[param] === true || params[param] === '') { @@ -288,7 +291,7 @@ function getFilterFields(params) { } if (param === 'cveIds') { - const filter = buildCveIdsFilter(params[param], fieldSpec); + const filter = buildCveIdsFilter(params[param], fieldSpec[0]); if (filter) fieldsToReturn.push(filter); return; } @@ -303,7 +306,7 @@ function getFilterFields(params) { if (isTrueValue(params[param])) { fieldsToReturn.push({ term: { - [fieldSpec]: true + [fieldSpec[0]]: true } }); } @@ -320,9 +323,7 @@ function getFilterFields(params) { const r = cvssV2SeverityToRange(params[param]); if (!r) return; - fieldsToReturn.push({ - range: { [fieldSpec]: r } - }); + fieldsToReturn.push(buildRangeAcrossFields(r, fieldSpec)); return; } @@ -552,6 +553,15 @@ function buildMatchPhraseAcrossFields(value, fieldSpec) { return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; } +function buildRangeAcrossFields(value, fieldSpec) { + const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + const clauses = fields.map((field) => ({ + range: { [field]: value } + })); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + function cvssV2SeverityToRange(sev) { const s = String(sev).toUpperCase(); if (s === 'LOW') return { gte: 0.0, lte: 3.9 }; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index b26f879..30ab44e 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -23,6 +23,7 @@ function prefixShould(prefixes) { describe('searchQueryBuilder buildSearchRequest()', () => { test('buildSearchRequest builds filters, query strings, date ranges, and pagination options', () => { const output = queryBuilder.buildSearchRequest({ + scope: 'CNA_ONLY', keywordSearch: 'remote code', cveIds: 'CVE-2024-0001', pubStartDate: '2024-01-01T00:00:00.000Z', @@ -189,6 +190,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { test('buildCpeCandidateSearchBody limits source fields for semantic post-filtering', () => { const output = queryBuilder.buildSearchRequest({ + scope: 'CNA_ONLY', keywordSearch: 'kernel', virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', versionStart: '2.2', @@ -233,6 +235,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { test('buildOpenSearchBody builds the complete standard search request', () => { const output = queryBuilder.buildSearchRequest({ + scope: 'CNA_ONLY', keywordSearch: 'remote code', cweId: 'CWE-79', resultsPerPage: 10, @@ -732,3 +735,32 @@ describe('searchQueryBuilder getFilterFields() function', () => { expect(output).toStrictEqual([]); }); }); + +describe('scope-aware search filters', () => { + test('FULL searches description fields in CNA and ADP containers', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + keywordSearch: 'remote code' + }); + + expect(output.queryStringObjects[0].simple_query_string.fields).toStrictEqual([ + 'containers.cna.descriptions.value', + 'containers.adp.descriptions.value' + ]); + }); + + test('ADP_ONLY searches only ADP CVSS fields', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + cvssV4Severity: 'CRITICAL' + }); + + expect(output.searchOptions.filters).toStrictEqual([ + { + term: { + 'containers.adp.metrics.cvssV4_0.baseSeverity.keyword': 'CRITICAL' + } + } + ]); + }); +}); diff --git a/utils/searchScope.js b/utils/searchScope.js new file mode 100644 index 0000000..9c7069d --- /dev/null +++ b/utils/searchScope.js @@ -0,0 +1,131 @@ +const SEARCH_SCOPES = Object.freeze({ + FULL: 'FULL', + CNA_ONLY: 'CNA_ONLY', + ADP_ONLY: 'ADP_ONLY' +}); + +const SEARCH_SCOPE_VALUES = Object.freeze(Object.values(SEARCH_SCOPES)); + +const FIELD_PATHS = Object.freeze({ + keywordSearch: { + cna: ['containers.cna.descriptions.value'], + adp: ['containers.adp.descriptions.value'] + }, + cveIds: { + base: ['cveMetadata.cveId'] + }, + noRejected: { + base: ['cveMetadata.state'], + cna: ['containers.cna.x_legacyV4Record.CVE_data_meta.STATE'] + }, + cweId: { + cna: ['containers.cna.problemTypes.descriptions.cweId'], + adp: ['containers.adp.problemTypes.descriptions.cweId'] + }, + cvssV2Severity: { + cna: ['containers.cna.metrics.cvssV2_0.baseScore'], + adp: ['containers.adp.metrics.cvssV2_0.baseScore'] + }, + cvssV2Metrics: { + cna: ['containers.cna.metrics.cvssV2_0.vectorString'], + adp: ['containers.adp.metrics.cvssV2_0.vectorString'] + }, + cvssV3_0Severity: { + cna: ['containers.cna.metrics.cvssV3_0.baseSeverity'], + adp: ['containers.adp.metrics.cvssV3_0.baseSeverity'] + }, + cvssV3_0Metrics: { + cna: ['containers.cna.metrics.cvssV3_0.vectorString'], + adp: ['containers.adp.metrics.cvssV3_0.vectorString'] + }, + cvssV3_1Severity: { + cna: ['containers.cna.metrics.cvssV3_1.baseSeverity'], + adp: ['containers.adp.metrics.cvssV3_1.baseSeverity'] + }, + cvssV3_1Metrics: { + cna: ['containers.cna.metrics.cvssV3_1.vectorString'], + adp: ['containers.adp.metrics.cvssV3_1.vectorString'] + }, + cvssV3Severity: { + cna: ['containers.cna.metrics.cvssV3_0.baseSeverity', 'containers.cna.metrics.cvssV3_1.baseSeverity'], + adp: ['containers.adp.metrics.cvssV3_0.baseSeverity', 'containers.adp.metrics.cvssV3_1.baseSeverity'] + }, + cvssV3Metrics: { + cna: ['containers.cna.metrics.cvssV3_0.vectorString', 'containers.cna.metrics.cvssV3_1.vectorString'], + adp: ['containers.adp.metrics.cvssV3_0.vectorString', 'containers.adp.metrics.cvssV3_1.vectorString'] + }, + cvssV4Severity: { + cna: ['containers.cna.metrics.cvssV4_0.baseSeverity'], + adp: ['containers.adp.metrics.cvssV4_0.baseSeverity'] + }, + cvssV4Metrics: { + cna: ['containers.cna.metrics.cvssV4_0.vectorString'], + adp: ['containers.adp.metrics.cvssV4_0.vectorString'] + }, + cpeName: { + cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.criteria'] + }, + isVulnerable: { + cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable'] + }, + virtualMatchString: { + cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.criteria'] + } +}); + +const CPE_SCOPE_PARAMS = Object.freeze([ + 'cpeName', + 'isVulnerable', + 'virtualMatchString', + 'versionStart', + 'versionStartType', + 'versionEnd', + 'versionEndType' +]); + +function normalizeScope(value) { + if (value === undefined) return SEARCH_SCOPES.FULL; + return String(value).trim().toUpperCase(); +} + +function isValidScope(value) { + return SEARCH_SCOPE_VALUES.includes(normalizeScope(value)); +} + +function resolveFields(param, value = SEARCH_SCOPES.FULL) { + const scope = normalizeScope(value); + const paths = FIELD_PATHS[param]; + if (!paths) return []; + + const fields = [...(paths.base ?? [])]; + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.CNA_ONLY) fields.push(...(paths.cna ?? [])); + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.ADP_ONLY) fields.push(...(paths.adp ?? [])); + + return [...new Set(fields)]; +} + +function getFullFields() { + return Object.fromEntries( + Object.keys(FIELD_PATHS).map((param) => { + const fields = resolveFields(param); + return [param, fields.length === 1 ? fields[0] : fields]; + }) + ); +} + +function getIncompatibleParams(params) { + if (normalizeScope(params?.scope) !== SEARCH_SCOPES.ADP_ONLY) return []; + return CPE_SCOPE_PARAMS.filter((param) => params?.[param] !== undefined); +} + +module.exports = { + CPE_SCOPE_PARAMS, + FIELD_PATHS, + SEARCH_SCOPES, + SEARCH_SCOPE_VALUES, + getFullFields, + getIncompatibleParams, + isValidScope, + normalizeScope, + resolveFields +}; diff --git a/utils/searchScope.test.js b/utils/searchScope.test.js new file mode 100644 index 0000000..33c2cc9 --- /dev/null +++ b/utils/searchScope.test.js @@ -0,0 +1,38 @@ +const searchScope = require('./searchScope'); + +describe('searchScope', () => { + test.each([ + [undefined, 'FULL'], + ['full', 'FULL'], + ['cna_only', 'CNA_ONLY'], + ['AdP_OnLy', 'ADP_ONLY'] + ])('normalizes %p to %s', (input, expected) => { + expect(searchScope.normalizeScope(input)).toBe(expected); + expect(searchScope.isValidScope(input)).toBe(true); + }); + + test('resolves base fields in every scope and selected container fields only', () => { + expect(searchScope.resolveFields('cveIds', 'ADP_ONLY')).toStrictEqual(['cveMetadata.cveId']); + expect(searchScope.resolveFields('keywordSearch', 'CNA_ONLY')).toStrictEqual([ + 'containers.cna.descriptions.value' + ]); + expect(searchScope.resolveFields('keywordSearch', 'ADP_ONLY')).toStrictEqual([ + 'containers.adp.descriptions.value' + ]); + expect(searchScope.resolveFields('keywordSearch', 'FULL')).toStrictEqual([ + 'containers.cna.descriptions.value', + 'containers.adp.descriptions.value' + ]); + }); + + test('reports CPE applicability parameters as incompatible with ADP_ONLY', () => { + expect( + searchScope.getIncompatibleParams({ + scope: 'adp_only', + cpeName: 'cpe:2.3:a:vendor:product:1.0', + isVulnerable: false, + versionStart: '1.0' + }) + ).toStrictEqual(['cpeName', 'isVulnerable', 'versionStart']); + }); +}); From e65ed3bf00d30afd7e8c0a17c8f9ee7172dd5215 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 17:44:02 -0400 Subject: [PATCH 02/27] feat: add SSVC search filters --- README.md | 7 +++ api-docs/openapi.json | 51 +++++++++++++++- api-docs/openapi.test.js | 3 + controllers/middleware.js | 23 ++++++- controllers/middleware.test.js | 28 +++++++++ controllers/searchController.js | 63 ++++++-------------- controllers/searchController.test.js | 77 ++++++++++++++++++++++++ routes/index.js | 13 +++- routes/index.test.js | 2 + routes/swagger.js | 46 ++++++++++++++ utils/constants.js | 11 +++- utils/errors.js | 4 ++ utils/searchContainerMetrics.js | 13 ++++ utils/searchPostFilters.js | 86 +++++++++++++++++++++++++++ utils/searchQueryBuilder.js | 86 +++++++++++++++++++++++---- utils/searchQueryBuilder.test.js | 28 +++++++++ utils/searchScope.js | 31 ++++++++++ utils/ssvc.js | 89 ++++++++++++++++++++++++++++ utils/ssvc.test.js | 64 ++++++++++++++++++++ 19 files changed, 661 insertions(+), 64 deletions(-) create mode 100644 utils/searchContainerMetrics.js create mode 100644 utils/searchPostFilters.js create mode 100644 utils/ssvc.js create mode 100644 utils/ssvc.test.js diff --git a/README.md b/README.md index 37d969a..a351f5d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ An AWS load balancer can use `/api/ready-check` to stop routing search traffic t 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`. +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`. + String inputs are bounded before query construction: | Field | Limit | @@ -109,6 +111,7 @@ String inputs are bounded before query construction: | `cpeName` | 2,048 characters | | `virtualMatchString` | 2,048 characters | | `versionStart`, `versionEnd` | 256 characters each | +| `ssvcRole` | 256 characters | | CVSS metric fields | 256 characters, 32 slash-delimited tokens, and 32 characters per token | Requests that exceed a limit return HTTP 400 before contacting OpenSearch. These bounds limit input size and query complexity without changing the documented CPE matching semantics. @@ -263,6 +266,10 @@ The fallback limiter uses one in-memory counter per Node.js process. Set `Search This is a process-wide safety limit, not a precise distributed quota. Each ECS task has an independent counter, so total permitted traffic scales with the number of tasks and load-balancer routing. It deliberately does not use caller IP addresses or forwarded headers until the ALB proxy chain and Express `trust proxy` setting are confirmed. AWS WAF or another infrastructure-level distributed limit remains the preferred public-facing control. +### Semantic Post-Filtering + +SSVC filters use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. SSVC candidates do not use the temporary CPE cache or cold-search queue. + ### CPE Post-Filter Cache Searches that use `cpeName`, `virtualMatchString`, or `virtualMatchString` with version range parameters use a semantic CPE post-filter. The API first asks OpenSearch for up to 10,000 lightweight candidate records, filters those candidates in Node.js, then fetches the requested page of full CVE records by CVE ID. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 3ef710b..5c4b70d 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -121,7 +121,13 @@ "versionStartType": "including", "versionEnd": "2.6", "versionEndType": "excluding", - "virtualMatchString": "cpe:2.3:o:linux:linux_kernel" + "virtualMatchString": "cpe:2.3:o:linux:linux_kernel", + "ssvcExploitation": "ACTIVE", + "ssvcAutomatable": "YES", + "ssvcTechnicalImpact": "TOTAL", + "ssvcRole": "CISA Coordinator", + "ssvcTimestampStartDate": "2024-01-01T00:00:00.000Z", + "ssvcTimestampEndDate": "2024-12-31T23:59:59.999Z" } }, "keywordSearch": { @@ -502,6 +508,49 @@ "maxLength": 2048, "example": "cpe:2.3:o:microsoft:windows_10_1809", "description": "Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component." + }, + "ssvcExploitation": { + "type": "string", + "enum": [ + "NONE", + "POC", + "ACTIVE" + ], + "description": "Returns records with a scope-selected SSVC metric whose Exploitation decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry." + }, + "ssvcAutomatable": { + "type": "string", + "enum": [ + "YES", + "NO" + ], + "description": "Returns records with a scope-selected SSVC metric whose Automatable decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry." + }, + "ssvcTechnicalImpact": { + "type": "string", + "enum": [ + "PARTIAL", + "TOTAL" + ], + "description": "Returns records with a scope-selected SSVC metric whose Technical Impact decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry." + }, + "ssvcRole": { + "type": "string", + "maxLength": 256, + "example": "CISA Coordinator", + "description": "Returns records with a scope-selected SSVC metric whose role exactly matches this case-insensitive value." + }, + "ssvcTimestampStartDate": { + "type": "string", + "format": "date-time", + "example": "2024-01-01T00:00:00.000Z", + "description": "Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + }, + "ssvcTimestampEndDate": { + "type": "string", + "format": "date-time", + "example": "2024-12-31T23:59:59.999Z", + "description": "Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 9885b97..159aab2 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -78,6 +78,9 @@ describe('OpenAPI contract', () => { expect(schema.properties.scope.enum).toStrictEqual(['FULL', 'CNA_ONLY', 'ADP_ONLY']); expect(schema.properties.scope.default).toBe('FULL'); expect(schema.properties.scope.description).toContain('does not remove data'); + expect(schema.properties.ssvcExploitation.enum).toStrictEqual(['NONE', 'POC', 'ACTIVE']); + expect(schema.properties.ssvcAutomatable.enum).toStrictEqual(['YES', 'NO']); + expect(schema.properties.ssvcTechnicalImpact.enum).toStrictEqual(['PARTIAL', 'TOTAL']); expect(schema.properties.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'); diff --git a/controllers/middleware.js b/controllers/middleware.js index a029361..1a5c8d6 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -2,6 +2,7 @@ const errors = require('../utils/errors'); const constants = require('../utils/constants'); const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); +const ssvc = require('../utils/ssvc'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -36,9 +37,22 @@ const severityValidators = [ } ]; +const ssvcEnumValidators = [ + { param: 'ssvcExploitation', allowed: ssvc.EXPLOITATION_VALUES }, + { param: 'ssvcAutomatable', allowed: ssvc.AUTOMATABLE_VALUES }, + { param: 'ssvcTechnicalImpact', allowed: ssvc.TECHNICAL_IMPACT_VALUES } +]; + const cvssMetricsParams = ['cvssV2Metrics', 'cvssV3Metrics', 'cvssV3_0Metrics', 'cvssV3_1Metrics', 'cvssV4Metrics']; -const dateParams = ['lastModStartDate', 'lastModEndDate', 'pubStartDate', 'pubEndDate']; +const dateParams = [ + 'lastModStartDate', + 'lastModEndDate', + 'pubStartDate', + 'pubEndDate', + 'ssvcTimestampStartDate', + 'ssvcTimestampEndDate' +]; const versionRangeParams = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; const versionRangeValueParams = ['versionStart', 'versionEnd']; const versionRangeTypeParams = ['versionStartType', 'versionEndType']; @@ -248,6 +262,13 @@ function validateParams(req, res, next) { } } + for (const { param, allowed } of ssvcEnumValidators) { + const value = params?.[param]; + if (value && !allowed.includes(value.toUpperCase())) { + return res.status(400).json(error.enumValueError(param, allowed)); + } + } + //Ensures keywordSearch is specified if keyWordExactMatch is true if (isTrueParam(params?.keywordExactMatch) && !params.keywordSearch) { return res.status(400).json(error.missingKeywordSearchError()); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index eec066e..c8288cd 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -896,6 +896,34 @@ describe('middleware validation function', () => { ); }); + test.each([ + ['ssvcExploitation', 'active'], + ['ssvcAutomatable', 'yes'], + ['ssvcTechnicalImpact', 'partial'] + ])('ValidateParams accepts case-insensitive SSVC enum %s.', (param, value) => { + validate({ [param]: value }); + expectValidationSuccess(); + }); + + test('ValidateParams rejects unsupported SSVC enum values', () => { + validate({ ssvcExploitation: 'unknown' }); + expectValidationError( + 'INVALID_ENUM_VALUE', + 'The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.' + ); + }); + + test.each(['ssvcTimestampStartDate', 'ssvcTimestampEndDate'])( + 'ValidateParams validates SSVC timestamp %s.', + (param) => { + validate({ [param]: '2024-02-30T00:00:00.000Z' }); + expectValidationError( + 'DATE_TIME_FORMAT', + `The ${param} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + ); + } + ); + test('ValidateParams delegates unexpected errors to next()', () => { const err = new Error('query failure'); Object.defineProperty(req, 'query', { diff --git a/controllers/searchController.js b/controllers/searchController.js index 5deabdb..8fb27b5 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -1,6 +1,5 @@ const PackageInfo = require('cve-core').packageInfo; const errors = require('../utils/errors'); -const cpe = require('../utils/cpe'); const searchPerformance = require('../utils/searchPerformance'); const searchDeadline = require('../utils/searchDeadline'); const searchPostFilterCache = require('../utils/searchPostFilterCache'); @@ -8,12 +7,13 @@ const searchInFlightRegistry = require('../utils/searchInFlightRegistry'); const searchPostFilterWorkQueue = require('../utils/searchPostFilterWorkQueue'); const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); +const searchPostFilters = require('../utils/searchPostFilters'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); function hasSearchPostFilters(postFilters) { - return Boolean(postFilters?.cpeName || postFilters?.virtualMatchString || postFilters?.virtualMatchStringRange); + return searchPostFilters.hasSearchPostFilters(postFilters); } function getHitsContainer(resp) { @@ -21,47 +21,7 @@ function getHitsContainer(resp) { } function filterSearchHits(resp, postFilters) { - const hitsContainer = getHitsContainer(resp); - const cpeEvaluationContext = cpe.createEvaluationContext(); - - if (!Array.isArray(hitsContainer?.hits)) { - return { - hitsContainer, - candidateCount: 0, - filteredHits: [] - }; - } - - return { - hitsContainer, - candidateCount: hitsContainer.hits.length, - filteredHits: hitsContainer.hits.filter((hit) => { - if ( - postFilters?.cpeName && - !cpe.hitAppliesToCpeName(hit, postFilters.cpeName, cpeEvaluationContext, { - requireVulnerable: postFilters.isVulnerable === true - }) - ) { - return false; - } - - if ( - postFilters?.virtualMatchString && - !cpe.hitMatchesVirtualMatchString(hit, postFilters.virtualMatchString, cpeEvaluationContext) - ) { - return false; - } - - if ( - postFilters?.virtualMatchStringRange && - !cpe.hitOverlapsVirtualMatchStringRange(hit, postFilters.virtualMatchStringRange, cpeEvaluationContext) - ) { - return false; - } - - return true; - }) - }; + return searchPostFilters.filterSearchHits(resp, postFilters); } function paginateHits(hits, responseOptions) { @@ -238,8 +198,9 @@ async function fetchFullHitsByCveIds(cveIds, metrics, signal) { } async function buildPostFilterCandidateResult(candidateBody, postFilters, metrics, signal) { + const operation = searchPostFilters.hasCpePostFilters(postFilters) ? 'cpe-candidate' : 'semantic-candidate'; const candidateStart = searchPerformance.markTime(); - const candidateData = await executeOpenSearch(candidateBody, 'cpe-candidate', signal, metrics); + const candidateData = await executeOpenSearch(candidateBody, operation, signal, metrics); metrics.openSearchElapsedMs = searchPerformance.elapsedMs(candidateStart); metrics.openSearchTookMs = candidateData?.took; @@ -258,6 +219,12 @@ async function buildPostFilterCandidateResult(candidateBody, postFilters, metric } async function getPostFilterCandidateResult(cacheKey, candidateBody, postFilters, metrics, signal) { + if (!searchPostFilters.hasCpePostFilters(postFilters)) { + metrics.cacheHit = false; + metrics.inFlightHit = false; + return buildPostFilterCandidateResult(candidateBody, postFilters, metrics, signal); + } + const cachedResult = searchPostFilterCache.get(cacheKey); if (cachedResult) { metrics.cacheHit = true; @@ -305,9 +272,13 @@ async function executeOptimizedPostFilterSearch( metrics, signal ) { - metrics.strategy = 'cpe-candidate'; + metrics.strategy = searchPostFilters.hasCpePostFilters(postFilters) ? 'cpe-candidate' : 'semantic-candidate'; - const candidateBody = searchQueryBuilder.buildCpeCandidateSearchBody(searchOptions, queryStringObjects); + const candidateBody = searchQueryBuilder.buildPostFilterCandidateSearchBody( + searchOptions, + queryStringObjects, + postFilters + ); const cacheKey = searchPostFilterCache.buildCacheKey(candidateBody, postFilters); const candidateResult = await getPostFilterCandidateResult(cacheKey, candidateBody, postFilters, metrics, signal); searchDeadline.throwIfAborted(signal); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 0ffca61..6294c7a 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -560,6 +560,83 @@ describe('Search controller SEARCH handler', () => { }); }); + test('SEARCH semantically filters SSVC candidates and fetches full records for the requested page', async () => { + const matching = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { + adp: [ + { + metrics: [ + { + other: { + type: 'ssvc', + content: { + role: 'CISA Coordinator', + options: [{ Exploitation: 'active' }, { Automatable: 'yes' }] + } + } + } + ] + } + ] + } + } + }; + const splitAcrossEntries = { + _id: 'CVE-2024-0002', + _source: { + cveMetadata: { cveId: 'CVE-2024-0002' }, + containers: { + adp: [ + { + metrics: [ + { other: { type: 'ssvc', content: { options: [{ Exploitation: 'active' }] } } }, + { other: { type: 'ssvc', content: { options: [{ Automatable: 'yes' }] } } } + ] + } + ] + } + } + }; + const fullRecord = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } + } + }; + mockApiSearch + .mockResolvedValueOnce(rawResponse(2, [matching, splitAcrossEntries])) + .mockResolvedValueOnce(advancedResponse(1, [fullRecord])); + + await controller.SEARCH( + { + searchParams: { + scope: 'ADP_ONLY', + ssvcExploitation: 'ACTIVE', + ssvcAutomatable: 'YES', + resultsPerPage: 1, + pageNumber: 1 + } + }, + res, + next + ); + + expect(mockSearchOpenSearch.mock.calls.map(([, options]) => options.operation)).toStrictEqual([ + 'semantic-candidate', + 'page-fetch' + ]); + expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ + 'cveMetadata.cveId', + 'containers.adp.metrics.other' + ]); + expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(1); + expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); + }); + test('applySearchPostFilters does not combine platform and version matches from different CPE entries', () => { const candidate = cpeHit('CVE-2026-0250', { criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', diff --git a/routes/index.js b/routes/index.js index f0fd183..6ebd4e9 100644 --- a/routes/index.js +++ b/routes/index.js @@ -152,7 +152,10 @@ function normalizeSearchParams(req, res, next) { 'cvssV3Severity', 'cvssV3_0Severity', 'cvssV3_1Severity', - 'cvssV4Severity' + 'cvssV4Severity', + 'ssvcExploitation', + 'ssvcAutomatable', + 'ssvcTechnicalImpact' ]; const lowerCaseEnums = ['versionStartType', 'versionEndType']; @@ -221,7 +224,13 @@ router.post( versionStartType: "including", versionEnd: "2.6", versionEndType: "excluding", - virtualMatchString: "cpe:2.3:o:linux:linux_kernel" + virtualMatchString: "cpe:2.3:o:linux:linux_kernel", + ssvcExploitation: "ACTIVE", + ssvcAutomatable: "YES", + ssvcTechnicalImpact: "TOTAL", + ssvcRole: "CISA Coordinator", + ssvcTimestampStartDate: "2024-01-01T00:00:00.000Z", + ssvcTimestampEndDate: "2024-12-31T23:59:59.999Z" } }, keywordSearch: { diff --git a/routes/index.test.js b/routes/index.test.js index 094a562..17faeb2 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -309,6 +309,7 @@ describe('routes/index router', () => { body: { scope: 'adp_only', cvssV3Severity: 'critical', + ssvcExploitation: 'active', versionStartType: 'INCLUDING' } }); @@ -317,6 +318,7 @@ describe('routes/index router', () => { expect(response.body.params).toStrictEqual({ scope: 'ADP_ONLY', cvssV3Severity: 'CRITICAL', + ssvcExploitation: 'ACTIVE', versionStartType: 'including' }); }); diff --git a/routes/swagger.js b/routes/swagger.js index 7c6638d..70cd161 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -3,6 +3,7 @@ const swaggerAutogen = require('swagger-autogen'); const packageInfo = require('../package.json'); const constants = require('../utils/constants'); const searchScope = require('../utils/searchScope'); +const ssvc = require('../utils/ssvc'); const projectRoot = path.join(__dirname, '..'); const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); @@ -237,6 +238,51 @@ const searchRequestProperties = { maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.virtualMatchString, example: 'cpe:2.3:o:microsoft:windows_10_1809' } + ), + ssvcExploitation: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose Exploitation decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry.', + { + type: 'string', + enum: ssvc.EXPLOITATION_VALUES + } + ), + ssvcAutomatable: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose Automatable decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry.', + { + type: 'string', + enum: ssvc.AUTOMATABLE_VALUES + } + ), + ssvcTechnicalImpact: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose Technical Impact decision has this value. Values are case-insensitive and all supplied SSVC filters must match the same SSVC metric entry.', + { + type: 'string', + enum: ssvc.TECHNICAL_IMPACT_VALUES + } + ), + ssvcRole: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric whose role exactly matches this case-insensitive value.', + { + type: 'string', + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.ssvcRole, + example: 'CISA Coordinator' + } + ), + ssvcTimestampStartDate: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', + { + type: 'string', + format: 'date-time', + example: '2024-01-01T00:00:00.000Z' + } + ), + ssvcTimestampEndDate: searchRequestProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', + { + type: 'string', + format: 'date-time', + example: '2024-12-31T23:59:59.999Z' + } ) }; diff --git a/utils/constants.js b/utils/constants.js index 9a76df6..07ba0d3 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -15,7 +15,8 @@ const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ cpeName: 2048, versionStart: 256, versionEnd: 256, - virtualMatchString: 2048 + virtualMatchString: 2048, + ssvcRole: 256 }); const SEARCH_POST_FILTER_CACHE_TTL_MS = 60 * 1000; const SEARCH_POST_FILTER_CACHE_MAX_ENTRIES = 100; @@ -108,7 +109,13 @@ const searchQueryParams = [ 'versionStartType', 'versionEnd', 'versionEndType', - 'virtualMatchString' + 'virtualMatchString', + 'ssvcExploitation', + 'ssvcAutomatable', + 'ssvcTechnicalImpact', + 'ssvcRole', + 'ssvcTimestampStartDate', + 'ssvcTimestampEndDate' ]; module.exports = { diff --git a/utils/errors.js b/utils/errors.js index aff3c60..00939d4 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -16,6 +16,10 @@ class errors { ); } + enumValueError(param, allowed) { + return makeError('INVALID_ENUM_VALUE', `The ${param} parameter must be one of: ${allowed.join(', ')}.`); + } + maxResultsError() { return makeError( 'MAX_RESULTS_LIMIT', diff --git a/utils/searchContainerMetrics.js b/utils/searchContainerMetrics.js new file mode 100644 index 0000000..61dbcbc --- /dev/null +++ b/utils/searchContainerMetrics.js @@ -0,0 +1,13 @@ +const searchScope = require('./searchScope'); + +function getOtherMetrics(source, scope) { + return searchScope + .getSelectedContainers(source, scope) + .flatMap((container) => (Array.isArray(container?.metrics) ? container.metrics : [])) + .map((metric) => metric?.other) + .filter(Boolean); +} + +module.exports = { + getOtherMetrics +}; diff --git a/utils/searchPostFilters.js b/utils/searchPostFilters.js new file mode 100644 index 0000000..536ecc4 --- /dev/null +++ b/utils/searchPostFilters.js @@ -0,0 +1,86 @@ +const cpe = require('./cpe'); +const searchScope = require('./searchScope'); +const ssvc = require('./ssvc'); + +const CPE_SOURCE_FIELDS = Object.freeze([ + 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', + 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' +]); + +function hasCpePostFilters(postFilters) { + return Boolean(postFilters?.cpeName || postFilters?.virtualMatchString || postFilters?.virtualMatchStringRange); +} + +function hasSearchPostFilters(postFilters) { + return hasCpePostFilters(postFilters) || Boolean(postFilters?.ssvc); +} + +function hitMatches(hit, postFilters, cpeEvaluationContext) { + if ( + postFilters?.cpeName && + !cpe.hitAppliesToCpeName(hit, postFilters.cpeName, cpeEvaluationContext, { + requireVulnerable: postFilters.isVulnerable === true + }) + ) { + return false; + } + + if ( + postFilters?.virtualMatchString && + !cpe.hitMatchesVirtualMatchString(hit, postFilters.virtualMatchString, cpeEvaluationContext) + ) { + return false; + } + + if ( + postFilters?.virtualMatchStringRange && + !cpe.hitOverlapsVirtualMatchStringRange(hit, postFilters.virtualMatchStringRange, cpeEvaluationContext) + ) { + return false; + } + + if (postFilters?.ssvc && !ssvc.hitMatches(hit, postFilters.ssvc)) return false; + + return true; +} + +function filterSearchHits(resp, postFilters) { + const hitsContainer = resp?.data?.hits; + const cpeEvaluationContext = cpe.createEvaluationContext(); + + if (!Array.isArray(hitsContainer?.hits)) { + return { + hitsContainer, + candidateCount: 0, + filteredHits: [] + }; + } + + return { + hitsContainer, + candidateCount: hitsContainer.hits.length, + filteredHits: hitsContainer.hits.filter((hit) => hitMatches(hit, postFilters, cpeEvaluationContext)) + }; +} + +function getCandidateSourceFields(postFilters) { + const fields = ['cveMetadata.cveId']; + if (hasCpePostFilters(postFilters)) fields.push(...CPE_SOURCE_FIELDS); + if (postFilters?.ssvc) { + fields.push(...searchScope.resolveContainerPaths('metrics.other', postFilters.ssvc.scope)); + } + + return [...new Set(fields)]; +} + +module.exports = { + filterSearchHits, + getCandidateSourceFields, + hasCpePostFilters, + hasSearchPostFilters, + hitMatches +}; diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 0c8f7f6..9608b98 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -1,6 +1,8 @@ const constants = require('./constants'); const cpe = require('./cpe'); const searchScope = require('./searchScope'); +const searchPostFilters = require('./searchPostFilters'); +const ssvc = require('./ssvc'); const { MAX_RESULTS_LIMIT, RESULTS_PER_PAGE } = constants; @@ -8,16 +10,6 @@ const matchParams = Object.keys(constants.FIELDS); const pubDatePath = 'cveMetadata.datePublished'; const modDatePath = 'cveMetadata.dateUpdated'; -const cpeCandidateSourceFields = [ - 'cveMetadata.cveId', - 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', - 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' -]; - function buildSearchRequest(params) { const scope = searchScope.normalizeScope(params?.scope); const processedParams = processParams(params); @@ -25,7 +17,10 @@ function buildSearchRequest(params) { const from = getFrom(params, resultsPerPage); const postFilters = buildPostFilters(params); const searchOptions = { - filters: getFilterFields(processedParams.matchPhrases, scope), + filters: [ + ...getFilterFields(processedParams.matchPhrases, scope), + ...buildSemanticCandidateFilters(params, scope) + ], rangeObjects: buildDateRangeObjects(params), resultsPerPage, from @@ -73,8 +68,14 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc } function buildCpeCandidateSearchBody(searchOptions, queryStringObjects = []) { + return buildPostFilterCandidateSearchBody(searchOptions, queryStringObjects, { + cpeName: true + }); +} + +function buildPostFilterCandidateSearchBody(searchOptions, queryStringObjects = [], postFilters = {}) { return buildOpenSearchBody(searchOptions, queryStringObjects, { - sourceIncludes: cpeCandidateSourceFields, + sourceIncludes: searchPostFilters.getCandidateSourceFields(postFilters), trackTotalHits: false }); } @@ -98,15 +99,23 @@ function buildPostFilters(params) { const cpeNamePostFilter = getCpeNamePostFilter(params); const virtualMatchStringPostFilter = getVirtualMatchStringPostFilter(params); const virtualMatchStringRangePostFilter = getVirtualMatchStringRangePostFilter(params); + const ssvcPostFilter = ssvc.buildPostFilter(params); if (cpeNamePostFilter) postFilters.cpeName = cpeNamePostFilter; if (cpeNamePostFilter && isTrueValue(params?.isVulnerable)) postFilters.isVulnerable = true; if (virtualMatchStringRangePostFilter) postFilters.virtualMatchStringRange = virtualMatchStringRangePostFilter; else if (virtualMatchStringPostFilter) postFilters.virtualMatchString = virtualMatchStringPostFilter; + if (ssvcPostFilter) postFilters.ssvc = ssvcPostFilter; return postFilters; } +function buildSemanticCandidateFilters(params, scope) { + const filters = []; + if (ssvc.buildPostFilter(params)) filters.push(buildSsvcCandidateFilter(params, scope)); + return filters; +} + function getResultsPerPage(params) { const requested = Number(params?.resultsPerPage); if (Number.isInteger(requested) && requested >= 1 && requested <= MAX_RESULTS_LIMIT) { @@ -380,6 +389,58 @@ function buildNoRejectedFilter(fieldSpec) { }; } +function buildSsvcCandidateFilter(params, scope) { + const clauses = [ + buildCaseInsensitiveExactAcrossFields('ssvc', searchScope.resolveContainerPaths('metrics.other.type', scope)) + ]; + const exactFields = [ + ['ssvcExploitation', 'metrics.other.content.options.Exploitation'], + ['ssvcAutomatable', 'metrics.other.content.options.Automatable'], + ['ssvcTechnicalImpact', 'metrics.other.content.options.Technical Impact'], + ['ssvcRole', 'metrics.other.content.role'] + ]; + + exactFields.forEach(([param, relativePath]) => { + if (params[param] !== undefined) { + clauses.push( + buildCaseInsensitiveExactAcrossFields( + params[param], + searchScope.resolveContainerPaths(relativePath, scope) + ) + ); + } + }); + + if (params.ssvcTimestampStartDate || params.ssvcTimestampEndDate) { + const bounds = {}; + if (params.ssvcTimestampStartDate) bounds.gte = params.ssvcTimestampStartDate; + if (params.ssvcTimestampEndDate) bounds.lte = params.ssvcTimestampEndDate; + clauses.push( + buildRangeAcrossFields(bounds, searchScope.resolveContainerPaths('metrics.other.content.timestamp', scope)) + ); + } + + return { + bool: { + filter: clauses + } + }; +} + +function buildCaseInsensitiveExactAcrossFields(value, fieldSpec) { + const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + const clauses = fields.map((field) => ({ + wildcard: { + [`${field}.keyword`]: { + value: escapeForWildcard(value), + case_insensitive: true + } + } + })); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + function buildCpeNameFilter(cpeName) { const parsedCpe = cpe.parseCpeName(cpeName); if (!parsedCpe.valid) return null; @@ -574,6 +635,7 @@ module.exports = { buildCpeCandidateSearchBody, buildCveIdsSearchRequest, buildOpenSearchBody, + buildPostFilterCandidateSearchBody, buildQueryStrings, buildSearchRequest, getFilterFields, diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 30ab44e..cb4cbf3 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -763,4 +763,32 @@ describe('scope-aware search filters', () => { } ]); }); + + test('SSVC filters use bounded semantic candidates with selected metric source fields', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + ssvcExploitation: 'ACTIVE', + ssvcRole: 'CISA Coordinator', + resultsPerPage: 10, + pageNumber: 2 + }); + const body = queryBuilder.buildPostFilterCandidateSearchBody( + output.searchOptions, + output.queryStringObjects, + output.postFilters + ); + + expect(output.searchOptions).toMatchObject({ resultsPerPage: 10000, from: 0 }); + expect(output.responseOptions).toStrictEqual({ resultsPerPage: 10, from: 10 }); + expect(output.postFilters.ssvc).toMatchObject({ + scope: 'ADP_ONLY', + exploitation: 'ACTIVE', + role: 'CISA Coordinator' + }); + expect(body._source).toStrictEqual(['cveMetadata.cveId', 'containers.adp.metrics.other']); + expect(JSON.stringify(body.query.bool.filter)).toContain( + 'containers.adp.metrics.other.content.options.Exploitation' + ); + expect(JSON.stringify(body.query.bool.filter)).not.toContain('containers.cna.metrics.other'); + }); }); diff --git a/utils/searchScope.js b/utils/searchScope.js index 9c7069d..dd48b07 100644 --- a/utils/searchScope.js +++ b/utils/searchScope.js @@ -118,6 +118,35 @@ function getIncompatibleParams(params) { return CPE_SCOPE_PARAMS.filter((param) => params?.[param] !== undefined); } +function getSelectedContainers(source, value = SEARCH_SCOPES.FULL) { + const scope = normalizeScope(value); + const containers = []; + + if ((scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.CNA_ONLY) && source?.containers?.cna) { + containers.push(source.containers.cna); + } + + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.ADP_ONLY) { + const adp = source?.containers?.adp; + if (Array.isArray(adp)) containers.push(...adp); + else if (adp) containers.push(adp); + } + + return containers; +} + +function resolveContainerPaths(relativePath, value = SEARCH_SCOPES.FULL) { + const scope = normalizeScope(value); + const paths = []; + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.CNA_ONLY) { + paths.push(`containers.cna.${relativePath}`); + } + if (scope === SEARCH_SCOPES.FULL || scope === SEARCH_SCOPES.ADP_ONLY) { + paths.push(`containers.adp.${relativePath}`); + } + return paths; +} + module.exports = { CPE_SCOPE_PARAMS, FIELD_PATHS, @@ -125,7 +154,9 @@ module.exports = { SEARCH_SCOPE_VALUES, getFullFields, getIncompatibleParams, + getSelectedContainers, isValidScope, normalizeScope, + resolveContainerPaths, resolveFields }; diff --git a/utils/ssvc.js b/utils/ssvc.js new file mode 100644 index 0000000..3b372f3 --- /dev/null +++ b/utils/ssvc.js @@ -0,0 +1,89 @@ +const searchScope = require('./searchScope'); +const searchContainerMetrics = require('./searchContainerMetrics'); + +const EXPLOITATION_VALUES = Object.freeze(['NONE', 'POC', 'ACTIVE']); +const AUTOMATABLE_VALUES = Object.freeze(['YES', 'NO']); +const TECHNICAL_IMPACT_VALUES = Object.freeze(['PARTIAL', 'TOTAL']); +const PARAMS = Object.freeze([ + 'ssvcExploitation', + 'ssvcAutomatable', + 'ssvcTechnicalImpact', + 'ssvcRole', + 'ssvcTimestampStartDate', + 'ssvcTimestampEndDate' +]); + +function getCaseInsensitiveProperty(object, propertyName) { + if (!object || typeof object !== 'object') return undefined; + const key = Object.keys(object).find((candidate) => candidate.toLowerCase() === propertyName.toLowerCase()); + return key === undefined ? undefined : object[key]; +} + +function getOption(content, optionName) { + if (!Array.isArray(content?.options)) return undefined; + + for (const option of content.options) { + const value = getCaseInsensitiveProperty(option, optionName); + if (value !== undefined) return value; + } + + return undefined; +} + +function equalIgnoreCase(actual, expected) { + return String(actual ?? '').toLowerCase() === String(expected ?? '').toLowerCase(); +} + +function timestampMatches(value, startDate, endDate) { + if (!startDate && !endDate) return true; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return false; + if (startDate && timestamp < Date.parse(startDate)) return false; + if (endDate && timestamp > Date.parse(endDate)) return false; + return true; +} + +function entryMatches(other, filter) { + if (!equalIgnoreCase(other?.type, 'ssvc')) return false; + const content = other?.content; + if (!content || typeof content !== 'object') return false; + + if (filter.exploitation && !equalIgnoreCase(getOption(content, 'Exploitation'), filter.exploitation)) return false; + if (filter.automatable && !equalIgnoreCase(getOption(content, 'Automatable'), filter.automatable)) return false; + if (filter.technicalImpact && !equalIgnoreCase(getOption(content, 'Technical Impact'), filter.technicalImpact)) { + return false; + } + if (filter.role && !equalIgnoreCase(content.role, filter.role)) return false; + + return timestampMatches(content.timestamp, filter.timestampStartDate, filter.timestampEndDate); +} + +function buildPostFilter(params) { + if (!PARAMS.some((param) => params?.[param] !== undefined)) return null; + + return { + scope: searchScope.normalizeScope(params.scope), + exploitation: params.ssvcExploitation, + automatable: params.ssvcAutomatable, + technicalImpact: params.ssvcTechnicalImpact, + role: params.ssvcRole, + timestampStartDate: params.ssvcTimestampStartDate, + timestampEndDate: params.ssvcTimestampEndDate + }; +} + +function hitMatches(hit, filter) { + return searchContainerMetrics + .getOtherMetrics(hit?._source, filter.scope) + .some((other) => entryMatches(other, filter)); +} + +module.exports = { + AUTOMATABLE_VALUES, + EXPLOITATION_VALUES, + PARAMS, + TECHNICAL_IMPACT_VALUES, + buildPostFilter, + entryMatches, + hitMatches +}; diff --git a/utils/ssvc.test.js b/utils/ssvc.test.js new file mode 100644 index 0000000..98c4343 --- /dev/null +++ b/utils/ssvc.test.js @@ -0,0 +1,64 @@ +const ssvc = require('./ssvc'); + +function metric(other) { + return { other }; +} + +function hit({ cna = [], adp = [] } = {}) { + return { + _source: { + containers: { + cna: { metrics: cna }, + adp: adp.map((metrics) => ({ metrics })) + } + } + }; +} + +const matchingSsvc = metric({ + type: 'ssvc', + content: { + role: 'CISA Coordinator', + timestamp: '2024-06-15T12:00:00.000Z', + options: [{ Exploitation: 'active' }, { Automatable: 'yes' }, { 'Technical Impact': 'total' }] + } +}); + +describe('SSVC semantic filtering', () => { + test('matches case-insensitive decisions and role with inclusive timestamp bounds', () => { + expect( + ssvc.hitMatches(hit({ adp: [[matchingSsvc]] }), { + scope: 'ADP_ONLY', + exploitation: 'ACTIVE', + automatable: 'YES', + technicalImpact: 'TOTAL', + role: 'cisa coordinator', + timestampStartDate: '2024-06-15T12:00:00.000Z', + timestampEndDate: '2024-06-15T12:00:00.000Z' + }) + ).toBe(true); + }); + + test('does not combine conditions from separate SSVC entries', () => { + const candidate = hit({ + cna: [ + metric({ type: 'ssvc', content: { options: [{ Exploitation: 'active' }] } }), + metric({ type: 'ssvc', content: { options: [{ Automatable: 'yes' }] } }) + ] + }); + + expect( + ssvc.hitMatches(candidate, { + scope: 'CNA_ONLY', + exploitation: 'ACTIVE', + automatable: 'YES' + }) + ).toBe(false); + }); + + test('ignores containers outside the selected scope', () => { + expect(ssvc.hitMatches(hit({ adp: [[matchingSsvc]] }), { scope: 'CNA_ONLY', exploitation: 'ACTIVE' })).toBe( + false + ); + }); +}); From dc5f2bf7d4fc020038549ed5f5f9b30d6e6cb6eb Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 17:53:54 -0400 Subject: [PATCH 03/27] feat: add KEV search filters --- README.md | 4 +- api-docs/openapi.json | 21 ++++++++- api-docs/openapi.test.js | 2 + controllers/middleware.js | 20 ++++++++- controllers/middleware.test.js | 25 +++++++++++ controllers/searchController.test.js | 64 ++++++++++++++++++++++++++++ routes/index.js | 20 ++++++++- routes/index.test.js | 13 ++++++ routes/search.integration.test.js | 14 ++++++ routes/swagger.js | 22 ++++++++++ utils/constants.js | 5 ++- utils/errors.js | 11 +++++ utils/kev.js | 46 ++++++++++++++++++++ utils/kev.test.js | 35 +++++++++++++++ utils/searchPostFilters.js | 10 +++-- utils/searchQueryBuilder.js | 39 +++++++++++++++++ utils/searchQueryBuilder.test.js | 32 ++++++++++++++ 17 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 utils/kev.js create mode 100644 utils/kev.test.js diff --git a/README.md b/README.md index a351f5d..bc57888 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ Use the optional `scope` field to control which parts of each record participate SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, timestamps use the API's full UTC timestamp format with inclusive boundaries, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. +Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries are inclusive, require `knownExploited: true`, and use the API's full UTC timestamp format. Stored KEV `dateAdded` values are interpreted as midnight UTC. + String inputs are bounded before query construction: | Field | Limit | @@ -268,7 +270,7 @@ This is a process-wide safety limit, not a precise distributed quota. Each ECS t ### Semantic Post-Filtering -SSVC filters use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. SSVC candidates do not use the temporary CPE cache or cold-search queue. +SSVC filters and date-bounded positive `knownExploited` searches use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. SSVC and KEV candidates do not use the temporary CPE cache or cold-search queue. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. ### CPE Post-Filter Cache diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 5c4b70d..bfd9c6f 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -127,7 +127,10 @@ "ssvcTechnicalImpact": "TOTAL", "ssvcRole": "CISA Coordinator", "ssvcTimestampStartDate": "2024-01-01T00:00:00.000Z", - "ssvcTimestampEndDate": "2024-12-31T23:59:59.999Z" + "ssvcTimestampEndDate": "2024-12-31T23:59:59.999Z", + "knownExploited": true, + "kevDateAddedStartDate": "2024-01-01T00:00:00.000Z", + "kevDateAddedEndDate": "2024-12-31T23:59:59.999Z" } }, "keywordSearch": { @@ -551,6 +554,22 @@ "format": "date-time", "example": "2024-12-31T23:59:59.999Z", "description": "Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + }, + "knownExploited": { + "type": "boolean", + "description": "When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied." + }, + "kevDateAddedStartDate": { + "type": "string", + "format": "date-time", + "example": "2024-01-01T00:00:00.000Z", + "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." + }, + "kevDateAddedEndDate": { + "type": "string", + "format": "date-time", + "example": "2024-12-31T23:59:59.999Z", + "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 159aab2..e4b867e 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -81,6 +81,8 @@ describe('OpenAPI contract', () => { expect(schema.properties.ssvcExploitation.enum).toStrictEqual(['NONE', 'POC', 'ACTIVE']); expect(schema.properties.ssvcAutomatable.enum).toStrictEqual(['YES', 'NO']); expect(schema.properties.ssvcTechnicalImpact.enum).toStrictEqual(['PARTIAL', 'TOTAL']); + expect(schema.properties.knownExploited.type).toBe('boolean'); + expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); expect(schema.properties.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'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 1a5c8d6..acb8f66 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -51,7 +51,9 @@ const dateParams = [ 'pubStartDate', 'pubEndDate', 'ssvcTimestampStartDate', - 'ssvcTimestampEndDate' + 'ssvcTimestampEndDate', + 'kevDateAddedStartDate', + 'kevDateAddedEndDate' ]; const versionRangeParams = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; const versionRangeValueParams = ['versionStart', 'versionEnd']; @@ -61,6 +63,7 @@ const nonStringSearchParams = [ 'cveIds', 'isVulnerable', 'noRejected', + 'knownExploited', 'resultsPerPage', 'pageNumber', ...versionRangeParams @@ -82,6 +85,10 @@ function isSingleNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +function isBoolean(value) { + return value === true || value === false; +} + function hasAnyParam(params, paramList) { return paramList.some((param) => hasParam(params, param)); } @@ -204,6 +211,10 @@ function validateParams(req, res, next) { return res.status(400).json(error.noRejectedValueError()); } + if (hasParam(params, 'knownExploited') && !isBoolean(params.knownExploited)) { + return res.status(400).json(error.knownExploitedInputError()); + } + if (hasAnyParam(params, versionRangeParams)) { if (!params.virtualMatchString) { return res.status(400).json(error.versionRangeVirtualMatchStringRequiredError()); @@ -269,6 +280,13 @@ function validateParams(req, res, next) { } } + if ( + hasAnyParam(params, ['kevDateAddedStartDate', 'kevDateAddedEndDate']) && + !isTrueParam(params.knownExploited) + ) { + return res.status(400).json(error.kevDateRequiresKnownExploitedError()); + } + //Ensures keywordSearch is specified if keyWordExactMatch is true if (isTrueParam(params?.keywordExactMatch) && !params.keywordSearch) { return res.status(400).json(error.missingKeywordSearchError()); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index c8288cd..fbe1b94 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -924,6 +924,31 @@ describe('middleware validation function', () => { } ); + test('ValidateParams requires knownExploited true for KEV date filters', () => { + validate({ + knownExploited: false, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z' + }); + expectValidationError( + 'KEV_DATE_REQUIRES_KNOWN_EXPLOITED', + 'knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.' + ); + }); + + test('ValidateParams accepts KEV dates with knownExploited true', () => { + validate({ + knownExploited: true, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z', + kevDateAddedEndDate: '2024-12-31T23:59:59.999Z' + }); + expectValidationSuccess(); + }); + + test('ValidateParams requires knownExploited to be a JSON boolean.', () => { + validate({ knownExploited: 'true' }); + expectValidationError('KNOWN_EXPLOITED_BOOLEAN', 'The knownExploited parameter must be a boolean.'); + }); + test('ValidateParams delegates unexpected errors to next()', () => { const err = new Error('query failure'); Object.defineProperty(req, 'query', { diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 6294c7a..425035c 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -637,6 +637,70 @@ describe('Search controller SEARCH handler', () => { expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); }); + test('SEARCH semantically filters date-bounded KEV candidates within the selected scope', async () => { + const matching = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { + adp: [ + { + metrics: [{ other: { type: 'kev', content: { dateAdded: '2024-06-15' } } }] + } + ] + } + } + }; + const outsideRange = { + _id: 'CVE-2024-0002', + _source: { + cveMetadata: { cveId: 'CVE-2024-0002' }, + containers: { + adp: [ + { + metrics: [{ other: { type: 'kev', content: { dateAdded: '2023-12-31' } } }] + } + ] + } + } + }; + const fullRecord = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } + } + }; + mockApiSearch + .mockResolvedValueOnce(rawResponse(2, [matching, outsideRange])) + .mockResolvedValueOnce(advancedResponse(1, [fullRecord])); + + await controller.SEARCH( + { + searchParams: { + scope: 'ADP_ONLY', + knownExploited: true, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z', + resultsPerPage: 1, + pageNumber: 1 + } + }, + res, + next + ); + + expect(mockSearchOpenSearch.mock.calls.map(([, options]) => options.operation)).toStrictEqual([ + 'semantic-candidate', + 'page-fetch' + ]); + expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ + 'cveMetadata.cveId', + 'containers.adp.metrics.other' + ]); + expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(1); + expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); + }); + test('applySearchPostFilters does not combine platform and version matches from different CPE entries', () => { const candidate = cpeHit('CVE-2026-0250', { criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', diff --git a/routes/index.js b/routes/index.js index 6ebd4e9..1dcbc61 100644 --- a/routes/index.js +++ b/routes/index.js @@ -15,7 +15,15 @@ const limitSearchRequests = searchRateLimiter.createSearchRateLimiter(); const SEARCH_BODY_FIELDS = new Set(constants.SEARCH_QUERY_PARAMS); const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( (field) => - !['keywordExactMatch', 'cveIds', 'isVulnerable', 'noRejected', 'resultsPerPage', 'pageNumber'].includes(field) + ![ + 'keywordExactMatch', + 'cveIds', + 'isVulnerable', + 'noRejected', + 'knownExploited', + 'resultsPerPage', + 'pageNumber' + ].includes(field) ); const WEB_SEARCH_BODY_FIELDS = new Set(['query', 'sort', 'from', 'size']); const WEB_SEARCH_SORT_FIELDS = new Set(['property', 'order']); @@ -102,6 +110,10 @@ function isStrictBoolean(value) { return value === true || value === false || value === 'true' || value === 'false'; } +function isJsonBoolean(value) { + return value === true || value === false; +} + function toBooleanValue(value) { if (value === 'true') return true; if (value === 'false') return false; @@ -230,7 +242,10 @@ router.post( ssvcTechnicalImpact: "TOTAL", ssvcRole: "CISA Coordinator", ssvcTimestampStartDate: "2024-01-01T00:00:00.000Z", - ssvcTimestampEndDate: "2024-12-31T23:59:59.999Z" + ssvcTimestampEndDate: "2024-12-31T23:59:59.999Z", + knownExploited: true, + kevDateAddedStartDate: "2024-01-01T00:00:00.000Z", + kevDateAddedEndDate: "2024-12-31T23:59:59.999Z" } }, keywordSearch: { @@ -341,6 +356,7 @@ description: 'Search exceeded its OpenSearch or overall processing deadline', .custom(isStrictBoolean) .withMessage(error.noRejectedValueError()) .customSanitizer(toBooleanValue), + body('knownExploited').optional().custom(isJsonBoolean).withMessage(error.knownExploitedInputError()), body(['resultsPerPage']) .optional() .isInt({ max: constants.MAX_RESULTS_LIMIT }) diff --git a/routes/index.test.js b/routes/index.test.js index 17faeb2..7f3abd4 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -323,6 +323,19 @@ 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 resultsPerPage above the configured max before custom middleware runs', async () => { const response = await request(app, { method: 'POST', diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 30b28f1..2804944 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -150,6 +150,20 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('POST /search rejects KEV date filters unless knownExploited is explicitly true', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('KEV_DATE_REQUIRES_KNOWN_EXPLOITED'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /search rejects an inverted virtualMatchString version range before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/routes/swagger.js b/routes/swagger.js index 70cd161..b04902e 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -283,6 +283,28 @@ const searchRequestProperties = { format: 'date-time', example: '2024-12-31T23:59:59.999Z' } + ), + knownExploited: searchRequestProperty( + 'When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied.', + { + type: 'boolean' + } + ), + kevDateAddedStartDate: searchRequestProperty( + 'Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC.', + { + type: 'string', + format: 'date-time', + example: '2024-01-01T00:00:00.000Z' + } + ), + kevDateAddedEndDate: searchRequestProperty( + 'Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC.', + { + type: 'string', + format: 'date-time', + example: '2024-12-31T23:59:59.999Z' + } ) }; diff --git a/utils/constants.js b/utils/constants.js index 07ba0d3..3c5cf45 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -115,7 +115,10 @@ const searchQueryParams = [ 'ssvcTechnicalImpact', 'ssvcRole', 'ssvcTimestampStartDate', - 'ssvcTimestampEndDate' + 'ssvcTimestampEndDate', + 'knownExploited', + 'kevDateAddedStartDate', + 'kevDateAddedEndDate' ]; module.exports = { diff --git a/utils/errors.js b/utils/errors.js index 00939d4..296a175 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -20,6 +20,17 @@ class errors { return makeError('INVALID_ENUM_VALUE', `The ${param} parameter must be one of: ${allowed.join(', ')}.`); } + kevDateRequiresKnownExploitedError() { + return makeError( + 'KEV_DATE_REQUIRES_KNOWN_EXPLOITED', + 'knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.' + ); + } + + knownExploitedInputError() { + return makeError('KNOWN_EXPLOITED_BOOLEAN', 'The knownExploited parameter must be a boolean.'); + } + maxResultsError() { return makeError( 'MAX_RESULTS_LIMIT', diff --git a/utils/kev.js b/utils/kev.js new file mode 100644 index 0000000..fe49131 --- /dev/null +++ b/utils/kev.js @@ -0,0 +1,46 @@ +const searchContainerMetrics = require('./searchContainerMetrics'); +const searchScope = require('./searchScope'); + +const DATE_PARAMS = Object.freeze(['kevDateAddedStartDate', 'kevDateAddedEndDate']); + +function parseStoredDate(value) { + if (typeof value !== 'string') return Number.NaN; + const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value; + return Date.parse(dateOnly); +} + +function entryMatches(other, filter) { + if (String(other?.type ?? '').toLowerCase() !== 'kev') return false; + if (!other?.content || typeof other.content !== 'object') return false; + if (!filter.dateAddedStartDate && !filter.dateAddedEndDate) return true; + + const dateAdded = parseStoredDate(other.content.dateAdded); + if (!Number.isFinite(dateAdded)) return false; + if (filter.dateAddedStartDate && dateAdded < Date.parse(filter.dateAddedStartDate)) return false; + if (filter.dateAddedEndDate && dateAdded > Date.parse(filter.dateAddedEndDate)) return false; + return true; +} + +function buildPostFilter(params) { + if (params?.knownExploited !== true || !DATE_PARAMS.some((param) => params[param] !== undefined)) return null; + + return { + scope: searchScope.normalizeScope(params.scope), + dateAddedStartDate: params.kevDateAddedStartDate, + dateAddedEndDate: params.kevDateAddedEndDate + }; +} + +function hitMatches(hit, filter) { + return searchContainerMetrics + .getOtherMetrics(hit?._source, filter.scope) + .some((other) => entryMatches(other, filter)); +} + +module.exports = { + DATE_PARAMS, + buildPostFilter, + entryMatches, + hitMatches, + parseStoredDate +}; diff --git a/utils/kev.test.js b/utils/kev.test.js new file mode 100644 index 0000000..44cb8f4 --- /dev/null +++ b/utils/kev.test.js @@ -0,0 +1,35 @@ +const kev = require('./kev'); + +function hit(dateAdded, container = 'adp') { + const metrics = [{ other: { type: 'kev', content: { dateAdded, reference: 'https://example.test' } } }]; + return { + _source: { + containers: container === 'cna' ? { cna: { metrics } } : { adp: [{ metrics }] } + } + }; +} + +describe('KEV semantic filtering', () => { + test('interprets stored date-only values as midnight UTC with inclusive bounds', () => { + expect( + kev.hitMatches(hit('2024-06-15'), { + scope: 'ADP_ONLY', + dateAddedStartDate: '2024-06-15T00:00:00.000Z', + dateAddedEndDate: '2024-06-15T00:00:00.000Z' + }) + ).toBe(true); + }); + + test('rejects dates outside the requested range', () => { + expect( + kev.hitMatches(hit('2024-06-14'), { + scope: 'ADP_ONLY', + dateAddedStartDate: '2024-06-15T00:00:00.000Z' + }) + ).toBe(false); + }); + + test('ignores KEV entries outside the selected scope', () => { + expect(kev.hitMatches(hit('2024-06-15', 'cna'), { scope: 'ADP_ONLY' })).toBe(false); + }); +}); diff --git a/utils/searchPostFilters.js b/utils/searchPostFilters.js index 536ecc4..4a7c059 100644 --- a/utils/searchPostFilters.js +++ b/utils/searchPostFilters.js @@ -1,6 +1,7 @@ const cpe = require('./cpe'); const searchScope = require('./searchScope'); const ssvc = require('./ssvc'); +const kev = require('./kev'); const CPE_SOURCE_FIELDS = Object.freeze([ 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', @@ -16,7 +17,7 @@ function hasCpePostFilters(postFilters) { } function hasSearchPostFilters(postFilters) { - return hasCpePostFilters(postFilters) || Boolean(postFilters?.ssvc); + return hasCpePostFilters(postFilters) || Boolean(postFilters?.ssvc || postFilters?.kev); } function hitMatches(hit, postFilters, cpeEvaluationContext) { @@ -44,6 +45,7 @@ function hitMatches(hit, postFilters, cpeEvaluationContext) { } if (postFilters?.ssvc && !ssvc.hitMatches(hit, postFilters.ssvc)) return false; + if (postFilters?.kev && !kev.hitMatches(hit, postFilters.kev)) return false; return true; } @@ -70,8 +72,10 @@ function filterSearchHits(resp, postFilters) { function getCandidateSourceFields(postFilters) { const fields = ['cveMetadata.cveId']; if (hasCpePostFilters(postFilters)) fields.push(...CPE_SOURCE_FIELDS); - if (postFilters?.ssvc) { - fields.push(...searchScope.resolveContainerPaths('metrics.other', postFilters.ssvc.scope)); + if (postFilters?.ssvc || postFilters?.kev) { + fields.push( + ...searchScope.resolveContainerPaths('metrics.other', postFilters.ssvc?.scope ?? postFilters.kev?.scope) + ); } return [...new Set(fields)]; diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 9608b98..06b7330 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -3,6 +3,7 @@ const cpe = require('./cpe'); const searchScope = require('./searchScope'); const searchPostFilters = require('./searchPostFilters'); const ssvc = require('./ssvc'); +const kev = require('./kev'); const { MAX_RESULTS_LIMIT, RESULTS_PER_PAGE } = constants; @@ -100,12 +101,14 @@ function buildPostFilters(params) { const virtualMatchStringPostFilter = getVirtualMatchStringPostFilter(params); const virtualMatchStringRangePostFilter = getVirtualMatchStringRangePostFilter(params); const ssvcPostFilter = ssvc.buildPostFilter(params); + const kevPostFilter = kev.buildPostFilter(params); if (cpeNamePostFilter) postFilters.cpeName = cpeNamePostFilter; if (cpeNamePostFilter && isTrueValue(params?.isVulnerable)) postFilters.isVulnerable = true; if (virtualMatchStringRangePostFilter) postFilters.virtualMatchStringRange = virtualMatchStringRangePostFilter; else if (virtualMatchStringPostFilter) postFilters.virtualMatchString = virtualMatchStringPostFilter; if (ssvcPostFilter) postFilters.ssvc = ssvcPostFilter; + if (kevPostFilter) postFilters.kev = kevPostFilter; return postFilters; } @@ -113,6 +116,8 @@ function buildPostFilters(params) { function buildSemanticCandidateFilters(params, scope) { const filters = []; if (ssvc.buildPostFilter(params)) filters.push(buildSsvcCandidateFilter(params, scope)); + if (params.knownExploited === true) filters.push(buildKevCandidateFilter(params, scope)); + if (params.knownExploited === false) filters.push(buildNoKevFilter(scope)); return filters; } @@ -427,6 +432,40 @@ function buildSsvcCandidateFilter(params, scope) { }; } +function buildKevCandidateFilter(params, scope) { + const clauses = [ + buildCaseInsensitiveExactAcrossFields('kev', searchScope.resolveContainerPaths('metrics.other.type', scope)) + ]; + + if (params.kevDateAddedStartDate || params.kevDateAddedEndDate) { + const bounds = {}; + if (params.kevDateAddedStartDate) bounds.gte = params.kevDateAddedStartDate; + if (params.kevDateAddedEndDate) bounds.lte = params.kevDateAddedEndDate; + clauses.push( + buildRangeAcrossFields(bounds, searchScope.resolveContainerPaths('metrics.other.content.dateAdded', scope)) + ); + } + + return { + bool: { + filter: clauses + } + }; +} + +function buildNoKevFilter(scope) { + return { + bool: { + must_not: [ + buildCaseInsensitiveExactAcrossFields( + 'kev', + searchScope.resolveContainerPaths('metrics.other.type', scope) + ) + ] + } + }; +} + function buildCaseInsensitiveExactAcrossFields(value, fieldSpec) { const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; const clauses = fields.map((field) => ({ diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index cb4cbf3..1e08505 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -791,4 +791,36 @@ describe('scope-aware search filters', () => { ); expect(JSON.stringify(body.query.bool.filter)).not.toContain('containers.cna.metrics.other'); }); + + test('positive KEV dates use semantic candidates and negative KEV is a direct exclusion', () => { + const positive = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + knownExploited: true, + kevDateAddedStartDate: '2024-01-01T00:00:00.000Z' + }); + const negative = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + knownExploited: false + }); + + expect(positive.postFilters.kev).toMatchObject({ + scope: 'ADP_ONLY', + dateAddedStartDate: '2024-01-01T00:00:00.000Z' + }); + expect(positive.searchOptions.resultsPerPage).toBe(10000); + expect(negative.postFilters).toBeUndefined(); + expect(negative.searchOptions.resultsPerPage).toBe(20); + expect(JSON.stringify(negative.searchOptions.filters)).toContain('containers.adp.metrics.other.type.keyword'); + }); + + test('knownExploited without date boundaries remains a direct search', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + knownExploited: true + }); + + expect(output.postFilters).toBeUndefined(); + expect(output.searchOptions.resultsPerPage).toBe(20); + expect(JSON.stringify(output.searchOptions.filters)).toContain('metrics.other.type.keyword'); + }); }); From 9d308b7b66a14fd635750507e3301da2590658c7 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 17:56:34 -0400 Subject: [PATCH 04/27] feat: add reference URL search filter --- README.md | 3 +++ api-docs/openapi.json | 9 ++++++++- api-docs/openapi.test.js | 1 + controllers/middleware.test.js | 4 +++- routes/index.js | 3 ++- routes/index.test.js | 20 ++++++++++++++++++++ routes/search.integration.test.js | 25 +++++++++++++++++++++++++ routes/swagger.js | 8 ++++++++ utils/constants.js | 6 ++++-- utils/searchQueryBuilder.js | 19 +++++++++++++++++++ utils/searchQueryBuilder.test.js | 27 +++++++++++++++++++++++++++ utils/searchScope.js | 4 ++++ utils/searchScope.test.js | 4 ++++ 13 files changed, 128 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bc57888..1573d15 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE` Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries are inclusive, require `knownExploited: true`, and use the API's full UTC timestamp format. Stored KEV `dateAdded` values are interpreted as midnight UTC. +Use `referenceURL` for a case-insensitive literal substring search over reference URLs in containers selected by `scope`. The filter accepts one string; user-supplied wildcard syntax is treated literally. + String inputs are bounded before query construction: | Field | Limit | @@ -114,6 +116,7 @@ String inputs are bounded before query construction: | `virtualMatchString` | 2,048 characters | | `versionStart`, `versionEnd` | 256 characters each | | `ssvcRole` | 256 characters | +| `referenceURL` | 2,048 characters | | CVSS metric fields | 256 characters, 32 slash-delimited tokens, and 32 characters per token | Requests that exceed a limit return HTTP 400 before contacting OpenSearch. These bounds limit input size and query complexity without changing the documented CPE matching semantics. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index bfd9c6f..a53b51c 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -130,7 +130,8 @@ "ssvcTimestampEndDate": "2024-12-31T23:59:59.999Z", "knownExploited": true, "kevDateAddedStartDate": "2024-01-01T00:00:00.000Z", - "kevDateAddedEndDate": "2024-12-31T23:59:59.999Z" + "kevDateAddedEndDate": "2024-12-31T23:59:59.999Z", + "referenceURL": "vendor.example/security/advisory" } }, "keywordSearch": { @@ -570,6 +571,12 @@ "format": "date-time", "example": "2024-12-31T23:59:59.999Z", "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." + }, + "referenceURL": { + "type": "string", + "maxLength": 2048, + "example": "vendor.example/security/advisory", + "description": "Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index e4b867e..6f0256b 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -83,6 +83,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.ssvcTechnicalImpact.enum).toStrictEqual(['PARTIAL', 'TOTAL']); expect(schema.properties.knownExploited.type).toBe('boolean'); expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); + expect(schema.properties.referenceURL.description).toContain('literal URL substring'); expect(schema.properties.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'); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index fbe1b94..0e37fee 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -370,6 +370,7 @@ describe('middleware validation function', () => { ['keywordSearch', ''], ['cpeName', ' '], ['virtualMatchString', ''], + ['referenceURL', ''], ['pubStartDate', ''], ['cvssV3Metrics', ''], ['cvssV3Severity', ''] @@ -405,7 +406,8 @@ describe('middleware validation function', () => { ['pubEndDate', '2024-01-04T00:00:00.000Z'], ['resultsPerPage', '10'], ['pageNumber', '2'], - ['virtualMatchString', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*'] + ['virtualMatchString', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*'], + ['referenceURL', 'vendor.example/security/advisory'] ])('ValidateParams accepts supported search parameter %s.', (param, value) => { validate({ [param]: value diff --git a/routes/index.js b/routes/index.js index 1dcbc61..45837e8 100644 --- a/routes/index.js +++ b/routes/index.js @@ -245,7 +245,8 @@ router.post( ssvcTimestampEndDate: "2024-12-31T23:59:59.999Z", knownExploited: true, kevDateAddedStartDate: "2024-01-01T00:00:00.000Z", - kevDateAddedEndDate: "2024-12-31T23:59:59.999Z" + kevDateAddedEndDate: "2024-12-31T23:59:59.999Z", + referenceURL: "vendor.example/security/advisory" } }, keywordSearch: { diff --git a/routes/index.test.js b/routes/index.test.js index 7f3abd4..021a94e 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -261,6 +261,26 @@ describe('routes/index router', () => { expect(controller.SEARCH).not.toHaveBeenCalled(); }); + test('POST /search rejects an array for referenceURL before custom middleware runs', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + referenceURL: ['example.test/advisory'] + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual([ + { + error: 'SEARCH_FIELD_STRING', + message: 'The referenceURL field must be a non-empty string.' + } + ]); + expect(mw.validateParams).not.toHaveBeenCalled(); + expect(controller.SEARCH).not.toHaveBeenCalled(); + }); + test('POST /search lets cveIds reach custom middleware for format validation', async () => { const response = await request(app, { method: 'POST', diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 2804944..bac5e0f 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -96,6 +96,31 @@ describe('search route integration', () => { expect(response.body.totalPages).toBe(1); }); + test('POST /search builds a scope-aware literal reference URL substring filter', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + referenceURL: 'EXAMPLE.test/a*?b' + } + }); + + expect(response.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + { + wildcard: { + 'containers.adp.references.url.keyword': { + value: '*EXAMPLE.test/a\\*\\?b*', + case_insensitive: true + } + } + } + ]); + }); + test('POST /search rejects invalid dates before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/routes/swagger.js b/routes/swagger.js index b04902e..0f4ca59 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -305,6 +305,14 @@ const searchRequestProperties = { format: 'date-time', example: '2024-12-31T23:59:59.999Z' } + ), + referenceURL: searchRequestProperty( + 'Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported.', + { + type: 'string', + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.referenceURL, + example: 'vendor.example/security/advisory' + } ) }; diff --git a/utils/constants.js b/utils/constants.js index 3c5cf45..63c228c 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -16,7 +16,8 @@ const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ versionStart: 256, versionEnd: 256, virtualMatchString: 2048, - ssvcRole: 256 + ssvcRole: 256, + referenceURL: 2048 }); const SEARCH_POST_FILTER_CACHE_TTL_MS = 60 * 1000; const SEARCH_POST_FILTER_CACHE_MAX_ENTRIES = 100; @@ -118,7 +119,8 @@ const searchQueryParams = [ 'ssvcTimestampEndDate', 'knownExploited', 'kevDateAddedStartDate', - 'kevDateAddedEndDate' + 'kevDateAddedEndDate', + 'referenceURL' ]; module.exports = { diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 06b7330..43c724c 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -333,6 +333,11 @@ function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { return; } + if (param === 'referenceURL') { + fieldsToReturn.push(buildLiteralSubstringAcrossFields(params[param], fieldSpec)); + return; + } + if (param === 'cvssV2Severity') { const r = cvssV2SeverityToRange(params[param]); if (!r) return; @@ -480,6 +485,20 @@ function buildCaseInsensitiveExactAcrossFields(value, fieldSpec) { return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; } +function buildLiteralSubstringAcrossFields(value, fieldSpec) { + const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; + const clauses = fields.map((field) => ({ + wildcard: { + [`${field}.keyword`]: { + value: `*${escapeForWildcard(value)}*`, + case_insensitive: true + } + } + })); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + function buildCpeNameFilter(cpeName) { const parsedCpe = cpe.parseCpeName(cpeName); if (!parsedCpe.valid) return null; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 1e08505..4390d32 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -823,4 +823,31 @@ describe('scope-aware search filters', () => { expect(output.searchOptions.resultsPerPage).toBe(20); expect(JSON.stringify(output.searchOptions.filters)).toContain('metrics.other.type.keyword'); }); + + test('referenceURL escapes wildcard syntax and uses a server-controlled substring query', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + referenceURL: 'EXAMPLE.test/a*?b' + }); + const clauses = output.searchOptions.filters[0].bool.should; + + expect(clauses).toStrictEqual([ + { + wildcard: { + 'containers.cna.references.url.keyword': { + value: '*EXAMPLE.test/a\\*\\?b*', + case_insensitive: true + } + } + }, + { + wildcard: { + 'containers.adp.references.url.keyword': { + value: '*EXAMPLE.test/a\\*\\?b*', + case_insensitive: true + } + } + } + ]); + }); }); diff --git a/utils/searchScope.js b/utils/searchScope.js index dd48b07..69f2125 100644 --- a/utils/searchScope.js +++ b/utils/searchScope.js @@ -70,6 +70,10 @@ const FIELD_PATHS = Object.freeze({ }, virtualMatchString: { cna: ['containers.cna.cpeApplicability.nodes.cpeMatch.criteria'] + }, + referenceURL: { + cna: ['containers.cna.references.url'], + adp: ['containers.adp.references.url'] } }); diff --git a/utils/searchScope.test.js b/utils/searchScope.test.js index 33c2cc9..7cfca1e 100644 --- a/utils/searchScope.test.js +++ b/utils/searchScope.test.js @@ -23,6 +23,10 @@ describe('searchScope', () => { 'containers.cna.descriptions.value', 'containers.adp.descriptions.value' ]); + expect(searchScope.resolveFields('referenceURL', 'FULL')).toStrictEqual([ + 'containers.cna.references.url', + 'containers.adp.references.url' + ]); }); test('reports CPE applicability parameters as incompatible with ADP_ONLY', () => { From 31616e1b99ad11abd5ae384c333823ed97dac6c8 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 17:59:36 -0400 Subject: [PATCH 05/27] feat: add disputed search filter --- README.md | 2 ++ api-docs/openapi.json | 7 ++++++- api-docs/openapi.test.js | 1 + controllers/middleware.js | 5 +++++ controllers/middleware.test.js | 5 +++++ routes/index.js | 5 ++++- routes/index.test.js | 13 +++++++++++++ routes/search.integration.test.js | 25 +++++++++++++++++++++++++ routes/swagger.js | 6 ++++++ utils/constants.js | 3 ++- utils/errors.js | 4 ++++ utils/searchQueryBuilder.js | 16 ++++++++++++++++ utils/searchQueryBuilder.test.js | 15 +++++++++++++++ utils/searchScope.js | 4 ++++ utils/searchScope.test.js | 1 + 15 files changed, 109 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1573d15..7663318 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Use `knownExploited` to include or exclude records with a structured KEV metric Use `referenceURL` for a case-insensitive literal substring search over reference URLs in containers selected by `scope`. The filter accepts one string; user-supplied wildcard syntax is treated literally. +Use the strict boolean `disputed` filter to require (`true`) or exclude (`false`) the exact `disputed` tag in containers selected by `scope`. This does not provide a general-purpose record tag filter. + String inputs are bounded before query construction: | Field | Limit | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index a53b51c..00e460f 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -131,7 +131,8 @@ "knownExploited": true, "kevDateAddedStartDate": "2024-01-01T00:00:00.000Z", "kevDateAddedEndDate": "2024-12-31T23:59:59.999Z", - "referenceURL": "vendor.example/security/advisory" + "referenceURL": "vendor.example/security/advisory", + "disputed": false } }, "keywordSearch": { @@ -577,6 +578,10 @@ "maxLength": 2048, "example": "vendor.example/security/advisory", "description": "Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported." + }, + "disputed": { + "type": "boolean", + "description": "When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 6f0256b..c2e1db1 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -84,6 +84,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.knownExploited.type).toBe('boolean'); expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); expect(schema.properties.referenceURL.description).toContain('literal URL substring'); + expect(schema.properties.disputed.type).toBe('boolean'); expect(schema.properties.isVulnerable.description).toContain('same CPE Match Criteria entry'); expect(schema.properties.isVulnerable.description).toContain('False or omitted'); expect(schema.properties.cpeName.description).toContain('Maven-style ordering'); diff --git a/controllers/middleware.js b/controllers/middleware.js index acb8f66..5be88e8 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -64,6 +64,7 @@ const nonStringSearchParams = [ 'isVulnerable', 'noRejected', 'knownExploited', + 'disputed', 'resultsPerPage', 'pageNumber', ...versionRangeParams @@ -215,6 +216,10 @@ function validateParams(req, res, next) { return res.status(400).json(error.knownExploitedInputError()); } + if (hasParam(params, 'disputed') && !isBoolean(params.disputed)) { + return res.status(400).json(error.disputedInputError()); + } + if (hasAnyParam(params, versionRangeParams)) { if (!params.virtualMatchString) { return res.status(400).json(error.versionRangeVirtualMatchStringRequiredError()); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 0e37fee..dd70330 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -951,6 +951,11 @@ describe('middleware validation function', () => { expectValidationError('KNOWN_EXPLOITED_BOOLEAN', 'The knownExploited parameter must be a boolean.'); }); + test('ValidateParams requires disputed to be a JSON boolean.', () => { + validate({ disputed: 'false' }); + expectValidationError('DISPUTED_BOOLEAN', 'The disputed parameter must be a boolean.'); + }); + test('ValidateParams delegates unexpected errors to next()', () => { const err = new Error('query failure'); Object.defineProperty(req, 'query', { diff --git a/routes/index.js b/routes/index.js index 45837e8..d4882f0 100644 --- a/routes/index.js +++ b/routes/index.js @@ -21,6 +21,7 @@ const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( 'isVulnerable', 'noRejected', 'knownExploited', + 'disputed', 'resultsPerPage', 'pageNumber' ].includes(field) @@ -246,7 +247,8 @@ router.post( knownExploited: true, kevDateAddedStartDate: "2024-01-01T00:00:00.000Z", kevDateAddedEndDate: "2024-12-31T23:59:59.999Z", - referenceURL: "vendor.example/security/advisory" + referenceURL: "vendor.example/security/advisory", + disputed: false } }, keywordSearch: { @@ -358,6 +360,7 @@ description: 'Search exceeded its OpenSearch or overall processing deadline', .withMessage(error.noRejectedValueError()) .customSanitizer(toBooleanValue), body('knownExploited').optional().custom(isJsonBoolean).withMessage(error.knownExploitedInputError()), + body('disputed').optional().custom(isJsonBoolean).withMessage(error.disputedInputError()), body(['resultsPerPage']) .optional() .isInt({ max: constants.MAX_RESULTS_LIMIT }) diff --git a/routes/index.test.js b/routes/index.test.js index 021a94e..415447d 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -356,6 +356,19 @@ describe('routes/index router', () => { expect(controller.SEARCH).not.toHaveBeenCalled(); }); + test('POST /search rejects a string value for disputed', async () => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + disputed: 'false' + } + }); + + expect(response.status).toBe(400); + expect(controller.SEARCH).not.toHaveBeenCalled(); + }); + test('POST /search rejects resultsPerPage above the configured max before custom middleware runs', async () => { const response = await request(app, { method: 'POST', diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index bac5e0f..86a3dbf 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -121,6 +121,31 @@ describe('search route integration', () => { ]); }); + test('POST /search builds a scope-aware disputed tag filter', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + scope: 'adp_only', + disputed: true + } + }); + + expect(response.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + { + wildcard: { + 'containers.adp.tags.keyword': { + value: 'disputed', + case_insensitive: true + } + } + } + ]); + }); + test('POST /search rejects invalid dates before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/routes/swagger.js b/routes/swagger.js index 0f4ca59..e32b3ee 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -313,6 +313,12 @@ const searchRequestProperties = { maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.referenceURL, example: 'vendor.example/security/advisory' } + ), + disputed: searchRequestProperty( + 'When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag.', + { + type: 'boolean' + } ) }; diff --git a/utils/constants.js b/utils/constants.js index 63c228c..c10c1c9 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -120,7 +120,8 @@ const searchQueryParams = [ 'knownExploited', 'kevDateAddedStartDate', 'kevDateAddedEndDate', - 'referenceURL' + 'referenceURL', + 'disputed' ]; module.exports = { diff --git a/utils/errors.js b/utils/errors.js index 296a175..921b2d2 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -31,6 +31,10 @@ class errors { return makeError('KNOWN_EXPLOITED_BOOLEAN', 'The knownExploited parameter must be a boolean.'); } + disputedInputError() { + return makeError('DISPUTED_BOOLEAN', 'The disputed parameter must be a boolean.'); + } + maxResultsError() { return makeError( 'MAX_RESULTS_LIMIT', diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 43c724c..22bf955 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -338,6 +338,11 @@ function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { return; } + if (param === 'disputed') { + fieldsToReturn.push(buildDisputedFilter(params[param], fieldSpec)); + return; + } + if (param === 'cvssV2Severity') { const r = cvssV2SeverityToRange(params[param]); if (!r) return; @@ -499,6 +504,17 @@ function buildLiteralSubstringAcrossFields(value, fieldSpec) { return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; } +function buildDisputedFilter(value, fieldSpec) { + const exactFilter = buildCaseInsensitiveExactAcrossFields('disputed', fieldSpec); + if (value === true) return exactFilter; + + return { + bool: { + must_not: [exactFilter] + } + }; +} + function buildCpeNameFilter(cpeName) { const parsedCpe = cpe.parseCpeName(cpeName); if (!parsedCpe.valid) return null; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index 4390d32..acd7477 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -850,4 +850,19 @@ describe('scope-aware search filters', () => { } ]); }); + + test.each([ + [true, 'should'], + [false, 'must_not'] + ])('disputed=%s builds the expected selected-container tag filter', (value, expectedClause) => { + const output = queryBuilder.buildSearchRequest({ + scope: 'FULL', + disputed: value + }); + const filter = output.searchOptions.filters[0]; + + expect(JSON.stringify(filter)).toContain('containers.cna.tags.keyword'); + expect(JSON.stringify(filter)).toContain('containers.adp.tags.keyword'); + expect(JSON.stringify(filter)).toContain(expectedClause); + }); }); diff --git a/utils/searchScope.js b/utils/searchScope.js index 69f2125..c25d1d4 100644 --- a/utils/searchScope.js +++ b/utils/searchScope.js @@ -74,6 +74,10 @@ const FIELD_PATHS = Object.freeze({ referenceURL: { cna: ['containers.cna.references.url'], adp: ['containers.adp.references.url'] + }, + disputed: { + cna: ['containers.cna.tags'], + adp: ['containers.adp.tags'] } }); diff --git a/utils/searchScope.test.js b/utils/searchScope.test.js index 7cfca1e..a42fc44 100644 --- a/utils/searchScope.test.js +++ b/utils/searchScope.test.js @@ -27,6 +27,7 @@ describe('searchScope', () => { 'containers.cna.references.url', 'containers.adp.references.url' ]); + expect(searchScope.resolveFields('disputed', 'ADP_ONLY')).toStrictEqual(['containers.adp.tags']); }); test('reports CPE applicability parameters as incompatible with ADP_ONLY', () => { From d5fa2ee49207336cdcaa0e5ea677dcf1bbc8ff42 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 18:05:58 -0400 Subject: [PATCH 06/27] test: make semantic search assertions portable --- controllers/searchController.test.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 425035c..f0283f4 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -625,10 +625,9 @@ describe('Search controller SEARCH handler', () => { next ); - expect(mockSearchOpenSearch.mock.calls.map(([, options]) => options.operation)).toStrictEqual([ - 'semantic-candidate', - 'page-fetch' - ]); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); + expect(mockSearchOpenSearch.mock.calls[0][1].operation).toBe('semantic-candidate'); + expect(mockSearchOpenSearch.mock.calls[1][1].operation).toBe('page-fetch'); expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ 'cveMetadata.cveId', 'containers.adp.metrics.other' @@ -689,10 +688,9 @@ describe('Search controller SEARCH handler', () => { next ); - expect(mockSearchOpenSearch.mock.calls.map(([, options]) => options.operation)).toStrictEqual([ - 'semantic-candidate', - 'page-fetch' - ]); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); + expect(mockSearchOpenSearch.mock.calls[0][1].operation).toBe('semantic-candidate'); + expect(mockSearchOpenSearch.mock.calls[1][1].operation).toBe('page-fetch'); expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ 'cveMetadata.cveId', 'containers.adp.metrics.other' From 52982000659b3f1d2ea314c33116398978a56746 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 18:45:59 -0400 Subject: [PATCH 07/27] docs: clarify scoped search contract --- README.md | 20 ++++++++++++++++++-- api-docs/openapi.json | 20 ++++++++++---------- routes/swagger.js | 20 ++++++++++---------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7663318..a936aed 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,20 @@ An AWS load balancer can use `/api/ready-check` to stop routing search traffic t 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`. +Scope applies to filter matching as follows: + +| Filters | Data used for matching | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `cveIds`, `pubStartDate`, `pubEndDate`, `lastModStartDate`, `lastModEndDate` | Base record metadata in every scope | +| `noRejected` | Base record state in every scope, plus the legacy CNA state when CNA data is selected | +| `keywordSearch`, `keywordExactMatch` | Description text in the selected CNA and/or ADP containers | +| `cweId`, all CVSS severity and metric filters | Problem types or metrics in the selected CNA and/or ADP containers | +| All SSVC filters, `knownExploited`, KEV date filters, `referenceURL`, `disputed` | Structured metrics, references, or tags in the selected CNA and/or ADP containers | +| `cpeName`, `isVulnerable`, `virtualMatchString`, and version range filters | CNA CPE applicability data; incompatible with `ADP_ONLY` | +| `resultsPerPage`, `pageNumber` | Response controls; they do not select record data | + +If the selected containers do not contain the requested data, the request is valid and returns zero matches. The API does not remove unselected containers from a returned record. + SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, timestamps use the API's full UTC timestamp format with inclusive boundaries, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries are inclusive, require `knownExploited: true`, and use the API's full UTC timestamp format. Stored KEV `dateAdded` values are interpreted as midnight UTC. @@ -228,13 +242,15 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al ## Swagger API docs -Swagger API docs are automatically generated on `docker compose` or `npm run dev`, and can be regenerated manually using: +Swagger API docs are regenerated by `npm run dev` and can be regenerated manually using: ```bash npm run swagger-autogen ``` -The Swagger API docs will be served at `localhost:3000/api-docs` while the CVE Search API is running via Docker or npm. +Docker builds copy the committed `api-docs/openapi.json` into the runtime image; they do not regenerate it. Regenerate and commit that file whenever the route or request schema changes. + +The Swagger API docs will be served at `http://localhost:3000/api-docs` while the CVE Search API is running via Docker or npm with the default port. ## Testing diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 00e460f..cd18a9d 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -358,7 +358,7 @@ "MEDIUM", "HIGH" ], - "description": "Returns CVE records that contain the provided CVSS v2 qualitative severity rating." + "description": "Returns CVE records with a scope-selected CVSS v2 base score in the range represented by the provided qualitative severity. Values are case-insensitive." }, "cvssV3Severity": { "type": "string", @@ -368,7 +368,7 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v3.0 or v3.1 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive." }, "cvssV3_0Severity": { "type": "string", @@ -378,7 +378,7 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v3.0 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive." }, "cvssV3_1Severity": { "type": "string", @@ -388,7 +388,7 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v3.1 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive." }, "cvssV4Severity": { "type": "string", @@ -398,37 +398,37 @@ "HIGH", "CRITICAL" ], - "description": "Returns CVE records that contain the provided CVSS v4.0 qualitative severity rating." + "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive." }, "cvssV2Metrics": { "type": "string", "maxLength": 256, "example": "AV:N/AC:L/Au:N/C:P/I:P/A:P", - "description": "Returns CVE records that contain the provided CVSS v2 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records that contain the provided CVSS v3.0 or v3.1 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_0Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records that contain the provided CVSS v3.0 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_1Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records that contain the provided CVSS v3.1 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV4Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", - "description": "Returns CVE records that contain the provided CVSS v4.0 vector string. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cpeName": { "type": "string", diff --git a/routes/swagger.js b/routes/swagger.js index e32b3ee..3c3e047 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -61,42 +61,42 @@ const searchRequestProperties = { } ), cvssV2Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v2 qualitative severity rating.', + 'Returns CVE records with a scope-selected CVSS v2 base score in the range represented by the provided qualitative severity. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] } ), cvssV3Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v3.0 or v3.1 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_0Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v3.0 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_1Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v3.1 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV4Severity: searchRequestProperty( - 'Returns CVE records that contain the provided CVSS v4.0 qualitative severity rating.', + 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive.', { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV2Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v2 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -104,7 +104,7 @@ const searchRequestProperties = { } ), cvssV3Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v3.0 or v3.1 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -112,7 +112,7 @@ const searchRequestProperties = { } ), cvssV3_0Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v3.0 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -120,7 +120,7 @@ const searchRequestProperties = { } ), cvssV3_1Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v3.1 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -128,7 +128,7 @@ const searchRequestProperties = { } ), cvssV4Metrics: searchRequestProperty( - `Returns CVE records that contain the provided CVSS v4.0 vector string. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, From f44b9ebcb89e3cb62d017fee3a4edf2fb2a1ece0 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 19 Aug 2026 14:17:30 -0400 Subject: [PATCH 08/27] refactor search controller helpers --- controllers/searchController.js | 35 +++++++-------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/controllers/searchController.js b/controllers/searchController.js index 8fb27b5..b31f590 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -12,18 +12,6 @@ const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); -function hasSearchPostFilters(postFilters) { - return searchPostFilters.hasSearchPostFilters(postFilters); -} - -function getHitsContainer(resp) { - return resp?.data?.hits; -} - -function filterSearchHits(resp, postFilters) { - return searchPostFilters.filterSearchHits(resp, postFilters); -} - function paginateHits(hits, responseOptions) { const from = responseOptions?.from ?? 0; const resultsPerPage = responseOptions?.resultsPerPage ?? hits.length; @@ -32,7 +20,7 @@ function paginateHits(hits, responseOptions) { } function buildPagedSearchResponse(resp, totalValue, pagedHits) { - const hitsContainer = getHitsContainer(resp); + const hitsContainer = resp?.data?.hits; return { ...resp, @@ -50,15 +38,6 @@ function buildPagedSearchResponse(resp, totalValue, pagedHits) { }; } -function buildPostFilteredResponse( - resp, - filteredHits, - responseOptions, - pagedHits = paginateHits(filteredHits, responseOptions) -) { - return buildPagedSearchResponse(resp, filteredHits.length, pagedHits); -} - function getOpenSearchErrorStatusCode(err) { return Number(err?.statusCode ?? err?.meta?.statusCode ?? err?.meta?.body?.status); } @@ -98,19 +77,19 @@ function buildWebSearchSort(sort) { } function applySearchPostFilters(resp, postFilters, responseOptions, metrics) { - if (!hasSearchPostFilters(postFilters)) return resp; + if (!searchPostFilters.hasSearchPostFilters(postFilters)) return resp; - const { hitsContainer, candidateCount, filteredHits } = filterSearchHits(resp, postFilters); + const { hitsContainer, candidateCount, filteredHits } = searchPostFilters.filterSearchHits(resp, postFilters); if (!Array.isArray(hitsContainer?.hits)) return resp; + const pagedHits = paginateHits(filteredHits, responseOptions); if (metrics) { - const pagedHits = paginateHits(filteredHits, responseOptions); metrics.candidateCount = candidateCount; metrics.filteredCount = filteredHits.length; metrics.returnedCount = pagedHits.length; } - return buildPostFilteredResponse(resp, filteredHits, responseOptions); + return buildPagedSearchResponse(resp, filteredHits.length, pagedHits); } function getHitCveId(hit) { @@ -208,7 +187,7 @@ async function buildPostFilterCandidateResult(candidateBody, postFilters, metric data: candidateData }; const postFilterStart = searchPerformance.markTime(); - const { candidateCount, filteredHits } = filterSearchHits(candidateResp, postFilters); + const { candidateCount, filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); metrics.postFilterElapsedMs = searchPerformance.elapsedMs(postFilterStart); return { @@ -304,7 +283,7 @@ async function executeOptimizedPostFilterSearch( } async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal) { - if (!hasSearchPostFilters(postFilters)) { + if (!searchPostFilters.hasSearchPostFilters(postFilters)) { return executeStandardSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal); } From 79d41d1b9f566f9462519f2ceec6203828a3430f Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:19:23 -0400 Subject: [PATCH 09/27] Fail closed on incomplete OpenSearch responses --- utils/searchProvider.js | 9 +++-- utils/searchProvider.test.js | 52 +++++++++++++-------------- utils/searchResponseValidator.js | 51 ++++++++++++++++++++++++++ utils/searchResponseValidator.test.js | 39 ++++++++++++++++++++ 4 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 utils/searchResponseValidator.js create mode 100644 utils/searchResponseValidator.test.js diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 929ec6b..e50b3a6 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -1,6 +1,7 @@ const { AppConfig, BasicSearchManager, SearchProviderSpec, SearchReader } = require('cve-core'); const constants = require('./constants'); const searchDeadline = require('./searchDeadline'); +const searchResponseValidator = require('./searchResponseValidator'); class SearchProviderContractError extends Error { constructor() { @@ -198,6 +199,7 @@ async function searchOpenSearch(body, { signal, operation = 'search', onStart } reader._client.search( { index: reader._cveIndex, + allow_partial_search_results: false, body }, { @@ -212,17 +214,18 @@ async function searchOpenSearch(body, { signal, operation = 'search', onStart } } ); - return response.body; + return searchResponseValidator.validateOpenSearchResponse(response.body); } async function checkOpenSearchReadiness({ signal } = {}) { searchDeadline.throwIfAborted(signal); const reader = getSearchReader(); - await executeOpenSearchRequest( + const response = await executeOpenSearchRequest( () => reader._client.search( { index: reader._cveIndex, + allow_partial_search_results: false, body: { query: { match_all: {} @@ -243,6 +246,8 @@ async function checkOpenSearchReadiness({ signal } = {}) { } ); + searchResponseValidator.validateOpenSearchResponse(response.body); + return true; } diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index c817ad9..b26888f 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -51,6 +51,24 @@ function deferred() { return state; } +function openSearchResponse(hits = []) { + return { + body: { + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0 + }, + hits: { + total: { value: hits.length }, + hits + } + } + }; +} + describe('searchProvider', () => { beforeEach(() => { searchProvider.resetSearchProviderForTests(); @@ -105,16 +123,7 @@ describe('searchProvider', () => { }); test('reuses one search reader per process for OpenSearch searches', async () => { - mockClientSearch.mockResolvedValue({ - body: { - hits: { - total: { - value: 0 - }, - hits: [] - } - } - }); + mockClientSearch.mockResolvedValue(openSearchResponse()); const first = searchProvider.getSearchReader(); const second = searchProvider.getSearchReader(); @@ -137,6 +146,7 @@ describe('searchProvider', () => { expect(mockClientSearch).toHaveBeenCalledWith( { index: 'test-index', + allow_partial_search_results: false, body }, { @@ -146,14 +156,7 @@ describe('searchProvider', () => { ); expect(first._client.transport.maxRetries).toBe(0); expect(first._client.transport.requestTimeout).toBe(20000); - expect(result).toStrictEqual({ - hits: { - total: { - value: 0 - }, - hits: [] - } - }); + expect(result).toStrictEqual(openSearchResponse().body); expect(onStart).toHaveBeenCalledWith({ activeCount: 1, activeByOperation: { @@ -167,19 +170,14 @@ describe('searchProvider', () => { }); test('checks OpenSearch readiness through the configured CVE index', async () => { - mockClientSearch.mockResolvedValue({ - body: { - hits: { - hits: [] - } - } - }); + mockClientSearch.mockResolvedValue(openSearchResponse()); await expect(searchProvider.checkOpenSearchReadiness()).resolves.toBe(true); expect(mockClientSearch).toHaveBeenCalledWith( { index: 'test-index', + allow_partial_search_results: false, body: { query: { match_all: {} @@ -325,8 +323,8 @@ describe('searchProvider', () => { } }); - candidate.resolve({ body: { hits: { hits: [] } } }); - pageFetch.resolve({ body: { hits: { hits: [] } } }); + candidate.resolve(openSearchResponse()); + pageFetch.resolve(openSearchResponse()); await Promise.all([candidateSearch, pageSearch]); expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ diff --git a/utils/searchResponseValidator.js b/utils/searchResponseValidator.js new file mode 100644 index 0000000..bc5d17e --- /dev/null +++ b/utils/searchResponseValidator.js @@ -0,0 +1,51 @@ +class InvalidOpenSearchResponseError extends Error { + constructor(message) { + super(message); + this.name = 'InvalidOpenSearchResponseError'; + this.code = 'INVALID_OPEN_SEARCH_RESPONSE'; + } +} + +function fail(message) { + throw new InvalidOpenSearchResponseError(message); +} + +function validateShardMetadata(shards) { + if (!shards || typeof shards !== 'object' || !Number.isInteger(shards.failed)) { + fail('OpenSearch response is missing valid shard metadata.'); + } + + if (shards.failed !== 0) { + fail('OpenSearch response contains failed shards.'); + } +} + +function validateHits(hits) { + if (!hits || typeof hits !== 'object' || !Array.isArray(hits.hits)) { + fail('OpenSearch response is missing a valid hits collection.'); + } + + if (hits.hits.some((hit) => !hit || typeof hit !== 'object' || !hit._source || typeof hit._source !== 'object')) { + fail('OpenSearch response contains an incomplete search hit.'); + } +} + +function validateOpenSearchResponse(body) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + fail('OpenSearch response body is invalid.'); + } + + if (body.timed_out !== false) { + fail('OpenSearch response timed out or omitted timeout status.'); + } + + validateShardMetadata(body._shards); + validateHits(body.hits); + + return body; +} + +module.exports = { + InvalidOpenSearchResponseError, + validateOpenSearchResponse +}; diff --git a/utils/searchResponseValidator.test.js b/utils/searchResponseValidator.test.js new file mode 100644 index 0000000..5562b9d --- /dev/null +++ b/utils/searchResponseValidator.test.js @@ -0,0 +1,39 @@ +const searchResponseValidator = require('./searchResponseValidator'); + +function validResponse(overrides = {}) { + return { + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0 + }, + hits: { + total: { value: 1 }, + hits: [{ _id: 'CVE-2024-0001', _source: { cveMetadata: { cveId: 'CVE-2024-0001' } } }] + }, + ...overrides + }; +} + +describe('searchResponseValidator', () => { + test('returns a complete OpenSearch response unchanged', () => { + const response = validResponse(); + + expect(searchResponseValidator.validateOpenSearchResponse(response)).toBe(response); + }); + + test.each([ + ['a timed-out response', { timed_out: true }], + ['missing timeout status', { timed_out: undefined }], + ['missing shard metadata', { _shards: undefined }], + ['failed shards', { _shards: { failed: 1 } }], + ['a malformed hits collection', { hits: { hits: null } }], + ['an incomplete hit', { hits: { hits: [{ _id: 'CVE-2024-0001' }] } }] + ])('rejects %s', (_description, override) => { + expect(() => searchResponseValidator.validateOpenSearchResponse(validResponse(override))).toThrow( + searchResponseValidator.InvalidOpenSearchResponseError + ); + }); +}); From d7d32b9df3b716f99736597e98d10db990659911 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:25:12 -0400 Subject: [PATCH 10/27] Update vulnerable dependency overrides --- package-lock.json | 57 +++++++++++++++++------------------------------ package.json | 3 ++- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61f936e..7bf9204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1595,14 +1595,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -1858,9 +1855,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2765,20 +2762,6 @@ "node": ">=0.4.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -4543,14 +4526,23 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -5976,13 +5968,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 2a59a9f..e99612a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ }, "overrides": { "axios": "1.18.0", - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", + "js-yaml": "4.3.1", "lodash": "4.18.1", "lodash.unset": "4.18.0", "path-to-regexp": "0.1.13", From 2f0ed4d92c28dc90e5a66546a950fc67d75718b0 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:27:30 -0400 Subject: [PATCH 11/27] Require CVSS tokens in the same vector --- README.md | 2 +- utils/cvss.js | 69 ++++++++++++++++++++++++++++++++ utils/cvss.test.js | 51 +++++++++++++++++++++++ utils/searchContainerMetrics.js | 9 ++++- utils/searchPostFilters.js | 5 ++- utils/searchQueryBuilder.js | 12 ++---- utils/searchQueryBuilder.test.js | 24 +++++++++++ 7 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 utils/cvss.js create mode 100644 utils/cvss.test.js diff --git a/README.md b/README.md index a936aed..31bf2d9 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ This is a process-wide safety limit, not a precise distributed quota. Each ECS t ### Semantic Post-Filtering -SSVC filters and date-bounded positive `knownExploited` searches use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. SSVC and KEV candidates do not use the temporary CPE cache or cold-search queue. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. +CVSS vector filters, SSVC filters, and date-bounded positive `knownExploited` searches use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same vector or structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. Every token supplied to one CVSS vector filter must occur in the same eligible vector. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. CVSS, SSVC, and KEV candidates do not use the temporary CPE cache or cold-search queue. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. ### CPE Post-Filter Cache diff --git a/utils/cvss.js b/utils/cvss.js new file mode 100644 index 0000000..d3f2ca5 --- /dev/null +++ b/utils/cvss.js @@ -0,0 +1,69 @@ +const searchContainerMetrics = require('./searchContainerMetrics'); +const searchScope = require('./searchScope'); + +const VECTOR_FIELDS = Object.freeze({ + cvssV2Metrics: ['cvssV2_0'], + cvssV3Metrics: ['cvssV3_0', 'cvssV3_1'], + cvssV3_0Metrics: ['cvssV3_0'], + cvssV3_1Metrics: ['cvssV3_1'], + cvssV4Metrics: ['cvssV4_0'] +}); +const PARAMS = Object.freeze(Object.keys(VECTOR_FIELDS)); + +function parseMetricTokens(inputVectorString) { + return String(inputVectorString ?? '') + .split('/') + .map((token) => token.trim()) + .filter(Boolean) + .filter((token) => { + const normalized = token.toUpperCase(); + return !normalized.startsWith('CVSS:') && !normalized.startsWith('CVSS#'); + }); +} + +function buildPostFilter(params) { + const vectors = Object.fromEntries( + PARAMS.filter((param) => params?.[param] !== undefined).map((param) => [param, params[param]]) + ); + if (!Object.keys(vectors).length) return null; + + return { + scope: searchScope.normalizeScope(params.scope), + vectors + }; +} + +function vectorContainsEveryToken(vector, tokens) { + return typeof vector === 'string' && tokens.every((token) => vector.includes(token)); +} + +function metricMatches(metric, fieldNames, tokens) { + return fieldNames.some((fieldName) => vectorContainsEveryToken(metric?.[fieldName]?.vectorString, tokens)); +} + +function hitMatches(hit, filter) { + const metrics = searchContainerMetrics.getMetrics(hit?._source, filter.scope); + + return Object.entries(filter.vectors).every(([param, inputVectorString]) => { + const tokens = parseMetricTokens(inputVectorString); + return tokens.length > 0 && metrics.some((metric) => metricMatches(metric, VECTOR_FIELDS[param], tokens)); + }); +} + +function getCandidateSourceFields(filter) { + const fieldNames = new Set(Object.keys(filter.vectors).flatMap((param) => VECTOR_FIELDS[param])); + + return [...fieldNames].flatMap((fieldName) => + searchScope.resolveContainerPaths(`metrics.${fieldName}.vectorString`, filter.scope) + ); +} + +module.exports = { + PARAMS, + VECTOR_FIELDS, + buildPostFilter, + getCandidateSourceFields, + hitMatches, + parseMetricTokens, + vectorContainsEveryToken +}; diff --git a/utils/cvss.test.js b/utils/cvss.test.js new file mode 100644 index 0000000..7d961e8 --- /dev/null +++ b/utils/cvss.test.js @@ -0,0 +1,51 @@ +const cvss = require('./cvss'); + +function hit(metrics) { + return { + _source: { + containers: { + adp: [{ metrics }] + } + } + }; +} + +describe('CVSS semantic vector matching', () => { + test('requires every token to occur in one eligible vector', () => { + const filter = cvss.buildPostFilter({ + scope: 'ADP_ONLY', + cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L' + }); + const splitAcrossVectors = hit([ + { cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:H' } }, + { cvssV3_1: { vectorString: 'CVSS:3.1/AV:L/AC:L' } } + ]); + const sameVector = hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:L' } }]); + + expect(cvss.hitMatches(splitAcrossVectors, filter)).toBe(false); + expect(cvss.hitMatches(sameVector, filter)).toBe(true); + }); + + test('combined CVSS v3 filters accept either v3.0 or v3.1 vectors', () => { + const filter = cvss.buildPostFilter({ + scope: 'ADP_ONLY', + cvssV3Metrics: 'AV:N/AC:L' + }); + + expect(cvss.hitMatches(hit([{ cvssV3_0: { vectorString: 'CVSS:3.0/AV:N/AC:L' } }]), filter)).toBe(true); + expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:L' } }]), filter)).toBe(true); + }); + + test('returns only source fields required by supplied vector filters and scope', () => { + const filter = cvss.buildPostFilter({ + scope: 'CNA_ONLY', + cvssV2Metrics: 'AV:N', + cvssV4Metrics: 'AV:N' + }); + + expect(cvss.getCandidateSourceFields(filter)).toStrictEqual([ + 'containers.cna.metrics.cvssV2_0.vectorString', + 'containers.cna.metrics.cvssV4_0.vectorString' + ]); + }); +}); diff --git a/utils/searchContainerMetrics.js b/utils/searchContainerMetrics.js index 61dbcbc..4135874 100644 --- a/utils/searchContainerMetrics.js +++ b/utils/searchContainerMetrics.js @@ -1,13 +1,18 @@ const searchScope = require('./searchScope'); -function getOtherMetrics(source, scope) { +function getMetrics(source, scope) { return searchScope .getSelectedContainers(source, scope) - .flatMap((container) => (Array.isArray(container?.metrics) ? container.metrics : [])) + .flatMap((container) => (Array.isArray(container?.metrics) ? container.metrics : [])); +} + +function getOtherMetrics(source, scope) { + return getMetrics(source, scope) .map((metric) => metric?.other) .filter(Boolean); } module.exports = { + getMetrics, getOtherMetrics }; diff --git a/utils/searchPostFilters.js b/utils/searchPostFilters.js index 4a7c059..5cca1a0 100644 --- a/utils/searchPostFilters.js +++ b/utils/searchPostFilters.js @@ -1,4 +1,5 @@ const cpe = require('./cpe'); +const cvss = require('./cvss'); const searchScope = require('./searchScope'); const ssvc = require('./ssvc'); const kev = require('./kev'); @@ -17,7 +18,7 @@ function hasCpePostFilters(postFilters) { } function hasSearchPostFilters(postFilters) { - return hasCpePostFilters(postFilters) || Boolean(postFilters?.ssvc || postFilters?.kev); + return hasCpePostFilters(postFilters) || Boolean(postFilters?.cvss || postFilters?.ssvc || postFilters?.kev); } function hitMatches(hit, postFilters, cpeEvaluationContext) { @@ -46,6 +47,7 @@ function hitMatches(hit, postFilters, cpeEvaluationContext) { if (postFilters?.ssvc && !ssvc.hitMatches(hit, postFilters.ssvc)) return false; if (postFilters?.kev && !kev.hitMatches(hit, postFilters.kev)) return false; + if (postFilters?.cvss && !cvss.hitMatches(hit, postFilters.cvss)) return false; return true; } @@ -77,6 +79,7 @@ function getCandidateSourceFields(postFilters) { ...searchScope.resolveContainerPaths('metrics.other', postFilters.ssvc?.scope ?? postFilters.kev?.scope) ); } + if (postFilters?.cvss) fields.push(...cvss.getCandidateSourceFields(postFilters.cvss)); return [...new Set(fields)]; } diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 22bf955..03e0f55 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -1,5 +1,6 @@ const constants = require('./constants'); const cpe = require('./cpe'); +const cvss = require('./cvss'); const searchScope = require('./searchScope'); const searchPostFilters = require('./searchPostFilters'); const ssvc = require('./ssvc'); @@ -102,6 +103,7 @@ function buildPostFilters(params) { const virtualMatchStringRangePostFilter = getVirtualMatchStringRangePostFilter(params); const ssvcPostFilter = ssvc.buildPostFilter(params); const kevPostFilter = kev.buildPostFilter(params); + const cvssPostFilter = cvss.buildPostFilter(params); if (cpeNamePostFilter) postFilters.cpeName = cpeNamePostFilter; if (cpeNamePostFilter && isTrueValue(params?.isVulnerable)) postFilters.isVulnerable = true; @@ -109,6 +111,7 @@ function buildPostFilters(params) { else if (virtualMatchStringPostFilter) postFilters.virtualMatchString = virtualMatchStringPostFilter; if (ssvcPostFilter) postFilters.ssvc = ssvcPostFilter; if (kevPostFilter) postFilters.kev = kevPostFilter; + if (cvssPostFilter) postFilters.cvss = cvssPostFilter; return postFilters; } @@ -631,14 +634,7 @@ function buildHasAnyRangeFieldFilter(rangeFields) { } function buildCvssWildcardFilter(inputVectorString, fieldSpec) { - const tokens = String(inputVectorString) - .split('/') - .map((t) => t.trim()) - .filter(Boolean) - .filter((t) => { - const up = t.toUpperCase(); - return !up.startsWith('CVSS:') && !up.startsWith('CVSS#'); - }); + const tokens = cvss.parseMetricTokens(inputVectorString); if (!tokens.length) return null; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index acd7477..b01470a 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -764,6 +764,30 @@ describe('scope-aware search filters', () => { ]); }); + test('CVSS vector filters retain an OpenSearch prefilter and add same-vector semantic filtering', () => { + const output = queryBuilder.buildSearchRequest({ + scope: 'ADP_ONLY', + cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L', + resultsPerPage: 10 + }); + const body = queryBuilder.buildPostFilterCandidateSearchBody( + output.searchOptions, + output.queryStringObjects, + output.postFilters + ); + + expect(output.searchOptions).toMatchObject({ resultsPerPage: 10000, from: 0 }); + expect(output.responseOptions).toStrictEqual({ resultsPerPage: 10, from: 0 }); + expect(output.postFilters.cvss).toStrictEqual({ + scope: 'ADP_ONLY', + vectors: { + cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L' + } + }); + expect(body._source).toStrictEqual(['cveMetadata.cveId', 'containers.adp.metrics.cvssV3_1.vectorString']); + expect(JSON.stringify(body.query.bool.filter)).toContain('containers.adp.metrics.cvssV3_1.vectorString'); + }); + test('SSVC filters use bounded semantic candidates with selected metric source fields', () => { const output = queryBuilder.buildSearchRequest({ scope: 'ADP_ONLY', From 69021acf09cf17866f04877a5c3e72b686890ad6 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:29:45 -0400 Subject: [PATCH 12/27] Bound reference URL substring searches --- README.md | 4 ++-- api-docs/openapi.json | 3 ++- api-docs/openapi.test.js | 1 + controllers/middleware.js | 9 +++++++++ controllers/middleware.test.js | 15 +++++++++++++++ routes/swagger.js | 3 ++- utils/constants.js | 2 ++ utils/errors.js | 4 ++++ 8 files changed, 37 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 31bf2d9..2b59074 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE` Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries are inclusive, require `knownExploited: true`, and use the API's full UTC timestamp format. Stored KEV `dateAdded` values are interpreted as midnight UTC. -Use `referenceURL` for a case-insensitive literal substring search over reference URLs in containers selected by `scope`. The filter accepts one string; user-supplied wildcard syntax is treated literally. +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. @@ -132,7 +132,7 @@ String inputs are bounded before query construction: | `virtualMatchString` | 2,048 characters | | `versionStart`, `versionEnd` | 256 characters each | | `ssvcRole` | 256 characters | -| `referenceURL` | 2,048 characters | +| `referenceURL` | 3 through 2,048 characters | | CVSS metric fields | 256 characters, 32 slash-delimited tokens, and 32 characters per token | Requests that exceed a limit return HTTP 400 before contacting OpenSearch. These bounds limit input size and query complexity without changing the documented CPE matching semantics. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index cd18a9d..ec16b87 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -575,9 +575,10 @@ }, "referenceURL": { "type": "string", + "minLength": 3, "maxLength": 2048, "example": "vendor.example/security/advisory", - "description": "Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported." + "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", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index c2e1db1..a230837 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -84,6 +84,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.knownExploited.type).toBe('boolean'); expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); expect(schema.properties.referenceURL.description).toContain('literal URL substring'); + expect(schema.properties.referenceURL.minLength).toBe(constants.MIN_REFERENCE_URL_LENGTH); expect(schema.properties.disputed.type).toBe('boolean'); expect(schema.properties.isVulnerable.description).toContain('same CPE Match Criteria entry'); expect(schema.properties.isVulnerable.description).toContain('False or omitted'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 5be88e8..6360429 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -186,6 +186,15 @@ function validateParams(req, res, next) { } } + if ( + typeof params.referenceURL === 'string' && + params.referenceURL.trim().length < constants.MIN_REFERENCE_URL_LENGTH + ) { + return res + .status(400) + .json(error.searchFieldMinimumLengthError('referenceURL', constants.MIN_REFERENCE_URL_LENGTH)); + } + if ( typeof params.keywordSearch === 'string' && countWhitespaceDelimitedTokens(params.keywordSearch) > constants.MAX_KEYWORD_SEARCH_TOKENS diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index dd70330..5b42763 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -956,6 +956,21 @@ describe('middleware validation function', () => { expectValidationError('DISPUTED_BOOLEAN', 'The disputed parameter must be a boolean.'); }); + test('ValidateParams rejects referenceURL substrings shorter than the minimum', () => { + validate({ referenceURL: 'ab' }); + + expectValidationError( + 'SEARCH_FIELD_MINIMUM_LENGTH', + `The referenceURL field must be at least ${constants.MIN_REFERENCE_URL_LENGTH} characters.` + ); + }); + + test('ValidateParams accepts referenceURL substrings at the minimum length', () => { + validate({ referenceURL: 'abc' }); + + expectValidationSuccess(); + }); + test('ValidateParams delegates unexpected errors to next()', () => { const err = new Error('query failure'); Object.defineProperty(req, 'query', { diff --git a/routes/swagger.js b/routes/swagger.js index 3c3e047..4d49c98 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -307,9 +307,10 @@ const searchRequestProperties = { } ), referenceURL: searchRequestProperty( - 'Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported.', + `Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported. The substring must contain at least ${constants.MIN_REFERENCE_URL_LENGTH} characters.`, { type: 'string', + minLength: constants.MIN_REFERENCE_URL_LENGTH, maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.referenceURL, example: 'vendor.example/security/advisory' } diff --git a/utils/constants.js b/utils/constants.js index c10c1c9..d80f19d 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -7,6 +7,7 @@ const MAX_CVSS_METRIC_TOKEN_LENGTH = 32; const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; const MAX_KEYWORD_SEARCH_TOKENS = 64; +const MIN_REFERENCE_URL_LENGTH = 3; const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, @@ -133,6 +134,7 @@ module.exports = { MAX_CVE_IDS, MAX_CVE_ID_LENGTH, MAX_KEYWORD_SEARCH_TOKENS, + MIN_REFERENCE_URL_LENGTH, SEARCH_STRING_LENGTH_LIMITS, SEARCH_POST_FILTER_CACHE_TTL_MS, SEARCH_POST_FILTER_CACHE_MAX_ENTRIES, diff --git a/utils/errors.js b/utils/errors.js index 921b2d2..c737b49 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -184,6 +184,10 @@ class errors { return makeError('SEARCH_FIELD_LENGTH', `The ${field} field must be ${maxLength} characters or fewer.`); } + searchFieldMinimumLengthError(field, minLength) { + return makeError('SEARCH_FIELD_MINIMUM_LENGTH', `The ${field} field must be at least ${minLength} characters.`); + } + searchFieldTokenLimitError(field, maxTokens) { return makeError('SEARCH_FIELD_TOKEN_LIMIT', `The ${field} field must contain ${maxTokens} or fewer terms.`); } From 0feea2cb4c7dd4e46811519451326e8344217612 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:30:59 -0400 Subject: [PATCH 13/27] Reject inverted search date ranges --- README.md | 2 ++ api-docs/openapi.json | 2 +- api-docs/openapi.test.js | 1 + controllers/middleware.js | 16 +++++++++++++++ controllers/middleware.test.js | 37 ++++++++++++++++++++++++++++++++++ routes/swagger.js | 2 +- utils/errors.js | 4 ++++ 7 files changed, 62 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2b59074..1863691 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ An AWS load balancer can use `/api/ready-check` to stop routing search traffic t Use the optional `scope` field to control which parts of each record participate in matching. Values are case-insensitive: `FULL` (the default) searches base record fields, CNA data, and every ADP provider; `CNA_ONLY` searches base record fields and CNA data; and `ADP_ONLY` searches base record fields and every ADP provider. Scope changes matching only, so successful responses always contain the complete CVE record. CPE applicability filters use CNA data and return HTTP 400 when combined with `ADP_ONLY`. +Date filters use the API's full UTC timestamp format and inclusive boundaries. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index ec16b87..c72f666 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -310,7 +310,7 @@ "schemas": { "SearchRequest": { "type": "object", - "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values.", + "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.", "additionalProperties": false, "properties": { "scope": { diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index a230837..af7f573 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -58,6 +58,7 @@ describe('OpenAPI contract', () => { expect(operation.parameters || []).toStrictEqual([]); expect(mediaType.schema).toStrictEqual({ $ref: '#/components/schemas/SearchRequest' }); expect(schema.additionalProperties).toBe(false); + expect(schema.description).toContain('the start must be before or equal to the end'); expect(Object.keys(schema.properties)).toStrictEqual(constants.SEARCH_QUERY_PARAMS); Object.values(schema.properties).forEach((property) => { expect(property.description).toEqual(expect.any(String)); diff --git a/controllers/middleware.js b/controllers/middleware.js index 6360429..07df429 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -55,6 +55,12 @@ const dateParams = [ 'kevDateAddedStartDate', 'kevDateAddedEndDate' ]; +const dateRangePairs = [ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] +]; const versionRangeParams = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; const versionRangeValueParams = ['versionStart', 'versionEnd']; const versionRangeTypeParams = ['versionStartType', 'versionEndType']; @@ -367,6 +373,16 @@ function validateParams(req, res, next) { } } + for (const [startParam, endParam] of dateRangePairs) { + if ( + hasParam(params, startParam) && + hasParam(params, endParam) && + Date.parse(params[startParam]) > Date.parse(params[endParam]) + ) { + return res.status(400).json(error.dateRangeOrderError(startParam, endParam)); + } + } + next(); } catch (err) { next(err); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 5b42763..a1d5841 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -818,6 +818,43 @@ describe('middleware validation function', () => { } ); + test.each([ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] + ])('ValidateParams rejects an inverted %s and %s range.', (startParam, endParam) => { + const params = { + [startParam]: '2024-12-31T00:00:00.000Z', + [endParam]: '2024-01-01T00:00:00.000Z' + }; + if (startParam.startsWith('kev')) params.knownExploited = true; + + validate(params); + + expectValidationError( + 'DATE_RANGE_ORDER', + `The ${startParam} parameter must be before or equal to ${endParam}.` + ); + }); + + test.each([ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] + ])('ValidateParams accepts equal inclusive %s and %s boundaries.', (startParam, endParam) => { + const params = { + [startParam]: '2024-01-01T00:00:00.000Z', + [endParam]: '2024-01-01T00:00:00.000Z' + }; + if (startParam.startsWith('kev')) params.knownExploited = true; + + validate(params); + + expectValidationSuccess(); + }); + test.each([ ['2024'], ['2024-01'], diff --git a/routes/swagger.js b/routes/swagger.js index 4d49c98..025ddd3 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -344,7 +344,7 @@ const doc = { SearchRequest: { type: 'object', description: - 'Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values.', + 'Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.', additionalProperties: false, properties: searchRequestProperties } diff --git a/utils/errors.js b/utils/errors.js index c737b49..6f8115a 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -107,6 +107,10 @@ class errors { ); } + dateRangeOrderError(startParam, endParam) { + return makeError('DATE_RANGE_ORDER', `The ${startParam} parameter must be before or equal to ${endParam}.`); + } + virtualMatchStringVersionRangeFormatError() { return makeError( 'VIRTUAL_MATCH_STRING_RANGE_FORMAT', From ac5fa7d31e37af2f0121279d1a62521e8af5dde7 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:31:46 -0400 Subject: [PATCH 14/27] Correct semantic search cache telemetry --- utils/searchPerformance.js | 3 ++- utils/searchPerformance.test.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/utils/searchPerformance.js b/utils/searchPerformance.js index 1240cfc..7b72bb6 100644 --- a/utils/searchPerformance.js +++ b/utils/searchPerformance.js @@ -1,4 +1,5 @@ const searchProvider = require('./searchProvider'); +const searchPostFilters = require('./searchPostFilters'); function markTime() { return process.hrtime.bigint(); @@ -13,7 +14,7 @@ function roundMs(value) { } function getCacheStatus(metrics, postFilters) { - if (!Object.keys(postFilters ?? {}).length) return 'not_applicable'; + if (!searchPostFilters.hasCpePostFilters(postFilters)) return 'not_applicable'; if (metrics.cacheHit === true) return 'hit'; if (metrics.inFlightHit === true) return 'in_flight'; diff --git a/utils/searchPerformance.test.js b/utils/searchPerformance.test.js index 5ec42f1..221521f 100644 --- a/utils/searchPerformance.test.js +++ b/utils/searchPerformance.test.js @@ -115,6 +115,15 @@ describe('searchPerformance', () => { expect(console.log).not.toHaveBeenCalled(); }); + test.each([{ cvss: {} }, { ssvc: {} }, { kev: {} }])( + 'getCacheStatus reports non-CPE semantic filters as not applicable', + (postFilters) => { + expect(searchPerformance.getCacheStatus({ cacheHit: false, inFlightHit: false }, postFilters)).toBe( + 'not_applicable' + ); + } + ); + test('logSearchPerformance always emits sanitized failure outcomes', () => { mockShouldLogSearchPerformance.mockReturnValue(false); From 9e3a6a5ddc939cb808c7cd875a68c55e2e4efe79 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:32:40 -0400 Subject: [PATCH 15/27] Bump release version to 0.5.0-beta --- api-docs/openapi.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index c72f666..a23c2a8 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "0.4.0-beta", + "version": "0.5.0-beta", "title": "CVE Search API", "description": "CVE Search API to retrieve all matching CVE records after applying request inputs as filters.", "contact": { diff --git a/package-lock.json b/package-lock.json index 7bf9204..2e5012a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cve-search-api", - "version": "0.4.0-beta", + "version": "0.5.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-search-api", - "version": "0.4.0-beta", + "version": "0.5.0-beta", "dependencies": { "cors": "^2.8.5", "cve-core": "github:CVEProject/cve-core.git#dev-package-test", diff --git a/package.json b/package.json index e99612a..cfe69fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cve-search-api", - "version": "0.4.0-beta", + "version": "0.5.0-beta", "scripts": { "dev": "node routes/swagger.js && nodemon server.js", "coverage": "jest --coverage", From e51993425d0d26fcf8bbfe9c7c6af3068f0ebc74 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:35:51 -0400 Subject: [PATCH 16/27] Remove unreachable post-filter paths --- controllers/searchController.js | 27 +---- controllers/searchController.test.js | 160 --------------------------- utils/searchPostFilters.test.js | 140 +++++++++++++++++++++++ utils/searchQueryBuilder.js | 7 -- utils/searchQueryBuilder.test.js | 64 ++++++----- 5 files changed, 177 insertions(+), 221 deletions(-) create mode 100644 utils/searchPostFilters.test.js diff --git a/controllers/searchController.js b/controllers/searchController.js index b31f590..d294171 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -76,22 +76,6 @@ function buildWebSearchSort(sort) { return undefined; } -function applySearchPostFilters(resp, postFilters, responseOptions, metrics) { - if (!searchPostFilters.hasSearchPostFilters(postFilters)) return resp; - - const { hitsContainer, candidateCount, filteredHits } = searchPostFilters.filterSearchHits(resp, postFilters); - if (!Array.isArray(hitsContainer?.hits)) return resp; - - const pagedHits = paginateHits(filteredHits, responseOptions); - if (metrics) { - metrics.candidateCount = candidateCount; - metrics.filteredCount = filteredHits.length; - metrics.returnedCount = pagedHits.length; - } - - return buildPagedSearchResponse(resp, filteredHits.length, pagedHits); -} - function getHitCveId(hit) { return hit?._source?.cveMetadata?.cveId ?? hit?._id; } @@ -127,7 +111,7 @@ async function executeOpenSearch(body, operation, signal, metrics) { }); } -async function executeStandardSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal) { +async function executeStandardSearch(searchOptions, queryStringObjects, metrics, signal) { metrics.strategy = 'standard'; const searchStart = searchPerformance.markTime(); @@ -140,11 +124,7 @@ async function executeStandardSearch(searchOptions, queryStringObjects, postFilt metrics.openSearchElapsedMs = searchPerformance.elapsedMs(searchStart); metrics.openSearchTookMs = searchData?.took; - const postFilterStart = searchPerformance.markTime(); - const resp = applySearchPostFilters(searchResp, postFilters, responseOptions, metrics); - metrics.postFilterElapsedMs = searchPerformance.elapsedMs(postFilterStart); - - return resp; + return searchResp; } async function fetchFullHitsByCveIds(cveIds, metrics, signal) { @@ -284,7 +264,7 @@ async function executeOptimizedPostFilterSearch( async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal) { if (!searchPostFilters.hasSearchPostFilters(postFilters)) { - return executeStandardSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal); + return executeStandardSearch(searchOptions, queryStringObjects, metrics, signal); } return executeOptimizedPostFilterSearch( @@ -460,7 +440,6 @@ async function webSearchHandler(req, res, next) { } module.exports = { - applySearchPostFilters, buildWebSearchSort, SEARCH: searchHandler, WEB_SEARCH: webSearchHandler diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index f0283f4..8df5e91 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -699,166 +699,6 @@ describe('Search controller SEARCH handler', () => { expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); }); - test('applySearchPostFilters does not combine platform and version matches from different CPE entries', () => { - const candidate = cpeHit('CVE-2026-0250', { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.0', - versionEndExcluding: '6.1' - }); - candidate._source.containers.cna.cpeApplicability.nodes[0].cpeMatch.push( - { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.2', - versionEndExcluding: '6.2.7' - }, - { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.1', - versionEndExcluding: '6.1.13' - }, - { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:chrome_os:*:*:*:*:*', - vulnerable: true, - versionStartIncluding: '6.1', - versionEndExcluding: '6.1.13' - } - ); - - const response = controller.applySearchPostFilters( - advancedResponse(1, [candidate]), - { - cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 - } - ); - - expect(response.data.hits.total.value).toBe(0); - expect(response.data.hits.hits).toStrictEqual([]); - }); - - test('applySearchPostFilters matches CPE string literals without lexical case', () => { - const candidate = cpeHit('CVE-2026-0251', { - criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', - vulnerable: true, - versionStartIncluding: '6.0', - versionEndExcluding: '6.1' - }); - - const response = controller.applySearchPostFilters( - advancedResponse(1, [candidate]), - { - cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 - } - ); - - expect(response.data.hits.total.value).toBe(1); - expect(response.data.hits.hits).toStrictEqual([candidate]); - }); - - test('applySearchPostFilters preserves exact version padding while normalizing numeric range segments', () => { - const exactCandidate = cpeHit('CVE-2025-9392', { - criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', - vulnerable: true - }); - const rangeCandidate = cpeHit('CVE-2026-33280', { - criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', - vulnerable: true, - versionEndExcluding: '6.02' - }); - - const exactResponse = controller.applySearchPostFilters( - advancedResponse(1, [exactCandidate]), - { - cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 - } - ); - const includedRangeResponse = controller.applySearchPostFilters( - advancedResponse(1, [rangeCandidate]), - { - cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 - } - ); - const excludedRangeResponse = controller.applySearchPostFilters( - advancedResponse(1, [rangeCandidate]), - { - cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 - } - ); - - expect(exactResponse.data.hits.total.value).toBe(0); - expect(includedRangeResponse.data.hits.hits).toStrictEqual([rangeCandidate]); - expect(excludedRangeResponse.data.hits.total.value).toBe(0); - }); - - test('applySearchPostFilters preserves CPE NA matches across numeric version ranges', () => { - const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; - const candidates = [ - cpeHit('CVE-2020-17153', { - criteria, - vulnerable: true, - versionStartIncluding: '-' - }), - cpeHit('CVE-2024-38208', { - criteria, - vulnerable: true, - versionStartIncluding: '1.0.0', - versionEndExcluding: '128.0.2739.42' - }), - cpeHit('CVE-2026-35429', { - criteria, - vulnerable: true, - versionStartIncluding: '1.0.0', - versionEndExcluding: '148.0.3967.55' - }) - ]; - - const response = controller.applySearchPostFilters( - advancedResponse(candidates.length, candidates), - { - cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', - isVulnerable: true - }, - { - from: 0, - resultsPerPage: 20 - } - ); - - expect(response.data.hits.total.value).toBe(3); - expect(response.data.hits.hits.map((hit) => hit._id)).toStrictEqual([ - 'CVE-2020-17153', - 'CVE-2024-38208', - 'CVE-2026-35429' - ]); - }); - test('SEARCH applies semantic virtualMatchString version range filtering before building response metadata', async () => { const applicable = cpeHit('CVE-2024-0001', { criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' diff --git a/utils/searchPostFilters.test.js b/utils/searchPostFilters.test.js new file mode 100644 index 0000000..be3329c --- /dev/null +++ b/utils/searchPostFilters.test.js @@ -0,0 +1,140 @@ +const searchPostFilters = require('./searchPostFilters'); + +function response(hits) { + return { + data: { + hits: { + total: { value: hits.length }, + hits + } + } + }; +} + +function cpeHit(id, cpeMatch) { + return { + _id: id, + _source: { + containers: { + cna: { + cpeApplicability: { + nodes: [{ cpeMatch: Array.isArray(cpeMatch) ? cpeMatch : [cpeMatch] }] + } + } + } + } + }; +} + +function filteredHits(hits, postFilters) { + return searchPostFilters.filterSearchHits(response(hits), postFilters).filteredHits; +} + +describe('searchPostFilters CPE semantics', () => { + test('does not combine platform and version matches from different CPE entries', () => { + const candidate = cpeHit('CVE-2026-0250', [ + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }, + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.2', + versionEndExcluding: '6.2.7' + }, + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.1', + versionEndExcluding: '6.1.13' + } + ]); + + expect( + filteredHits([candidate], { + cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + }); + + test('matches CPE string literals without lexical case', () => { + const candidate = cpeHit('CVE-2026-0251', { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }); + + expect( + filteredHits([candidate], { + cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', + isVulnerable: true + }) + ).toStrictEqual([candidate]); + }); + + test('preserves exact version padding while normalizing numeric range segments', () => { + const exactCandidate = cpeHit('CVE-2025-9392', { + criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', + vulnerable: true + }); + const rangeCandidate = cpeHit('CVE-2026-33280', { + criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', + vulnerable: true, + versionEndExcluding: '6.02' + }); + + expect( + filteredHits([exactCandidate], { + cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + expect( + filteredHits([rangeCandidate], { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([rangeCandidate]); + expect( + filteredHits([rangeCandidate], { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + }); + + test('preserves CPE NA matches across numeric version ranges', () => { + const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; + const candidates = [ + cpeHit('CVE-2020-17153', { + criteria, + vulnerable: true, + versionStartIncluding: '-' + }), + cpeHit('CVE-2024-38208', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '128.0.2739.42' + }), + cpeHit('CVE-2026-35429', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '148.0.3967.55' + }) + ]; + + expect( + filteredHits(candidates, { + cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', + isVulnerable: true + }).map((hit) => hit._id) + ).toStrictEqual(['CVE-2020-17153', 'CVE-2024-38208', 'CVE-2026-35429']); + }); +}); diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 03e0f55..8e24b8d 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -69,12 +69,6 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc return body; } -function buildCpeCandidateSearchBody(searchOptions, queryStringObjects = []) { - return buildPostFilterCandidateSearchBody(searchOptions, queryStringObjects, { - cpeName: true - }); -} - function buildPostFilterCandidateSearchBody(searchOptions, queryStringObjects = [], postFilters = {}) { return buildOpenSearchBody(searchOptions, queryStringObjects, { sourceIncludes: searchPostFilters.getCandidateSourceFields(postFilters), @@ -702,7 +696,6 @@ function cvssV2SeverityToRange(sev) { } module.exports = { - buildCpeCandidateSearchBody, buildCveIdsSearchRequest, buildOpenSearchBody, buildPostFilterCandidateSearchBody, diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index b01470a..c039a05 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -188,7 +188,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { expect(output.queryStringObjects).toStrictEqual([]); }); - test('buildCpeCandidateSearchBody limits source fields for semantic post-filtering', () => { + test('buildPostFilterCandidateSearchBody limits source fields for semantic CPE filtering', () => { const output = queryBuilder.buildSearchRequest({ scope: 'CNA_ONLY', keywordSearch: 'kernel', @@ -201,36 +201,40 @@ describe('searchQueryBuilder buildSearchRequest()', () => { pageNumber: '2' }); - expect(queryBuilder.buildCpeCandidateSearchBody(output.searchOptions, output.queryStringObjects)).toStrictEqual( - { - query: { - bool: { - must: [ - { - simple_query_string: { - query: 'kernel*', - default_operator: 'AND', - fields: ['containers.cna.descriptions.value'] - } + expect( + queryBuilder.buildPostFilterCandidateSearchBody( + output.searchOptions, + output.queryStringObjects, + output.postFilters + ) + ).toStrictEqual({ + query: { + bool: { + must: [ + { + simple_query_string: { + query: 'kernel*', + default_operator: 'AND', + fields: ['containers.cna.descriptions.value'] } - ], - filter: output.searchOptions.filters - } - }, - track_total_hits: false, - size: 10000, - from: 0, - _source: [ - 'cveMetadata.cveId', - 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', - 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', - 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' - ] - } - ); + } + ], + filter: output.searchOptions.filters + } + }, + track_total_hits: false, + size: 10000, + from: 0, + _source: [ + 'cveMetadata.cveId', + 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', + 'containers.cna.cpeApplicability.nodes.cpeMatch.vulnerable', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionStartExcluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndIncluding', + 'containers.cna.cpeApplicability.nodes.cpeMatch.versionEndExcluding' + ] + }); }); test('buildOpenSearchBody builds the complete standard search request', () => { From 90c407675e03c41039ea3f2af3f6f53406d1e2f6 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 20 Aug 2026 10:20:52 -0400 Subject: [PATCH 17/27] Add Postman search contract tests --- README.md | 2 + controllers/searchController.js | 1 + controllers/searchController.test.js | 10 + .../CVE-Search-API.postman_collection.json | 8382 +++++++++++++++++ test/postman/README.md | 93 + test/postman/buildCollection.js | 1240 +++ test/postman/dev.postman_environment.json | 21 + test/postman/local.postman_environment.json | 21 + 8 files changed, 9770 insertions(+) create mode 100644 test/postman/CVE-Search-API.postman_collection.json create mode 100644 test/postman/README.md create mode 100644 test/postman/buildCollection.js create mode 100644 test/postman/dev.postman_environment.json create mode 100644 test/postman/local.postman_environment.json diff --git a/README.md b/README.md index 1863691..b1f5cd0 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,8 @@ npm run coverage npm run quality ``` +An importable Postman collection for manual `/search` contract and filter testing is documented in [test/postman/README.md](test/postman/README.md). + ### Operational Logging Every request receives an `X-Request-ID` response header. A caller-provided `X-Request-ID` is preserved when it contains only letters, numbers, `.`, `_`, `:`, or `-` and is no longer than 128 characters; otherwise the API generates a UUID. The same value appears as `request-id` in access logs and `requestId` in structured `/search` events. diff --git a/controllers/searchController.js b/controllers/searchController.js index d294171..742ce0a 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -248,6 +248,7 @@ async function executeOptimizedPostFilterSearch( const fullPageHits = await fetchFullHitsByCveIds(pageCveIds, metrics, signal); const response = { + status: 'ok', data: { took: combineTook(metrics.openSearchTookMs, metrics.pageFetchTookMs), hits: { diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8df5e91..667013b 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -477,6 +477,7 @@ describe('Search controller SEARCH handler', () => { ); expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].status).toBe('ok'); expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(0); expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([]); expect(mockApiSearch).not.toHaveBeenCalled(); @@ -549,6 +550,7 @@ describe('Search controller SEARCH handler', () => { page: 1, pageSize: 1, totalPages: 1, + status: 'ok', data: { hits: { total: { @@ -632,6 +634,7 @@ describe('Search controller SEARCH handler', () => { 'cveMetadata.cveId', 'containers.adp.metrics.other' ]); + expect(res.json.mock.calls[0][0].status).toBe('ok'); expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(1); expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); }); @@ -695,6 +698,7 @@ describe('Search controller SEARCH handler', () => { 'cveMetadata.cveId', 'containers.adp.metrics.other' ]); + expect(res.json.mock.calls[0][0].status).toBe('ok'); expect(res.json.mock.calls[0][0].data.hits.total.value).toBe(1); expect(res.json.mock.calls[0][0].data.hits.hits).toStrictEqual([fullRecord]); }); @@ -764,6 +768,7 @@ describe('Search controller SEARCH handler', () => { page: 1, pageSize: 1, totalPages: 1, + status: 'ok', data: { hits: { total: { @@ -829,6 +834,7 @@ describe('Search controller SEARCH handler', () => { page: 1, pageSize: 1, totalPages: 1, + status: 'ok', data: { took: undefined, hits: { @@ -889,6 +895,7 @@ describe('Search controller SEARCH handler', () => { page: 1, pageSize: 1, totalPages: 1, + status: 'ok', data: { took: undefined, hits: { @@ -994,6 +1001,7 @@ describe('Search controller SEARCH handler', () => { page: 1, pageSize: 1, totalPages: 1, + status: 'ok', data: { hits: { total: { @@ -1261,6 +1269,7 @@ describe('Search controller SEARCH handler', () => { } } ]); + expect(res.json.mock.calls[0][0].status).toBe('ok'); }); test('SEARCH builds version-specific CVSS v3 metric filters', async () => { @@ -1320,6 +1329,7 @@ describe('Search controller SEARCH handler', () => { } } ]); + expect(res.json.mock.calls[0][0].status).toBe('ok'); }); test('SEARCH escapes Lucene reserved characters in keywordSearch terms', async () => { diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json new file mode 100644 index 0000000..21ea532 --- /dev/null +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -0,0 +1,8382 @@ +{ + "info": { + "_postman_id": "28911974-2b15-4ddf-a88f-3fa6fe001a50", + "name": "CVE Search API - Manual Contract Tests", + "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The deprecated /webSearch endpoint is intentionally excluded.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:4000", + "type": "string" + }, + { + "key": "runFaultInjection", + "value": "false", + "type": "string" + } + ], + "item": [ + { + "name": "00 - Service checks", + "description": "Basic API, dependency, and contract availability.", + "item": [ + { + "name": "Liveness returns 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/health-check", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "health-check" + ], + "query": [] + } + }, + "response": [] + }, + { + "name": "Readiness returns 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ready-check", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "ready-check" + ], + "query": [] + } + }, + "response": [] + }, + { + "name": "OpenAPI document is available", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const document = pm.response.json();", + "pm.test('OpenAPI includes POST /search', () => pm.expect(document.paths?.['/search']?.post).to.be.an('object'));" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api-docs/openapi.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api-docs", + "openapi.json" + ], + "query": [] + } + }, + "response": [] + } + ] + }, + { + "name": "01 - Baseline and pagination", + "description": "Default search behavior and shallow-pagination boundaries.", + "item": [ + { + "name": "No request body", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + } + }, + "response": [] + }, + { + "name": "Default empty-object search", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Single CVE ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Multiple CVE IDs", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"resultsPerPage\": 2,\n \"pageNumber\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Second valid page can be empty", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 2\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Maximum valid search window boundary", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 500,\n \"pageNumber\": 20\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "02 - Scope coverage", + "description": "Base, CNA, and ADP matching behavior.", + "item": [ + { + "name": "FULL scope accepts lowercase input", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"full\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CNA_ONLY keyword match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"cna_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY excludes a CNA-only keyword match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"adp_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Base CVE ID participates in ADP_ONLY", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CNA_ONLY CWE match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY CWE match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "03 - Keyword and record filters", + "description": "Description, ID, and record-state filters.", + "item": [ + { + "name": "keywordSearch", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch false without keyword", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordExactMatch\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "noRejected true excludes rejected records", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": true,\n \"resultsPerPage\": 2\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "noRejected false does not filter", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": false,\n \"resultsPerPage\": 2\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cweId", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "04 - CVSS filters", + "description": "Every supported CVSS severity and vector filter.", + "item": [ + { + "name": "cvssV2Severity is case-insensitive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Severity\": \"medium\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV2Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Metrics\": \"AV:N/AC:L/Au:S\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV3Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Severity\": \"medium\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV3Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Metrics\": \"AV:N/AC:L/PR:L\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV3_0Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Severity\": \"medium\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV3_0Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Metrics\": \"CVSS:3.0/AV:N/AC:L/PR:L\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV3_1Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Severity\": \"medium\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV3_1Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Metrics\": \"CVSS:3.1/AV:N/AC:L/PR:L\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV4Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Severity\": \"medium\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cvssV4Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Metrics\": \"CVSS:4.0/AV:N/AC:L/AT:N\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP CVSS and SSVC must match selected containers", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cvssV3_1Severity\": \"high\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"none\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "05 - Record date filters", + "description": "Publication and modification boundaries.", + "item": [ + { + "name": "pubStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "pubEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Publication date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "lastModStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "lastModEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Modification date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "06 - CPE filters", + "description": "Concrete CPE and virtual match-string behavior.", + "item": [ + { + "name": "cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "cpeName with isVulnerable true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "isVulnerable false without cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"isVulnerable\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with versionStart pair", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"INCLUDING\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with versionEnd pair", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with complete range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "07 - SSVC filters", + "description": "Individual and same-entry SSVC matching.", + "item": [ + { + "name": "ssvcExploitation", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"none\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ssvcAutomatable", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcAutomatable\": \"no\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ssvcTechnicalImpact", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTechnicalImpact\": \"partial\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ssvcRole is case-insensitive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcRole\": \"cisa coordinator\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ssvcTimestampStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ssvcTimestampEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Complete SSVC same-entry combination", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"PARTIAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "08 - KEV filters", + "description": "Structured KEV presence and inclusive date filtering.", + "item": [ + { + "name": "knownExploited true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "knownExploited false", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "kevDateAddedStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "kevDateAddedEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Inclusive KEV date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "09 - Reference and dispute filters", + "description": "Reference URL substring and disputed-tag behavior.", + "item": [ + { + "name": "referenceURL FULL", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "referenceURL CNA_ONLY", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "referenceURL ADP_ONLY", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "referenceURL treats wildcard characters literally", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/*/cve/issues/1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "disputed true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-38625\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2022-38625\",\n \"disputed\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "disputed false", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "10 - Intentional combinations", + "description": "Representative AND combinations across related and independent filter groups.", + "item": [ + { + "name": "CNA keyword, CWE, CVSS, dates, and status", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweId\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP CWE, CVSS, SSVC, and reference", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "FULL cross-container KEV, SSVC, CVSS, and reference", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"FULL\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\",\n \"ssvcExploitation\": \"ACTIVE\",\n \"ssvcAutomatable\": \"YES\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"referenceURL\": \"cisa.gov/known-exploited-vulnerabilities-catalog\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "11 - Expected failures - request contract", + "description": "Transport and JSON body failures.", + "item": [ + { + "name": "Query parameters are rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_QUERY_UNSUPPORTED\",\"message\":\"The /search endpoint accepts JSON request body fields, not query parameters.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search?keywordSearch=apache", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [ + { + "key": "keywordSearch", + "value": "apache" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Non-JSON content type is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 415', () => pm.response.to.have.status(415));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_CONTENT_TYPE\",\"message\":\"The /search request body must use the application/json media type with UTF-8 encoding.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "text/plain", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "text" + } + } + } + }, + "response": [] + }, + { + "name": "Malformed JSON is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\"keywordSearch\":", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Array body is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "[]", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Top-level JSON string is rejected by the parser", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "\"search\"", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Unsupported body field is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"unsupportedFilter\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Empty string field is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordSearch\": \" \"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Array-valued enum is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": [\n \"FULL\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "12 - Expected failures - limits and pagination", + "description": "Input complexity and result-window limits.", + "item": [ + { + "name": "String length limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordSearch\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Keyword token limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordSearch\": \"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Reference URL minimum length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"referenceURL\": \"ab\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVE ID format", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVE_IDS_FORMAT\",\"message\":\"The cveIds parameter must be a comma-separated list of CVE IDs.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"not-a-cve\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVE ID length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVE ID count limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-1000,CVE-2024-1001,CVE-2024-1002,CVE-2024-1003,CVE-2024-1004,CVE-2024-1005,CVE-2024-1006,CVE-2024-1007,CVE-2024-1008,CVE-2024-1009,CVE-2024-1010,CVE-2024-1011,CVE-2024-1012,CVE-2024-1013,CVE-2024-1014,CVE-2024-1015,CVE-2024-1016,CVE-2024-1017,CVE-2024-1018,CVE-2024-1019,CVE-2024-1020,CVE-2024-1021,CVE-2024-1022,CVE-2024-1023,CVE-2024-1024,CVE-2024-1025,CVE-2024-1026,CVE-2024-1027,CVE-2024-1028,CVE-2024-1029,CVE-2024-1030,CVE-2024-1031,CVE-2024-1032,CVE-2024-1033,CVE-2024-1034,CVE-2024-1035,CVE-2024-1036,CVE-2024-1037,CVE-2024-1038,CVE-2024-1039,CVE-2024-1040,CVE-2024-1041,CVE-2024-1042,CVE-2024-1043,CVE-2024-1044,CVE-2024-1045,CVE-2024-1046,CVE-2024-1047,CVE-2024-1048,CVE-2024-1049,CVE-2024-1050,CVE-2024-1051,CVE-2024-1052,CVE-2024-1053,CVE-2024-1054,CVE-2024-1055,CVE-2024-1056,CVE-2024-1057,CVE-2024-1058,CVE-2024-1059,CVE-2024-1060,CVE-2024-1061,CVE-2024-1062,CVE-2024-1063,CVE-2024-1064,CVE-2024-1065,CVE-2024-1066,CVE-2024-1067,CVE-2024-1068,CVE-2024-1069,CVE-2024-1070,CVE-2024-1071,CVE-2024-1072,CVE-2024-1073,CVE-2024-1074,CVE-2024-1075,CVE-2024-1076,CVE-2024-1077,CVE-2024-1078,CVE-2024-1079,CVE-2024-1080,CVE-2024-1081,CVE-2024-1082,CVE-2024-1083,CVE-2024-1084,CVE-2024-1085,CVE-2024-1086,CVE-2024-1087,CVE-2024-1088,CVE-2024-1089,CVE-2024-1090,CVE-2024-1091,CVE-2024-1092,CVE-2024-1093,CVE-2024-1094,CVE-2024-1095,CVE-2024-1096,CVE-2024-1097,CVE-2024-1098,CVE-2024-1099,CVE-2024-1100\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "resultsPerPage must be positive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": -1\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "resultsPerPage maximum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 501\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "pageNumber must be positive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pageNumber\": 0\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Pagination window limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS vector total length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS vector token count", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV4Metrics\": \"T0:A/T1:A/T2:A/T3:A/T4:A/T5:A/T6:A/T7:A/T8:A/T9:A/T10:A/T11:A/T12:A/T13:A/T14:A/T15:A/T16:A/T17:A/T18:A/T19:A/T20:A/T21:A/T22:A/T23:A/T24:A/T25:A/T26:A/T27:A/T28:A/T29:A/T30:A/T31:A/T32:A\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS vector token length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "13 - Expected failures - booleans and enums", + "description": "Strict booleans and enum domains.", + "item": [ + { + "name": "isVulnerable boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"isVulnerable\": \"yes\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordExactMatch\": \"yes\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "noRejected boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"noRejected\": \"yes\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "knownExploited boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"knownExploited\": \"true\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "disputed boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"disputed\": \"false\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "scope enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_AND_ADP\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS v2 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV2_SEVERITY\",\"message\":\"CRITICAL is not an allowed input for the cvssV2Severity. Please use LOW, MEDIUM, or HIGH.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV2Severity\": \"CRITICAL\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS v3 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3Severity\": \"UNKNOWN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS v3.0 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_0Severity\": \"UNKNOWN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS v3.1 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_1Severity\": \"UNKNOWN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "CVSS v4 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV4Severity\": \"UNKNOWN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "SSVC exploitation enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcExploitation\": \"UNKNOWN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "SSVC automatable enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcAutomatable\": \"MAYBE\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "SSVC technical impact enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcTechnicalImpact\": \"UNKNOWN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "14 - Expected failures - dependent filters", + "description": "Filters whose validity depends on another field.", + "item": [ + { + "name": "keywordExactMatch requires keywordSearch", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MISSING_KEYWORD_SEARCH_PARAM\",\"message\":\"The keywordSearch parameter is required when the keywordExactMatch parameter is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordExactMatch\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "isVulnerable requires cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MISSING_CPE_NAME_PARAM\",\"message\":\"The cpeName parameter is required when the isVulnerable parameter is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"isVulnerable\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "isVulnerable conflicts with virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_AND_IS_VULN\",\"message\":\"The virtualMatchString parameter is not allowed when the isVulnerable parameter is provided. Please use cpeName instead.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"isVulnerable\": true,\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "KEV start date requires knownExploited true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"knownExploited\": false,\n \"kevDateAddedStartDate\": \"2022-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "KEV end date requires knownExploited true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"kevDateAddedEndDate\": \"2022-12-31T23:59:59.999Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "15 - Expected failures - CPE and version ranges", + "description": "CPE formats, scope compatibility, and range pair/order rules.", + "item": [ + { + "name": "ADP_ONLY rejects cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects isVulnerable", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"isVulnerable\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionStart", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStart\": \"2.2\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionStartType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStartType\": \"including\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionEnd", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEnd\": \"2.6\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionEndType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEndType\": \"excluding\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Invalid cpeName returns 404", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 404', () => pm.response.to.have.status(404));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor:product:*\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Invalid virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must be a CPE 2.3 match string.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"not-a-cpe\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Version range requires virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_VIRTUAL_MATCH_STRING_REQUIRED\",\"message\":\"The virtualMatchString parameter is required when versionStart, versionStartType, versionEnd, or versionEndType is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "versionStart requires versionStartType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "versionEnd requires versionEndType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionEnd\": \"2.0\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Invalid version range type", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"before\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Invalid ranged virtualMatchString format", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_RANGE_FORMAT\",\"message\":\"The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"not-a-cpe\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Ranged virtualMatchString cannot contain version", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_VERSION_COMPONENT\",\"message\":\"The virtualMatchString parameter cannot include a version component when versionStart or versionEnd is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product:1.0\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Inverted version range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"2.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Empty equal exclusive version range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"excluding\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "16 - Expected failures - dates", + "description": "Timestamp format, calendar validity, and range order.", + "item": [ + { + "name": "Partial timestamp", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pubStartDate\": \"2024-01-01\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Impossible calendar date", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"lastModStartDate\": \"2024-02-30T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Inverted publication date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The pubStartDate parameter must be before or equal to pubEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pubStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"pubEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Inverted modification date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The lastModStartDate parameter must be before or equal to lastModEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"lastModStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"lastModEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Inverted SSVC timestamp range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The ssvcTimestampStartDate parameter must be before or equal to ssvcTimestampEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcTimestampStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Inverted KEV date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The kevDateAddedStartDate parameter must be before or equal to kevDateAddedEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + }, + { + "name": "17 - Manual fault injection - skipped by default", + "description": "These requests require deliberate infrastructure or application configuration. Setting runFaultInjection=true without preparing the named condition will make them fail rather than create that condition.", + "item": [ + { + "name": "OpenSearch readiness failure", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping OpenSearch readiness failure; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 503', () => pm.response.to.have.status(503));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_NOT_READY\",\"message\":\"Search service is not ready. Please try again later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ready-check", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "ready-check" + ], + "query": [] + }, + "description": "Run only while the API cannot reach or query its configured OpenSearch index." + }, + "response": [] + }, + { + "name": "OpenSearch unavailable search", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping OpenSearch unavailable search; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 503', () => pm.response.to.have.status(503));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"OPEN_SEARCH_ERROR\",\"message\":\"OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "description": "Run only while OpenSearch connectivity is intentionally unavailable.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Search timeout", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Search timeout; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 504', () => pm.response.to.have.status(504));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_TIMEOUT\",\"message\":\"Search request timed out. Please try again later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "description": "Run only with a deliberately reduced request deadline or delayed OpenSearch response.", + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Cold-search queue full", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Cold-search queue full; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 429', () => pm.response.to.have.status(429));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_QUEUE_FULL\",\"message\":\"Search service is busy. Please retry later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "description": "Requires concurrent unique cold CPE searches and a deliberately constrained queue.", + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Cold-search queue timeout", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Cold-search queue timeout; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 429', () => pm.response.to.have.status(429));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_QUEUE_TIMEOUT\",\"message\":\"Search service is busy. Please retry later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "description": "Requires concurrent cold CPE searches and a deliberately short queue wait timeout.", + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Application rate limit", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Application rate limit; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 429', () => pm.response.to.have.status(429));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_RATE_LIMITED\",\"message\":\"Too many search requests. Please retry later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "description": "Requires the application limiter to be enabled with an exhausted allowance. A WAF response may use a different contract.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + }, + { + "name": "Unexpected upstream response", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Unexpected upstream response; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 502', () => pm.response.to.have.status(502));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"UPSTREAM_SEARCH_ERROR\",\"message\":\"Search backend returned an error. Please try again later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "search" + ], + "query": [] + }, + "description": "Requires an intentionally malformed or unexpected OpenSearch response.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [] + } + ] + } + ] +} diff --git a/test/postman/README.md b/test/postman/README.md new file mode 100644 index 0000000..2355dfe --- /dev/null +++ b/test/postman/README.md @@ -0,0 +1,93 @@ +# Postman Manual Contract Tests + +This directory contains an importable Postman collection for manually validating the CVE Search API. It covers every documented `POST /search` request field, representative intentional filter combinations, and the API's deterministic validation errors. The deprecated `/webSearch` endpoint is intentionally excluded. + +## Files + +- `CVE-Search-API.postman_collection.json`: generated Postman collection. +- `local.postman_environment.json`: local environment with `baseUrl` set to `http://localhost:4000`. +- `dev.postman_environment.json`: dev environment with `baseUrl` set to `https://dev.search.cve.org`. +- `buildCollection.js`: maintainable source used to generate and consistency-check the JSON artifacts. + +## Run The Collection + +1. Start the API and its configured OpenSearch dependency. +2. Import `CVE-Search-API.postman_collection.json` into Postman. +3. Import `local.postman_environment.json` or `dev.postman_environment.json`. +4. Confirm that `baseUrl` points to the intended API instance. +5. Open the collection runner and run folders `00` through `16`. + +Folders are grouped by service checks, positive filter coverage, intentional combinations, and expected validation failures. Positive search requests assert the response contract and use stable CVE fixtures to check totals or returned IDs. Negative requests assert the exact HTTP status, error code, and error message. + +The collection checks that each response includes an `X-Request-ID`. Search responses also must contain the documented status, pagination, total, and hit-array fields. + +## Environments + +The collection uses the `baseUrl` variable and does not embed deployment credentials. Select **CVE Search API - Local** for a locally running API or **CVE Search API - Dev** for the deployed dev environment. To target another environment, duplicate either Postman environment and change only `baseUrl`, for example: + +```text +https:// +``` + +Review and approve tests before targeting a shared or production deployment. The included fixtures were calibrated against both the dev CVE index and a smaller 1,111-record local index on August 20, 2026. + +| Fixture | Coverage | +| ---------------- | --------------------------------------------------------- | +| `CVE-2024-5981` | CNA keyword, CWE, CVSS v2-v4, SSVC, references, and dates | +| `CVE-2022-31640` | ADP CWE, CVSS, SSVC, and references | +| `CVE-2020-1472` | KEV, SSVC, CVSS, and cross-container matching | +| `CVE-2022-31279` | Rejected-record filtering | +| `CVE-2022-38625` | Disputed-record filtering | +| `CVE-2025-23022` | CPE name, virtual match string, and version boundaries | + +Index-wide searches assert only that at least one result exists. Exact totals are limited to requests constrained by one or more fixture `cveIds`, so different index sizes do not cause false failures. Constrained zero-result requests intentionally verify scope exclusion or literal substring behavior. + +Because CVE records can be updated, refresh a fixture when a positive request stops satisfying its documented filter rather than weakening its assertions without investigating the data change. A local index must include the fixtures above to run every positive folder unchanged; validation-error folders do not depend on index contents. + +## Fault-Injection Folder + +Folder `17 - Manual fault injection - skipped by default` describes runtime errors that cannot be created by an ordinary black-box request. Its requests are skipped while `runFaultInjection` is `false`. + +Setting `runFaultInjection` to `true` only enables the requests; it does not create their required failure conditions. Prepare and run one request at a time because the conditions are different and can conflict. + +| Request | Required condition | Expected response | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | +| OpenSearch readiness failure | Make the configured OpenSearch index unavailable to the API. | `503 SEARCH_NOT_READY` | +| OpenSearch unavailable search | Make OpenSearch connectivity, permissions, or index access unavailable. | `503 OPEN_SEARCH_ERROR` | +| Search timeout | Deliberately lower the request deadline or delay OpenSearch beyond the configured deadline. | `504 SEARCH_TIMEOUT` | +| Cold-search queue full | Constrain cold CPE concurrency and queue depth, then keep all active and queued slots occupied. | `429 SEARCH_QUEUE_FULL` | +| Cold-search queue timeout | Keep cold CPE workers occupied longer than a deliberately shortened queue wait timeout. | `429 SEARCH_QUEUE_TIMEOUT` | +| Application rate limit | Enable the application limiter with a low threshold and exhaust the allowance before sending the check. | `429 SEARCH_RATE_LIMITED` | +| Unexpected upstream response | Use a test double or proxy that returns an invalid or incomplete OpenSearch response. | `502 UPSTREAM_SEARCH_ERROR` | + +Run a fault check as follows: + +1. Target a disposable local environment and prepare exactly one condition from the table. +2. Set `runFaultInjection` to `true` in the selected Postman environment. +3. Open folder `17` and send only the request for the prepared condition. +4. Confirm the asserted HTTP status, sanitized error code, message, and `X-Request-ID`. +5. Set `runFaultInjection` back to `false` and restore the normal application and dependency configuration. + +The two queue checks require concurrent external setup because the Postman collection runner sends requests sequentially. A separate request must occupy the worker or queue before sending the Postman assertion request. + +Do not enable fault injection against a shared or production environment without the target owner's approval. A WAF or load balancer can return a different error contract than the application-level response asserted by this folder. + +## Known Validation Boundary + +`VERSION_RANGE_VALUE` remains a defined middleware error, but it is not independently reachable through the current HTTP route. Empty or non-string `versionStart` and `versionEnd` values are rejected earlier by the route's shared string validation as `SEARCH_FIELD_STRING`. The collection tests the observable route contract and does not include an unreachable black-box assertion. + +## Regenerate Or Check Artifacts + +After changing the `/search` request schema, validation errors, or collection source, regenerate the Postman JSON files: + +```bash +node test/postman/buildCollection.js +``` + +Check that committed artifacts match the builder without rewriting them: + +```bash +node test/postman/buildCollection.js --check +``` + +The builder fails when a documented `SearchRequest` property has no positive Postman example. This protects field coverage but does not replace unit, integration, or live environment testing. diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js new file mode 100644 index 0000000..689f994 --- /dev/null +++ b/test/postman/buildCollection.js @@ -0,0 +1,1240 @@ +const fs = require('fs'); +const path = require('path'); +const constants = require('../../utils/constants'); +const { errors: SearchErrors } = require('../../utils/errors'); +const openApi = require('../../api-docs/openapi.json'); + +const error = new SearchErrors(); +const outputDirectory = __dirname; +const collectionPath = path.join(outputDirectory, 'CVE-Search-API.postman_collection.json'); +const environmentPath = path.join(outputDirectory, 'local.postman_environment.json'); +const devEnvironmentPath = path.join(outputDirectory, 'dev.postman_environment.json'); +const positiveCoveredFields = new Set(); + +const ids = Object.freeze({ + primary: 'CVE-2024-5981', + adp: 'CVE-2022-31640', + rejected: 'CVE-2022-31279', + kev: 'CVE-2020-1472', + cpe: 'CVE-2025-23022', + disputed: 'CVE-2022-38625' +}); + +function jsonBody(value) { + return JSON.stringify(value, null, 2); +} + +function requestUrl(requestPath) { + return { + raw: `{{baseUrl}}${requestPath}`, + host: ['{{baseUrl}}'], + path: requestPath.split('?')[0].split('/').filter(Boolean), + query: requestPath.includes('?') + ? requestPath + .split('?')[1] + .split('&') + .map((entry) => { + const [key, value] = entry.split('='); + return { key, value }; + }) + : [] + }; +} + +function requestIdTests() { + return [ + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + '});' + ]; +} + +function successTests({ status = 200, exactTotal, minimumTotal, expectedIds = [], expectedHitCount } = {}) { + const tests = [ + `pm.test('status is ${status}', () => pm.response.to.have.status(${status}));`, + ...requestIdTests(), + `const isExpectedSuccess = pm.response.code === ${status};`, + 'if (isExpectedSuccess) {', + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + ' const payload = pm.response.json();', + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + '});' + ]; + + if (exactTotal !== undefined) { + tests.push( + `pm.test('total is ${exactTotal}', () => pm.expect(payload.data.hits.total.value).to.eql(${exactTotal}));` + ); + } else if (minimumTotal !== undefined) { + tests.push( + `pm.test('total is at least ${minimumTotal}', () => pm.expect(payload.data.hits.total.value).to.be.at.least(${minimumTotal}));` + ); + } + + if (expectedHitCount !== undefined) { + tests.push( + `pm.test('returned hit count is ${expectedHitCount}', () => pm.expect(payload.data.hits.hits.length).to.eql(${expectedHitCount}));` + ); + } + + if (expectedIds.length) { + tests.push( + ' const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);', + `pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members(${JSON.stringify(expectedIds)}));` + ); + } + + tests.push('}'); + return tests; +} + +function errorTests(expectedStatus, expectedError) { + return [ + `pm.test('status is ${expectedStatus}', () => pm.response.to.have.status(${expectedStatus}));`, + ...requestIdTests(), + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + 'const payload = pm.response.json();', + 'const errors = Array.isArray(payload) ? payload : [payload];', + `const expectedError = ${JSON.stringify(expectedError)};`, + "pm.test('expected error code is returned', () => {", + ' pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);', + '});', + "pm.test('expected error message is returned', () => {", + ' const match = errors.find((entry) => entry.error === expectedError.error);', + ' pm.expect(match?.message).to.eql(expectedError.message);', + '});' + ]; +} + +function item({ + name, + description, + method = 'POST', + requestPath = '/search', + body, + rawBody, + contentType = 'application/json', + tests = [], + skipUnless +}) { + const request = { + method, + header: [], + url: requestUrl(requestPath), + description + }; + + if (body !== undefined || rawBody !== undefined) { + request.header.push({ key: 'Content-Type', value: contentType, type: 'text' }); + request.body = { + mode: 'raw', + raw: rawBody ?? jsonBody(body), + options: { + raw: { + language: contentType === 'application/json' ? 'json' : 'text' + } + } + }; + } + + const events = []; + if (skipUnless) { + events.push({ + listen: 'prerequest', + script: { + type: 'text/javascript', + exec: [ + `if (pm.collectionVariables.get('${skipUnless}') !== 'true') {`, + ` console.log('Skipping ${name}; set ${skipUnless}=true to enable it.');`, + ' pm.execution.skipRequest();', + '}' + ] + } + }); + } + if (tests.length) { + events.push({ + listen: 'test', + script: { + type: 'text/javascript', + exec: tests + } + }); + } + + return { + name, + event: events, + request, + response: [] + }; +} + +function positive(name, body, options = {}) { + Object.keys(body ?? {}).forEach((field) => positiveCoveredFields.add(field)); + + return item({ + name, + description: options.description, + body, + tests: successTests(options) + }); +} + +function negative(name, body, expectedError, options = {}) { + return item({ + name, + description: options.description, + body, + rawBody: options.rawBody, + contentType: options.contentType, + method: options.method, + requestPath: options.requestPath, + tests: errorTests(options.status ?? 400, expectedError) + }); +} + +function folder(name, description, items) { + return { + name, + description, + item: items + }; +} + +const serviceChecks = folder('00 - Service checks', 'Basic API, dependency, and contract availability.', [ + item({ + name: 'Liveness returns 200', + method: 'GET', + requestPath: '/api/health-check', + tests: [`pm.test('status is 200', () => pm.response.to.have.status(200));`, ...requestIdTests()] + }), + item({ + name: 'Readiness returns 200', + method: 'GET', + requestPath: '/api/ready-check', + tests: [`pm.test('status is 200', () => pm.response.to.have.status(200));`, ...requestIdTests()] + }), + item({ + name: 'OpenAPI document is available', + method: 'GET', + requestPath: '/api-docs/openapi.json', + tests: [ + `pm.test('status is 200', () => pm.response.to.have.status(200));`, + ...requestIdTests(), + 'const document = pm.response.json();', + "pm.test('OpenAPI includes POST /search', () => pm.expect(document.paths?.['/search']?.post).to.be.an('object'));" + ] + }) +]); + +const baselineAndPagination = folder( + '01 - Baseline and pagination', + 'Default search behavior and shallow-pagination boundaries.', + [ + positive('No request body', undefined, { minimumTotal: 1 }), + positive('Default empty-object search', {}, { minimumTotal: 1 }), + positive('Single CVE ID', { cveIds: ids.primary }, { exactTotal: 1, expectedIds: [ids.primary] }), + positive( + 'Multiple CVE IDs', + { cveIds: `${ids.primary},${ids.rejected}`, resultsPerPage: 2, pageNumber: 1 }, + { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } + ), + positive( + 'Second valid page can be empty', + { cveIds: ids.primary, resultsPerPage: 1, pageNumber: 2 }, + { + exactTotal: 1, + expectedHitCount: 0 + } + ), + positive( + 'Maximum valid search window boundary', + { cveIds: ids.primary, resultsPerPage: 500, pageNumber: 20 }, + { exactTotal: 1, expectedHitCount: 0 } + ) + ] +); + +const scopeCoverage = folder('02 - Scope coverage', 'Base, CNA, and ADP matching behavior.', [ + positive( + 'FULL scope accepts lowercase input', + { scope: 'full', cveIds: ids.primary }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'CNA_ONLY keyword match', + { scope: 'cna_only', cveIds: ids.primary, keywordSearch: 'sql injection' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'ADP_ONLY excludes a CNA-only keyword match', + { scope: 'adp_only', cveIds: ids.primary, keywordSearch: 'sql injection' }, + { exactTotal: 0, expectedHitCount: 0 } + ), + positive( + 'Base CVE ID participates in ADP_ONLY', + { scope: 'ADP_ONLY', cveIds: ids.primary }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'CNA_ONLY CWE match', + { scope: 'CNA_ONLY', cveIds: ids.primary, cweId: 'CWE-89' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ADP_ONLY CWE match', + { scope: 'ADP_ONLY', cveIds: ids.adp, cweId: 'CWE-367' }, + { + exactTotal: 1, + expectedIds: [ids.adp] + } + ) +]); + +const keywordAndRecordFilters = folder( + '03 - Keyword and record filters', + 'Description, ID, and record-state filters.', + [ + positive( + 'keywordSearch', + { cveIds: ids.primary, keywordSearch: 'sql injection' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'keywordExactMatch true', + { cveIds: ids.primary, keywordSearch: 'sql injection', keywordExactMatch: true }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'keywordExactMatch false without keyword', + { cveIds: ids.primary, keywordExactMatch: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'noRejected true excludes rejected records', + { cveIds: `${ids.primary},${ids.rejected}`, noRejected: true, resultsPerPage: 2 }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'noRejected false does not filter', + { cveIds: `${ids.primary},${ids.rejected}`, noRejected: false, resultsPerPage: 2 }, + { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } + ), + positive('cweId', { cveIds: ids.primary, cweId: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] }) + ] +); + +const cvssCoverage = folder('04 - CVSS filters', 'Every supported CVSS severity and vector filter.', [ + positive( + 'cvssV2Severity is case-insensitive', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV2Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV2Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV2Metrics: 'AV:N/AC:L/Au:S' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3Metrics: 'AV:N/AC:L/PR:L' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_0Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_0Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_0Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_0Metrics: 'CVSS:3.0/AV:N/AC:L/PR:L' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_1Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_1Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV3_1Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L/PR:L' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV4Severity', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV4Severity: 'medium' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'cvssV4Metrics', + { scope: 'CNA_ONLY', cveIds: ids.primary, cvssV4Metrics: 'CVSS:4.0/AV:N/AC:L/AT:N' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ADP CVSS and SSVC must match selected containers', + { + scope: 'ADP_ONLY', + cveIds: ids.adp, + cvssV3_1Severity: 'high', + cvssV3_1Metrics: 'AV:L/AC:H/PR:L', + ssvcExploitation: 'none' + }, + { exactTotal: 1, expectedIds: [ids.adp] } + ) +]); + +const recordDates = folder('05 - Record date filters', 'Publication and modification boundaries.', [ + positive( + 'pubStartDate', + { cveIds: ids.primary, pubStartDate: '2024-01-01T00:00:00.000Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'pubEndDate', + { cveIds: ids.primary, pubEndDate: '2024-12-31T23:59:59.999Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'Publication date range', + { + cveIds: ids.primary, + pubStartDate: '2024-01-01T00:00:00.000Z', + pubEndDate: '2024-12-31T23:59:59.999Z' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'lastModStartDate', + { cveIds: ids.primary, lastModStartDate: '2024-01-01T00:00:00.000Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'lastModEndDate', + { cveIds: ids.primary, lastModEndDate: '2030-12-31T23:59:59.999Z' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'Modification date range', + { + cveIds: ids.primary, + lastModStartDate: '2024-01-01T00:00:00.000Z', + lastModEndDate: '2030-12-31T23:59:59.999Z' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ) +]); + +const cpeCoverage = folder('06 - CPE filters', 'Concrete CPE and virtual match-string behavior.', [ + positive( + 'cpeName', + { cveIds: ids.cpe, cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*' }, + { + exactTotal: 1, + expectedIds: [ids.cpe] + } + ), + positive( + 'cpeName with isVulnerable true', + { + cveIds: ids.cpe, + cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', + isVulnerable: true + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'isVulnerable false without cpeName', + { cveIds: ids.primary, isVulnerable: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'virtualMatchString', + { cveIds: ids.cpe, virtualMatchString: 'cpe:2.3:a:freetype:freetype' }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'virtualMatchString with versionStart pair', + { + cveIds: ids.cpe, + virtualMatchString: 'cpe:2.3:a:freetype:freetype', + versionStart: '2.8', + versionStartType: 'INCLUDING' + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'virtualMatchString with versionEnd pair', + { + cveIds: ids.cpe, + virtualMatchString: 'cpe:2.3:a:freetype:freetype', + versionEnd: '2.9', + versionEndType: 'excluding' + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ), + positive( + 'virtualMatchString with complete range', + { + cveIds: ids.cpe, + virtualMatchString: 'cpe:2.3:a:freetype:freetype', + versionStart: '2.8', + versionStartType: 'including', + versionEnd: '2.9', + versionEndType: 'excluding' + }, + { exactTotal: 1, expectedIds: [ids.cpe] } + ) +]); + +const ssvcCoverage = folder('07 - SSVC filters', 'Individual and same-entry SSVC matching.', [ + positive( + 'ssvcExploitation', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcExploitation: 'none' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcAutomatable', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcAutomatable: 'no' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcTechnicalImpact', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcTechnicalImpact: 'partial' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcRole is case-insensitive', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcRole: 'cisa coordinator' }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'ssvcTimestampStartDate', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcTimestampStartDate: '2024-01-01T00:00:00.000Z' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'ssvcTimestampEndDate', + { scope: 'ADP_ONLY', cveIds: ids.primary, ssvcTimestampEndDate: '2030-12-31T23:59:59.999Z' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'Complete SSVC same-entry combination', + { + scope: 'ADP_ONLY', + cveIds: ids.primary, + ssvcExploitation: 'NONE', + ssvcAutomatable: 'NO', + ssvcTechnicalImpact: 'PARTIAL', + ssvcRole: 'CISA Coordinator', + ssvcTimestampStartDate: '2024-01-01T00:00:00.000Z', + ssvcTimestampEndDate: '2030-12-31T23:59:59.999Z' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ) +]); + +const kevCoverage = folder('08 - KEV filters', 'Structured KEV presence and inclusive date filtering.', [ + positive( + 'knownExploited true', + { scope: 'ADP_ONLY', cveIds: ids.kev, knownExploited: true }, + { + exactTotal: 1, + expectedIds: [ids.kev] + } + ), + positive( + 'knownExploited false', + { scope: 'ADP_ONLY', cveIds: ids.primary, knownExploited: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ), + positive( + 'kevDateAddedStartDate', + { + scope: 'ADP_ONLY', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedStartDate: '2021-11-03T00:00:00.000Z' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ), + positive( + 'kevDateAddedEndDate', + { + scope: 'ADP_ONLY', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedEndDate: '2021-11-03T00:00:00.000Z' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ), + positive( + 'Inclusive KEV date range', + { + scope: 'ADP_ONLY', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedStartDate: '2021-11-03T00:00:00.000Z', + kevDateAddedEndDate: '2021-11-03T00:00:00.000Z' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ) +]); + +const referenceAndTags = folder( + '09 - Reference and dispute filters', + 'Reference URL substring and disputed-tag behavior.', + [ + positive( + 'referenceURL FULL', + { cveIds: ids.primary, referenceURL: 'github.com/liuyongxiang-git/cve/issues/1' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'referenceURL CNA_ONLY', + { + scope: 'CNA_ONLY', + cveIds: ids.primary, + referenceURL: 'GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'referenceURL ADP_ONLY', + { + scope: 'ADP_ONLY', + cveIds: ids.primary, + referenceURL: 'github.com/liuyongxiang-git/cve/issues/1' + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'referenceURL treats wildcard characters literally', + { cveIds: ids.primary, referenceURL: 'github.com/*/cve/issues/1' }, + { exactTotal: 0, expectedHitCount: 0 } + ), + positive( + 'disputed true', + { cveIds: ids.disputed, disputed: true }, + { + exactTotal: 1, + expectedIds: [ids.disputed] + } + ), + positive( + 'disputed false', + { cveIds: ids.primary, disputed: false }, + { + exactTotal: 1, + expectedIds: [ids.primary] + } + ) + ] +); + +const intentionalCombinations = folder( + '10 - Intentional combinations', + 'Representative AND combinations across related and independent filter groups.', + [ + positive( + 'CNA keyword, CWE, CVSS, dates, and status', + { + scope: 'CNA_ONLY', + cveIds: ids.primary, + noRejected: true, + keywordSearch: 'sql injection', + keywordExactMatch: true, + cweId: 'CWE-89', + cvssV3_1Severity: 'MEDIUM', + cvssV3_1Metrics: 'AV:N/AC:L/PR:L', + pubStartDate: '2024-01-01T00:00:00.000Z', + pubEndDate: '2024-12-31T23:59:59.999Z', + lastModStartDate: '2024-01-01T00:00:00.000Z', + lastModEndDate: '2030-12-31T23:59:59.999Z', + resultsPerPage: 1, + pageNumber: 1 + }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'ADP CWE, CVSS, SSVC, and reference', + { + scope: 'ADP_ONLY', + cveIds: ids.adp, + cweId: 'CWE-367', + cvssV3_1Severity: 'HIGH', + cvssV3_1Metrics: 'AV:L/AC:H/PR:L', + ssvcExploitation: 'NONE', + ssvcAutomatable: 'NO', + ssvcTechnicalImpact: 'TOTAL', + ssvcRole: 'CISA Coordinator', + referenceURL: 'support.hp.com/us-en/document/ish_6662920' + }, + { exactTotal: 1, expectedIds: [ids.adp] } + ), + positive( + 'FULL cross-container KEV, SSVC, CVSS, and reference', + { + scope: 'FULL', + cveIds: ids.kev, + knownExploited: true, + kevDateAddedStartDate: '2021-11-03T00:00:00.000Z', + kevDateAddedEndDate: '2021-11-03T00:00:00.000Z', + ssvcExploitation: 'ACTIVE', + ssvcAutomatable: 'YES', + ssvcTechnicalImpact: 'TOTAL', + cvssV3_1Severity: 'MEDIUM', + referenceURL: 'cisa.gov/known-exploited-vulnerabilities-catalog' + }, + { exactTotal: 1, expectedIds: [ids.kev] } + ) + ] +); + +const contractFailures = folder('11 - Expected failures - request contract', 'Transport and JSON body failures.', [ + negative('Query parameters are rejected', {}, error.searchQueryUnsupportedError(), { + requestPath: '/search?keywordSearch=apache' + }), + negative('Non-JSON content type is rejected', undefined, error.searchContentTypeError(), { + rawBody: '{}', + contentType: 'text/plain', + status: 415 + }), + negative('Malformed JSON is rejected', undefined, error.malformedJsonError(), { + rawBody: '{"keywordSearch":' + }), + negative('Array body is rejected', [], error.searchBodyError()), + negative('Top-level JSON string is rejected by the parser', undefined, error.malformedJsonError(), { + rawBody: '"search"' + }), + negative( + 'Unsupported body field is rejected', + { unsupportedFilter: true }, + error.invalidParamError('unsupportedFilter') + ), + negative('Empty string field is rejected', { keywordSearch: ' ' }, error.searchFieldStringError('keywordSearch')), + negative('Array-valued enum is rejected', { scope: ['FULL'] }, error.searchFieldStringError('scope')) +]); + +const limitFailures = folder( + '12 - Expected failures - limits and pagination', + 'Input complexity and result-window limits.', + [ + negative( + 'String length limit', + { keywordSearch: 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch + 1) }, + error.searchFieldLengthError('keywordSearch', constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch) + ), + negative( + 'Keyword token limit', + { + keywordSearch: Array(constants.MAX_KEYWORD_SEARCH_TOKENS + 1) + .fill('a') + .join(' ') + }, + error.searchFieldTokenLimitError('keywordSearch', constants.MAX_KEYWORD_SEARCH_TOKENS) + ), + negative( + 'Reference URL minimum length', + { referenceURL: 'ab' }, + error.searchFieldMinimumLengthError('referenceURL', 3) + ), + negative('CVE ID format', { cveIds: 'not-a-cve' }, error.cveIdsFormatError()), + negative('CVE ID length', { cveIds: `CVE-2024-${'1'.repeat(56)}` }, error.cveIdLengthError()), + negative( + 'CVE ID count limit', + { + cveIds: Array.from( + { length: constants.MAX_CVE_IDS + 1 }, + (_value, index) => `CVE-2024-${String(index + 1000).padStart(4, '0')}` + ).join(',') + }, + error.cveIdsLimitError() + ), + negative('resultsPerPage must be positive', { resultsPerPage: -1 }, error.resultsPerPageError()), + negative( + 'resultsPerPage maximum', + { resultsPerPage: constants.MAX_RESULTS_LIMIT + 1 }, + error.maxResultsError() + ), + negative('pageNumber must be positive', { pageNumber: 0 }, error.pageNumberError()), + negative('Pagination window limit', { resultsPerPage: 500, pageNumber: 21 }, error.searchWindowError()), + negative( + 'CVSS vector total length', + { cvssV3Metrics: 'A'.repeat(constants.MAX_CVSS_METRICS_LENGTH + 1) }, + error.cvssMetricsLimitError('cvssV3Metrics') + ), + negative( + 'CVSS vector token count', + { + cvssV4Metrics: Array.from( + { length: constants.MAX_CVSS_METRIC_TOKENS + 1 }, + (_value, index) => `T${index}:A` + ).join('/') + }, + error.cvssMetricsLimitError('cvssV4Metrics') + ), + negative( + 'CVSS vector token length', + { cvssV2Metrics: `AV:${'A'.repeat(constants.MAX_CVSS_METRIC_TOKEN_LENGTH + 1)}` }, + error.cvssMetricsLimitError('cvssV2Metrics') + ) + ] +); + +const booleanAndEnumFailures = folder( + '13 - Expected failures - booleans and enums', + 'Strict booleans and enum domains.', + [ + negative('isVulnerable boolean', { isVulnerable: 'yes' }, error.isVulnInputError()), + negative('keywordExactMatch boolean', { keywordExactMatch: 'yes' }, error.exactMatchInputError()), + negative('noRejected boolean', { noRejected: 'yes' }, error.noRejectedValueError()), + negative('knownExploited boolean', { knownExploited: 'true' }, error.knownExploitedInputError()), + negative('disputed boolean', { disputed: 'false' }, error.disputedInputError()), + negative('scope enum', { scope: 'CNA_AND_ADP' }, error.scopeValueError()), + negative('CVSS v2 severity enum', { cvssV2Severity: 'CRITICAL' }, error.cvssV2SeverityError('CRITICAL')), + negative('CVSS v3 severity enum', { cvssV3Severity: 'UNKNOWN' }, error.cvssV3SeverityError('UNKNOWN')), + negative('CVSS v3.0 severity enum', { cvssV3_0Severity: 'UNKNOWN' }, error.cvssV3_0SeverityError('UNKNOWN')), + negative('CVSS v3.1 severity enum', { cvssV3_1Severity: 'UNKNOWN' }, error.cvssV3_1SeverityError('UNKNOWN')), + negative('CVSS v4 severity enum', { cvssV4Severity: 'UNKNOWN' }, error.cvssV4SeverityError('UNKNOWN')), + negative( + 'SSVC exploitation enum', + { ssvcExploitation: 'UNKNOWN' }, + error.enumValueError('ssvcExploitation', ['NONE', 'POC', 'ACTIVE']) + ), + negative( + 'SSVC automatable enum', + { ssvcAutomatable: 'MAYBE' }, + error.enumValueError('ssvcAutomatable', ['YES', 'NO']) + ), + negative( + 'SSVC technical impact enum', + { ssvcTechnicalImpact: 'UNKNOWN' }, + error.enumValueError('ssvcTechnicalImpact', ['PARTIAL', 'TOTAL']) + ) + ] +); + +const dependencyFailures = folder( + '14 - Expected failures - dependent filters', + 'Filters whose validity depends on another field.', + [ + negative( + 'keywordExactMatch requires keywordSearch', + { keywordExactMatch: true }, + error.missingKeywordSearchError() + ), + negative('isVulnerable requires cpeName', { isVulnerable: true }, error.missingCpeNameError()), + negative( + 'isVulnerable conflicts with virtualMatchString', + { isVulnerable: true, virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' }, + error.virtualMatchVulnError() + ), + negative( + 'KEV start date requires knownExploited true', + { knownExploited: false, kevDateAddedStartDate: '2022-01-01T00:00:00.000Z' }, + error.kevDateRequiresKnownExploitedError() + ), + negative( + 'KEV end date requires knownExploited true', + { kevDateAddedEndDate: '2022-12-31T23:59:59.999Z' }, + error.kevDateRequiresKnownExploitedError() + ) + ] +); + +const cpeScopeFailures = []; +for (const param of [ + 'cpeName', + 'isVulnerable', + 'virtualMatchString', + 'versionStart', + 'versionStartType', + 'versionEnd', + 'versionEndType' +]) { + const values = { + cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', + isVulnerable: false, + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + versionStart: '2.2', + versionStartType: 'including', + versionEnd: '2.6', + versionEndType: 'excluding' + }; + cpeScopeFailures.push( + negative( + `ADP_ONLY rejects ${param}`, + { scope: 'ADP_ONLY', [param]: values[param] }, + error.scopeCompatibilityError('ADP_ONLY', [param]) + ) + ); +} + +const cpeAndVersionFailures = folder( + '15 - Expected failures - CPE and version ranges', + 'CPE formats, scope compatibility, and range pair/order rules.', + [ + ...cpeScopeFailures, + negative( + 'Invalid cpeName returns 404', + { cpeName: 'cpe:2.3:a:vendor:product:*' }, + error.invalidCpeNameError(), + { + status: 404 + } + ), + negative( + 'Invalid virtualMatchString', + { virtualMatchString: 'not-a-cpe' }, + error.virtualMatchStringFormatError() + ), + negative( + 'Version range requires virtualMatchString', + { versionStart: '1.0', versionStartType: 'including' }, + error.versionRangeVirtualMatchStringRequiredError() + ), + negative( + 'versionStart requires versionStartType', + { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0' }, + error.versionStartPairError() + ), + negative( + 'versionEnd requires versionEndType', + { virtualMatchString: 'cpe:2.3:a:vendor:product', versionEnd: '2.0' }, + error.versionEndPairError() + ), + negative( + 'Invalid version range type', + { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0', versionStartType: 'before' }, + error.versionRangeTypeError('versionStartType') + ), + negative( + 'Invalid ranged virtualMatchString format', + { virtualMatchString: 'not-a-cpe', versionStart: '1.0', versionStartType: 'including' }, + error.virtualMatchStringVersionRangeFormatError() + ), + negative( + 'Ranged virtualMatchString cannot contain version', + { virtualMatchString: 'cpe:2.3:a:vendor:product:1.0', versionStart: '1.0', versionStartType: 'including' }, + error.virtualMatchStringVersionComponentError() + ), + negative( + 'Inverted version range', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '2.0', + versionStartType: 'including', + versionEnd: '1.0', + versionEndType: 'including' + }, + error.versionRangeOrderError() + ), + negative( + 'Empty equal exclusive version range', + { + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '1.0', + versionStartType: 'excluding', + versionEnd: '1.0', + versionEndType: 'including' + }, + error.versionRangeOrderError() + ) + ] +); + +const dateFailures = folder('16 - Expected failures - dates', 'Timestamp format, calendar validity, and range order.', [ + negative('Partial timestamp', { pubStartDate: '2024-01-01' }, error.dateTimeError('pubStartDate')), + negative( + 'Impossible calendar date', + { lastModStartDate: '2024-02-30T00:00:00.000Z' }, + error.dateTimeError('lastModStartDate') + ), + negative( + 'Inverted publication date range', + { pubStartDate: '2024-12-31T00:00:00.000Z', pubEndDate: '2024-01-01T00:00:00.000Z' }, + error.dateRangeOrderError('pubStartDate', 'pubEndDate') + ), + negative( + 'Inverted modification date range', + { lastModStartDate: '2024-12-31T00:00:00.000Z', lastModEndDate: '2024-01-01T00:00:00.000Z' }, + error.dateRangeOrderError('lastModStartDate', 'lastModEndDate') + ), + negative( + 'Inverted SSVC timestamp range', + { ssvcTimestampStartDate: '2024-12-31T00:00:00.000Z', ssvcTimestampEndDate: '2024-01-01T00:00:00.000Z' }, + error.dateRangeOrderError('ssvcTimestampStartDate', 'ssvcTimestampEndDate') + ), + negative( + 'Inverted KEV date range', + { + knownExploited: true, + kevDateAddedStartDate: '2024-12-31T00:00:00.000Z', + kevDateAddedEndDate: '2024-01-01T00:00:00.000Z' + }, + error.dateRangeOrderError('kevDateAddedStartDate', 'kevDateAddedEndDate') + ) +]); + +function faultInjectionItem(name, requestPath, body, status, expectedError, description, method = 'POST') { + return item({ + name, + description, + method, + requestPath, + body, + skipUnless: 'runFaultInjection', + tests: errorTests(status, expectedError) + }); +} + +const faultInjection = folder( + '17 - Manual fault injection - skipped by default', + 'These requests require deliberate infrastructure or application configuration. Setting runFaultInjection=true without preparing the named condition will make them fail rather than create that condition.', + [ + faultInjectionItem( + 'OpenSearch readiness failure', + '/api/ready-check', + undefined, + 503, + error.searchNotReadyError(), + 'Run only while the API cannot reach or query its configured OpenSearch index.', + 'GET' + ), + faultInjectionItem( + 'OpenSearch unavailable search', + '/search', + { cveIds: ids.primary }, + 503, + error.openSearchError(), + 'Run only while OpenSearch connectivity is intentionally unavailable.' + ), + faultInjectionItem( + 'Search timeout', + '/search', + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' }, + 504, + error.searchTimeoutError(), + 'Run only with a deliberately reduced request deadline or delayed OpenSearch response.' + ), + faultInjectionItem( + 'Cold-search queue full', + '/search', + { cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', isVulnerable: true }, + 429, + error.searchQueueFullError(), + 'Requires concurrent unique cold CPE searches and a deliberately constrained queue.' + ), + faultInjectionItem( + 'Cold-search queue timeout', + '/search', + { cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', isVulnerable: true }, + 429, + error.searchQueueTimeoutError(), + 'Requires concurrent cold CPE searches and a deliberately short queue wait timeout.' + ), + faultInjectionItem( + 'Application rate limit', + '/search', + { cveIds: ids.primary }, + 429, + error.searchRateLimitedError(), + 'Requires the application limiter to be enabled with an exhausted allowance. A WAF response may use a different contract.' + ), + faultInjectionItem( + 'Unexpected upstream response', + '/search', + { cveIds: ids.primary }, + 502, + error.upstreamSearchError(), + 'Requires an intentionally malformed or unexpected OpenSearch response.' + ) + ] +); + +const folders = [ + serviceChecks, + baselineAndPagination, + scopeCoverage, + keywordAndRecordFilters, + cvssCoverage, + recordDates, + cpeCoverage, + ssvcCoverage, + kevCoverage, + referenceAndTags, + intentionalCombinations, + contractFailures, + limitFailures, + booleanAndEnumFailures, + dependencyFailures, + cpeAndVersionFailures, + dateFailures, + faultInjection +]; + +const collection = { + info: { + _postman_id: '28911974-2b15-4ddf-a88f-3fa6fe001a50', + name: 'CVE Search API - Manual Contract Tests', + description: + 'Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The deprecated /webSearch endpoint is intentionally excluded.', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + variable: [ + { key: 'baseUrl', value: 'http://localhost:4000', type: 'string' }, + { key: 'runFaultInjection', value: 'false', type: 'string' } + ], + item: folders +}; + +const documentedFields = Object.keys(openApi.components.schemas.SearchRequest.properties); +const acceptedFields = constants.SEARCH_QUERY_PARAMS; +const undocumentedFields = acceptedFields.filter((field) => !documentedFields.includes(field)); +const unsupportedDocumentedFields = documentedFields.filter((field) => !acceptedFields.includes(field)); +if (undocumentedFields.length || unsupportedDocumentedFields.length) { + throw new Error( + `SearchRequest and accepted search fields differ. Undocumented: ${undocumentedFields.join(', ') || 'none'}. Unsupported: ${unsupportedDocumentedFields.join(', ') || 'none'}.` + ); +} + +const missingFields = documentedFields.filter((field) => !positiveCoveredFields.has(field)); +if (missingFields.length) { + throw new Error(`Postman positive coverage is missing SearchRequest fields: ${missingFields.join(', ')}`); +} + +const environment = { + id: '5f714896-d2bb-45cb-9bfa-c7db3a4e1141', + name: 'CVE Search API - Local', + values: [ + { key: 'baseUrl', value: 'http://localhost:4000', type: 'default', enabled: true }, + { key: 'runFaultInjection', value: 'false', type: 'default', enabled: true } + ], + _postman_variable_scope: 'environment', + _postman_exported_at: '2026-08-20T00:00:00.000Z', + _postman_exported_using: 'CVE-Search-API test/postman/buildCollection.js' +}; + +const devEnvironment = { + id: 'abf7f52a-6cb0-4e52-9c1e-f07095a0de21', + name: 'CVE Search API - Dev', + values: [ + { key: 'baseUrl', value: 'https://dev.search.cve.org', type: 'default', enabled: true }, + { key: 'runFaultInjection', value: 'false', type: 'default', enabled: true } + ], + _postman_variable_scope: 'environment', + _postman_exported_at: '2026-08-20T00:00:00.000Z', + _postman_exported_using: 'CVE-Search-API test/postman/buildCollection.js' +}; + +function serialize(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function writeOrCheck(filePath, contents) { + if (process.argv.includes('--check')) { + const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; + if (existing !== contents) { + throw new Error(`${path.basename(filePath)} is out of date. Run node test/postman/buildCollection.js.`); + } + return; + } + + fs.mkdirSync(outputDirectory, { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +writeOrCheck(collectionPath, serialize(collection)); +writeOrCheck(environmentPath, serialize(environment)); +writeOrCheck(devEnvironmentPath, serialize(devEnvironment)); diff --git a/test/postman/dev.postman_environment.json b/test/postman/dev.postman_environment.json new file mode 100644 index 0000000..49a5e33 --- /dev/null +++ b/test/postman/dev.postman_environment.json @@ -0,0 +1,21 @@ +{ + "id": "abf7f52a-6cb0-4e52-9c1e-f07095a0de21", + "name": "CVE Search API - Dev", + "values": [ + { + "key": "baseUrl", + "value": "https://dev.search.cve.org", + "type": "default", + "enabled": true + }, + { + "key": "runFaultInjection", + "value": "false", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-08-20T00:00:00.000Z", + "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" +} diff --git a/test/postman/local.postman_environment.json b/test/postman/local.postman_environment.json new file mode 100644 index 0000000..9f584c1 --- /dev/null +++ b/test/postman/local.postman_environment.json @@ -0,0 +1,21 @@ +{ + "id": "5f714896-d2bb-45cb-9bfa-c7db3a4e1141", + "name": "CVE Search API - Local", + "values": [ + { + "key": "baseUrl", + "value": "http://localhost:4000", + "type": "default", + "enabled": true + }, + { + "key": "runFaultInjection", + "value": "false", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-08-20T00:00:00.000Z", + "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" +} From 8d5021f911e6fe6de9422d3539021720b62ba4a3 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 20 Aug 2026 10:31:18 -0400 Subject: [PATCH 18/27] Format generated Postman artifacts --- .../CVE-Search-API.postman_collection.json | 14792 +++++++--------- test/postman/buildCollection.js | 28 +- test/postman/dev.postman_environment.json | 28 +- test/postman/local.postman_environment.json | 28 +- 4 files changed, 6489 insertions(+), 8387 deletions(-) diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 21ea532..4153034 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -1,8382 +1,6486 @@ { - "info": { - "_postman_id": "28911974-2b15-4ddf-a88f-3fa6fe001a50", - "name": "CVE Search API - Manual Contract Tests", - "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The deprecated /webSearch endpoint is intentionally excluded.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:4000", - "type": "string" + "info": { + "_postman_id": "28911974-2b15-4ddf-a88f-3fa6fe001a50", + "name": "CVE Search API - Manual Contract Tests", + "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The deprecated /webSearch endpoint is intentionally excluded.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, - { - "key": "runFaultInjection", - "value": "false", - "type": "string" - } - ], - "item": [ - { - "name": "00 - Service checks", - "description": "Basic API, dependency, and contract availability.", - "item": [ - { - "name": "Liveness returns 200", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/health-check", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "health-check" - ], - "query": [] - } - }, - "response": [] - }, - { - "name": "Readiness returns 200", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/ready-check", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "ready-check" - ], - "query": [] - } - }, - "response": [] - }, - { - "name": "OpenAPI document is available", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const document = pm.response.json();", - "pm.test('OpenAPI includes POST /search', () => pm.expect(document.paths?.['/search']?.post).to.be.an('object'));" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api-docs/openapi.json", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api-docs", - "openapi.json" - ], - "query": [] - } - }, - "response": [] - } - ] - }, - { - "name": "01 - Baseline and pagination", - "description": "Default search behavior and shallow-pagination boundaries.", - "item": [ - { - "name": "No request body", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - } - }, - "response": [] - }, - { - "name": "Default empty-object search", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Single CVE ID", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Multiple CVE IDs", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"resultsPerPage\": 2,\n \"pageNumber\": 1\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Second valid page can be empty", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Maximum valid search window boundary", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 500,\n \"pageNumber\": 20\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "02 - Scope coverage", - "description": "Base, CNA, and ADP matching behavior.", - "item": [ - { - "name": "FULL scope accepts lowercase input", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"full\",\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CNA_ONLY keyword match", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"cna_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY excludes a CNA-only keyword match", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"adp_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Base CVE ID participates in ADP_ONLY", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CNA_ONLY CWE match", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY CWE match", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "03 - Keyword and record filters", - "description": "Description, ID, and record-state filters.", - "item": [ - { - "name": "keywordSearch", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "keywordExactMatch true", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "keywordExactMatch false without keyword", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordExactMatch\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "noRejected true excludes rejected records", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": true,\n \"resultsPerPage\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "noRejected false does not filter", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": false,\n \"resultsPerPage\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cweId", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "04 - CVSS filters", - "description": "Every supported CVSS severity and vector filter.", - "item": [ - { - "name": "cvssV2Severity is case-insensitive", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Severity\": \"medium\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV2Metrics", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Metrics\": \"AV:N/AC:L/Au:S\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV3Severity", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Severity\": \"medium\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV3Metrics", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Metrics\": \"AV:N/AC:L/PR:L\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV3_0Severity", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Severity\": \"medium\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV3_0Metrics", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Metrics\": \"CVSS:3.0/AV:N/AC:L/PR:L\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV3_1Severity", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Severity\": \"medium\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV3_1Metrics", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Metrics\": \"CVSS:3.1/AV:N/AC:L/PR:L\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV4Severity", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Severity\": \"medium\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cvssV4Metrics", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Metrics\": \"CVSS:4.0/AV:N/AC:L/AT:N\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP CVSS and SSVC must match selected containers", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cvssV3_1Severity\": \"high\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"none\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "05 - Record date filters", - "description": "Publication and modification boundaries.", - "item": [ - { - "name": "pubStartDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "pubEndDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Publication date range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "lastModStartDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "lastModEndDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Modification date range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "06 - CPE filters", - "description": "Concrete CPE and virtual match-string behavior.", - "item": [ - { - "name": "cpeName", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "cpeName with isVulnerable true", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "isVulnerable false without cpeName", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"isVulnerable\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "virtualMatchString", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "virtualMatchString with versionStart pair", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"INCLUDING\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "virtualMatchString with versionEnd pair", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "virtualMatchString with complete range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "07 - SSVC filters", - "description": "Individual and same-entry SSVC matching.", - "item": [ - { - "name": "ssvcExploitation", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"none\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ssvcAutomatable", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcAutomatable\": \"no\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ssvcTechnicalImpact", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTechnicalImpact\": \"partial\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ssvcRole is case-insensitive", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcRole\": \"cisa coordinator\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ssvcTimestampStartDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ssvcTimestampEndDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Complete SSVC same-entry combination", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"PARTIAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "08 - KEV filters", - "description": "Structured KEV presence and inclusive date filtering.", - "item": [ - { - "name": "knownExploited true", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "knownExploited false", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "kevDateAddedStartDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "kevDateAddedEndDate", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Inclusive KEV date range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "09 - Reference and dispute filters", - "description": "Reference URL substring and disputed-tag behavior.", - "item": [ - { - "name": "referenceURL FULL", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "referenceURL CNA_ONLY", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "referenceURL ADP_ONLY", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "referenceURL treats wildcard characters literally", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", - "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/*/cve/issues/1\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "disputed true", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-38625\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2022-38625\",\n \"disputed\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "disputed false", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "10 - Intentional combinations", - "description": "Representative AND combinations across related and independent filter groups.", - "item": [ - { - "name": "CNA keyword, CWE, CVSS, dates, and status", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweId\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP CWE, CVSS, SSVC, and reference", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "FULL cross-container KEV, SSVC, CVSS, and reference", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 200', () => pm.response.to.have.status(200));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "const isExpectedSuccess = pm.response.code === 200;", - "if (isExpectedSuccess) {", - " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - " const payload = pm.response.json();", - "pm.test('search response has the documented shape', () => {", - " pm.expect(payload.status).to.eql('ok');", - " pm.expect(payload.page).to.be.a('number');", - " pm.expect(payload.pageSize).to.be.a('number');", - " pm.expect(payload.totalPages).to.be.a('number');", - " pm.expect(payload.data).to.be.an('object');", - " pm.expect(payload.data.hits.total.value).to.be.a('number');", - " pm.expect(payload.data.hits.hits).to.be.an('array');", - "});", - "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", - " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", - "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", - "}" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"FULL\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\",\n \"ssvcExploitation\": \"ACTIVE\",\n \"ssvcAutomatable\": \"YES\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"referenceURL\": \"cisa.gov/known-exploited-vulnerabilities-catalog\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "11 - Expected failures - request contract", - "description": "Transport and JSON body failures.", - "item": [ - { - "name": "Query parameters are rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_QUERY_UNSUPPORTED\",\"message\":\"The /search endpoint accepts JSON request body fields, not query parameters.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search?keywordSearch=apache", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [ - { - "key": "keywordSearch", - "value": "apache" - } - ] - }, - "body": { - "mode": "raw", - "raw": "{}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Non-JSON content type is rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 415', () => pm.response.to.have.status(415));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_CONTENT_TYPE\",\"message\":\"The /search request body must use the application/json media type with UTF-8 encoding.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "text/plain", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{}", - "options": { - "raw": { - "language": "text" - } - } - } - }, - "response": [] - }, - { - "name": "Malformed JSON is rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\"keywordSearch\":", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Array body is rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "[]", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Top-level JSON string is rejected by the parser", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "\"search\"", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Unsupported body field is rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"unsupportedFilter\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Empty string field is rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"keywordSearch\": \" \"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Array-valued enum is rejected", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": [\n \"FULL\"\n ]\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "12 - Expected failures - limits and pagination", - "description": "Input complexity and result-window limits.", - "item": [ - { - "name": "String length limit", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"keywordSearch\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Keyword token limit", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"keywordSearch\": \"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Reference URL minimum length", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"referenceURL\": \"ab\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVE ID format", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVE_IDS_FORMAT\",\"message\":\"The cveIds parameter must be a comma-separated list of CVE IDs.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"not-a-cve\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVE ID length", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVE ID count limit", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-1000,CVE-2024-1001,CVE-2024-1002,CVE-2024-1003,CVE-2024-1004,CVE-2024-1005,CVE-2024-1006,CVE-2024-1007,CVE-2024-1008,CVE-2024-1009,CVE-2024-1010,CVE-2024-1011,CVE-2024-1012,CVE-2024-1013,CVE-2024-1014,CVE-2024-1015,CVE-2024-1016,CVE-2024-1017,CVE-2024-1018,CVE-2024-1019,CVE-2024-1020,CVE-2024-1021,CVE-2024-1022,CVE-2024-1023,CVE-2024-1024,CVE-2024-1025,CVE-2024-1026,CVE-2024-1027,CVE-2024-1028,CVE-2024-1029,CVE-2024-1030,CVE-2024-1031,CVE-2024-1032,CVE-2024-1033,CVE-2024-1034,CVE-2024-1035,CVE-2024-1036,CVE-2024-1037,CVE-2024-1038,CVE-2024-1039,CVE-2024-1040,CVE-2024-1041,CVE-2024-1042,CVE-2024-1043,CVE-2024-1044,CVE-2024-1045,CVE-2024-1046,CVE-2024-1047,CVE-2024-1048,CVE-2024-1049,CVE-2024-1050,CVE-2024-1051,CVE-2024-1052,CVE-2024-1053,CVE-2024-1054,CVE-2024-1055,CVE-2024-1056,CVE-2024-1057,CVE-2024-1058,CVE-2024-1059,CVE-2024-1060,CVE-2024-1061,CVE-2024-1062,CVE-2024-1063,CVE-2024-1064,CVE-2024-1065,CVE-2024-1066,CVE-2024-1067,CVE-2024-1068,CVE-2024-1069,CVE-2024-1070,CVE-2024-1071,CVE-2024-1072,CVE-2024-1073,CVE-2024-1074,CVE-2024-1075,CVE-2024-1076,CVE-2024-1077,CVE-2024-1078,CVE-2024-1079,CVE-2024-1080,CVE-2024-1081,CVE-2024-1082,CVE-2024-1083,CVE-2024-1084,CVE-2024-1085,CVE-2024-1086,CVE-2024-1087,CVE-2024-1088,CVE-2024-1089,CVE-2024-1090,CVE-2024-1091,CVE-2024-1092,CVE-2024-1093,CVE-2024-1094,CVE-2024-1095,CVE-2024-1096,CVE-2024-1097,CVE-2024-1098,CVE-2024-1099,CVE-2024-1100\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "resultsPerPage must be positive", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"resultsPerPage\": -1\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "resultsPerPage maximum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"resultsPerPage\": 501\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "pageNumber must be positive", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"pageNumber\": 0\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Pagination window limit", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS vector total length", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS vector token count", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV4Metrics\": \"T0:A/T1:A/T2:A/T3:A/T4:A/T5:A/T6:A/T7:A/T8:A/T9:A/T10:A/T11:A/T12:A/T13:A/T14:A/T15:A/T16:A/T17:A/T18:A/T19:A/T20:A/T21:A/T22:A/T23:A/T24:A/T25:A/T26:A/T27:A/T28:A/T29:A/T30:A/T31:A/T32:A\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS vector token length", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "13 - Expected failures - booleans and enums", - "description": "Strict booleans and enum domains.", - "item": [ - { - "name": "isVulnerable boolean", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"isVulnerable\": \"yes\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "keywordExactMatch boolean", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"keywordExactMatch\": \"yes\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "noRejected boolean", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"noRejected\": \"yes\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "knownExploited boolean", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"knownExploited\": \"true\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "disputed boolean", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"disputed\": \"false\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "scope enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"CNA_AND_ADP\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS v2 severity enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV2_SEVERITY\",\"message\":\"CRITICAL is not an allowed input for the cvssV2Severity. Please use LOW, MEDIUM, or HIGH.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV2Severity\": \"CRITICAL\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS v3 severity enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV3Severity\": \"UNKNOWN\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS v3.0 severity enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV3_0Severity\": \"UNKNOWN\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS v3.1 severity enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV3_1Severity\": \"UNKNOWN\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "CVSS v4 severity enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cvssV4Severity\": \"UNKNOWN\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "SSVC exploitation enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"ssvcExploitation\": \"UNKNOWN\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "SSVC automatable enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"ssvcAutomatable\": \"MAYBE\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "SSVC technical impact enum", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"ssvcTechnicalImpact\": \"UNKNOWN\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "14 - Expected failures - dependent filters", - "description": "Filters whose validity depends on another field.", - "item": [ - { - "name": "keywordExactMatch requires keywordSearch", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MISSING_KEYWORD_SEARCH_PARAM\",\"message\":\"The keywordSearch parameter is required when the keywordExactMatch parameter is provided.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"keywordExactMatch\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "isVulnerable requires cpeName", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"MISSING_CPE_NAME_PARAM\",\"message\":\"The cpeName parameter is required when the isVulnerable parameter is provided.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"isVulnerable\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "isVulnerable conflicts with virtualMatchString", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_AND_IS_VULN\",\"message\":\"The virtualMatchString parameter is not allowed when the isVulnerable parameter is provided. Please use cpeName instead.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"isVulnerable\": true,\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "KEV start date requires knownExploited true", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"knownExploited\": false,\n \"kevDateAddedStartDate\": \"2022-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "KEV end date requires knownExploited true", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"kevDateAddedEndDate\": \"2022-12-31T23:59:59.999Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "15 - Expected failures - CPE and version ranges", - "description": "CPE formats, scope compatibility, and range pair/order rules.", - "item": [ - { - "name": "ADP_ONLY rejects cpeName", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY rejects isVulnerable", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"isVulnerable\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY rejects virtualMatchString", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY rejects versionStart", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStart\": \"2.2\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY rejects versionStartType", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStartType\": \"including\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY rejects versionEnd", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEnd\": \"2.6\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "ADP_ONLY rejects versionEndType", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEndType\": \"excluding\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Invalid cpeName returns 404", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 404', () => pm.response.to.have.status(404));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor:product:*\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Invalid virtualMatchString", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must be a CPE 2.3 match string.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"not-a-cpe\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "Version range requires virtualMatchString", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_RANGE_VIRTUAL_MATCH_STRING_REQUIRED\",\"message\":\"The virtualMatchString parameter is required when versionStart, versionStartType, versionEnd, or versionEndType is provided.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - }, - { - "name": "versionStart requires versionStartType", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "variable": [ + { "key": "baseUrl", "value": "http://localhost:4000", "type": "string" }, + { "key": "runFaultInjection", "value": "false", "type": "string" } + ], + "item": [ + { + "name": "00 - Service checks", + "description": "Basic API, dependency, and contract availability.", + "item": [ + { + "name": "Liveness returns 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/health-check", + "host": ["{{baseUrl}}"], + "path": ["api", "health-check"], + "query": [] + } + }, + "response": [] + }, + { + "name": "Readiness returns 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ready-check", + "host": ["{{baseUrl}}"], + "path": ["api", "ready-check"], + "query": [] + } + }, + "response": [] + }, + { + "name": "OpenAPI document is available", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const document = pm.response.json();", + "pm.test('OpenAPI includes POST /search', () => pm.expect(document.paths?.['/search']?.post).to.be.an('object'));" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api-docs/openapi.json", + "host": ["{{baseUrl}}"], + "path": ["api-docs", "openapi.json"], + "query": [] + } + }, + "response": [] + } + ] }, { - "name": "versionEnd requires versionEndType", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionEnd\": \"2.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "01 - Baseline and pagination", + "description": "Default search behavior and shallow-pagination boundaries.", + "item": [ + { + "name": "No request body", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [], + "url": { "raw": "{{baseUrl}}/search", "host": ["{{baseUrl}}"], "path": ["search"], "query": [] } + }, + "response": [] + }, + { + "name": "Default empty-object search", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { "mode": "raw", "raw": "{}", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Single CVE ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Multiple CVE IDs", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"resultsPerPage\": 2,\n \"pageNumber\": 1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Second valid page can be empty", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 2\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Maximum valid search window boundary", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"resultsPerPage\": 500,\n \"pageNumber\": 20\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Invalid version range type", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_RANGE_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"before\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "02 - Scope coverage", + "description": "Base, CNA, and ADP matching behavior.", + "item": [ + { + "name": "FULL scope accepts lowercase input", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"full\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CNA_ONLY keyword match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"cna_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY excludes a CNA-only keyword match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"adp_only\",\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Base CVE ID participates in ADP_ONLY", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CNA_ONLY CWE match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY CWE match", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Invalid ranged virtualMatchString format", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_RANGE_FORMAT\",\"message\":\"The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"not-a-cpe\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "03 - Keyword and record filters", + "description": "Description, ID, and record-state filters.", + "item": [ + { + "name": "keywordSearch", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch false without keyword", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"keywordExactMatch\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "noRejected true excludes rejected records", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": true,\n \"resultsPerPage\": 2\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "noRejected false does not filter", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\",\"CVE-2022-31279\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981,CVE-2022-31279\",\n \"noRejected\": false,\n \"resultsPerPage\": 2\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cweId", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Ranged virtualMatchString cannot contain version", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_VERSION_COMPONENT\",\"message\":\"The virtualMatchString parameter cannot include a version component when versionStart or versionEnd is provided.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product:1.0\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "04 - CVSS filters", + "description": "Every supported CVSS severity and vector filter.", + "item": [ + { + "name": "cvssV2Severity is case-insensitive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV2Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV2Metrics\": \"AV:N/AC:L/Au:S\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3Metrics\": \"AV:N/AC:L/PR:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_0Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_0Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_0Metrics\": \"CVSS:3.0/AV:N/AC:L/PR:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_1Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV3_1Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV3_1Metrics\": \"CVSS:3.1/AV:N/AC:L/PR:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV4Severity", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Severity\": \"medium\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cvssV4Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cvssV4Metrics\": \"CVSS:4.0/AV:N/AC:L/AT:N\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP CVSS and SSVC must match selected containers", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cvssV3_1Severity\": \"high\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"none\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Inverted version range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"2.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "05 - Record date filters", + "description": "Publication and modification boundaries.", + "item": [ + { + "name": "pubStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "pubEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Publication date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "lastModStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "lastModEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Modification date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Empty equal exclusive version range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"excluding\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "16 - Expected failures - dates", - "description": "Timestamp format, calendar validity, and range order.", - "item": [ - { - "name": "Partial timestamp", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"pubStartDate\": \"2024-01-01\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "06 - CPE filters", + "description": "Concrete CPE and virtual match-string behavior.", + "item": [ + { + "name": "cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "cpeName with isVulnerable true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "isVulnerable false without cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"isVulnerable\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with versionStart pair", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"INCLUDING\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with versionEnd pair", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "virtualMatchString with complete range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2025-23022\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2025-23022\",\n \"virtualMatchString\": \"cpe:2.3:a:freetype:freetype\",\n \"versionStart\": \"2.8\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.9\",\n \"versionEndType\": \"excluding\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Impossible calendar date", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"lastModStartDate\": \"2024-02-30T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "07 - SSVC filters", + "description": "Individual and same-entry SSVC matching.", + "item": [ + { + "name": "ssvcExploitation", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"none\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcAutomatable", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcAutomatable\": \"no\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcTechnicalImpact", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTechnicalImpact\": \"partial\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcRole is case-insensitive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcRole\": \"cisa coordinator\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcTimestampStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ssvcTimestampEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Complete SSVC same-entry combination", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"PARTIAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"ssvcTimestampStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2030-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Inverted publication date range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The pubStartDate parameter must be before or equal to pubEndDate.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"pubStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"pubEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "08 - KEV filters", + "description": "Structured KEV presence and inclusive date filtering.", + "item": [ + { + "name": "knownExploited true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited false", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "kevDateAddedStartDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "kevDateAddedEndDate", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inclusive KEV date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Inverted modification date range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The lastModStartDate parameter must be before or equal to lastModEndDate.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"lastModStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"lastModEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "09 - Reference and dispute filters", + "description": "Reference URL substring and disputed-tag behavior.", + "item": [ + { + "name": "referenceURL FULL", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "referenceURL CNA_ONLY", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"GITHUB.COM/LIUYONGXIANG-GIT/CVE/ISSUES/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "referenceURL ADP_ONLY", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/liuyongxiang-git/cve/issues/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "referenceURL treats wildcard characters literally", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", + "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"referenceURL\": \"github.com/*/cve/issues/1\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-38625\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2022-38625\",\n \"disputed\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed false", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Inverted SSVC timestamp range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The ssvcTimestampStartDate parameter must be before or equal to ssvcTimestampEndDate.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"ssvcTimestampStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "10 - Intentional combinations", + "description": "Representative AND combinations across related and independent filter groups.", + "item": [ + { + "name": "CNA keyword, CWE, CVSS, dates, and status", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweId\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP CWE, CVSS, SSVC, and reference", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2022-31640\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "FULL cross-container KEV, SSVC, CVSS, and reference", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.page).to.be.a('number');", + " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.totalPages).to.be.a('number');", + " pm.expect(payload.data).to.be.an('object');", + " pm.expect(payload.data.hits.total.value).to.be.a('number');", + " pm.expect(payload.data.hits.hits).to.be.an('array');", + "});", + "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", + " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2020-1472\"]));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"FULL\",\n \"cveIds\": \"CVE-2020-1472\",\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2021-11-03T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2021-11-03T00:00:00.000Z\",\n \"ssvcExploitation\": \"ACTIVE\",\n \"ssvcAutomatable\": \"YES\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"referenceURL\": \"cisa.gov/known-exploited-vulnerabilities-catalog\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Inverted KEV date range", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 400', () => pm.response.to.have.status(400));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The kevDateAddedStartDate parameter must be before or equal to kevDateAddedEndDate.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "body": { - "mode": "raw", - "raw": "{\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] - } - ] - }, - { - "name": "17 - Manual fault injection - skipped by default", - "description": "These requests require deliberate infrastructure or application configuration. Setting runFaultInjection=true without preparing the named condition will make them fail rather than create that condition.", - "item": [ - { - "name": "OpenSearch readiness failure", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping OpenSearch readiness failure; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 503', () => pm.response.to.have.status(503));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_NOT_READY\",\"message\":\"Search service is not ready. Please try again later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/ready-check", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "ready-check" - ], - "query": [] - }, - "description": "Run only while the API cannot reach or query its configured OpenSearch index." - }, - "response": [] + "name": "11 - Expected failures - request contract", + "description": "Transport and JSON body failures.", + "item": [ + { + "name": "Query parameters are rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_QUERY_UNSUPPORTED\",\"message\":\"The /search endpoint accepts JSON request body fields, not query parameters.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search?keywordSearch=apache", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [{ "key": "keywordSearch", "value": "apache" }] + }, + "body": { "mode": "raw", "raw": "{}", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Non-JSON content type is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 415', () => pm.response.to.have.status(415));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_CONTENT_TYPE\",\"message\":\"The /search request body must use the application/json media type with UTF-8 encoding.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "text/plain", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { "mode": "raw", "raw": "{}", "options": { "raw": { "language": "text" } } } + }, + "response": [] + }, + { + "name": "Malformed JSON is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\"keywordSearch\":", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Array body is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { "mode": "raw", "raw": "[]", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Top-level JSON string is rejected by the parser", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { "mode": "raw", "raw": "\"search\"", "options": { "raw": { "language": "json" } } } + }, + "response": [] + }, + { + "name": "Unsupported body field is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"unsupportedFilter\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Empty string field is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordSearch\": \" \"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Array-valued enum is rejected", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": [\n \"FULL\"\n ]\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "OpenSearch unavailable search", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping OpenSearch unavailable search; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 503', () => pm.response.to.have.status(503));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"OPEN_SEARCH_ERROR\",\"message\":\"OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "description": "Run only while OpenSearch connectivity is intentionally unavailable.", - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "12 - Expected failures - limits and pagination", + "description": "Input complexity and result-window limits.", + "item": [ + { + "name": "String length limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordSearch\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Keyword token limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordSearch\": \"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Reference URL minimum length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"referenceURL\": \"ab\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID format", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVE_IDS_FORMAT\",\"message\":\"The cveIds parameter must be a comma-separated list of CVE IDs.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"not-a-cve\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID count limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-1000,CVE-2024-1001,CVE-2024-1002,CVE-2024-1003,CVE-2024-1004,CVE-2024-1005,CVE-2024-1006,CVE-2024-1007,CVE-2024-1008,CVE-2024-1009,CVE-2024-1010,CVE-2024-1011,CVE-2024-1012,CVE-2024-1013,CVE-2024-1014,CVE-2024-1015,CVE-2024-1016,CVE-2024-1017,CVE-2024-1018,CVE-2024-1019,CVE-2024-1020,CVE-2024-1021,CVE-2024-1022,CVE-2024-1023,CVE-2024-1024,CVE-2024-1025,CVE-2024-1026,CVE-2024-1027,CVE-2024-1028,CVE-2024-1029,CVE-2024-1030,CVE-2024-1031,CVE-2024-1032,CVE-2024-1033,CVE-2024-1034,CVE-2024-1035,CVE-2024-1036,CVE-2024-1037,CVE-2024-1038,CVE-2024-1039,CVE-2024-1040,CVE-2024-1041,CVE-2024-1042,CVE-2024-1043,CVE-2024-1044,CVE-2024-1045,CVE-2024-1046,CVE-2024-1047,CVE-2024-1048,CVE-2024-1049,CVE-2024-1050,CVE-2024-1051,CVE-2024-1052,CVE-2024-1053,CVE-2024-1054,CVE-2024-1055,CVE-2024-1056,CVE-2024-1057,CVE-2024-1058,CVE-2024-1059,CVE-2024-1060,CVE-2024-1061,CVE-2024-1062,CVE-2024-1063,CVE-2024-1064,CVE-2024-1065,CVE-2024-1066,CVE-2024-1067,CVE-2024-1068,CVE-2024-1069,CVE-2024-1070,CVE-2024-1071,CVE-2024-1072,CVE-2024-1073,CVE-2024-1074,CVE-2024-1075,CVE-2024-1076,CVE-2024-1077,CVE-2024-1078,CVE-2024-1079,CVE-2024-1080,CVE-2024-1081,CVE-2024-1082,CVE-2024-1083,CVE-2024-1084,CVE-2024-1085,CVE-2024-1086,CVE-2024-1087,CVE-2024-1088,CVE-2024-1089,CVE-2024-1090,CVE-2024-1091,CVE-2024-1092,CVE-2024-1093,CVE-2024-1094,CVE-2024-1095,CVE-2024-1096,CVE-2024-1097,CVE-2024-1098,CVE-2024-1099,CVE-2024-1100\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "resultsPerPage must be positive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": -1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "resultsPerPage maximum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 501\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "pageNumber must be positive", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pageNumber\": 0\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Pagination window limit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector total length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector token count", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV4Metrics\": \"T0:A/T1:A/T2:A/T3:A/T4:A/T5:A/T6:A/T7:A/T8:A/T9:A/T10:A/T11:A/T12:A/T13:A/T14:A/T15:A/T16:A/T17:A/T18:A/T19:A/T20:A/T21:A/T22:A/T23:A/T24:A/T25:A/T26:A/T27:A/T28:A/T29:A/T30:A/T31:A/T32:A\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector token length", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Search timeout", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Search timeout; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 504', () => pm.response.to.have.status(504));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_TIMEOUT\",\"message\":\"Search request timed out. Please try again later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "description": "Run only with a deliberately reduced request deadline or delayed OpenSearch response.", - "body": { - "mode": "raw", - "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "13 - Expected failures - booleans and enums", + "description": "Strict booleans and enum domains.", + "item": [ + { + "name": "isVulnerable boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"isVulnerable\": \"yes\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "keywordExactMatch boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordExactMatch\": \"yes\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "noRejected boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"noRejected\": \"yes\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"knownExploited\": \"true\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed boolean", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"disputed\": \"false\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "scope enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"CNA_AND_ADP\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v2 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV2_SEVERITY\",\"message\":\"CRITICAL is not an allowed input for the cvssV2Severity. Please use LOW, MEDIUM, or HIGH.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV2Severity\": \"CRITICAL\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v3 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v3.0 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_0Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v3.1 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV3_1Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS v4 severity enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cvssV4Severity\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "SSVC exploitation enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcExploitation\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "SSVC automatable enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcAutomatable\": \"MAYBE\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "SSVC technical impact enum", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcTechnicalImpact\": \"UNKNOWN\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Cold-search queue full", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Cold-search queue full; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 429', () => pm.response.to.have.status(429));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_QUEUE_FULL\",\"message\":\"Search service is busy. Please retry later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "description": "Requires concurrent unique cold CPE searches and a deliberately constrained queue.", - "body": { - "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "14 - Expected failures - dependent filters", + "description": "Filters whose validity depends on another field.", + "item": [ + { + "name": "keywordExactMatch requires keywordSearch", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MISSING_KEYWORD_SEARCH_PARAM\",\"message\":\"The keywordSearch parameter is required when the keywordExactMatch parameter is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"keywordExactMatch\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "isVulnerable requires cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"MISSING_CPE_NAME_PARAM\",\"message\":\"The cpeName parameter is required when the isVulnerable parameter is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"isVulnerable\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "isVulnerable conflicts with virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_AND_IS_VULN\",\"message\":\"The virtualMatchString parameter is not allowed when the isVulnerable parameter is provided. Please use cpeName instead.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"isVulnerable\": true,\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "KEV start date requires knownExploited true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"knownExploited\": false,\n \"kevDateAddedStartDate\": \"2022-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "KEV end date requires knownExploited true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"kevDateAddedEndDate\": \"2022-12-31T23:59:59.999Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Cold-search queue timeout", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Cold-search queue timeout; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 429', () => pm.response.to.have.status(429));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_QUEUE_TIMEOUT\",\"message\":\"Search service is busy. Please retry later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "description": "Requires concurrent cold CPE searches and a deliberately short queue wait timeout.", - "body": { - "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "15 - Expected failures - CPE and version ranges", + "description": "CPE formats, scope compatibility, and range pair/order rules.", + "item": [ + { + "name": "ADP_ONLY rejects cpeName", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects isVulnerable", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"isVulnerable\": false\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionStart", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStart\": \"2.2\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionStartType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionEnd", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEnd\": \"2.6\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "ADP_ONLY rejects versionEndType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"versionEndType\": \"excluding\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid cpeName returns 404", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 404', () => pm.response.to.have.status(404));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:vendor:product:*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must be a CPE 2.3 match string.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"not-a-cpe\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Version range requires virtualMatchString", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_VIRTUAL_MATCH_STRING_REQUIRED\",\"message\":\"The virtualMatchString parameter is required when versionStart, versionStartType, versionEnd, or versionEndType is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "versionStart requires versionStartType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "versionEnd requires versionEndType", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionEnd\": \"2.0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid version range type", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"before\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid ranged virtualMatchString format", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_RANGE_FORMAT\",\"message\":\"The virtualMatchString parameter must be a partial CPE 2.3 match string when used with version range parameters.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"not-a-cpe\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Ranged virtualMatchString cannot contain version", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_VERSION_COMPONENT\",\"message\":\"The virtualMatchString parameter cannot include a version component when versionStart or versionEnd is provided.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product:1.0\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted version range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"2.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Empty equal exclusive version range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:vendor:product\",\n \"versionStart\": \"1.0\",\n \"versionStartType\": \"excluding\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Application rate limit", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Application rate limit; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 429', () => pm.response.to.have.status(429));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_RATE_LIMITED\",\"message\":\"Too many search requests. Please retry later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "description": "Requires the application limiter to be enabled with an exhausted allowance. A WAF response may use a different contract.", - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "16 - Expected failures - dates", + "description": "Timestamp format, calendar validity, and range order.", + "item": [ + { + "name": "Partial timestamp", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pubStartDate\": \"2024-01-01\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Impossible calendar date", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"lastModStartDate\": \"2024-02-30T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted publication date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The pubStartDate parameter must be before or equal to pubEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"pubStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"pubEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted modification date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The lastModStartDate parameter must be before or equal to lastModEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"lastModStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"lastModEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted SSVC timestamp range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The ssvcTimestampStartDate parameter must be before or equal to ssvcTimestampEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"ssvcTimestampStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"ssvcTimestampEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted KEV date range", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The kevDateAddedStartDate parameter must be before or equal to kevDateAddedEndDate.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"knownExploited\": true,\n \"kevDateAddedStartDate\": \"2024-12-31T00:00:00.000Z\",\n \"kevDateAddedEndDate\": \"2024-01-01T00:00:00.000Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] }, { - "name": "Unexpected upstream response", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Unexpected upstream response; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 502', () => pm.response.to.have.status(502));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"UPSTREAM_SEARCH_ERROR\",\"message\":\"Search backend returned an error. Please try again later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/search", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "search" - ], - "query": [] - }, - "description": "Requires an intentionally malformed or unexpected OpenSearch response.", - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { - "raw": { - "language": "json" - } - } - } - }, - "response": [] + "name": "17 - Manual fault injection - skipped by default", + "description": "These requests require deliberate infrastructure or application configuration. Setting runFaultInjection=true without preparing the named condition will make them fail rather than create that condition.", + "item": [ + { + "name": "OpenSearch readiness failure", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping OpenSearch readiness failure; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 503', () => pm.response.to.have.status(503));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_NOT_READY\",\"message\":\"Search service is not ready. Please try again later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ready-check", + "host": ["{{baseUrl}}"], + "path": ["api", "ready-check"], + "query": [] + }, + "description": "Run only while the API cannot reach or query its configured OpenSearch index." + }, + "response": [] + }, + { + "name": "OpenSearch unavailable search", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping OpenSearch unavailable search; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 503', () => pm.response.to.have.status(503));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"OPEN_SEARCH_ERROR\",\"message\":\"OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Run only while OpenSearch connectivity is intentionally unavailable.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Search timeout", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Search timeout; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 504', () => pm.response.to.have.status(504));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_TIMEOUT\",\"message\":\"Search request timed out. Please try again later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Run only with a deliberately reduced request deadline or delayed OpenSearch response.", + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Cold-search queue full", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Cold-search queue full; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 429', () => pm.response.to.have.status(429));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_QUEUE_FULL\",\"message\":\"Search service is busy. Please retry later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Requires concurrent unique cold CPE searches and a deliberately constrained queue.", + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Cold-search queue timeout", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Cold-search queue timeout; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 429', () => pm.response.to.have.status(429));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_QUEUE_TIMEOUT\",\"message\":\"Search service is busy. Please retry later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Requires concurrent cold CPE searches and a deliberately short queue wait timeout.", + "body": { + "mode": "raw", + "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Application rate limit", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Application rate limit; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 429', () => pm.response.to.have.status(429));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"SEARCH_RATE_LIMITED\",\"message\":\"Too many search requests. Please retry later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Requires the application limiter to be enabled with an exhausted allowance. A WAF response may use a different contract.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Unexpected upstream response", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", + " console.log('Skipping Unexpected upstream response; set runFaultInjection=true to enable it.');", + " pm.execution.skipRequest();", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 502', () => pm.response.to.have.status(502));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"UPSTREAM_SEARCH_ERROR\",\"message\":\"Search backend returned an error. Please try again later.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Requires an intentionally malformed or unexpected OpenSearch response.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + } + ] } - ] - } - ] + ] } diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 689f994..7d511c7 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const prettier = require('prettier'); const constants = require('../../utils/constants'); const { errors: SearchErrors } = require('../../utils/errors'); const openApi = require('../../api-docs/openapi.json'); @@ -1218,8 +1219,11 @@ const devEnvironment = { _postman_exported_using: 'CVE-Search-API test/postman/buildCollection.js' }; -function serialize(value) { - return `${JSON.stringify(value, null, 2)}\n`; +async function serialize(value, filePath, prettierConfig) { + return prettier.format(JSON.stringify(value), { + ...prettierConfig, + filepath: filePath + }); } function writeOrCheck(filePath, contents) { @@ -1235,6 +1239,20 @@ function writeOrCheck(filePath, contents) { fs.writeFileSync(filePath, contents); } -writeOrCheck(collectionPath, serialize(collection)); -writeOrCheck(environmentPath, serialize(environment)); -writeOrCheck(devEnvironmentPath, serialize(devEnvironment)); +async function buildArtifacts() { + const prettierConfig = (await prettier.resolveConfig(collectionPath)) ?? {}; + const artifacts = [ + [collectionPath, collection], + [environmentPath, environment], + [devEnvironmentPath, devEnvironment] + ]; + + for (const [filePath, value] of artifacts) { + writeOrCheck(filePath, await serialize(value, filePath, prettierConfig)); + } +} + +buildArtifacts().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/test/postman/dev.postman_environment.json b/test/postman/dev.postman_environment.json index 49a5e33..1891c05 100644 --- a/test/postman/dev.postman_environment.json +++ b/test/postman/dev.postman_environment.json @@ -1,21 +1,11 @@ { - "id": "abf7f52a-6cb0-4e52-9c1e-f07095a0de21", - "name": "CVE Search API - Dev", - "values": [ - { - "key": "baseUrl", - "value": "https://dev.search.cve.org", - "type": "default", - "enabled": true - }, - { - "key": "runFaultInjection", - "value": "false", - "type": "default", - "enabled": true - } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2026-08-20T00:00:00.000Z", - "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" + "id": "abf7f52a-6cb0-4e52-9c1e-f07095a0de21", + "name": "CVE Search API - Dev", + "values": [ + { "key": "baseUrl", "value": "https://dev.search.cve.org", "type": "default", "enabled": true }, + { "key": "runFaultInjection", "value": "false", "type": "default", "enabled": true } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-08-20T00:00:00.000Z", + "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" } diff --git a/test/postman/local.postman_environment.json b/test/postman/local.postman_environment.json index 9f584c1..c9f7efd 100644 --- a/test/postman/local.postman_environment.json +++ b/test/postman/local.postman_environment.json @@ -1,21 +1,11 @@ { - "id": "5f714896-d2bb-45cb-9bfa-c7db3a4e1141", - "name": "CVE Search API - Local", - "values": [ - { - "key": "baseUrl", - "value": "http://localhost:4000", - "type": "default", - "enabled": true - }, - { - "key": "runFaultInjection", - "value": "false", - "type": "default", - "enabled": true - } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2026-08-20T00:00:00.000Z", - "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" + "id": "5f714896-d2bb-45cb-9bfa-c7db3a4e1141", + "name": "CVE Search API - Local", + "values": [ + { "key": "baseUrl", "value": "http://localhost:4000", "type": "default", "enabled": true }, + { "key": "runFaultInjection", "value": "false", "type": "default", "enabled": true } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-08-20T00:00:00.000Z", + "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" } From c8b6150f29a4a9988ae4835b42d0b355fa536991 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 20 Aug 2026 10:36:58 -0400 Subject: [PATCH 19/27] Keep deployment Postman environments private --- .gitignore | 6 ++++++ test/postman/README.md | 25 ++++++++++++++++++----- test/postman/buildCollection.js | 16 +-------------- test/postman/dev.postman_environment.json | 11 ---------- 4 files changed, 27 insertions(+), 31 deletions(-) delete mode 100644 test/postman/dev.postman_environment.json diff --git a/.gitignore b/.gitignore index 5e19885..e2eb191 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,12 @@ web_modules/ .env.production.local .env.local +# Local Postman deployment environments +/test/postman/dev.postman_environment.json +/test/postman/test.postman_environment.json +/test/postman/int.postman_environment.json +/test/postman/prod.postman_environment.json + # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache diff --git a/test/postman/README.md b/test/postman/README.md index 2355dfe..a1a042c 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -6,14 +6,15 @@ This directory contains an importable Postman collection for manually validating - `CVE-Search-API.postman_collection.json`: generated Postman collection. - `local.postman_environment.json`: local environment with `baseUrl` set to `http://localhost:4000`. -- `dev.postman_environment.json`: dev environment with `baseUrl` set to `https://dev.search.cve.org`. - `buildCollection.js`: maintainable source used to generate and consistency-check the JSON artifacts. +Deployment-specific environment files are intentionally ignored by Git so deployment URLs remain local. Use the local-only filenames `dev.postman_environment.json`, `test.postman_environment.json`, `int.postman_environment.json`, and `prod.postman_environment.json`. + ## Run The Collection 1. Start the API and its configured OpenSearch dependency. 2. Import `CVE-Search-API.postman_collection.json` into Postman. -3. Import `local.postman_environment.json` or `dev.postman_environment.json`. +3. Import `local.postman_environment.json` or an ignored deployment-specific environment file. 4. Confirm that `baseUrl` points to the intended API instance. 5. Open the collection runner and run folders `00` through `16`. @@ -23,10 +24,24 @@ The collection checks that each response includes an `X-Request-ID`. Search resp ## Environments -The collection uses the `baseUrl` variable and does not embed deployment credentials. Select **CVE Search API - Local** for a locally running API or **CVE Search API - Dev** for the deployed dev environment. To target another environment, duplicate either Postman environment and change only `baseUrl`, for example: +The collection uses the `baseUrl` variable and does not embed deployment credentials or URLs. The tracked localhost environment is a safe template for creating a deployment environment: + +1. Duplicate `local.postman_environment.json`. +2. Rename the copy for the target deployment using one of the ignored filenames listed above. +3. Give the environment a distinct Postman `id` and `name`. +4. Replace only the `baseUrl` value with the approved deployment URL. +5. Keep `runFaultInjection` set to `false` unless performing an explicitly approved fault test. + +The deployment file should resemble: -```text -https:// +```json +{ + "name": "CVE Search API - ", + "values": [ + { "key": "baseUrl", "value": "https://", "type": "default", "enabled": true }, + { "key": "runFaultInjection", "value": "false", "type": "default", "enabled": true } + ] +} ``` Review and approve tests before targeting a shared or production deployment. The included fixtures were calibrated against both the dev CVE index and a smaller 1,111-record local index on August 20, 2026. diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 7d511c7..db293fe 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -9,7 +9,6 @@ const error = new SearchErrors(); const outputDirectory = __dirname; const collectionPath = path.join(outputDirectory, 'CVE-Search-API.postman_collection.json'); const environmentPath = path.join(outputDirectory, 'local.postman_environment.json'); -const devEnvironmentPath = path.join(outputDirectory, 'dev.postman_environment.json'); const positiveCoveredFields = new Set(); const ids = Object.freeze({ @@ -1207,18 +1206,6 @@ const environment = { _postman_exported_using: 'CVE-Search-API test/postman/buildCollection.js' }; -const devEnvironment = { - id: 'abf7f52a-6cb0-4e52-9c1e-f07095a0de21', - name: 'CVE Search API - Dev', - values: [ - { key: 'baseUrl', value: 'https://dev.search.cve.org', type: 'default', enabled: true }, - { key: 'runFaultInjection', value: 'false', type: 'default', enabled: true } - ], - _postman_variable_scope: 'environment', - _postman_exported_at: '2026-08-20T00:00:00.000Z', - _postman_exported_using: 'CVE-Search-API test/postman/buildCollection.js' -}; - async function serialize(value, filePath, prettierConfig) { return prettier.format(JSON.stringify(value), { ...prettierConfig, @@ -1243,8 +1230,7 @@ async function buildArtifacts() { const prettierConfig = (await prettier.resolveConfig(collectionPath)) ?? {}; const artifacts = [ [collectionPath, collection], - [environmentPath, environment], - [devEnvironmentPath, devEnvironment] + [environmentPath, environment] ]; for (const [filePath, value] of artifacts) { diff --git a/test/postman/dev.postman_environment.json b/test/postman/dev.postman_environment.json deleted file mode 100644 index 1891c05..0000000 --- a/test/postman/dev.postman_environment.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "abf7f52a-6cb0-4e52-9c1e-f07095a0de21", - "name": "CVE Search API - Dev", - "values": [ - { "key": "baseUrl", "value": "https://dev.search.cve.org", "type": "default", "enabled": true }, - { "key": "runFaultInjection", "value": "false", "type": "default", "enabled": true } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2026-08-20T00:00:00.000Z", - "_postman_exported_using": "CVE-Search-API test/postman/buildCollection.js" -} From 5945201f877b0504d88807804846e7898e920899 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 20 Aug 2026 16:25:31 -0400 Subject: [PATCH 20/27] Remove process-local search performance controls --- .env.example | 9 - README.md | 66 +-- api-docs/openapi.json | 23 - api-docs/openapi.test.js | 2 +- config/custom-environment-variables.jsonc | 13 - config/default.jsonc | 21 - config/runtime-config.test.js | 15 - controllers/searchController.js | 215 ++------ controllers/searchController.test.js | 465 +----------------- package-lock.json | 46 -- package.json | 1 - routes/index.js | 26 - routes/index.test.js | 34 +- routes/search.integration.test.js | 8 +- .../CVE-Search-API.postman_collection.json | 171 ------- test/postman/README.md | 17 +- test/postman/buildCollection.js | 24 - utils/constants.js | 10 - utils/errors.js | 12 - utils/errors.test.js | 24 - utils/searchInFlightRegistry.js | 118 ----- utils/searchInFlightRegistry.test.js | 133 ----- utils/searchPerformance.js | 78 --- utils/searchPerformance.test.js | 174 ------- utils/searchPostFilterCache.js | 73 --- utils/searchPostFilterCache.test.js | 61 --- utils/searchPostFilterWorkQueue.js | 149 ------ utils/searchPostFilterWorkQueue.test.js | 200 -------- utils/searchProvider.js | 99 +--- utils/searchProvider.test.js | 114 +---- utils/searchRateLimiter.js | 100 ---- utils/searchRateLimiter.test.js | 127 ----- 32 files changed, 72 insertions(+), 2556 deletions(-) delete mode 100644 utils/searchInFlightRegistry.js delete mode 100644 utils/searchInFlightRegistry.test.js delete mode 100644 utils/searchPerformance.js delete mode 100644 utils/searchPerformance.test.js delete mode 100644 utils/searchPostFilterCache.js delete mode 100644 utils/searchPostFilterCache.test.js delete mode 100644 utils/searchPostFilterWorkQueue.js delete mode 100644 utils/searchPostFilterWorkQueue.test.js delete mode 100644 utils/searchRateLimiter.js delete mode 100644 utils/searchRateLimiter.test.js diff --git a/.env.example b/.env.example index a90f4b1..a6ac790 100644 --- a/.env.example +++ b/.env.example @@ -2,19 +2,10 @@ PORT=3000 OpenSearchCveIndex=cve-index-local OpenSearchAllowUnknownSslCerts=false OpenSearchDomainEndpoint= -SearchLogPerformanceTimings=false -SearchPostFilterCacheTtlMs=60000 -SearchPostFilterCacheMaxEntries=100 -SearchPostFilterColdSearchConcurrency=10 -SearchPostFilterColdSearchMaxQueueDepth=500 -SearchPostFilterColdSearchMaxWaitMs=10000 SearchOpenSearchRequestTimeoutMs=20000 SearchRequestDeadlineMs=30000 SearchReadinessTimeoutMs=5000 RequestLogSuccessSampleRate=0.1 RequestLogHealthChecks=false WebSearchEnabled=true -SearchRateLimitEnabled=false -SearchRateLimitWindowMs=60000 -SearchRateLimitMaxRequests=0 NPM_CA_CERT= diff --git a/README.md b/README.md index b1f5cd0..c3e01e7 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,12 @@ Rename `.env.example` to `.env` and replace or check the following values. - `OpenSearchCveIndex` - existing CVE index name in your OpenSearch deployment - `OpenSearchAllowUnknownSslCerts` - set to `true` only for local development against self-signed certificates - `OpenSearchDomainEndpoint` - absolute URL to an existing OpenSearch deployment. This API does not create or manage the OpenSearch index or mapping. -- `SearchLogPerformanceTimings` - optional diagnostic flag; set to `true` temporarily to log redacted `/search` timing breakdowns -- `SearchPostFilterCacheTtlMs` - optional CPE post-filter cache TTL override in milliseconds, default `60000` -- `SearchPostFilterCacheMaxEntries` - optional per-process CPE post-filter cache entry limit, default `100` -- `SearchPostFilterColdSearchConcurrency` - optional per-process concurrent cold CPE candidate search limit, default `10` -- `SearchPostFilterColdSearchMaxQueueDepth` - optional per-process waiting queue depth for cold CPE candidate searches, default `500` -- `SearchPostFilterColdSearchMaxWaitMs` - optional maximum cold CPE candidate queue wait, default `10000` - `SearchOpenSearchRequestTimeoutMs` - optional timeout for each OpenSearch request, default `20000` - `SearchRequestDeadlineMs` - optional overall `/search` processing deadline, default `30000` - `SearchReadinessTimeoutMs` - optional timeout for the OpenSearch index readiness check, default `5000` - `RequestLogSuccessSampleRate` - optional fraction of successful non-health-check access logs to retain, default `0.1` - `RequestLogHealthChecks` - optional flag to retain successful health-check access logs, default `false` - `WebSearchEnabled` - optional deprecated `/webSearch` feature flag, default `true` -- `SearchRateLimitEnabled` - optional per-process `/search` rate-limit flag, default `false` -- `SearchRateLimitWindowMs` - optional rate-limit window in milliseconds, default `60000` -- `SearchRateLimitMaxRequests` - required positive per-process request limit when rate limiting is enabled - `NPM_CA_CERT` - optional absolute host path to a corporate or self-signed CA certificate bundle used only during local Docker Compose builds ```bash @@ -275,68 +266,21 @@ An importable Postman collection for manual `/search` contract and filter testin ### Operational Logging -Every request receives an `X-Request-ID` response header. A caller-provided `X-Request-ID` is preserved when it contains only letters, numbers, `.`, `_`, `:`, or `-` and is no longer than 128 characters; otherwise the API generates a UUID. The same value appears as `request-id` in access logs and `requestId` in structured `/search` events. +Every request receives an `X-Request-ID` response header. A caller-provided `X-Request-ID` is preserved when it contains only letters, numbers, `.`, `_`, `:`, or `-` and is no longer than 128 characters; otherwise the API generates a UUID. The same value appears as `request-id` in access logs. Access logs omit query strings and request bodies. By default, the API retains 10% of successful non-health-check access logs, suppresses successful `/api/health-check` and `/api/ready-check` access logs, and retains every HTTP 4xx or 5xx access log. Set `RequestLogSuccessSampleRate` from `0` through `1` to change successful-request sampling. Set `RequestLogHealthChecks=true` to retain successful health-check logs. -The API always emits a sanitized JSON `search.performance` event for `/search` queue saturation, queue wait timeout, search timeout, client disconnect, OpenSearch connection errors, and upstream search errors. These events include the request ID, outcome, HTTP status, strategy, cache state, total elapsed time, cancellation reason, and queue state when applicable. Search terms, CPE values, request bodies, credentials, and upstream error details are never included. - -Set `SearchLogPerformanceTimings=true` on the API deployment when diagnosing slow `/search` requests. Successful requests then emit the same structured event with additional redacted timing details, including candidate counts, filtered counts, returned count, OpenSearch operation types, maximum observed active OpenSearch requests in the process, OpenSearch elapsed time, OpenSearch `took`, post-filter elapsed time, cold-search queue wait time, page fetch elapsed or wait time, and total handler elapsed time. Leave it disabled during normal operation unless the deployment logging volume is acceptable. - Each `/search` request owns a lifecycle cancellation signal. When its overall deadline expires, the API aborts the active OpenSearch transport request before returning HTTP 504. When the caller disconnects, the API aborts work that is needed only by that caller and does not attempt to write a response. This prevents timed-out or abandoned standard searches from continuing until the transport timeout. -### Application Rate Limiting - -Application rate limiting is disabled by default and applies only to `POST /search`. It does not apply to `/webSearch`, health checks, Swagger, OpenAPI, or static assets. - -The fallback limiter uses one in-memory counter per Node.js process. Set `SearchRateLimitEnabled=true`, provide a positive `SearchRateLimitMaxRequests`, and optionally change `SearchRateLimitWindowMs` to enable it. Invalid enabled configuration prevents the application from starting. Rejected requests return HTTP 429 with `SEARCH_RATE_LIMITED`, `Retry-After`, `RateLimit`, and `RateLimit-Policy` headers, and emit a sanitized `search.performance` event with the `rate_limited` outcome. - -This is a process-wide safety limit, not a precise distributed quota. Each ECS task has an independent counter, so total permitted traffic scales with the number of tasks and load-balancer routing. It deliberately does not use caller IP addresses or forwarded headers until the ALB proxy chain and Express `trust proxy` setting are confirmed. AWS WAF or another infrastructure-level distributed limit remains the preferred public-facing control. - ### Semantic Post-Filtering -CVSS vector filters, SSVC filters, and date-bounded positive `knownExploited` searches use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same vector or structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. Every token supplied to one CVSS vector filter must occur in the same eligible vector. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. CVSS, SSVC, and KEV candidates do not use the temporary CPE cache or cold-search queue. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. - -### CPE Post-Filter Cache - -Searches that use `cpeName`, `virtualMatchString`, or `virtualMatchString` with version range parameters use a semantic CPE post-filter. The API first asks OpenSearch for up to 10,000 lightweight candidate records, filters those candidates in Node.js, then fetches the requested page of full CVE records by CVE ID. - -The post-filter cache stores only the filtered CVE ID list and candidate count for that expensive candidate query. It does not cache full CVE records. On a cache hit, the API skips the 10,000-candidate OpenSearch query and semantic post-filtering, slices the cached CVE IDs for the requested page, and fetches that page of full CVE records by ID. - -The cache is intentionally small and short-lived: - -- TTL: 60 seconds -- Max entries: 100 -- Scope: one Node.js process -- Stored value: filtered CVE IDs and candidate count -- In-flight behavior: concurrent identical cold searches share one reference-counted candidate query per process -- Cold-search concurrency: cold CPE candidate queries are limited to 10 per process before reaching OpenSearch -- Cold-search queue depth: up to 500 unique cold searches can wait per process behind the concurrency limit -- Cold-search queue wait: up to 10 seconds before a waiting search returns HTTP 429 -- OpenSearch request timeout: 20 seconds for each operation -- Overall `/search` deadline: 30 seconds -- Eviction: oldest entries are removed when the cache exceeds its max size - -When the cold-search queue is full, new unique CPE post-filter searches fail closed with HTTP 429 and a `SEARCH_QUEUE_FULL` response. Identical requests for a search that is already in flight still wait on the shared in-flight result and do not consume additional queue slots. - -When a cold search waits in the queue for more than 10 seconds, it fails closed with HTTP 429 and a `SEARCH_QUEUE_TIMEOUT` response. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed 20 seconds. The same response is returned when a `/search` request exceeds the 30-second overall processing deadline, and the active OpenSearch request is aborted. - -Healthy dev timing logs showed the impact for repeated identical `virtualMatchString` range searches: +CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every token supplied to one CVSS vector filter must occur in the same eligible vector. -| Metric | Cold Cache | Warm Cache | -| ----------------------------- | ---------: | -----------------------: | -| Avg total handler time | 1709.34 ms | 11.72 ms | -| Median total handler time | 1701.51 ms | 7.68 ms | -| p95 total handler time | 2010.52 ms | 50.66 ms | -| Avg candidate OpenSearch time | 1435.57 ms | skipped | -| Avg post-filter time | 169.21 ms | skipped | -| Avg page fetch time | 104.25 ms | 11.53 ms | -| Avg candidates scanned | 10000 | cached from prior result | -| Avg filtered CVEs | 4 | 4 | +The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. -This helps most when multiple users submit the same expensive CPE search within the TTL. The first request is a cold cache miss; later identical requests on the same running task are warm cache hits. If identical cold requests arrive concurrently before the first one finishes, the later requests wait on the same candidate query instead of starting duplicate 10,000-candidate OpenSearch requests. Disconnecting one waiter does not cancel work still needed by another waiter; the shared operation is aborted when its final waiter disconnects or times out. Each active request still fetches its requested page of full CVE records after the shared candidate result is available. +The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and performance telemetry must be supplied by the deployment infrastructure when those capabilities are enabled. -The cache does not share entries across ECS tasks. With multiple tasks, each task warms independently depending on load balancer routing. Steady repeated traffic can warm every active task, but unique one-off searches still pay the cold-cache cost. The request lifecycle and in-flight waiter registry are separate from this result cache, so the cache can later be replaced with Redis or ElastiCache without changing cancellation behavior. The per-process TTL, entry limit, cold-search concurrency, queue depth, queue wait, OpenSearch timeout, and overall request deadline can be tuned with `SearchPostFilterCacheTtlMs`, `SearchPostFilterCacheMaxEntries`, `SearchPostFilterColdSearchConcurrency`, `SearchPostFilterColdSearchMaxQueueDepth`, `SearchPostFilterColdSearchMaxWaitMs`, `SearchOpenSearchRequestTimeoutMs`, and `SearchRequestDeadlineMs`. +OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed the 20-second default timeout. The same response is returned when a `/search` request exceeds the 30-second default overall deadline, and the active OpenSearch request is aborted. Docker runtime smoke checks can be run after building the image. The default `Dockerfile` avoids BuildKit `RUN --mount` syntax for AWS builders that do not support it: diff --git a/api-docs/openapi.json b/api-docs/openapi.json index a23c2a8..c1f1f50 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -44,29 +44,6 @@ "415": { "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding" }, - "429": { - "description": "Application rate limit exceeded, or the cold CPE search queue is full or exceeded its maximum wait", - "headers": { - "Retry-After": { - "description": "Seconds until a rate-limited request can be retried", - "schema": { - "type": "integer" - } - }, - "RateLimit": { - "description": "Current application rate-limit state when application limiting is enabled", - "schema": { - "type": "string" - } - }, - "RateLimit-Policy": { - "description": "Application rate-limit policy when application limiting is enabled", - "schema": { - "type": "string" - } - } - } - }, "503": { "description": "OpenSearch is unavailable", "headers": { diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index af7f573..8adb8d3 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -155,7 +155,7 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('virtualMatchString'); expect(cpeName.example).toBe('cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*'); expect(openapi.paths['/search'].post.responses['404'].description).toBe('Invalid cpeName value'); - expect(openapi.paths['/search'].post.responses['429'].description).toContain('rate limit'); + expect(openapi.paths['/search'].post.responses['429']).toBeUndefined(); }); test('documents fail-closed request and dependency responses for POST /search', () => { diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 3470a97..4eb1457 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -11,12 +11,6 @@ "index": "OpenSearchCveIndex", // allows local development using containers that do not have SSL certs "allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts", - "logPerformanceTimings": "SearchLogPerformanceTimings", - "postFilterCacheTtlMs": "SearchPostFilterCacheTtlMs", - "postFilterCacheMaxEntries": "SearchPostFilterCacheMaxEntries", - "postFilterColdSearchConcurrency": "SearchPostFilterColdSearchConcurrency", - "postFilterColdSearchMaxQueueDepth": "SearchPostFilterColdSearchMaxQueueDepth", - "postFilterColdSearchMaxWaitMs": "SearchPostFilterColdSearchMaxWaitMs", "openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs", "requestDeadlineMs": "SearchRequestDeadlineMs", "readinessTimeoutMs": "SearchReadinessTimeoutMs" @@ -28,13 +22,6 @@ "features": { "webSearchEnabled": "WebSearchEnabled" }, - "rateLimiting": { - "search": { - "enabled": "SearchRateLimitEnabled", - "windowMs": "SearchRateLimitWindowMs", - "maxRequests": "SearchRateLimitMaxRequests" - } - }, // constants for testing node-config // these values are only used to test node-config in AppConfig.test.int.ts // DO NOT USE THIS FOR ANYTHING ELSE diff --git a/config/default.jsonc b/config/default.jsonc index 8cdef78..8759707 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -14,17 +14,6 @@ // The only time this should be allowed to be true is when developing or testing // using containers that do not have SSL certs "allowUnknownSslCerts": "FALSE", - // set to TRUE temporarily when diagnosing slow /search requests - "logPerformanceTimings": "FALSE", - // CPE post-filter candidate result cache. These values are per Node.js process. - "postFilterCacheTtlMs": "60000", - "postFilterCacheMaxEntries": "100", - // Limits concurrent cold CPE candidate OpenSearch requests per Node.js process. - "postFilterColdSearchConcurrency": "10", - // Limits queued cold CPE candidate searches waiting behind the concurrency cap per Node.js process. - "postFilterColdSearchMaxQueueDepth": "500", - // Limits how long a cold CPE candidate search can wait in the per-process queue. - "postFilterColdSearchMaxWaitMs": "10000", // Limits each request from the API process to OpenSearch. "openSearchRequestTimeoutMs": "20000", // Limits total processing time for a /search request. @@ -43,16 +32,6 @@ "features": { // Keep enabled until existing consumers have migrated to POST /search. "webSearchEnabled": "TRUE" - }, - // Application-level fallback protection. Infrastructure-level enforcement remains preferred. - "rateLimiting": { - "search": { - // Disabled until a per-task threshold is approved. - "enabled": "FALSE", - "windowMs": "60000", - // A positive value is required when enabled. - "maxRequests": "0" - } } } } diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index 350a6b1..c65066b 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -86,18 +86,6 @@ describe('runtime container and compose configuration', () => { expect(customEnvironmentVariables).toContain('"providerEndpoint": "OpenSearchDomainEndpoint"'); expect(customEnvironmentVariables).toContain('"index": "OpenSearchCveIndex"'); expect(customEnvironmentVariables).toContain('"allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts"'); - expect(customEnvironmentVariables).toContain('"logPerformanceTimings": "SearchLogPerformanceTimings"'); - expect(customEnvironmentVariables).toContain('"postFilterCacheTtlMs": "SearchPostFilterCacheTtlMs"'); - expect(customEnvironmentVariables).toContain('"postFilterCacheMaxEntries": "SearchPostFilterCacheMaxEntries"'); - expect(customEnvironmentVariables).toContain( - '"postFilterColdSearchConcurrency": "SearchPostFilterColdSearchConcurrency"' - ); - expect(customEnvironmentVariables).toContain( - '"postFilterColdSearchMaxQueueDepth": "SearchPostFilterColdSearchMaxQueueDepth"' - ); - expect(customEnvironmentVariables).toContain( - '"postFilterColdSearchMaxWaitMs": "SearchPostFilterColdSearchMaxWaitMs"' - ); expect(customEnvironmentVariables).toContain( '"openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs"' ); @@ -106,9 +94,6 @@ describe('runtime container and compose configuration', () => { expect(customEnvironmentVariables).toContain('"successSampleRate": "RequestLogSuccessSampleRate"'); expect(customEnvironmentVariables).toContain('"healthChecks": "RequestLogHealthChecks"'); expect(customEnvironmentVariables).toContain('"webSearchEnabled": "WebSearchEnabled"'); - expect(customEnvironmentVariables).toContain('"enabled": "SearchRateLimitEnabled"'); - expect(customEnvironmentVariables).toContain('"windowMs": "SearchRateLimitWindowMs"'); - expect(customEnvironmentVariables).toContain('"maxRequests": "SearchRateLimitMaxRequests"'); }); test.each([['config/default.jsonc'], ['config/devel.jsonc'], ['config/prod.jsonc']])( diff --git a/controllers/searchController.js b/controllers/searchController.js index 742ce0a..572a925 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -1,10 +1,6 @@ const PackageInfo = require('cve-core').packageInfo; const errors = require('../utils/errors'); -const searchPerformance = require('../utils/searchPerformance'); const searchDeadline = require('../utils/searchDeadline'); -const searchPostFilterCache = require('../utils/searchPostFilterCache'); -const searchInFlightRegistry = require('../utils/searchInFlightRegistry'); -const searchPostFilterWorkQueue = require('../utils/searchPostFilterWorkQueue'); const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); @@ -97,220 +93,107 @@ function combineTook(...values) { return numericValues.reduce((sum, value) => sum + value, 0); } -function recordOpenSearchStart(metrics, operation, state) { - metrics.openSearchOperations ??= []; - metrics.openSearchOperations.push(operation); - metrics.maxOpenSearchActiveRequests = Math.max(metrics.maxOpenSearchActiveRequests ?? 0, state.activeCount); +async function executeOpenSearch(body, signal) { + return searchProvider.searchOpenSearch(body, { signal }); } -async function executeOpenSearch(body, operation, signal, metrics) { - return searchProvider.searchOpenSearch(body, { - signal, - operation, - onStart: (state) => recordOpenSearchStart(metrics, operation, state) - }); -} - -async function executeStandardSearch(searchOptions, queryStringObjects, metrics, signal) { - metrics.strategy = 'standard'; - - const searchStart = searchPerformance.markTime(); +async function executeStandardSearch(searchOptions, queryStringObjects, signal) { const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const searchData = await executeOpenSearch(body, 'standard', signal, metrics); - const searchResp = { + const searchData = await executeOpenSearch(body, signal); + + return { status: 'ok', data: searchData }; - metrics.openSearchElapsedMs = searchPerformance.elapsedMs(searchStart); - metrics.openSearchTookMs = searchData?.took; - - return searchResp; } -async function fetchFullHitsByCveIds(cveIds, metrics, signal) { - if (!cveIds.length) return []; - - const inFlightKey = searchInFlightRegistry.buildKey('pageFetch', cveIds); - const waitStart = searchPerformance.markTime(); - const inFlight = searchInFlightRegistry.acquire( - inFlightKey, - async (operationSignal) => { - const { searchOptions, queryStringObjects } = searchQueryBuilder.buildCveIdsSearchRequest(cveIds); - const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const pageFetchStart = searchPerformance.markTime(); - const pageData = await executeOpenSearch(body, 'page-fetch', operationSignal, metrics); - metrics.pageFetchElapsedMs = searchPerformance.elapsedMs(pageFetchStart); - metrics.pageFetchTookMs = pageData?.took; - - return orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds); - }, - { signal } - ); +async function fetchFullHitsByCveIds(cveIds, signal) { + if (!cveIds.length) return { hits: [], took: undefined }; - metrics.pageFetchInFlightHit = inFlight.shared; - const result = await inFlight.promise; - if (inFlight.shared) { - metrics.pageFetchWaitElapsedMs = searchPerformance.elapsedMs(waitStart); - } + const { searchOptions, queryStringObjects } = searchQueryBuilder.buildCveIdsSearchRequest(cveIds); + const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); + const pageData = await executeOpenSearch(body, signal); - return result; + return { + hits: orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds), + took: pageData?.took + }; } -async function buildPostFilterCandidateResult(candidateBody, postFilters, metrics, signal) { - const operation = searchPostFilters.hasCpePostFilters(postFilters) ? 'cpe-candidate' : 'semantic-candidate'; - const candidateStart = searchPerformance.markTime(); - const candidateData = await executeOpenSearch(candidateBody, operation, signal, metrics); - metrics.openSearchElapsedMs = searchPerformance.elapsedMs(candidateStart); - metrics.openSearchTookMs = candidateData?.took; +async function buildPostFilterCandidateResult(candidateBody, postFilters, signal) { + const candidateData = await executeOpenSearch(candidateBody, signal); const candidateResp = { data: candidateData }; - const postFilterStart = searchPerformance.markTime(); - const { candidateCount, filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); - metrics.postFilterElapsedMs = searchPerformance.elapsedMs(postFilterStart); + const { filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); return { - candidateCount, filteredCount: filteredHits.length, - filteredCveIds: getHitCveIds(filteredHits) + filteredCveIds: getHitCveIds(filteredHits), + took: candidateData?.took }; } -async function getPostFilterCandidateResult(cacheKey, candidateBody, postFilters, metrics, signal) { - if (!searchPostFilters.hasCpePostFilters(postFilters)) { - metrics.cacheHit = false; - metrics.inFlightHit = false; - return buildPostFilterCandidateResult(candidateBody, postFilters, metrics, signal); - } - - const cachedResult = searchPostFilterCache.get(cacheKey); - if (cachedResult) { - metrics.cacheHit = true; - metrics.inFlightHit = false; - return cachedResult; - } - - metrics.cacheHit = false; - const inFlightKey = searchInFlightRegistry.buildKey('candidate', cacheKey); - const queueStart = searchPerformance.markTime(); - const waitStart = searchPerformance.markTime(); - const inFlight = searchInFlightRegistry.acquire( - inFlightKey, - (operationSignal) => - searchPostFilterWorkQueue - .run( - () => { - metrics.coldSearchQueueWaitElapsedMs = searchPerformance.elapsedMs(queueStart); - metrics.coldSearchQueueState = searchPostFilterWorkQueue.getState(); - return buildPostFilterCandidateResult(candidateBody, postFilters, metrics, operationSignal); - }, - { signal: operationSignal } - ) - .then((result) => { - searchPostFilterCache.set(cacheKey, result); - return result; - }), - { signal } - ); - - metrics.inFlightHit = inFlight.shared; - const result = await inFlight.promise; - if (inFlight.shared) { - metrics.inFlightWaitElapsedMs = searchPerformance.elapsedMs(waitStart); - } - - return result; -} - -async function executeOptimizedPostFilterSearch( - searchOptions, - queryStringObjects, - postFilters, - responseOptions, - metrics, - signal -) { - metrics.strategy = searchPostFilters.hasCpePostFilters(postFilters) ? 'cpe-candidate' : 'semantic-candidate'; - +async function executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { const candidateBody = searchQueryBuilder.buildPostFilterCandidateSearchBody( searchOptions, queryStringObjects, postFilters ); - const cacheKey = searchPostFilterCache.buildCacheKey(candidateBody, postFilters); - const candidateResult = await getPostFilterCandidateResult(cacheKey, candidateBody, postFilters, metrics, signal); + const candidateResult = await buildPostFilterCandidateResult(candidateBody, postFilters, signal); searchDeadline.throwIfAborted(signal); const pageCveIds = paginateHits(candidateResult.filteredCveIds, responseOptions); - metrics.candidateCount = candidateResult.candidateCount; - metrics.filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; - metrics.returnedCount = pageCveIds.length; + const filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; - const fullPageHits = await fetchFullHitsByCveIds(pageCveIds, metrics, signal); + const pageResult = await fetchFullHitsByCveIds(pageCveIds, signal); const response = { status: 'ok', data: { - took: combineTook(metrics.openSearchTookMs, metrics.pageFetchTookMs), + took: combineTook(candidateResult.took, pageResult.took), hits: { total: { - value: metrics.filteredCount + value: filteredCount }, hits: [] } } }; - return buildPagedSearchResponse(response, metrics.filteredCount, fullPageHits); + return buildPagedSearchResponse(response, filteredCount, pageResult.hits); } -async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, metrics, signal) { +async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { if (!searchPostFilters.hasSearchPostFilters(postFilters)) { - return executeStandardSearch(searchOptions, queryStringObjects, metrics, signal); + return executeStandardSearch(searchOptions, queryStringObjects, signal); } - return executeOptimizedPostFilterSearch( - searchOptions, - queryStringObjects, - postFilters, - responseOptions, - metrics, - signal - ); + return executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal); } async function searchHandler(req, res, next) { const params = req.searchParams ?? req.query ?? {}; - const metrics = { - requestId: req.requestId - }; - const handlerStart = searchPerformance.markTime(); const lifecycle = searchDeadline.createSearchRequestLifecycle( req, res, searchProvider.getSearchRequestDeadlineMs() ); - let searchOptions; - let postFilters; - let responseOptions; try { const searchRequest = searchQueryBuilder.buildSearchRequest(params); - searchOptions = searchRequest.searchOptions; - postFilters = searchRequest.postFilters; - responseOptions = searchRequest.responseOptions; + const searchOptions = searchRequest.searchOptions; + const postFilters = searchRequest.postFilters; + const responseOptions = searchRequest.responseOptions; const resp = await executeSearch( searchOptions, searchRequest.queryStringObjects, postFilters, responseOptions, - metrics, lifecycle.signal ); lifecycle.throwIfAborted(); const pagingOptions = responseOptions ?? searchOptions; - metrics.outcome = 'success'; - metrics.statusCode = 200; //Calculate current page and total pages to append to JSON output const totalValue = resp.data.hits.total.value; @@ -327,54 +210,22 @@ async function searchHandler(req, res, next) { }); } catch (err) { if (searchDeadline.isSearchClientDisconnectedError(err)) { - metrics.outcome = 'client_disconnected'; - metrics.statusCode = 499; - metrics.abortReason = 'client_disconnect'; return; } - if (searchPostFilterWorkQueue.isQueueFullError(err)) { - metrics.outcome = 'queue_full'; - metrics.statusCode = 429; - metrics.coldSearchQueueState = err.state; - if (typeof res.set === 'function') res.set('Retry-After', '5'); - - return res.status(429).json(error.searchQueueFullError()); - } - - if (searchPostFilterWorkQueue.isQueueTimeoutError(err)) { - metrics.outcome = 'queue_timeout'; - metrics.statusCode = 429; - metrics.coldSearchQueueState = err.state; - metrics.coldSearchQueueWaitElapsedMs = err.waitElapsedMs; - if (typeof res.set === 'function') res.set('Retry-After', '5'); - - return res.status(429).json(error.searchQueueTimeoutError()); - } - if (searchDeadline.isSearchTimeoutError(err)) { - metrics.outcome = 'search_timeout'; - metrics.statusCode = 504; - metrics.timeoutSource = err?.name === 'TimeoutError' ? 'opensearch' : 'request_deadline'; - metrics.abortReason = metrics.timeoutSource; return res.status(504).json(error.searchTimeoutError()); } if (isOpenSearchUnavailableError(err)) { - metrics.outcome = 'opensearch_unavailable'; - metrics.statusCode = 503; if (typeof res.set === 'function') res.set('Retry-After', '5'); return res.status(503).json(error.openSearchError()); } - metrics.outcome = 'upstream_error'; - metrics.statusCode = 502; return res.status(502).json(error.upstreamSearchError()); } finally { lifecycle.complete(); - metrics.totalElapsedMs = searchPerformance.elapsedMs(handlerStart); - searchPerformance.logSearchPerformance(metrics, searchOptions, responseOptions, postFilters); } } diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 667013b..ade95e3 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -4,23 +4,10 @@ const mockApiSearch = jest.fn(); const mockBasicSearch = jest.fn(); const mockSearchRaw = jest.fn(); const mockSearchOpenSearch = jest.fn((body, options) => { - const operation = options?.operation; - const search = operation === 'cpe-candidate' ? mockSearchRaw : mockApiSearch; - options?.onStart?.({ - activeCount: 1, - activeByOperation: { - [operation]: 1 - } - }); + const search = Array.isArray(body?._source) ? mockSearchRaw : mockApiSearch; return Promise.resolve(search(body, options)).then((result) => result?.data ?? result); }); -const mockShouldLogSearchPerformance = jest.fn(); -const mockGetPostFilterCacheTtlMs = jest.fn(() => 60000); -const mockGetPostFilterCacheMaxEntries = jest.fn(() => 100); -const mockGetPostFilterColdSearchConcurrency = jest.fn(() => 10); -const mockGetPostFilterColdSearchMaxQueueDepth = jest.fn(() => 500); -const mockGetPostFilterColdSearchMaxWaitMs = jest.fn(() => 10000); const mockGetSearchRequestDeadlineMs = jest.fn(() => 30000); const mockIsSearchProviderContractError = jest.fn((err) => err?.code === 'SEARCH_PROVIDER_CONTRACT'); const mockGetBasicSearchManager = jest.fn(() => ({ @@ -37,20 +24,12 @@ jest.mock('cve-core', () => { jest.mock('../utils/searchProvider', () => ({ getBasicSearchManager: mockGetBasicSearchManager, - getPostFilterCacheMaxEntries: mockGetPostFilterCacheMaxEntries, - getPostFilterCacheTtlMs: mockGetPostFilterCacheTtlMs, - getPostFilterColdSearchConcurrency: mockGetPostFilterColdSearchConcurrency, - getPostFilterColdSearchMaxQueueDepth: mockGetPostFilterColdSearchMaxQueueDepth, - getPostFilterColdSearchMaxWaitMs: mockGetPostFilterColdSearchMaxWaitMs, getSearchRequestDeadlineMs: mockGetSearchRequestDeadlineMs, isSearchProviderContractError: mockIsSearchProviderContractError, - searchOpenSearch: mockSearchOpenSearch, - shouldLogSearchPerformance: mockShouldLogSearchPerformance + searchOpenSearch: mockSearchOpenSearch })); const controller = require('./searchController.js'); -const searchInFlightRegistry = require('../utils/searchInFlightRegistry'); -const searchPostFilterCache = require('../utils/searchPostFilterCache'); function createResponse() { const res = {}; @@ -95,15 +74,6 @@ function basicResponse({ ok = true, status = 'ok', total = 0, hits = [], notes = }; } -function deferred() { - const state = {}; - state.promise = new Promise((resolve, reject) => { - state.resolve = resolve; - state.reject = reject; - }); - return state; -} - function captureTimers() { const timers = []; jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => { @@ -149,34 +119,16 @@ describe('Search controller SEARCH handler', () => { mockBasicSearch.mockReset(); mockSearchRaw.mockReset(); mockSearchOpenSearch.mockClear(); - mockShouldLogSearchPerformance.mockReset(); - mockShouldLogSearchPerformance.mockReturnValue(false); mockGetBasicSearchManager.mockClear(); - mockGetPostFilterCacheTtlMs.mockClear(); - mockGetPostFilterCacheMaxEntries.mockClear(); - mockGetPostFilterColdSearchConcurrency.mockClear(); - mockGetPostFilterColdSearchMaxQueueDepth.mockClear(); - mockGetPostFilterColdSearchMaxWaitMs.mockClear(); mockGetSearchRequestDeadlineMs.mockClear(); mockIsSearchProviderContractError.mockClear(); - mockGetPostFilterCacheTtlMs.mockReturnValue(60000); - mockGetPostFilterCacheMaxEntries.mockReturnValue(100); - mockGetPostFilterColdSearchConcurrency.mockReturnValue(10); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(500); - mockGetPostFilterColdSearchMaxWaitMs.mockReturnValue(10000); mockGetSearchRequestDeadlineMs.mockReturnValue(30000); - searchPostFilterCache.clear(); - searchInFlightRegistry.clear(); - jest.spyOn(console, 'log').mockImplementation(() => {}); res = createResponse(); next = jest.fn(); }); - afterEach(() => { - searchInFlightRegistry.clear(); - jest.restoreAllMocks(); - }); + afterEach(() => jest.restoreAllMocks()); test('SEARCH builds filters, query strings, date ranges, and pagination options', async () => { const resp = advancedResponse(25, [{ _id: 'CVE-2024-0001' }]); @@ -243,9 +195,7 @@ describe('Search controller SEARCH handler', () => { from: 20 }, expect.objectContaining({ - operation: 'standard', - signal: expect.any(AbortSignal), - onStart: expect.any(Function) + signal: expect.any(AbortSignal) }) ); @@ -541,7 +491,6 @@ describe('Search controller SEARCH handler', () => { from: 0 }, expect.objectContaining({ - operation: 'page-fetch', signal: expect.any(AbortSignal) }) ); @@ -609,9 +558,8 @@ describe('Search controller SEARCH handler', () => { containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } } }; - mockApiSearch - .mockResolvedValueOnce(rawResponse(2, [matching, splitAcrossEntries])) - .mockResolvedValueOnce(advancedResponse(1, [fullRecord])); + mockSearchRaw.mockResolvedValue(rawResponse(2, [matching, splitAcrossEntries])); + mockApiSearch.mockResolvedValue(advancedResponse(1, [fullRecord])); await controller.SEARCH( { @@ -628,8 +576,6 @@ describe('Search controller SEARCH handler', () => { ); expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); - expect(mockSearchOpenSearch.mock.calls[0][1].operation).toBe('semantic-candidate'); - expect(mockSearchOpenSearch.mock.calls[1][1].operation).toBe('page-fetch'); expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ 'cveMetadata.cveId', 'containers.adp.metrics.other' @@ -673,9 +619,8 @@ describe('Search controller SEARCH handler', () => { containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] } } }; - mockApiSearch - .mockResolvedValueOnce(rawResponse(2, [matching, outsideRange])) - .mockResolvedValueOnce(advancedResponse(1, [fullRecord])); + mockSearchRaw.mockResolvedValue(rawResponse(2, [matching, outsideRange])); + mockApiSearch.mockResolvedValue(advancedResponse(1, [fullRecord])); await controller.SEARCH( { @@ -692,8 +637,6 @@ describe('Search controller SEARCH handler', () => { ); expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2); - expect(mockSearchOpenSearch.mock.calls[0][1].operation).toBe('semantic-candidate'); - expect(mockSearchOpenSearch.mock.calls[1][1].operation).toBe('page-fetch'); expect(mockSearchOpenSearch.mock.calls[0][0]._source).toStrictEqual([ 'cveMetadata.cveId', 'containers.adp.metrics.other' @@ -759,7 +702,6 @@ describe('Search controller SEARCH handler', () => { }); expect(mockApiSearch.mock.calls[0][1]).toEqual( expect.objectContaining({ - operation: 'page-fetch', signal: expect.any(AbortSignal) }) ); @@ -780,184 +722,6 @@ describe('Search controller SEARCH handler', () => { }); }); - test('SEARCH reuses cached semantic post-filter IDs for repeated virtualMatchString range searches', async () => { - const applicable = cpeHit('CVE-2024-0001', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' - }); - const notApplicable = cpeHit('CVE-2024-0002', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.6:*:*:*:*:*:*:*' - }); - const fullApplicable = { - _id: 'CVE-2024-0001', - _source: { - cveMetadata: { - cveId: 'CVE-2024-0001' - } - } - }; - const req = { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - versionStart: '2.2', - versionStartType: 'including', - versionEnd: '2.6', - versionEndType: 'excluding', - resultsPerPage: '1', - pageNumber: '1' - } - }; - - mockSearchRaw.mockResolvedValue(rawResponse(2, [applicable, notApplicable])); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); - - await controller.SEARCH(req, res, next); - - const secondRes = createResponse(); - await controller.SEARCH(req, secondRes, next); - - expect(mockSearchRaw).toHaveBeenCalledTimes(1); - expect(mockApiSearch).toHaveBeenCalledTimes(2); - expect(mockApiSearch.mock.calls[1][0].query.bool.filter).toStrictEqual([ - { - terms: { - 'cveMetadata.cveId.keyword': ['CVE-2024-0001'] - } - } - ]); - expect(mockApiSearch.mock.calls[1][0]).toMatchObject({ - track_total_hits: true, - size: 1, - from: 0 - }); - expect(secondRes.status).toHaveBeenCalledWith(200); - expect(secondRes.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - totalPages: 1, - status: 'ok', - data: { - took: undefined, - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); - }); - - test('SEARCH coalesces concurrent identical cold semantic post-filter searches', async () => { - const candidateSearch = deferred(); - const applicable = cpeHit('CVE-2024-0001', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' - }); - const notApplicable = cpeHit('CVE-2024-0002', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.6:*:*:*:*:*:*:*' - }); - const fullApplicable = { - _id: 'CVE-2024-0001', - _source: { - cveMetadata: { - cveId: 'CVE-2024-0001' - } - } - }; - const req = { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - versionStart: '2.2', - versionStartType: 'including', - versionEnd: '2.6', - versionEndType: 'excluding', - resultsPerPage: '1', - pageNumber: '1' - } - }; - const firstRes = createResponse(); - const secondRes = createResponse(); - - mockSearchRaw.mockReturnValue(candidateSearch.promise); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); - - const firstSearch = controller.SEARCH(req, firstRes, next); - await Promise.resolve(); - const secondSearch = controller.SEARCH(req, secondRes, next); - - candidateSearch.resolve(rawResponse(2, [applicable, notApplicable])); - await Promise.all([firstSearch, secondSearch]); - - expect(mockSearchRaw).toHaveBeenCalledTimes(1); - expect(mockApiSearch).toHaveBeenCalledTimes(1); - expect(firstRes.status).toHaveBeenCalledWith(200); - expect(secondRes.status).toHaveBeenCalledWith(200); - expect(secondRes.json).toHaveBeenCalledWith({ - page: 1, - pageSize: 1, - totalPages: 1, - status: 'ok', - data: { - took: undefined, - hits: { - total: { - value: 1 - }, - hits: [fullApplicable] - } - } - }); - }); - - test('SEARCH keeps shared CPE work active when one client disconnects', async () => { - const candidateSearch = deferred(); - const applicable = cpeHit('CVE-2024-0001', { - criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' - }); - const fullApplicable = { - _id: 'CVE-2024-0001', - _source: { - cveMetadata: { - cveId: 'CVE-2024-0001' - } - } - }; - const firstReq = new EventEmitter(); - const secondReq = new EventEmitter(); - const query = { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - resultsPerPage: 1, - pageNumber: 1 - }; - firstReq.query = query; - secondReq.query = query; - const firstRes = createResponse(); - const secondRes = createResponse(); - let operationSignal; - - mockSearchRaw.mockImplementation((_body, { signal }) => { - operationSignal = signal; - return candidateSearch.promise; - }); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); - - const firstSearch = controller.SEARCH(firstReq, firstRes, next); - await Promise.resolve(); - const secondSearch = controller.SEARCH(secondReq, secondRes, next); - await Promise.resolve(); - - firstReq.emit('aborted'); - await firstSearch; - - expect(operationSignal.aborted).toBe(false); - candidateSearch.resolve(rawResponse(1, [applicable])); - await secondSearch; - - expect(mockSearchRaw).toHaveBeenCalledTimes(1); - expect(mockApiSearch).toHaveBeenCalledTimes(1); - expect(firstRes.status).not.toHaveBeenCalled(); - expect(secondRes.status).toHaveBeenCalledWith(200); - }); - test('SEARCH applies semantic virtualMatchString filtering before building response metadata', async () => { const applicable = cpeHit('CVE-2024-0001', { criteria: 'cpe:2.3:o:microsoft:windows_10_1809:-:*:*:*:*:*:x86:*' @@ -1013,128 +777,6 @@ describe('Search controller SEARCH handler', () => { }); }); - test('SEARCH returns 429 when the cold semantic post-filter queue is full', async () => { - const candidateSearch = deferred(); - const firstRes = createResponse(); - const secondRes = createResponse(); - - mockGetPostFilterColdSearchConcurrency.mockReturnValue(1); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(0); - mockSearchRaw.mockReturnValue(candidateSearch.promise); - - const firstSearch = controller.SEARCH( - { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - resultsPerPage: '1', - pageNumber: '1' - } - }, - firstRes, - next - ); - await Promise.resolve(); - - await controller.SEARCH( - { - requestId: 'queue-full-request', - query: { - virtualMatchString: 'cpe:2.3:o:microsoft:windows_10_1809', - resultsPerPage: '1', - pageNumber: '1' - } - }, - secondRes, - next - ); - - expect(secondRes.set).toHaveBeenCalledWith('Retry-After', '5'); - expect(secondRes.status).toHaveBeenCalledWith(429); - expect(secondRes.json).toHaveBeenCalledWith({ - error: 'SEARCH_QUEUE_FULL', - message: 'Search service is busy. Please retry later.' - }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'queue-full-request', - outcome: 'queue_full', - statusCode: 429, - strategy: 'cpe-candidate', - cacheStatus: 'miss', - coldSearchQueueState: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 1, - maxQueueDepth: 0, - maxWaitMs: 10000 - } - }); - - candidateSearch.resolve(rawResponse(0, [])); - await firstSearch; - }); - - test('SEARCH returns 429 when a cold semantic post-filter search exceeds the queue wait deadline', async () => { - const timers = captureTimers(); - const candidateSearch = deferred(); - const firstRes = createResponse(); - const secondRes = createResponse(); - - mockGetPostFilterColdSearchConcurrency.mockReturnValue(1); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(1); - mockGetPostFilterColdSearchMaxWaitMs.mockReturnValue(10000); - mockSearchRaw.mockReturnValue(candidateSearch.promise); - - const firstSearch = controller.SEARCH( - { - query: { - virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', - resultsPerPage: '1', - pageNumber: '1' - } - }, - firstRes, - next - ); - await Promise.resolve(); - - const secondSearch = controller.SEARCH( - { - requestId: 'queue-timeout-request', - query: { - virtualMatchString: 'cpe:2.3:o:microsoft:windows_10_1809', - resultsPerPage: '1', - pageNumber: '1' - } - }, - secondRes, - next - ); - await Promise.resolve(); - - timers.find(({ delay }) => delay === 10000).callback(); - await secondSearch; - - expect(secondRes.set).toHaveBeenCalledWith('Retry-After', '5'); - expect(secondRes.status).toHaveBeenCalledWith(429); - expect(secondRes.json).toHaveBeenCalledWith({ - error: 'SEARCH_QUEUE_TIMEOUT', - message: 'Search service is busy. Please retry later.' - }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'queue-timeout-request', - outcome: 'queue_timeout', - statusCode: 429, - strategy: 'cpe-candidate', - cacheStatus: 'miss', - coldSearchQueueWaitElapsedMs: expect.any(Number) - }); - - candidateSearch.resolve(rawResponse(0, [])); - await firstSearch; - }); - test('SEARCH returns sanitized upstream errors when optimized post-filter search fails', async () => { mockSearchRaw.mockRejectedValue(new Error('candidate search failed')); @@ -1158,45 +800,6 @@ describe('Search controller SEARCH handler', () => { }); }); - test('SEARCH logs redacted performance timing when enabled', async () => { - mockShouldLogSearchPerformance.mockReturnValue(true); - mockApiSearch.mockResolvedValue(advancedResponse(1, [{ _id: 'CVE-2024-0001' }])); - - await controller.SEARCH( - { - requestId: 'request-123', - query: { - keywordSearch: 'remote code', - resultsPerPage: '1' - } - }, - res, - next - ); - - expect(console.log).toHaveBeenCalledTimes(1); - const timingLog = JSON.parse(console.log.mock.calls[0][0]); - expect(timingLog).toMatchObject({ - event: 'search.performance', - route: '/search', - requestId: 'request-123', - outcome: 'success', - statusCode: 200, - strategy: 'standard', - cacheStatus: 'not_applicable', - optimizedFallback: false, - cacheHit: false, - inFlightHit: false, - openSearchOperations: ['standard'], - maxOpenSearchActiveRequests: 1, - postFilters: [], - requestedFrom: 0, - requestedSize: 1, - openSearchSize: 1 - }); - expect(console.log.mock.calls[0][0]).not.toContain('remote code'); - }); - test('SEARCH builds version-specific CVSS v3 severity filters', async () => { mockApiSearch.mockResolvedValue(advancedResponse(1)); @@ -1227,7 +830,7 @@ describe('Search controller SEARCH handler', () => { }); test('SEARCH builds cvssV2 metric filters', async () => { - mockApiSearch.mockResolvedValue(advancedResponse(1)); + mockSearchRaw.mockResolvedValue(rawResponse(0)); await controller.SEARCH( { @@ -1240,7 +843,7 @@ describe('Search controller SEARCH handler', () => { next ); - expect(mockApiSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + expect(mockSearchRaw.mock.calls[0][0].query.bool.filter).toStrictEqual([ { bool: { filter: [ @@ -1273,7 +876,7 @@ describe('Search controller SEARCH handler', () => { }); test('SEARCH builds version-specific CVSS v3 metric filters', async () => { - mockApiSearch.mockResolvedValue(advancedResponse(1)); + mockSearchRaw.mockResolvedValue(rawResponse(0)); await controller.SEARCH( { @@ -1287,7 +890,7 @@ describe('Search controller SEARCH handler', () => { next ); - expect(mockApiSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + expect(mockSearchRaw.mock.calls[0][0].query.bool.filter).toStrictEqual([ { bool: { filter: [ @@ -1517,12 +1120,6 @@ describe('Search controller SEARCH handler', () => { message: 'OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'connection-error-request', - outcome: 'opensearch_unavailable', - statusCode: 503 - }); }); test('SEARCH returns a sanitized 504 when OpenSearch times out', async () => { @@ -1545,13 +1142,6 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_TIMEOUT', message: 'Search request timed out. Please try again later.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'timeout-request', - outcome: 'search_timeout', - statusCode: 504, - timeoutSource: 'opensearch' - }); }); test('SEARCH returns a sanitized 504 when the overall request deadline is exceeded', async () => { @@ -1582,14 +1172,6 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_TIMEOUT', message: 'Search request timed out. Please try again later.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'request-deadline-request', - outcome: 'search_timeout', - statusCode: 504, - timeoutSource: 'request_deadline', - abortReason: 'request_deadline' - }); }); test('SEARCH stops without writing a response when the client disconnects', async () => { @@ -1610,14 +1192,6 @@ describe('Search controller SEARCH handler', () => { expect(res.status).not.toHaveBeenCalled(); expect(res.json).not.toHaveBeenCalled(); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'client-disconnect-request', - outcome: 'client_disconnected', - statusCode: 499, - abortReason: 'client_disconnect', - strategy: 'standard' - }); }); test('SEARCH does not start a page fetch after a client disconnects from a CPE search', async () => { @@ -1669,12 +1243,6 @@ describe('Search controller SEARCH handler', () => { error: 'UPSTREAM_SEARCH_ERROR', message: 'Search backend returned an error. Please try again later.' }); - expect(JSON.parse(console.log.mock.calls[0][0])).toMatchObject({ - event: 'search.performance', - requestId: 'upstream-error-request', - outcome: 'upstream_error', - statusCode: 502 - }); }); }); @@ -1686,21 +1254,14 @@ describe('Search controller WEB_SEARCH handler', () => { mockBasicSearch.mockReset(); mockSearchRaw.mockReset(); mockSearchOpenSearch.mockClear(); - mockShouldLogSearchPerformance.mockReset(); - mockShouldLogSearchPerformance.mockReturnValue(false); mockGetBasicSearchManager.mockClear(); - searchPostFilterCache.clear(); - searchInFlightRegistry.clear(); jest.spyOn(console, 'log').mockImplementation(() => {}); res = createResponse(); next = jest.fn(); }); - afterEach(() => { - searchInFlightRegistry.clear(); - jest.restoreAllMocks(); - }); + afterEach(() => jest.restoreAllMocks()); test('WEB_SEARCH sends cveId ascending sort, paging, and response metadata', async () => { const hits = [{ _id: 'CVE-2024-0001' }, { _id: 'CVE-2024-0002' }]; diff --git a/package-lock.json b/package-lock.json index 2e5012a..e8b2003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "debug": "~3.2.7", "dotenv": "^6.1.0", "express": "^4.22.2", - "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", "http-errors": "~1.8.1", "morgan": "~1.11.0", @@ -2920,42 +2919,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", - "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-rate-limit/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/express-validator": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", @@ -3613,15 +3576,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index cfe69fd..5b84401 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "debug": "~3.2.7", "dotenv": "^6.1.0", "express": "^4.22.2", - "express-rate-limit": "^8.6.1", "express-validator": "^7.2.1", "http-errors": "~1.8.1", "morgan": "~1.11.0", diff --git a/routes/index.js b/routes/index.js index d4882f0..9a62b1f 100644 --- a/routes/index.js +++ b/routes/index.js @@ -7,11 +7,9 @@ const constants = require('../utils/constants'); const featureFlags = require('../utils/featureFlags'); const searchDeadline = require('../utils/searchDeadline'); const searchProvider = require('../utils/searchProvider'); -const searchRateLimiter = require('../utils/searchRateLimiter'); const searchScope = require('../utils/searchScope'); const error = new errors.errors(); -const limitSearchRequests = searchRateLimiter.createSearchRateLimiter(); const SEARCH_BODY_FIELDS = new Set(constants.SEARCH_QUERY_PARAMS); const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( (field) => @@ -300,29 +298,6 @@ description: 'Invalid cpeName value', #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', } -#swagger.responses[429] = { -description: 'Application rate limit exceeded, or the cold CPE search queue is full or exceeded its maximum wait', -headers: { - "Retry-After": { - description: "Seconds until a rate-limited request can be retried", - schema: { - type: "integer" - } - }, - "RateLimit": { - description: "Current application rate-limit state when application limiting is enabled", - schema: { - type: "string" - } - }, - "RateLimit-Policy": { - description: "Application rate-limit policy when application limiting is enabled", - schema: { - type: "string" - } - } -} -} #swagger.responses[503] = { description: 'OpenSearch is unavailable', headers: { @@ -339,7 +314,6 @@ description: 'Search exceeded its OpenSearch or overall processing deadline', } */ requireSearchJsonContentType, - limitSearchRequests, rejectSearchQueryParams, body().custom(isSearchBodyObject).withMessage(error.searchBodyError()), body().custom(hasOnlySearchBodyFields), diff --git a/routes/index.test.js b/routes/index.test.js index 415447d..a041d7a 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -23,13 +23,8 @@ jest.mock('../utils/featureFlags', () => ({ isWebSearchEnabled: jest.fn(() => true) })); -const mockLimitSearchRequests = jest.fn((req, res, next) => next()); const mockCheckOpenSearchReadiness = jest.fn(); -jest.mock('../utils/searchRateLimiter', () => ({ - createSearchRateLimiter: jest.fn(() => mockLimitSearchRequests) -})); - jest.mock('../utils/searchProvider', () => ({ checkOpenSearchReadiness: mockCheckOpenSearchReadiness, getOpenSearchReadinessTimeoutMs: jest.fn(() => 5000) @@ -47,7 +42,6 @@ describe('routes/index router', () => { app = createRouterApp(router); jest.clearAllMocks(); featureFlags.isWebSearchEnabled.mockReturnValue(true); - mockLimitSearchRequests.mockImplementation((req, res, next) => next()); mockCheckOpenSearchReadiness.mockResolvedValue(true); }); @@ -100,7 +94,6 @@ describe('routes/index router', () => { }); expect(response.status).toBe(200); - expect(mockLimitSearchRequests).toHaveBeenCalledTimes(1); expect(mw.validateParams).toHaveBeenCalledTimes(1); expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ @@ -120,7 +113,6 @@ describe('routes/index router', () => { }); expect(response.status).toBe(200); - expect(mockLimitSearchRequests).toHaveBeenCalledTimes(1); expect(mw.validateParams).toHaveBeenCalledTimes(1); expect(controller.SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ @@ -131,7 +123,7 @@ describe('routes/index router', () => { }); }); - test('POST /search rejects a non-empty body with an unsupported media type before rate limiting', async () => { + test('POST /search rejects a non-empty body with an unsupported media type before validation', async () => { const response = await request(app, { method: 'POST', path: '/search', @@ -146,29 +138,6 @@ describe('routes/index router', () => { error: 'SEARCH_CONTENT_TYPE', message: 'The /search request body must use the application/json media type with UTF-8 encoding.' }); - expect(mockLimitSearchRequests).not.toHaveBeenCalled(); - expect(mw.validateParams).not.toHaveBeenCalled(); - expect(controller.SEARCH).not.toHaveBeenCalled(); - }); - - test('POST /search returns a rate-limit response before validation and controller work', async () => { - mockLimitSearchRequests.mockImplementation((req, res) => - res.status(429).json({ - error: 'SEARCH_RATE_LIMITED', - message: 'Too many search requests. Please retry later.' - }) - ); - - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - keywordSearch: 'test' - } - }); - - expect(response.status).toBe(429); - expect(response.body.error).toBe('SEARCH_RATE_LIMITED'); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -405,7 +374,6 @@ describe('routes/index router', () => { }); expect(response.status).toBe(200); - expect(mockLimitSearchRequests).not.toHaveBeenCalled(); expect(controller.WEB_SEARCH).toHaveBeenCalledTimes(1); expect(response.body).toStrictEqual({ route: 'webSearch', diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 86a3dbf..22b0c81 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -85,11 +85,9 @@ describe('search route integration', () => { size: 1, from: 0 }, - expect.objectContaining({ - operation: 'standard', - signal: expect.any(AbortSignal), - onStart: expect.any(Function) - }) + { + signal: expect.any(AbortSignal) + } ); expect(response.body.page).toBe(1); expect(response.body.pageSize).toBe(1); diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 4153034..d4a90fa 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -6252,177 +6252,6 @@ }, "response": [] }, - { - "name": "Cold-search queue full", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Cold-search queue full; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 429', () => pm.response.to.have.status(429));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_QUEUE_FULL\",\"message\":\"Search service is busy. Please retry later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], - "url": { - "raw": "{{baseUrl}}/search", - "host": ["{{baseUrl}}"], - "path": ["search"], - "query": [] - }, - "description": "Requires concurrent unique cold CPE searches and a deliberately constrained queue.", - "body": { - "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", - "options": { "raw": { "language": "json" } } - } - }, - "response": [] - }, - { - "name": "Cold-search queue timeout", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Cold-search queue timeout; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 429', () => pm.response.to.have.status(429));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_QUEUE_TIMEOUT\",\"message\":\"Search service is busy. Please retry later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], - "url": { - "raw": "{{baseUrl}}/search", - "host": ["{{baseUrl}}"], - "path": ["search"], - "query": [] - }, - "description": "Requires concurrent cold CPE searches and a deliberately short queue wait timeout.", - "body": { - "mode": "raw", - "raw": "{\n \"cpeName\": \"cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*\",\n \"isVulnerable\": true\n}", - "options": { "raw": { "language": "json" } } - } - }, - "response": [] - }, - { - "name": "Application rate limit", - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "if (pm.collectionVariables.get('runFaultInjection') !== 'true') {", - " console.log('Skipping Application rate limit; set runFaultInjection=true to enable it.');", - " pm.execution.skipRequest();", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('status is 429', () => pm.response.to.have.status(429));", - "pm.test('X-Request-ID is present', () => {", - " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", - "});", - "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", - "const payload = pm.response.json();", - "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"SEARCH_RATE_LIMITED\",\"message\":\"Too many search requests. Please retry later.\"};", - "pm.test('expected error code is returned', () => {", - " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", - "});", - "pm.test('expected error message is returned', () => {", - " const match = errors.find((entry) => entry.error === expectedError.error);", - " pm.expect(match?.message).to.eql(expectedError.message);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], - "url": { - "raw": "{{baseUrl}}/search", - "host": ["{{baseUrl}}"], - "path": ["search"], - "query": [] - }, - "description": "Requires the application limiter to be enabled with an exhausted allowance. A WAF response may use a different contract.", - "body": { - "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\"\n}", - "options": { "raw": { "language": "json" } } - } - }, - "response": [] - }, { "name": "Unexpected upstream response", "event": [ diff --git a/test/postman/README.md b/test/postman/README.md index a1a042c..4aa8afc 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -65,15 +65,12 @@ Folder `17 - Manual fault injection - skipped by default` describes runtime erro Setting `runFaultInjection` to `true` only enables the requests; it does not create their required failure conditions. Prepare and run one request at a time because the conditions are different and can conflict. -| Request | Required condition | Expected response | -| ----------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | -| OpenSearch readiness failure | Make the configured OpenSearch index unavailable to the API. | `503 SEARCH_NOT_READY` | -| OpenSearch unavailable search | Make OpenSearch connectivity, permissions, or index access unavailable. | `503 OPEN_SEARCH_ERROR` | -| Search timeout | Deliberately lower the request deadline or delay OpenSearch beyond the configured deadline. | `504 SEARCH_TIMEOUT` | -| Cold-search queue full | Constrain cold CPE concurrency and queue depth, then keep all active and queued slots occupied. | `429 SEARCH_QUEUE_FULL` | -| Cold-search queue timeout | Keep cold CPE workers occupied longer than a deliberately shortened queue wait timeout. | `429 SEARCH_QUEUE_TIMEOUT` | -| Application rate limit | Enable the application limiter with a low threshold and exhaust the allowance before sending the check. | `429 SEARCH_RATE_LIMITED` | -| Unexpected upstream response | Use a test double or proxy that returns an invalid or incomplete OpenSearch response. | `502 UPSTREAM_SEARCH_ERROR` | +| Request | Required condition | Expected response | +| ----------------------------- | ------------------------------------------------------------------------------------------- | --------------------------- | +| OpenSearch readiness failure | Make the configured OpenSearch index unavailable to the API. | `503 SEARCH_NOT_READY` | +| OpenSearch unavailable search | Make OpenSearch connectivity, permissions, or index access unavailable. | `503 OPEN_SEARCH_ERROR` | +| Search timeout | Deliberately lower the request deadline or delay OpenSearch beyond the configured deadline. | `504 SEARCH_TIMEOUT` | +| Unexpected upstream response | Use a test double or proxy that returns an invalid or incomplete OpenSearch response. | `502 UPSTREAM_SEARCH_ERROR` | Run a fault check as follows: @@ -83,8 +80,6 @@ Run a fault check as follows: 4. Confirm the asserted HTTP status, sanitized error code, message, and `X-Request-ID`. 5. Set `runFaultInjection` back to `false` and restore the normal application and dependency configuration. -The two queue checks require concurrent external setup because the Postman collection runner sends requests sequentially. A separate request must occupy the worker or queue before sending the Postman assertion request. - Do not enable fault injection against a shared or production environment without the target owner's approval. A WAF or load balancer can return a different error contract than the application-level response asserted by this folder. ## Known Validation Boundary diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index db293fe..f58b62f 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -1108,30 +1108,6 @@ const faultInjection = folder( error.searchTimeoutError(), 'Run only with a deliberately reduced request deadline or delayed OpenSearch response.' ), - faultInjectionItem( - 'Cold-search queue full', - '/search', - { cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', isVulnerable: true }, - 429, - error.searchQueueFullError(), - 'Requires concurrent unique cold CPE searches and a deliberately constrained queue.' - ), - faultInjectionItem( - 'Cold-search queue timeout', - '/search', - { cpeName: 'cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*', isVulnerable: true }, - 429, - error.searchQueueTimeoutError(), - 'Requires concurrent cold CPE searches and a deliberately short queue wait timeout.' - ), - faultInjectionItem( - 'Application rate limit', - '/search', - { cveIds: ids.primary }, - 429, - error.searchRateLimitedError(), - 'Requires the application limiter to be enabled with an exhausted allowance. A WAF response may use a different contract.' - ), faultInjectionItem( 'Unexpected upstream response', '/search', diff --git a/utils/constants.js b/utils/constants.js index d80f19d..a86aecc 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -20,11 +20,6 @@ const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ ssvcRole: 256, referenceURL: 2048 }); -const SEARCH_POST_FILTER_CACHE_TTL_MS = 60 * 1000; -const SEARCH_POST_FILTER_CACHE_MAX_ENTRIES = 100; -const SEARCH_POST_FILTER_COLD_SEARCH_CONCURRENCY = 10; -const SEARCH_POST_FILTER_COLD_SEARCH_MAX_QUEUE_DEPTH = 500; -const SEARCH_POST_FILTER_COLD_SEARCH_MAX_WAIT_MS = 10 * 1000; const SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS = 20 * 1000; const SEARCH_REQUEST_DEADLINE_MS = 30 * 1000; const SEARCH_READINESS_TIMEOUT_MS = 5 * 1000; @@ -136,11 +131,6 @@ module.exports = { MAX_KEYWORD_SEARCH_TOKENS, MIN_REFERENCE_URL_LENGTH, SEARCH_STRING_LENGTH_LIMITS, - SEARCH_POST_FILTER_CACHE_TTL_MS, - SEARCH_POST_FILTER_CACHE_MAX_ENTRIES, - SEARCH_POST_FILTER_COLD_SEARCH_CONCURRENCY, - SEARCH_POST_FILTER_COLD_SEARCH_MAX_QUEUE_DEPTH, - SEARCH_POST_FILTER_COLD_SEARCH_MAX_WAIT_MS, SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, SEARCH_REQUEST_DEADLINE_MS, SEARCH_READINESS_TIMEOUT_MS, diff --git a/utils/errors.js b/utils/errors.js index 6f8115a..d942253 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -319,21 +319,9 @@ class errors { return makeError('UPSTREAM_SEARCH_ERROR', 'Search backend returned an error. Please try again later.'); } - searchQueueFullError() { - return makeError('SEARCH_QUEUE_FULL', 'Search service is busy. Please retry later.'); - } - - searchQueueTimeoutError() { - return makeError('SEARCH_QUEUE_TIMEOUT', 'Search service is busy. Please retry later.'); - } - searchTimeoutError() { return makeError('SEARCH_TIMEOUT', 'Search request timed out. Please try again later.'); } - - searchRateLimitedError() { - return makeError('SEARCH_RATE_LIMITED', 'Too many search requests. Please retry later.'); - } } module.exports = { diff --git a/utils/errors.test.js b/utils/errors.test.js index 682af30..128e6ad 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -175,22 +175,6 @@ describe('error response helpers', () => { message: 'The noRejected field must be a boolean.' } ], - [ - 'searchQueueFullError', - [], - { - error: 'SEARCH_QUEUE_FULL', - message: 'Search service is busy. Please retry later.' - } - ], - [ - 'searchQueueTimeoutError', - [], - { - error: 'SEARCH_QUEUE_TIMEOUT', - message: 'Search service is busy. Please retry later.' - } - ], [ 'searchTimeoutError', [], @@ -199,14 +183,6 @@ describe('error response helpers', () => { message: 'Search request timed out. Please try again later.' } ], - [ - 'searchRateLimitedError', - [], - { - error: 'SEARCH_RATE_LIMITED', - message: 'Too many search requests. Please retry later.' - } - ], [ 'searchNotReadyError', [], diff --git a/utils/searchInFlightRegistry.js b/utils/searchInFlightRegistry.js deleted file mode 100644 index 07f4e85..0000000 --- a/utils/searchInFlightRegistry.js +++ /dev/null @@ -1,118 +0,0 @@ -const searchDeadline = require('./searchDeadline'); - -const entries = new Map(); - -class SearchOperationAbandonedError extends Error { - constructor() { - super('Shared search operation no longer has active waiters.'); - this.name = 'SearchOperationAbandonedError'; - this.code = 'SEARCH_OPERATION_ABANDONED'; - } -} - -function buildKey(type, value) { - return `${type}:${JSON.stringify(value)}`; -} - -function settleEntry(key, entry) { - entry.settled = true; - if (entries.get(key) === entry) { - entries.delete(key); - } -} - -function createEntry(key, createOperation) { - const controller = new AbortController(); - const entry = { - controller, - key, - promise: undefined, - settled: false, - waiterCount: 0 - }; - - entries.set(key, entry); - entry.promise = Promise.resolve().then(() => createOperation(controller.signal)); - entry.promise.then( - () => settleEntry(key, entry), - () => settleEntry(key, entry) - ); - - return entry; -} - -function releaseWaiter(entry) { - entry.waiterCount = Math.max(0, entry.waiterCount - 1); - if (!entry.settled && entry.waiterCount === 0 && !entry.controller.signal.aborted) { - settleEntry(entry.key, entry); - entry.controller.abort(new SearchOperationAbandonedError()); - } -} - -function waitForEntry(entry, signal) { - entry.waiterCount += 1; - - return new Promise((resolve, reject) => { - let settled = false; - - const finish = (callback, value) => { - if (settled) return; - - settled = true; - signal?.removeEventListener('abort', onAbort); - releaseWaiter(entry); - callback(value); - }; - const onAbort = () => finish(reject, searchDeadline.getAbortReason(signal)); - - if (signal?.aborted) { - onAbort(); - return; - } - - signal?.addEventListener('abort', onAbort, { once: true }); - entry.promise.then( - (value) => finish(resolve, value), - (err) => finish(reject, err) - ); - }); -} - -function acquire(key, createOperation, { signal } = {}) { - searchDeadline.throwIfAborted(signal); - - let entry = entries.get(key); - const shared = Boolean(entry); - if (!entry) { - entry = createEntry(key, createOperation); - } - - return { - promise: waitForEntry(entry, signal), - shared - }; -} - -function clear() { - for (const entry of entries.values()) { - if (!entry.controller.signal.aborted) { - entry.controller.abort(new SearchOperationAbandonedError()); - } - } - entries.clear(); -} - -function getState() { - return { - operationCount: entries.size, - waiterCount: [...entries.values()].reduce((total, entry) => total + entry.waiterCount, 0) - }; -} - -module.exports = { - acquire, - buildKey, - clear, - getState, - SearchOperationAbandonedError -}; diff --git a/utils/searchInFlightRegistry.test.js b/utils/searchInFlightRegistry.test.js deleted file mode 100644 index aed7ad9..0000000 --- a/utils/searchInFlightRegistry.test.js +++ /dev/null @@ -1,133 +0,0 @@ -const searchDeadline = require('./searchDeadline'); -const searchInFlightRegistry = require('./searchInFlightRegistry'); - -function deferred() { - const state = {}; - state.promise = new Promise((resolve, reject) => { - state.resolve = resolve; - state.reject = reject; - }); - return state; -} - -describe('searchInFlightRegistry', () => { - beforeEach(() => { - searchInFlightRegistry.clear(); - }); - - afterEach(() => { - searchInFlightRegistry.clear(); - }); - - test('coalesces waiters for the same operation key', async () => { - const operation = deferred(); - const createOperation = jest.fn(() => operation.promise); - const first = searchInFlightRegistry.acquire('same-key', createOperation); - const second = searchInFlightRegistry.acquire('same-key', createOperation); - await Promise.resolve(); - - expect(first.shared).toBe(false); - expect(second.shared).toBe(true); - expect(createOperation).toHaveBeenCalledTimes(1); - expect(searchInFlightRegistry.getState()).toStrictEqual({ - operationCount: 1, - waiterCount: 2 - }); - - operation.resolve('result'); - - await expect(first.promise).resolves.toBe('result'); - await expect(second.promise).resolves.toBe('result'); - expect(searchInFlightRegistry.getState()).toStrictEqual({ - operationCount: 0, - waiterCount: 0 - }); - }); - - test('keeps shared work active while another waiter remains', async () => { - const operation = deferred(); - const firstController = new AbortController(); - const secondController = new AbortController(); - let operationSignal; - const createOperation = jest.fn((signal) => { - operationSignal = signal; - return operation.promise; - }); - const first = searchInFlightRegistry.acquire('same-key', createOperation, { - signal: firstController.signal - }); - const second = searchInFlightRegistry.acquire('same-key', createOperation, { - signal: secondController.signal - }); - await Promise.resolve(); - - firstController.abort(new searchDeadline.SearchClientDisconnectedError()); - - await expect(first.promise).rejects.toMatchObject({ - code: 'SEARCH_CLIENT_DISCONNECTED' - }); - expect(operationSignal.aborted).toBe(false); - expect(searchInFlightRegistry.getState().waiterCount).toBe(1); - - operation.resolve('result'); - await expect(second.promise).resolves.toBe('result'); - }); - - test('aborts shared work after its final waiter cancels', async () => { - const operation = deferred(); - const controller = new AbortController(); - let operationSignal; - const acquired = searchInFlightRegistry.acquire( - 'same-key', - (signal) => { - operationSignal = signal; - return operation.promise; - }, - { - signal: controller.signal - } - ); - await Promise.resolve(); - - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - await expect(acquired.promise).rejects.toMatchObject({ - code: 'SEARCH_TIMEOUT' - }); - expect(operationSignal.reason).toMatchObject({ - code: 'SEARCH_OPERATION_ABANDONED' - }); - expect(searchInFlightRegistry.getState().operationCount).toBe(0); - - const replacementOperation = jest.fn(() => Promise.resolve('replacement')); - const replacement = searchInFlightRegistry.acquire('same-key', replacementOperation); - - expect(replacement.shared).toBe(false); - await expect(replacement.promise).resolves.toBe('replacement'); - expect(replacementOperation).toHaveBeenCalledTimes(1); - - operation.reject(operationSignal.reason); - await expect(operation.promise).rejects.toMatchObject({ - code: 'SEARCH_OPERATION_ABANDONED' - }); - }); - - test('does not start work for an already-aborted waiter', () => { - const controller = new AbortController(); - const createOperation = jest.fn(); - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - expect(() => - searchInFlightRegistry.acquire('same-key', createOperation, { - signal: controller.signal - }) - ).toThrow(searchDeadline.SearchDeadlineExceededError); - expect(createOperation).not.toHaveBeenCalled(); - }); - - test('buildKey preserves operation type boundaries', () => { - expect(searchInFlightRegistry.buildKey('candidate', ['CVE-2024-0001'])).not.toBe( - searchInFlightRegistry.buildKey('pageFetch', ['CVE-2024-0001']) - ); - }); -}); diff --git a/utils/searchPerformance.js b/utils/searchPerformance.js deleted file mode 100644 index 7b72bb6..0000000 --- a/utils/searchPerformance.js +++ /dev/null @@ -1,78 +0,0 @@ -const searchProvider = require('./searchProvider'); -const searchPostFilters = require('./searchPostFilters'); - -function markTime() { - return process.hrtime.bigint(); -} - -function elapsedMs(start) { - return Number(process.hrtime.bigint() - start) / 1e6; -} - -function roundMs(value) { - return value === undefined ? undefined : Math.round(value * 100) / 100; -} - -function getCacheStatus(metrics, postFilters) { - if (!searchPostFilters.hasCpePostFilters(postFilters)) return 'not_applicable'; - if (metrics.cacheHit === true) return 'hit'; - if (metrics.inFlightHit === true) return 'in_flight'; - - return 'miss'; -} - -function logSearchPerformance(metrics, searchOptions, responseOptions, postFilters) { - const outcome = metrics.outcome ?? 'success'; - const includeDetailedTimings = searchProvider.shouldLogSearchPerformance(); - if (outcome === 'success' && !includeDetailedTimings) return; - - const event = { - event: 'search.performance', - route: '/search', - requestId: metrics.requestId, - outcome, - statusCode: metrics.statusCode, - timeoutSource: metrics.timeoutSource, - abortReason: metrics.abortReason, - strategy: metrics.strategy, - cacheStatus: getCacheStatus(metrics, postFilters), - coldSearchQueueWaitElapsedMs: roundMs(metrics.coldSearchQueueWaitElapsedMs), - coldSearchQueueState: metrics.coldSearchQueueState, - totalElapsedMs: roundMs(metrics.totalElapsedMs) - }; - - if (includeDetailedTimings) { - Object.assign(event, { - optimizedFallback: metrics.optimizedFallback === true, - cacheHit: metrics.cacheHit === true, - inFlightHit: metrics.inFlightHit === true, - pageFetchInFlightHit: metrics.pageFetchInFlightHit === true, - openSearchOperations: metrics.openSearchOperations ?? [], - maxOpenSearchActiveRequests: metrics.maxOpenSearchActiveRequests, - postFilters: Object.keys(postFilters ?? {}), - requestedFrom: responseOptions?.from ?? searchOptions?.from, - requestedSize: responseOptions?.resultsPerPage ?? searchOptions?.resultsPerPage, - openSearchSize: searchOptions?.resultsPerPage, - candidateCount: metrics.candidateCount, - filteredCount: metrics.filteredCount, - returnedCount: metrics.returnedCount, - openSearchTookMs: metrics.openSearchTookMs, - pageFetchTookMs: metrics.pageFetchTookMs, - openSearchElapsedMs: roundMs(metrics.openSearchElapsedMs), - postFilterElapsedMs: roundMs(metrics.postFilterElapsedMs), - inFlightWaitElapsedMs: roundMs(metrics.inFlightWaitElapsedMs), - pageFetchElapsedMs: roundMs(metrics.pageFetchElapsedMs), - pageFetchWaitElapsedMs: roundMs(metrics.pageFetchWaitElapsedMs) - }); - } - - console.log(JSON.stringify(event)); -} - -module.exports = { - elapsedMs, - getCacheStatus, - logSearchPerformance, - markTime, - roundMs -}; diff --git a/utils/searchPerformance.test.js b/utils/searchPerformance.test.js deleted file mode 100644 index 221521f..0000000 --- a/utils/searchPerformance.test.js +++ /dev/null @@ -1,174 +0,0 @@ -const mockShouldLogSearchPerformance = jest.fn(); - -jest.mock('./searchProvider', () => ({ - shouldLogSearchPerformance: mockShouldLogSearchPerformance -})); - -const searchPerformance = require('./searchPerformance'); - -describe('searchPerformance', () => { - beforeEach(() => { - mockShouldLogSearchPerformance.mockReset(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('logSearchPerformance emits redacted search timing metadata when enabled', () => { - mockShouldLogSearchPerformance.mockReturnValue(true); - - searchPerformance.logSearchPerformance( - { - requestId: 'request-123', - outcome: 'success', - statusCode: 200, - strategy: 'cpe-candidate', - cacheHit: true, - inFlightHit: true, - pageFetchInFlightHit: true, - openSearchOperations: ['cpe-candidate', 'page-fetch'], - maxOpenSearchActiveRequests: 3, - candidateCount: 10000, - filteredCount: 4, - returnedCount: 1, - openSearchTookMs: 97, - pageFetchTookMs: 8, - openSearchElapsedMs: 123.456, - postFilterElapsedMs: 7.891, - coldSearchQueueWaitElapsedMs: 2.345, - coldSearchQueueState: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 4 - }, - inFlightWaitElapsedMs: 5.678, - pageFetchElapsedMs: 10.234, - pageFetchWaitElapsedMs: 3.456, - totalElapsedMs: 150.567 - }, - { - resultsPerPage: 10000, - from: 0 - }, - { - resultsPerPage: 1, - from: 0 - }, - { - virtualMatchStringRange: {} - } - ); - - expect(console.log).toHaveBeenCalledTimes(1); - expect(JSON.parse(console.log.mock.calls[0][0])).toStrictEqual({ - event: 'search.performance', - route: '/search', - requestId: 'request-123', - outcome: 'success', - statusCode: 200, - strategy: 'cpe-candidate', - cacheStatus: 'hit', - optimizedFallback: false, - cacheHit: true, - inFlightHit: true, - pageFetchInFlightHit: true, - openSearchOperations: ['cpe-candidate', 'page-fetch'], - maxOpenSearchActiveRequests: 3, - postFilters: ['virtualMatchStringRange'], - requestedFrom: 0, - requestedSize: 1, - openSearchSize: 10000, - candidateCount: 10000, - filteredCount: 4, - returnedCount: 1, - openSearchTookMs: 97, - pageFetchTookMs: 8, - openSearchElapsedMs: 123.46, - postFilterElapsedMs: 7.89, - coldSearchQueueWaitElapsedMs: 2.35, - coldSearchQueueState: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 4 - }, - inFlightWaitElapsedMs: 5.68, - pageFetchElapsedMs: 10.23, - pageFetchWaitElapsedMs: 3.46, - totalElapsedMs: 150.57 - }); - }); - - test('logSearchPerformance does not emit logs when disabled', () => { - mockShouldLogSearchPerformance.mockReturnValue(false); - - searchPerformance.logSearchPerformance( - { - outcome: 'success' - }, - { resultsPerPage: 1, from: 0 }, - undefined, - {} - ); - - expect(console.log).not.toHaveBeenCalled(); - }); - - test.each([{ cvss: {} }, { ssvc: {} }, { kev: {} }])( - 'getCacheStatus reports non-CPE semantic filters as not applicable', - (postFilters) => { - expect(searchPerformance.getCacheStatus({ cacheHit: false, inFlightHit: false }, postFilters)).toBe( - 'not_applicable' - ); - } - ); - - test('logSearchPerformance always emits sanitized failure outcomes', () => { - mockShouldLogSearchPerformance.mockReturnValue(false); - - searchPerformance.logSearchPerformance( - { - requestId: 'request-456', - outcome: 'queue_timeout', - statusCode: 429, - strategy: 'cpe-candidate', - coldSearchQueueWaitElapsedMs: 10000.123, - coldSearchQueueState: { - activeCount: 10, - queuedCount: 499, - maxConcurrent: 10, - maxQueueDepth: 500, - maxWaitMs: 10000 - }, - totalElapsedMs: 10001.456 - }, - undefined, - undefined, - { - virtualMatchString: 'sensitive-value' - } - ); - - expect(console.log).toHaveBeenCalledTimes(1); - expect(JSON.parse(console.log.mock.calls[0][0])).toStrictEqual({ - event: 'search.performance', - route: '/search', - requestId: 'request-456', - outcome: 'queue_timeout', - statusCode: 429, - strategy: 'cpe-candidate', - cacheStatus: 'miss', - coldSearchQueueWaitElapsedMs: 10000.12, - coldSearchQueueState: { - activeCount: 10, - queuedCount: 499, - maxConcurrent: 10, - maxQueueDepth: 500, - maxWaitMs: 10000 - }, - totalElapsedMs: 10001.46 - }); - expect(console.log.mock.calls[0][0]).not.toContain('sensitive-value'); - }); -}); diff --git a/utils/searchPostFilterCache.js b/utils/searchPostFilterCache.js deleted file mode 100644 index f7ed9d8..0000000 --- a/utils/searchPostFilterCache.js +++ /dev/null @@ -1,73 +0,0 @@ -const searchProvider = require('./searchProvider'); - -const entries = new Map(); - -function normalizeForKey(value) { - if (Array.isArray(value)) return value.map(normalizeForKey); - - if (value && typeof value === 'object') { - return Object.keys(value) - .sort() - .reduce((normalized, key) => { - if (value[key] !== undefined) { - normalized[key] = normalizeForKey(value[key]); - } - return normalized; - }, {}); - } - - return value; -} - -function buildCacheKey(candidateBody, postFilters) { - return JSON.stringify( - normalizeForKey({ - candidateBody, - postFilters - }) - ); -} - -function get(cacheKey, now = Date.now()) { - const entry = entries.get(cacheKey); - if (!entry) return null; - - if (entry.expiresAt <= now) { - entries.delete(cacheKey); - return null; - } - - entries.delete(cacheKey); - entries.set(cacheKey, entry); - - return entry.value; -} - -function set(cacheKey, value, now = Date.now()) { - const ttlMs = searchProvider.getPostFilterCacheTtlMs(); - const maxEntries = searchProvider.getPostFilterCacheMaxEntries(); - - if (!maxEntries || maxEntries < 1 || !ttlMs || ttlMs < 1) return; - - entries.set(cacheKey, { - expiresAt: now + ttlMs, - value - }); - - while (entries.size > maxEntries) { - const oldestKey = entries.keys().next().value; - entries.delete(oldestKey); - } -} - -function clear() { - entries.clear(); -} - -module.exports = { - buildCacheKey, - clear, - get, - normalizeForKey, - set -}; diff --git a/utils/searchPostFilterCache.test.js b/utils/searchPostFilterCache.test.js deleted file mode 100644 index 77aff79..0000000 --- a/utils/searchPostFilterCache.test.js +++ /dev/null @@ -1,61 +0,0 @@ -const searchPostFilterCache = require('./searchPostFilterCache'); - -describe('searchPostFilterCache', () => { - beforeEach(() => { - searchPostFilterCache.clear(); - }); - - test('buildCacheKey is stable for equivalent object key ordering', () => { - const left = searchPostFilterCache.buildCacheKey( - { - query: { - bool: { - filter: [{ term: { b: '2' } }], - must: [] - } - }, - size: 10000, - from: 0 - }, - { - virtualMatchStringRange: { - versionEnd: '2.6', - versionStart: '2.2' - } - } - ); - const right = searchPostFilterCache.buildCacheKey( - { - from: 0, - size: 10000, - query: { - bool: { - must: [], - filter: [{ term: { b: '2' } }] - } - } - }, - { - virtualMatchStringRange: { - versionStart: '2.2', - versionEnd: '2.6' - } - } - ); - - expect(left).toBe(right); - }); - - test('get returns cached values until the entry expires', () => { - const key = 'cache-key'; - const value = { - candidateCount: 2, - filteredCveIds: ['CVE-2024-0001'] - }; - - searchPostFilterCache.set(key, value, 1000); - - expect(searchPostFilterCache.get(key, 1001)).toBe(value); - expect(searchPostFilterCache.get(key, 61001)).toBeNull(); - }); -}); diff --git a/utils/searchPostFilterWorkQueue.js b/utils/searchPostFilterWorkQueue.js deleted file mode 100644 index 8180f97..0000000 --- a/utils/searchPostFilterWorkQueue.js +++ /dev/null @@ -1,149 +0,0 @@ -const searchProvider = require('./searchProvider'); -const searchDeadline = require('./searchDeadline'); - -let activeCount = 0; -const queue = []; - -class SearchPostFilterQueueFullError extends Error { - constructor(state) { - super('CPE post-filter cold search queue is full.'); - this.name = 'SearchPostFilterQueueFullError'; - this.code = 'SEARCH_QUEUE_FULL'; - this.state = state; - } -} - -class SearchPostFilterQueueTimeoutError extends Error { - constructor(state, waitElapsedMs) { - super('CPE post-filter cold search queue wait timed out.'); - this.name = 'SearchPostFilterQueueTimeoutError'; - this.code = 'SEARCH_QUEUE_TIMEOUT'; - this.state = state; - this.waitElapsedMs = waitElapsedMs; - } -} - -function getMaxConcurrent() { - return searchProvider.getPostFilterColdSearchConcurrency(); -} - -function getMaxQueueDepth() { - return searchProvider.getPostFilterColdSearchMaxQueueDepth(); -} - -function getMaxWaitMs() { - return searchProvider.getPostFilterColdSearchMaxWaitMs(); -} - -function getState() { - return { - activeCount, - queuedCount: queue.length, - maxConcurrent: getMaxConcurrent(), - maxQueueDepth: getMaxQueueDepth(), - maxWaitMs: getMaxWaitMs() - }; -} - -function isQueueFullError(err) { - return err instanceof SearchPostFilterQueueFullError || err?.code === 'SEARCH_QUEUE_FULL'; -} - -function isQueueTimeoutError(err) { - return err instanceof SearchPostFilterQueueTimeoutError || err?.code === 'SEARCH_QUEUE_TIMEOUT'; -} - -function drain() { - const maxConcurrent = getMaxConcurrent(); - - while (activeCount < maxConcurrent && queue.length) { - const item = queue.shift(); - clearTimeout(item.timeoutId); - item.signal?.removeEventListener('abort', item.onAbort); - activeCount += 1; - - void (async () => { - try { - searchDeadline.throwIfAborted(item.signal); - item.resolve(await item.task(item.signal)); - } catch (err) { - item.reject(err); - } finally { - activeCount -= 1; - drain(); - } - })(); - } -} - -function run(task, { signal } = {}) { - return new Promise((resolve, reject) => { - try { - searchDeadline.throwIfAborted(signal); - } catch (err) { - reject(err); - return; - } - - if (activeCount >= getMaxConcurrent() && queue.length >= getMaxQueueDepth()) { - reject(new SearchPostFilterQueueFullError(getState())); - return; - } - - const item = { - task, - resolve, - reject, - queuedAt: process.hrtime.bigint(), - timeoutId: undefined, - signal, - onAbort: undefined - }; - - if (activeCount >= getMaxConcurrent()) { - item.onAbort = () => { - const queueIndex = queue.indexOf(item); - if (queueIndex === -1) return; - - queue.splice(queueIndex, 1); - clearTimeout(item.timeoutId); - reject(searchDeadline.getAbortReason(signal)); - drain(); - }; - signal?.addEventListener('abort', item.onAbort, { once: true }); - item.timeoutId = setTimeout(() => { - const queueIndex = queue.indexOf(item); - if (queueIndex === -1) return; - - queue.splice(queueIndex, 1); - signal?.removeEventListener('abort', item.onAbort); - const waitElapsedMs = Number(process.hrtime.bigint() - item.queuedAt) / 1e6; - reject(new SearchPostFilterQueueTimeoutError(getState(), waitElapsedMs)); - drain(); - }, getMaxWaitMs()); - item.timeoutId.unref?.(); - } - - queue.push(item); - drain(); - }); -} - -function resetForTests() { - activeCount = 0; - queue.forEach((item) => { - clearTimeout(item.timeoutId); - item.signal?.removeEventListener('abort', item.onAbort); - }); - queue.length = 0; -} - -module.exports = { - SearchPostFilterQueueFullError, - SearchPostFilterQueueTimeoutError, - getState, - isQueueFullError, - isQueueTimeoutError, - resetForTests, - run -}; diff --git a/utils/searchPostFilterWorkQueue.test.js b/utils/searchPostFilterWorkQueue.test.js deleted file mode 100644 index 02ef0b1..0000000 --- a/utils/searchPostFilterWorkQueue.test.js +++ /dev/null @@ -1,200 +0,0 @@ -const mockGetPostFilterColdSearchConcurrency = jest.fn(); -const mockGetPostFilterColdSearchMaxQueueDepth = jest.fn(); -const mockGetPostFilterColdSearchMaxWaitMs = jest.fn(); - -jest.mock('./searchProvider', () => ({ - getPostFilterColdSearchConcurrency: mockGetPostFilterColdSearchConcurrency, - getPostFilterColdSearchMaxQueueDepth: mockGetPostFilterColdSearchMaxQueueDepth, - getPostFilterColdSearchMaxWaitMs: mockGetPostFilterColdSearchMaxWaitMs -})); - -const searchPostFilterWorkQueue = require('./searchPostFilterWorkQueue'); -const searchDeadline = require('./searchDeadline'); - -function deferred() { - const state = {}; - state.promise = new Promise((resolve, reject) => { - state.resolve = resolve; - state.reject = reject; - }); - return state; -} - -function captureTimers() { - const timers = []; - jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => { - const timer = { - unref: jest.fn() - }; - timers.push({ - callback, - delay, - timer - }); - return timer; - }); - jest.spyOn(global, 'clearTimeout').mockImplementation(() => {}); - - return timers; -} - -describe('searchPostFilterWorkQueue', () => { - beforeEach(() => { - searchPostFilterWorkQueue.resetForTests(); - mockGetPostFilterColdSearchConcurrency.mockReset(); - mockGetPostFilterColdSearchMaxQueueDepth.mockReset(); - mockGetPostFilterColdSearchMaxWaitMs.mockReset(); - mockGetPostFilterColdSearchConcurrency.mockReturnValue(1); - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(1); - mockGetPostFilterColdSearchMaxWaitMs.mockReturnValue(10000); - }); - - afterEach(() => { - searchPostFilterWorkQueue.resetForTests(); - jest.restoreAllMocks(); - }); - - test('runs no more than the configured number of cold searches concurrently', async () => { - const first = deferred(); - const second = deferred(); - const firstTask = jest.fn(() => first.promise); - const secondTask = jest.fn(() => second.promise); - - const firstRun = searchPostFilterWorkQueue.run(firstTask); - const secondRun = searchPostFilterWorkQueue.run(secondTask); - await Promise.resolve(); - - expect(firstTask).toHaveBeenCalledTimes(1); - expect(secondTask).not.toHaveBeenCalled(); - expect(searchPostFilterWorkQueue.getState()).toStrictEqual({ - activeCount: 1, - queuedCount: 1, - maxConcurrent: 1, - maxQueueDepth: 1, - maxWaitMs: 10000 - }); - - first.resolve('first'); - await firstRun; - await Promise.resolve(); - - expect(secondTask).toHaveBeenCalledTimes(1); - second.resolve('second'); - - await expect(secondRun).resolves.toBe('second'); - }); - - test('continues draining after a cold search rejects', async () => { - const first = deferred(); - const secondTask = jest.fn(() => 'second'); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(secondTask); - await Promise.resolve(); - - first.reject(new Error('failed')); - await expect(firstRun).rejects.toThrow('failed'); - await expect(secondRun).resolves.toBe('second'); - }); - - test('rejects new cold searches when the waiting queue is full', async () => { - const first = deferred(); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(() => 'second'); - await Promise.resolve(); - - await expect(searchPostFilterWorkQueue.run(() => 'third')).rejects.toMatchObject({ - code: 'SEARCH_QUEUE_FULL', - state: { - activeCount: 1, - queuedCount: 1, - maxConcurrent: 1, - maxQueueDepth: 1, - maxWaitMs: 10000 - } - }); - - first.resolve('first'); - await expect(firstRun).resolves.toBe('first'); - await expect(secondRun).resolves.toBe('second'); - }); - - test('allows immediate execution when queue depth is zero and concurrency is available', async () => { - mockGetPostFilterColdSearchMaxQueueDepth.mockReturnValue(0); - - await expect(searchPostFilterWorkQueue.run(() => 'first')).resolves.toBe('first'); - }); - - test('rejects queued work that exceeds the configured maximum wait', async () => { - const timers = captureTimers(); - const first = deferred(); - const secondTask = jest.fn(() => 'second'); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(secondTask); - const timeoutExpectation = expect(secondRun).rejects.toMatchObject({ - code: 'SEARCH_QUEUE_TIMEOUT', - waitElapsedMs: expect.any(Number), - state: { - activeCount: 1, - queuedCount: 0, - maxConcurrent: 1, - maxQueueDepth: 1, - maxWaitMs: 10000 - } - }); - await Promise.resolve(); - - timers.find(({ delay }) => delay === 10000).callback(); - await timeoutExpectation; - expect(secondTask).not.toHaveBeenCalled(); - - first.resolve('first'); - await expect(firstRun).resolves.toBe('first'); - }); - - test('removes queued work when its operation is aborted', async () => { - const first = deferred(); - const controller = new AbortController(); - const secondTask = jest.fn(); - - const firstRun = searchPostFilterWorkQueue.run(() => first.promise); - const secondRun = searchPostFilterWorkQueue.run(secondTask, { - signal: controller.signal - }); - await Promise.resolve(); - - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - await expect(secondRun).rejects.toMatchObject({ - code: 'SEARCH_TIMEOUT' - }); - expect(secondTask).not.toHaveBeenCalled(); - expect(searchPostFilterWorkQueue.getState().queuedCount).toBe(0); - - first.resolve('first'); - await expect(firstRun).resolves.toBe('first'); - }); - - test('passes the operation signal to active work', async () => { - const controller = new AbortController(); - const task = jest.fn( - (signal) => - new Promise((resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }) - ); - const run = searchPostFilterWorkQueue.run(task, { - signal: controller.signal - }); - await Promise.resolve(); - - controller.abort(new searchDeadline.SearchDeadlineExceededError()); - - await expect(run).rejects.toMatchObject({ - code: 'SEARCH_TIMEOUT' - }); - expect(task).toHaveBeenCalledWith(controller.signal); - }); -}); diff --git a/utils/searchProvider.js b/utils/searchProvider.js index e50b3a6..98e373d 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -14,8 +14,6 @@ class SearchProviderContractError extends Error { let searchProviderConfig; let basicSearchManager; let searchReader; -let activeOpenSearchRequestCount = 0; -const activeOpenSearchRequestsByOperation = new Map(); function configureClientRequestTimeout(client) { if (client?.transport) { @@ -120,31 +118,6 @@ function getSearchReader() { return searchReader; } -function getOpenSearchRequestState() { - return { - activeCount: activeOpenSearchRequestCount, - activeByOperation: Object.fromEntries(activeOpenSearchRequestsByOperation) - }; -} - -function beginOpenSearchRequest(operation) { - activeOpenSearchRequestCount += 1; - activeOpenSearchRequestsByOperation.set(operation, (activeOpenSearchRequestsByOperation.get(operation) ?? 0) + 1); - - return getOpenSearchRequestState(); -} - -function endOpenSearchRequest(operation) { - activeOpenSearchRequestCount = Math.max(0, activeOpenSearchRequestCount - 1); - - const operationCount = activeOpenSearchRequestsByOperation.get(operation) ?? 0; - if (operationCount <= 1) { - activeOpenSearchRequestsByOperation.delete(operation); - } else { - activeOpenSearchRequestsByOperation.set(operation, operationCount - 1); - } -} - function awaitAbortableRequest(request, signal) { if (!signal) return Promise.resolve(request); @@ -177,21 +150,13 @@ function awaitAbortableRequest(request, signal) { }); } -async function executeOpenSearchRequest(createRequest, { signal, operation, onStart } = {}) { +async function executeOpenSearchRequest(createRequest, { signal } = {}) { searchDeadline.throwIfAborted(signal); - const state = beginOpenSearchRequest(operation); - try { - onStart?.(state); - const response = await awaitAbortableRequest(createRequest(), signal); - - return response; - } finally { - endOpenSearchRequest(operation); - } + return awaitAbortableRequest(createRequest(), signal); } -async function searchOpenSearch(body, { signal, operation = 'search', onStart } = {}) { +async function searchOpenSearch(body, { signal } = {}) { searchDeadline.throwIfAborted(signal); const reader = getSearchReader(); const response = await executeOpenSearchRequest( @@ -207,11 +172,7 @@ async function searchOpenSearch(body, { signal, operation = 'search', onStart } requestTimeout: getOpenSearchRequestTimeoutMs() } ), - { - signal, - operation, - onStart - } + { signal } ); return searchResponseValidator.validateOpenSearchResponse(response.body); @@ -240,10 +201,7 @@ async function checkOpenSearchReadiness({ signal } = {}) { requestTimeout: getOpenSearchReadinessTimeoutMs() } ), - { - signal, - operation: 'readiness' - } + { signal } ); searchResponseValidator.validateOpenSearchResponse(response.body); @@ -261,44 +219,6 @@ function getOpenSearchReadinessTimeoutMs() { }); } -function shouldLogSearchPerformance() { - if (!AppConfig.has('search.logPerformanceTimings')) return false; - - return normalizeBoolean(AppConfig.get('search.logPerformanceTimings')); -} - -function getPostFilterCacheTtlMs() { - return getIntegerConfig('search.postFilterCacheTtlMs', constants.SEARCH_POST_FILTER_CACHE_TTL_MS); -} - -function getPostFilterCacheMaxEntries() { - return getIntegerConfig('search.postFilterCacheMaxEntries', constants.SEARCH_POST_FILTER_CACHE_MAX_ENTRIES); -} - -function getPostFilterColdSearchConcurrency() { - return getIntegerConfig( - 'search.postFilterColdSearchConcurrency', - constants.SEARCH_POST_FILTER_COLD_SEARCH_CONCURRENCY, - { min: 1 } - ); -} - -function getPostFilterColdSearchMaxQueueDepth() { - return getIntegerConfig( - 'search.postFilterColdSearchMaxQueueDepth', - constants.SEARCH_POST_FILTER_COLD_SEARCH_MAX_QUEUE_DEPTH, - { min: 0 } - ); -} - -function getPostFilterColdSearchMaxWaitMs() { - return getIntegerConfig( - 'search.postFilterColdSearchMaxWaitMs', - constants.SEARCH_POST_FILTER_COLD_SEARCH_MAX_WAIT_MS, - { min: 1 } - ); -} - function getOpenSearchRequestTimeoutMs() { return getIntegerConfig('search.openSearchRequestTimeoutMs', constants.SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, { min: 1 @@ -313,8 +233,6 @@ function resetSearchProviderForTests() { searchProviderConfig = undefined; basicSearchManager = undefined; searchReader = undefined; - activeOpenSearchRequestCount = 0; - activeOpenSearchRequestsByOperation.clear(); } module.exports = { @@ -322,13 +240,7 @@ module.exports = { getBasicSearchManager, getAllowUnknownSslCerts, getIntegerConfig, - getOpenSearchRequestState, getOpenSearchReadinessTimeoutMs, - getPostFilterCacheMaxEntries, - getPostFilterCacheTtlMs, - getPostFilterColdSearchConcurrency, - getPostFilterColdSearchMaxQueueDepth, - getPostFilterColdSearchMaxWaitMs, getOpenSearchRequestTimeoutMs, getSearchRequestDeadlineMs, getSearchProviderConfig, @@ -337,7 +249,6 @@ module.exports = { normalizeBoolean, resetSearchProviderForTests, searchOpenSearch, - shouldLogSearchPerformance, validateSearchReader, SearchProviderContractError }; diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index b26888f..d18f6e5 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -132,11 +132,7 @@ describe('searchProvider', () => { match_all: {} } }; - const onStart = jest.fn(); - const result = await searchProvider.searchOpenSearch(body, { - operation: 'standard', - onStart - }); + const result = await searchProvider.searchOpenSearch(body); expect(first).toBe(second); expect(mockSearchReader).toHaveBeenCalledTimes(1); @@ -157,16 +153,6 @@ describe('searchProvider', () => { expect(first._client.transport.maxRetries).toBe(0); expect(first._client.transport.requestTimeout).toBe(20000); expect(result).toStrictEqual(openSearchResponse().body); - expect(onStart).toHaveBeenCalledWith({ - activeCount: 1, - activeByOperation: { - standard: 1 - } - }); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('checks OpenSearch readiness through the configured CVE index', async () => { @@ -192,10 +178,6 @@ describe('searchProvider', () => { requestTimeout: 5000 } ); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('fails readiness when the configured CVE index is unavailable', async () => { @@ -222,10 +204,6 @@ describe('searchProvider', () => { code: 'SEARCH_TIMEOUT' }); expect(request.abort).toHaveBeenCalledTimes(1); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('does not construct a SearchReader for an already-canceled readiness check', async () => { @@ -269,8 +247,7 @@ describe('searchProvider', () => { } }, { - signal: controller.signal, - operation: 'standard' + signal: controller.signal } ); await Promise.resolve(); @@ -281,56 +258,6 @@ describe('searchProvider', () => { code: 'SEARCH_TIMEOUT' }); expect(request.abort).toHaveBeenCalledTimes(1); - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); - }); - - test('tracks concurrent OpenSearch requests by operation type', async () => { - const candidate = deferred(); - const pageFetch = deferred(); - const candidateStart = jest.fn(); - const pageFetchStart = jest.fn(); - mockClientSearch.mockReturnValueOnce(candidate.promise).mockReturnValueOnce(pageFetch.promise); - - const candidateSearch = searchProvider.searchOpenSearch( - {}, - { - operation: 'cpe-candidate', - onStart: candidateStart - } - ); - const pageSearch = searchProvider.searchOpenSearch( - {}, - { - operation: 'page-fetch', - onStart: pageFetchStart - } - ); - - expect(candidateStart).toHaveBeenCalledWith({ - activeCount: 1, - activeByOperation: { - 'cpe-candidate': 1 - } - }); - expect(pageFetchStart).toHaveBeenCalledWith({ - activeCount: 2, - activeByOperation: { - 'cpe-candidate': 1, - 'page-fetch': 1 - } - }); - - candidate.resolve(openSearchResponse()); - pageFetch.resolve(openSearchResponse()); - await Promise.all([candidateSearch, pageSearch]); - - expect(searchProvider.getOpenSearchRequestState()).toStrictEqual({ - activeCount: 0, - activeByOperation: {} - }); }); test('does not start an OpenSearch request for an already-aborted signal', async () => { @@ -390,33 +317,12 @@ describe('searchProvider', () => { expect(searchProvider.getSearchProviderConfig().allowUnknownSslCerts).toBe(true); }); - test('reads search performance timing flag from config', () => { - mockAppConfigHas.mockImplementation((key) => key === 'search.logPerformanceTimings'); - mockAppConfigGet.mockImplementation((key) => (key === 'search.logPerformanceTimings' ? 'TRUE' : undefined)); - - expect(searchProvider.shouldLogSearchPerformance()).toBe(true); - }); - - test('reads CPE post-filter runtime tuning from config', () => { + test('reads search timeout runtime settings from config', () => { mockAppConfigHas.mockImplementation((key) => - [ - 'search.postFilterCacheTtlMs', - 'search.postFilterCacheMaxEntries', - 'search.postFilterColdSearchConcurrency', - 'search.postFilterColdSearchMaxQueueDepth', - 'search.postFilterColdSearchMaxWaitMs', - 'search.openSearchRequestTimeoutMs', - 'search.requestDeadlineMs', - 'search.readinessTimeoutMs' - ].includes(key) + ['search.openSearchRequestTimeoutMs', 'search.requestDeadlineMs', 'search.readinessTimeoutMs'].includes(key) ); mockAppConfigGet.mockImplementation((key) => { const values = { - 'search.postFilterCacheTtlMs': '120000', - 'search.postFilterCacheMaxEntries': '250', - 'search.postFilterColdSearchConcurrency': '6', - 'search.postFilterColdSearchMaxQueueDepth': '50', - 'search.postFilterColdSearchMaxWaitMs': '15000', 'search.openSearchRequestTimeoutMs': '25000', 'search.requestDeadlineMs': '45000', 'search.readinessTimeoutMs': '7000' @@ -424,25 +330,15 @@ describe('searchProvider', () => { return values[key]; }); - expect(searchProvider.getPostFilterCacheTtlMs()).toBe(120000); - expect(searchProvider.getPostFilterCacheMaxEntries()).toBe(250); - expect(searchProvider.getPostFilterColdSearchConcurrency()).toBe(6); - expect(searchProvider.getPostFilterColdSearchMaxQueueDepth()).toBe(50); - expect(searchProvider.getPostFilterColdSearchMaxWaitMs()).toBe(15000); expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(25000); expect(searchProvider.getSearchRequestDeadlineMs()).toBe(45000); expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(7000); }); - test('falls back to CPE post-filter runtime defaults when config values are invalid', () => { + test('falls back to search timeout defaults when config values are invalid', () => { mockAppConfigHas.mockReturnValue(true); mockAppConfigGet.mockReturnValue('not-a-number'); - expect(searchProvider.getPostFilterCacheTtlMs()).toBe(60000); - expect(searchProvider.getPostFilterCacheMaxEntries()).toBe(100); - expect(searchProvider.getPostFilterColdSearchConcurrency()).toBe(10); - expect(searchProvider.getPostFilterColdSearchMaxQueueDepth()).toBe(500); - expect(searchProvider.getPostFilterColdSearchMaxWaitMs()).toBe(10000); expect(searchProvider.getOpenSearchRequestTimeoutMs()).toBe(20000); expect(searchProvider.getSearchRequestDeadlineMs()).toBe(30000); expect(searchProvider.getOpenSearchReadinessTimeoutMs()).toBe(5000); diff --git a/utils/searchRateLimiter.js b/utils/searchRateLimiter.js deleted file mode 100644 index 00026e1..0000000 --- a/utils/searchRateLimiter.js +++ /dev/null @@ -1,100 +0,0 @@ -const { AppConfig } = require('cve-core'); -const { rateLimit } = require('express-rate-limit'); -const errors = require('./errors'); -const searchPerformance = require('./searchPerformance'); - -const DEFAULT_SEARCH_RATE_LIMIT_ENABLED = false; -const DEFAULT_SEARCH_RATE_LIMIT_WINDOW_MS = 60000; -const GLOBAL_SEARCH_RATE_LIMIT_KEY = 'search-api-process'; - -const error = new errors.errors(); - -function parseBoolean(value) { - if (typeof value === 'boolean') return value; - - const normalized = String(value).trim().toLowerCase(); - if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; - if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; - - return undefined; -} - -function readBooleanConfig(path, defaultValue) { - if (!AppConfig.has(path)) return defaultValue; - - const value = parseBoolean(AppConfig.get(path)); - if (value === undefined) throw new Error(`${path} must be a boolean.`); - - return value; -} - -function readPositiveIntegerConfig(path, defaultValue) { - if (!AppConfig.has(path)) return defaultValue; - - const value = Number(AppConfig.get(path)); - if (!Number.isInteger(value) || value < 1) return undefined; - - return value; -} - -function getSearchRateLimitConfig() { - const enabled = readBooleanConfig('rateLimiting.search.enabled', DEFAULT_SEARCH_RATE_LIMIT_ENABLED); - if (!enabled) { - return Object.freeze({ - enabled, - windowMs: DEFAULT_SEARCH_RATE_LIMIT_WINDOW_MS - }); - } - - const windowMs = readPositiveIntegerConfig('rateLimiting.search.windowMs', DEFAULT_SEARCH_RATE_LIMIT_WINDOW_MS); - const maxRequests = readPositiveIntegerConfig('rateLimiting.search.maxRequests'); - - if (!windowMs) throw new Error('rateLimiting.search.windowMs must be a positive integer when enabled.'); - if (!maxRequests) throw new Error('rateLimiting.search.maxRequests must be a positive integer when enabled.'); - - return Object.freeze({ - enabled, - windowMs, - maxRequests - }); -} - -function disabledRateLimiter(_req, _res, next) { - next(); -} - -function handleRateLimit(req, res, _next, options) { - searchPerformance.logSearchPerformance( - { - requestId: req.requestId, - outcome: 'rate_limited', - statusCode: options.statusCode - }, - undefined, - undefined, - undefined - ); - - return res.status(options.statusCode).json(error.searchRateLimitedError()); -} - -function createSearchRateLimiter(config = getSearchRateLimitConfig()) { - if (!config.enabled) return disabledRateLimiter; - - return rateLimit({ - windowMs: config.windowMs, - limit: config.maxRequests, - standardHeaders: 'draft-8', - legacyHeaders: false, - identifier: GLOBAL_SEARCH_RATE_LIMIT_KEY, - keyGenerator: () => GLOBAL_SEARCH_RATE_LIMIT_KEY, - handler: handleRateLimit, - passOnStoreError: false - }); -} - -module.exports = { - createSearchRateLimiter, - getSearchRateLimitConfig, - handleRateLimit -}; diff --git a/utils/searchRateLimiter.test.js b/utils/searchRateLimiter.test.js deleted file mode 100644 index da00ea6..0000000 --- a/utils/searchRateLimiter.test.js +++ /dev/null @@ -1,127 +0,0 @@ -const express = require('express'); -const { request } = require('../test/http'); - -const mockAppConfigHas = jest.fn(); -const mockAppConfigGet = jest.fn(); -const mockLogSearchPerformance = jest.fn(); - -jest.mock('cve-core', () => ({ - AppConfig: { - get: mockAppConfigGet, - has: mockAppConfigHas - } -})); - -jest.mock('./searchPerformance', () => ({ - logSearchPerformance: mockLogSearchPerformance -})); - -const searchRateLimiter = require('./searchRateLimiter'); - -describe('searchRateLimiter', () => { - beforeEach(() => { - mockAppConfigHas.mockReset(); - mockAppConfigGet.mockReset(); - mockLogSearchPerformance.mockReset(); - mockAppConfigHas.mockReturnValue(false); - }); - - test('defaults to a disabled limiter that passes requests through', () => { - const next = jest.fn(); - const limiter = searchRateLimiter.createSearchRateLimiter(); - - limiter({}, {}, next); - - expect(next).toHaveBeenCalledTimes(1); - }); - - test('resolves enabled rate limit configuration from AppConfig', () => { - const values = { - 'rateLimiting.search.enabled': 'TRUE', - 'rateLimiting.search.windowMs': '30000', - 'rateLimiting.search.maxRequests': '25' - }; - mockAppConfigHas.mockImplementation((path) => Object.hasOwn(values, path)); - mockAppConfigGet.mockImplementation((path) => values[path]); - - expect(searchRateLimiter.getSearchRateLimitConfig()).toStrictEqual({ - enabled: true, - windowMs: 30000, - maxRequests: 25 - }); - }); - - test.each([ - [ - { - 'rateLimiting.search.enabled': 'invalid' - }, - 'rateLimiting.search.enabled must be a boolean.' - ], - [ - { - 'rateLimiting.search.enabled': 'TRUE', - 'rateLimiting.search.windowMs': '0', - 'rateLimiting.search.maxRequests': '25' - }, - 'rateLimiting.search.windowMs must be a positive integer when enabled.' - ], - [ - { - 'rateLimiting.search.enabled': 'TRUE', - 'rateLimiting.search.windowMs': '60000', - 'rateLimiting.search.maxRequests': '0' - }, - 'rateLimiting.search.maxRequests must be a positive integer when enabled.' - ] - ])('rejects invalid explicit configuration', (values, expectedMessage) => { - mockAppConfigHas.mockImplementation((path) => Object.hasOwn(values, path)); - mockAppConfigGet.mockImplementation((path) => values[path]); - - expect(() => searchRateLimiter.getSearchRateLimitConfig()).toThrow(expectedMessage); - }); - - test('enforces a process-wide limit with standard headers and sanitized telemetry', async () => { - const app = express(); - const limiter = searchRateLimiter.createSearchRateLimiter({ - enabled: true, - windowMs: 60000, - maxRequests: 1 - }); - - app.use((req, res, next) => { - req.requestId = 'request-123'; - next(); - }); - app.post('/search', limiter, (req, res) => res.status(200).json({ ok: true })); - - const accepted = await request(app, { - method: 'POST', - path: '/search' - }); - const rejected = await request(app, { - method: 'POST', - path: '/search' - }); - - expect(accepted.status).toBe(200); - expect(accepted.headers.ratelimit).toBeDefined(); - expect(accepted.headers['ratelimit-policy']).toBeDefined(); - expect(rejected.status).toBe(429); - expect(rejected.headers['retry-after']).toBeDefined(); - expect(rejected.body).toStrictEqual({ - error: 'SEARCH_RATE_LIMITED', - message: 'Too many search requests. Please retry later.' - }); - expect(mockLogSearchPerformance).toHaveBeenCalledWith( - { - requestId: 'request-123', - outcome: 'rate_limited', - statusCode: 429 - }, - undefined, - undefined, - undefined - ); - }); -}); From fad3eefe991021b17ef9232e194d5450e8f010e9 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 20 Aug 2026 16:34:00 -0400 Subject: [PATCH 21/27] Override vulnerable adm-zip dependency --- package-lock.json | 8 ++++---- package.json | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8b2003..48d2e11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1512,12 +1512,12 @@ } }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/ajv": { diff --git a/package.json b/package.json index 5b84401..1cd2c2b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "swagger-autogen": "^2.23.7" }, "overrides": { + "adm-zip": "0.6.0", "axios": "1.18.0", "brace-expansion": "5.0.9", "js-yaml": "4.3.1", From b75378d2ae4159470d22a6ea59a734a5a0ec4cd7 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Fri, 21 Aug 2026 11:42:23 -0400 Subject: [PATCH 22/27] Address pre-publication review findings --- README.md | 14 +- api-docs/openapi.json | 324 +++++++++++++++++- api-docs/openapi.test.js | 37 +- config/default.jsonc | 6 +- config/devel.jsonc | 2 - config/prod.jsonc | 1 - config/runtime-config.test.js | 5 +- controllers/searchController.js | 37 +- controllers/searchController.test.js | 129 ++++--- jest.config.js | 8 +- package-lock.json | 45 --- package.json | 2 - routes/index.js | 99 +++++- routes/index.test.js | 2 +- routes/search.integration.test.js | 12 +- routes/swagger.js | 177 +++++++++- server.js | 2 - .../CVE-Search-API.postman_collection.json | 2 +- test/postman/README.md | 2 +- test/postman/buildCollection.js | 2 +- utils/cvss.js | 12 +- utils/cvss.test.js | 10 + utils/errors.js | 2 +- utils/errors.test.js | 2 +- utils/searchProvider.js | 35 +- utils/searchProvider.test.js | 51 ++- utils/searchQueryBuilder.js | 54 ++- utils/searchQueryBuilder.test.js | 113 +++--- utils/searchReadiness.js | 133 +++++++ utils/searchReadiness.test.js | 48 +++ 30 files changed, 1068 insertions(+), 300 deletions(-) create mode 100644 utils/searchReadiness.js create mode 100644 utils/searchReadiness.test.js diff --git a/README.md b/README.md index c3e01e7..3d3b8e0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rename `.env.example` to `.env` and replace or check the following values. - `SearchReadinessTimeoutMs` - optional timeout for the OpenSearch index readiness check, default `5000` - `RequestLogSuccessSampleRate` - optional fraction of successful non-health-check access logs to retain, default `0.1` - `RequestLogHealthChecks` - optional flag to retain successful health-check access logs, default `false` -- `WebSearchEnabled` - optional deprecated `/webSearch` feature flag, default `true` +- `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` - `NPM_CA_CERT` - optional absolute host path to a corporate or self-signed CA certificate bundle used only during local Docker Compose builds ```bash @@ -80,7 +80,7 @@ The Search API will be served at `localhost:3000`. `GET /api/health-check` is a process liveness check. It returns HTTP 200 when the Node.js process is running and deliberately does not contact OpenSearch. Use it where a dependency outage should not restart otherwise healthy API tasks. -`GET /api/ready-check` sends a minimal zero-result search to the configured CVE index. This verifies connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when search requests can be served. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. The OpenSearch request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. +`GET /api/ready-check` first verifies that the configured CVE index exposes the field capabilities required by the `/search` contract, then sends a minimal zero-result search. This checks required mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when search requests can be served. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, missing or incompatible field mappings, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. Each OpenSearch readiness request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior. @@ -212,7 +212,7 @@ Numeric segments used in version range comparisons ignore leading zeros. A store Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring concrete leading components, such as `part` and `vendor` for normal searches and `part`, `vendor`, and `product` for version range searches, would reduce the most expensive public search shapes. Narrowing the accepted searches should be handled as a policy decision before implementation. -`POST /webSearch` is deprecated in favor of `POST /search`. It remains enabled by default during consumer migration and can be disabled without a code change by setting `WebSearchEnabled=false` and restarting the service. When disabled, it returns HTTP `410` with the stable error code `WEB_SEARCH_DISABLED` before validating the request or contacting OpenSearch. +`POST /webSearch` is a supported raw-query endpoint. It is enabled by default and can be disabled without a code change by setting `WebSearchEnabled=false` and restarting the service. When disabled, it returns HTTP `410` with the stable error code `WEB_SEARCH_DISABLED` before validating the request or contacting OpenSearch. While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It also accepts optional `sort`, `from`, and `size` fields: @@ -233,6 +233,10 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al - `from` must be an integer greater than or equal to `0` - `size` must be an integer from `1` to `500` +The controller validates successful `BasicSearchManager` responses before returning HTTP 200. A response without the expected status metadata, total-hit count, or hit array is treated as an invalid upstream response and returns a sanitized HTTP 502. + +Known dependency limitation: the current `cve-core` `BasicSearchManager` changes `size` to `from + 1` whenever the requested `size` is smaller than `from`. For example, `{ "from": 100, "size": 1 }` is sent to OpenSearch with `size: 101`. This behavior originates in `cve-core` and remains tracked while `/webSearch` uses `BasicSearchManager`; the Search API controller cannot correct the request after the dependency constructs and executes it. + ## Swagger API docs Swagger API docs are regenerated by `npm run dev` and can be regenerated manually using: @@ -274,10 +278,12 @@ Each `/search` request owns a lifecycle cancellation signal. When its overall de ### Semantic Post-Filtering -CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every token supplied to one CVSS vector filter must occur in the same eligible vector. +CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every slash-delimited token supplied to one CVSS vector filter must match a complete token in the same eligible vector; a base token such as `AV:N` does not match a modified token such as `MAV:N`. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. +`/search` orders OpenSearch results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pageSize` is the number of records actually returned, including zero for an out-of-range page. A stable sort does not provide a point-in-time snapshot, so index updates between separate page requests can still shift results until PIT-based pagination is implemented. + The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and performance telemetry must be supplied by the deployment infrastructure when those capabilities are enabled. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed the 20-second default timeout. The same response is returned when a `/search` request exceeds the 30-second default overall deadline, and the active OpenSearch request is aborted. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index c1f1f50..283caa7 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -26,6 +26,13 @@ "responses": { "200": { "description": "A list of matching CVE records", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + }, "headers": { "X-Request-ID": { "description": "Request correlation ID", @@ -36,16 +43,54 @@ } }, "400": { - "description": "Malformed JSON, invalid request body, parameter combination, or version range" + "description": "Malformed JSON, invalid request body, parameter combination, or version range", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } }, "404": { - "description": "Invalid cpeName value" + "description": "Invalid cpeName value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } }, "415": { - "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding" + "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "OpenSearch returned an invalid or unexpected response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } }, "503": { "description": "OpenSearch is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, "headers": { "Retry-After": { "description": "Seconds before retrying an unavailable OpenSearch dependency", @@ -56,7 +101,14 @@ } }, "504": { - "description": "Search exceeded its OpenSearch or overall processing deadline" + "description": "Search exceeded its OpenSearch or overall processing deadline", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } } }, "requestBody": { @@ -166,7 +218,7 @@ "/api/ready-check": { "get": { "summary": "OpenSearch readiness check", - "description": "Sends a minimal zero-result search to the configured CVE index. Returns 200 when the index is available with search permission, or a sanitized 503 response when configuration, connectivity, timeout, permission, or index availability checks fail.", + "description": "Validates the field capabilities required by the search contract, then sends a minimal zero-result search to the configured CVE index. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails.", "operationId": "readinessCheck", "responses": { "200": { @@ -182,6 +234,13 @@ }, "503": { "description": "Search service is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, "headers": { "Retry-After": { "description": "Seconds before retrying the readiness check", @@ -205,12 +264,19 @@ "tags": [ "CVE Record" ], - "summary": "Deprecated raw query search", - "description": "

Expected Behavior

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

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

Expected Behavior

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

", "operationId": "webSearch", "responses": { "200": { "description": "A list of matching CVE records", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchResponse" + } + } + }, "headers": { "X-Request-ID": { "description": "Request correlation ID", @@ -224,10 +290,34 @@ "description": "Bad Request" }, "410": { - "description": "The deprecated endpoint is disabled; use POST /search" + "description": "The endpoint is disabled by service configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "The search backend returned an error or invalid response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchResponse" + } + } + } }, "504": { - "description": "Search exceeded its OpenSearch request deadline" + "description": "Search exceeded its OpenSearch request deadline", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebSearchResponse" + } + } + } } }, "requestBody": { @@ -278,13 +368,180 @@ } } } - }, - "deprecated": true + } } } }, "components": { "schemas": { + "ApiError": { + "type": "object", + "additionalProperties": false, + "required": [ + "error", + "message" + ], + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ApiErrorResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/ApiError" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiError" + } + } + ] + }, + "SearchHit": { + "type": "object", + "additionalProperties": true, + "required": [ + "_source" + ], + "properties": { + "_id": { + "type": "string" + }, + "_source": { + "type": "object", + "additionalProperties": true + } + } + }, + "SearchHits": { + "type": "object", + "additionalProperties": true, + "required": [ + "total", + "hits" + ], + "properties": { + "total": { + "type": "object", + "additionalProperties": true, + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "minimum": 0 + } + } + }, + "hits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchHit" + } + } + } + }, + "SearchResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "page", + "pageSize", + "totalPages", + "status", + "data" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 0, + "description": "One-based page number, or 0 when the search has no matches." + }, + "pageSize": { + "type": "integer", + "minimum": 0, + "description": "Number of CVE records returned in this response." + }, + "totalPages": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "data": { + "type": "object", + "additionalProperties": true, + "required": [ + "hits" + ], + "properties": { + "took": { + "type": "number", + "minimum": 0 + }, + "hits": { + "$ref": "#/components/schemas/SearchHits" + } + } + } + } + }, + "WebSearchResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "searchMetadata", + "resultsTotal", + "data" + ], + "properties": { + "searchMetadata": { + "type": "object", + "additionalProperties": true, + "required": [ + "searchStatus", + "version", + "errors" + ], + "properties": { + "searchStatus": { + "type": "string" + }, + "version": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "resultsTotal": { + "type": "integer", + "minimum": 0 + }, + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, "SearchRequest": { "type": "object", "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.", @@ -307,9 +564,20 @@ "description": "Returns CVE records where a word or phrase is included in a description selected by scope. Multiple keywords operate like an AND statement, returning records whose descriptions include every keyword. Limited to 512 characters and 64 whitespace-delimited terms." }, "keywordExactMatch": { - "type": "boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], "default": false, - "description": "When true, a multi-word keywordSearch value must appear exactly and in the same order. keywordSearch is required when keywordExactMatch is true." + "description": "When true, a multi-word keywordSearch value must appear exactly and in the same order. keywordSearch is required when keywordExactMatch is true. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "cveIds": { "type": "string", @@ -318,9 +586,20 @@ "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." }, "noRejected": { - "type": "boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], "default": false, - "description": "When true, excludes CVE records with REJECT, REJECTED, or Rejected status." + "description": "When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "cweId": { "type": "string", @@ -414,9 +693,20 @@ "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { - "type": "boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], "default": false, - "description": "When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable." + "description": "When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "lastModStartDate": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 8adb8d3..b3c5d0a 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -74,8 +74,18 @@ describe('OpenAPI contract', () => { Object.entries(constants.SEARCH_STRING_LENGTH_LIMITS).forEach(([field, maxLength]) => { expect(schema.properties[field].maxLength).toBe(maxLength); }); - expect(schema.properties.noRejected.type).toBe('boolean'); + expect(schema.properties.noRejected.oneOf).toStrictEqual([ + { + type: 'boolean' + }, + { + type: 'string', + enum: ['true', 'false'] + } + ]); expect(schema.properties.noRejected.description).toContain('When true'); + expect(schema.properties.keywordExactMatch.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); + expect(schema.properties.isVulnerable.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); expect(schema.properties.scope.enum).toStrictEqual(['FULL', 'CNA_ONLY', 'ADP_ONLY']); expect(schema.properties.scope.default).toBe('FULL'); expect(schema.properties.scope.description).toContain('does not remove data'); @@ -128,8 +138,8 @@ describe('OpenAPI contract', () => { const operation = openapi.paths['/webSearch'].post; const schema = operation.requestBody.content['application/json'].schema; - expect(operation.deprecated).toBe(true); - expect(operation.description).toContain('Use POST /search instead'); + expect(operation.deprecated).toBeUndefined(); + expect(operation.description).toContain('raw query expression'); expect(operation.parameters || []).toStrictEqual([]); expect(operation.requestBody.required).toBe(true); expect(schema.required).toStrictEqual(['query']); @@ -138,7 +148,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.sort.additionalProperties).toBe(false); expect(schema.properties.sort.properties.property.enum).toStrictEqual(['cveId', 'relevancy']); expect(schema.properties.size.maximum).toBe(constants.MAX_RESULTS_LIMIT); - expect(operation.responses['410'].description).toContain('use POST /search'); + expect(operation.responses['410'].description).toContain('disabled by service configuration'); }); test('documents cpeName as a CPE Match Criteria filter with required components', () => { @@ -163,6 +173,7 @@ describe('OpenAPI contract', () => { expect(responses['400'].description).toContain('Malformed JSON'); expect(responses['415'].description).toContain('application/json'); + expect(responses['502'].description).toContain('invalid or unexpected response'); expect(responses['503'].description).toContain('OpenSearch'); expect(responses['503'].headers['Retry-After']).toStrictEqual({ description: 'Seconds before retrying an unavailable OpenSearch dependency', @@ -172,6 +183,24 @@ describe('OpenAPI contract', () => { }); }); + test('documents reusable success and error response schemas', () => { + const schemas = openapi.components.schemas; + const searchResponse = schemas.SearchResponse; + + expect(searchResponse.required).toStrictEqual(['page', 'pageSize', 'totalPages', 'status', 'data']); + expect(searchResponse.properties.pageSize.description).toContain('returned in this response'); + expect(searchResponse.properties.data.properties.hits.$ref).toBe('#/components/schemas/SearchHits'); + expect(schemas.SearchHits.properties.hits.items.$ref).toBe('#/components/schemas/SearchHit'); + expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); + expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); + expect(openapi.paths['/search'].post.responses['200'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/SearchResponse' + ); + expect(openapi.paths['/webSearch'].post.responses['200'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/WebSearchResponse' + ); + }); + test('documents request correlation headers', () => { expect(openapi.paths['/search'].post.responses['200'].headers['X-Request-ID']).toStrictEqual({ description: 'Request correlation ID', diff --git a/config/default.jsonc b/config/default.jsonc index 8759707..45d6a0a 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -8,8 +8,6 @@ "appConfig": { // constants for search capability "search": { - // minimum versions for servers that are compatible with current code - "minServer": ["elasticsearch:7.10.2", "opensearch:3.5.0"], // setting this to FALSE (recommended) requires an SSL cert to access the search server // The only time this should be allowed to be true is when developing or testing // using containers that do not have SSL certs @@ -28,9 +26,9 @@ // Set to TRUE to retain successful health-check access logs. "healthChecks": "FALSE" }, - // Feature flags used for controlled endpoint migrations. + // Feature flags used for operator-controlled endpoint availability. "features": { - // Keep enabled until existing consumers have migrated to POST /search. + // Set to FALSE only when an environment must disable POST /webSearch. "webSearchEnabled": "TRUE" } } diff --git a/config/devel.jsonc b/config/devel.jsonc index 0a6569f..0eb0fc4 100644 --- a/config/devel.jsonc +++ b/config/devel.jsonc @@ -6,8 +6,6 @@ "appConfig": { // constants for search capability "search": { - // minimum versions for servers that are compatible with current code - "minServer": ["opensearch:3.5.0"], // URL to reach search server "providerEndpoint": "https://localhost:9200", // index on search server related to searching CVEs diff --git a/config/prod.jsonc b/config/prod.jsonc index 5aeb014..eb4d49c 100644 --- a/config/prod.jsonc +++ b/config/prod.jsonc @@ -5,7 +5,6 @@ // NOTE for consistency, all values need to be strings for proper type when using .env overrides "appConfig": { "search": { - "minServer": ["elasticsearch:7.10.2", "opensearch:3.5.0"], "providerEndpoint": "", // preference is to specify this in a (secret) environment variable on production platforms "index": "", // preference is to specify this in a (secret) environment variable on production platforms "allowUnknownSslCerts": "FALSE" diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index c65066b..c94173e 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -97,12 +97,11 @@ describe('runtime container and compose configuration', () => { }); test.each([['config/default.jsonc'], ['config/devel.jsonc'], ['config/prod.jsonc']])( - '%s references OpenSearch 3.5 as the minimum OpenSearch version', + '%s does not retain the unused minServer capability configuration', (filePath) => { const config = readProjectFile(filePath); - expect(config).toContain('"opensearch:3.5.0"'); - expect(config).not.toContain('"opensearch:2.10.0"'); + expect(config).not.toContain('"minServer"'); } ); diff --git a/controllers/searchController.js b/controllers/searchController.js index 572a925..b8d97a8 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -72,6 +72,26 @@ function buildWebSearchSort(sort) { return undefined; } +function getWebSearchSuccessData(searchResponse) { + if (typeof searchResponse?.isOk !== 'function' || searchResponse.isOk() !== true) return null; + + const hits = searchResponse?.data?.hits; + const totalValue = hits?.total?.value; + const hasValidMetadata = + typeof searchResponse.status === 'string' && + Array.isArray(searchResponse.notes) && + Array.isArray(searchResponse.errors); + + if (!hasValidMetadata || !Array.isArray(hits?.hits) || !Number.isSafeInteger(totalValue) || totalValue < 0) { + return null; + } + + return { + hits: hits.hits, + totalValue + }; +} + function getHitCveId(hit) { return hit?._source?.cveMetadata?.cveId ?? hit?._id; } @@ -198,7 +218,7 @@ async function searchHandler(req, res, next) { //Calculate current page and total pages to append to JSON output const totalValue = resp.data.hits.total.value; const requestedPageSize = pagingOptions.resultsPerPage; - const pageSize = Math.min(totalValue, requestedPageSize); + const pageSize = resp.data.hits.hits.length; const page = totalValue === 0 ? 0 : Math.floor(pagingOptions.from / requestedPageSize) + 1; const totalPages = totalValue === 0 ? 0 : Math.ceil(totalValue / requestedPageSize); @@ -261,21 +281,14 @@ async function webSearchHandler(req, res, next) { const queryTime = Date.now() - _startTime; body.searchMetadata.queryTime_ms = queryTime; - /* build response */ - const respData = searchResponse?.data; - let resultsTotal = 0; - let data = []; - if (respData && respData['hits'] && respData['hits']['hits']) { - resultsTotal = searchResponse?.data['hits']['total']['value'] || 0; - data = searchResponse?.data['hits']['hits'] || []; - } - if (searchResponse.isOk()) { + const successData = getWebSearchSuccessData(searchResponse); + if (successData) { status = 200; body.searchMetadata.searchStatus = searchResponse.status; body.searchMetadata.notes = searchResponse.notes; body.searchMetadata.errors = searchResponse.errors; - body.resultsTotal = resultsTotal; - body.data = data; + body.resultsTotal = successData.totalValue; + body.data = successData.hits; } else { status = 502; body.searchMetadata.errors = [error.upstreamSearchError()]; diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index ade95e3..c7d1702 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -74,6 +74,17 @@ function basicResponse({ ok = true, status = 'ok', total = 0, hits = [], notes = }; } +function cvssTokenRegexp(field, token) { + return { + regexp: { + [`${field}.keyword`]: { + value: `(.*/)?${token}(/.*)?`, + case_insensitive: true + } + } + }; +} + function captureTimers() { const timers = []; jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => { @@ -192,7 +203,14 @@ describe('Search controller SEARCH handler', () => { }, track_total_hits: true, size: 10, - from: 20 + from: 20, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }, expect.objectContaining({ signal: expect.any(AbortSignal) @@ -202,7 +220,7 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 3, - pageSize: 10, + pageSize: 1, totalPages: 3, ...resp }); @@ -488,7 +506,14 @@ describe('Search controller SEARCH handler', () => { }, track_total_hits: true, size: 1, - from: 0 + from: 0, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }, expect.objectContaining({ signal: expect.any(AbortSignal) @@ -847,27 +872,9 @@ describe('Search controller SEARCH handler', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AC:L*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*Au:N*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AC:L'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AU:N') ] } } @@ -894,40 +901,16 @@ describe('Search controller SEARCH handler', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AC:L*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AC:L') ] } }, { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*PR:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*UI:R*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'PR:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'UI:R') ] } } @@ -1090,7 +1073,7 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 2, - pageSize: 5, + pageSize: 0, totalPages: 1, ...resp }); @@ -1467,6 +1450,46 @@ describe('Search controller WEB_SEARCH handler', () => { }); }); + test('WEB_SEARCH fails closed when cve-core reports success without a complete hits response', async () => { + mockBasicSearch.mockResolvedValue({ + status: 'ok', + notes: [], + errors: [], + data: {}, + isOk: jest.fn(() => true) + }); + + await controller.WEB_SEARCH( + { + body: { + query: 'apache' + } + }, + res, + next + ); + + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json.mock.calls[0][0]).toStrictEqual({ + searchMetadata: { + searchStatus: 'errors', + version: '2025-08-26', + cveCore: 'test-core-version', + cveMitreCore: 'test-core-version', + notes: [], + errors: [ + { + error: 'UPSTREAM_SEARCH_ERROR', + message: 'Search backend returned an error. Please try again later.' + } + ], + queryTime_ms: expect.any(Number) + }, + resultsTotal: 0, + data: [] + }); + }); + test('WEB_SEARCH returns a sanitized error when BasicSearchManager throws', async () => { mockBasicSearch.mockRejectedValue(new Error('boom')); diff --git a/jest.config.js b/jest.config.js index dd834cb..36247ba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,13 +2,7 @@ const config = { clearMocks: true, collectCoverage: false, - collectCoverageFrom: [ - 'controllers/**/*.js', - 'routes/index.js', - 'server.js', - 'utils/**/*.js', - '!controllers/wrapper.js' - ], + collectCoverageFrom: ['controllers/**/*.js', 'routes/index.js', 'server.js', 'utils/**/*.js'], coverageDirectory: 'coverage', coverageProvider: 'v8', coverageReporters: ['json', 'text', 'lcov'], diff --git a/package-lock.json b/package-lock.json index 48d2e11..cfad6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,9 @@ "dependencies": { "cors": "^2.8.5", "cve-core": "github:CVEProject/cve-core.git#dev-package-test", - "debug": "~3.2.7", "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "http-errors": "~1.8.1", "morgan": "~1.11.0", "swagger-ui-express": "^5.0.1" }, @@ -2319,15 +2317,6 @@ "date-fns": "2.x" } }, - "node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -3413,40 +3402,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-errors/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", diff --git a/package.json b/package.json index 1cd2c2b..1f07d5b 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,9 @@ "dependencies": { "cors": "^2.8.5", "cve-core": "github:CVEProject/cve-core.git#dev-package-test", - "debug": "~3.2.7", "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "http-errors": "~1.8.1", "morgan": "~1.11.0", "swagger-ui-express": "^5.0.1" }, diff --git a/routes/index.js b/routes/index.js index 9a62b1f..1858700 100644 --- a/routes/index.js +++ b/routes/index.js @@ -280,6 +280,13 @@ router.post( } #swagger.responses[200] = { description: 'A list of matching CVE records', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/SearchResponse" + } + } +}, headers: { "X-Request-ID": { description: "Request correlation ID", @@ -291,15 +298,53 @@ headers: { } #swagger.responses[400] = { description: 'Malformed JSON, invalid request body, parameter combination, or version range', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiErrorResponse" + } + } +}, } #swagger.responses[404] = { description: 'Invalid cpeName value', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiErrorResponse" + } + } +}, } #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, +} +#swagger.responses[502] = { +description: 'OpenSearch returned an invalid or unexpected response', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, } #swagger.responses[503] = { description: 'OpenSearch is unavailable', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, headers: { "Retry-After": { description: "Seconds before retrying an unavailable OpenSearch dependency", @@ -311,6 +356,13 @@ headers: { } #swagger.responses[504] = { description: 'Search exceeded its OpenSearch or overall processing deadline', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, } */ requireSearchJsonContentType, @@ -375,7 +427,7 @@ router.get( /* #swagger.operationId = 'readinessCheck' #swagger.summary = "OpenSearch readiness check" -#swagger.description = "Sends a minimal zero-result search to the configured CVE index. Returns 200 when the index is available with search permission, or a sanitized 503 response when configuration, connectivity, timeout, permission, or index availability checks fail." +#swagger.description = "Validates the field capabilities required by the search contract, then sends a minimal zero-result search to the configured CVE index. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails." #swagger.responses[200] = { description: 'API is ready to serve search requests', headers: { @@ -389,6 +441,13 @@ headers: { } #swagger.responses[503] = { description: 'Search service is not ready', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, headers: { "Retry-After": { description: "Seconds before retrying the readiness check", @@ -413,11 +472,10 @@ router.post( /* #swagger.tags = ['CVE Record'] #swagger.operationId = 'webSearch' -#swagger.deprecated = true -#swagger.summary = "Deprecated raw query search" +#swagger.summary = "Raw query search" #swagger.description = "

Expected Behavior

-

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

+

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

#swagger.requestBody = { required: true, content: { @@ -461,6 +519,13 @@ router.post( } #swagger.responses[200] = { description: 'A list of matching CVE records', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebSearchResponse" + } + } +}, headers: { "X-Request-ID": { description: "Request correlation ID", @@ -472,9 +537,33 @@ headers: { } #swagger.responses[504] = { description: 'Search exceeded its OpenSearch request deadline', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebSearchResponse" + } + } +}, } #swagger.responses[410] = { -description: 'The deprecated endpoint is disabled; use POST /search', +description: 'The endpoint is disabled by service configuration', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ApiError" + } + } +}, +} +#swagger.responses[502] = { +description: 'The search backend returned an error or invalid response', +content: { + "application/json": { + schema: { + $ref: "#/components/schemas/WebSearchResponse" + } + } +}, } */ requireWebSearchEnabled, diff --git a/routes/index.test.js b/routes/index.test.js index a041d7a..9be9c4d 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -401,7 +401,7 @@ describe('routes/index router', () => { expect(response.status).toBe(410); expect(response.body).toStrictEqual({ error: 'WEB_SEARCH_DISABLED', - message: 'The /webSearch endpoint is disabled. Use POST /search instead.' + message: 'The /webSearch endpoint is disabled by service configuration.' }); expect(controller.WEB_SEARCH).not.toHaveBeenCalled(); }); diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 22b0c81..df381d0 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -9,8 +9,7 @@ jest.mock('../utils/searchProvider', () => ({ search: mockBasicSearch })), getSearchRequestDeadlineMs: jest.fn(() => 30000), - searchOpenSearch: mockSearchOpenSearch, - shouldLogSearchPerformance: jest.fn(() => false) + searchOpenSearch: mockSearchOpenSearch })); const { createApp } = require('../server'); @@ -83,7 +82,14 @@ describe('search route integration', () => { }, track_total_hits: true, size: 1, - from: 0 + from: 0, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }, { signal: expect.any(AbortSignal) diff --git a/routes/swagger.js b/routes/swagger.js index 025ddd3..0e0c273 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -16,6 +16,21 @@ function searchRequestProperty(description, schema) { }; } +function coercibleBooleanSchema(defaultValue = false) { + return { + oneOf: [ + { + type: 'boolean' + }, + { + type: 'string', + enum: ['true', 'false'] + } + ], + default: defaultValue + }; +} + const searchRequestProperties = { scope: searchRequestProperty( 'Controls which containers participate in filter matching. FULL searches base record fields, CNA data, and all ADP providers. CNA_ONLY searches base record fields and CNA data. ADP_ONLY searches base record fields and all ADP providers. Scope does not remove data from returned records. Values are case-insensitive. CPE applicability filters are not available with ADP_ONLY.', @@ -34,11 +49,8 @@ const searchRequestProperties = { } ), keywordExactMatch: searchRequestProperty( - 'When true, a multi-word keywordSearch value must appear exactly and in the same order. keywordSearch is required when keywordExactMatch is true.', - { - type: 'boolean', - default: false - } + 'When true, a multi-word keywordSearch value must appear exactly and in the same order. keywordSearch is required when keywordExactMatch is true. JSON booleans and the lowercase strings "true" and "false" are accepted.', + coercibleBooleanSchema() ), cveIds: searchRequestProperty( `Returns one or more CVE records identified by a comma-separated list of CVE IDs. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, @@ -48,10 +60,10 @@ const searchRequestProperties = { example: 'CVE-2024-1234,CVE-2024-5678' } ), - noRejected: searchRequestProperty('When true, excludes CVE records with REJECT, REJECTED, or Rejected status.', { - type: 'boolean', - default: false - }), + noRejected: searchRequestProperty( + 'When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings "true" and "false" are accepted.', + coercibleBooleanSchema() + ), cweId: searchRequestProperty( 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID.', { @@ -144,11 +156,8 @@ const searchRequestProperties = { } ), isVulnerable: searchRequestProperty( - 'When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable.', - { - type: 'boolean', - default: false - } + 'When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable. JSON booleans and the lowercase strings "true" and "false" are accepted.', + coercibleBooleanSchema() ), lastModStartDate: searchRequestProperty( 'Returns CVE records with dateUpdated timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', @@ -341,6 +350,146 @@ const doc = { ], components: { '@schemas': { + ApiError: { + type: 'object', + additionalProperties: false, + required: ['error', 'message'], + properties: { + error: { + type: 'string' + }, + message: { + type: 'string' + } + } + }, + ApiErrorResponse: { + oneOf: [ + { + $ref: '#/components/schemas/ApiError' + }, + { + type: 'array', + items: { + $ref: '#/components/schemas/ApiError' + } + } + ] + }, + SearchHit: { + type: 'object', + additionalProperties: true, + required: ['_source'], + properties: { + _id: { + type: 'string' + }, + _source: { + type: 'object', + additionalProperties: true + } + } + }, + SearchHits: { + type: 'object', + additionalProperties: true, + required: ['total', 'hits'], + properties: { + total: { + type: 'object', + additionalProperties: true, + required: ['value'], + properties: { + value: { + type: 'integer', + minimum: 0 + } + } + }, + hits: { + type: 'array', + items: { + $ref: '#/components/schemas/SearchHit' + } + } + } + }, + SearchResponse: { + type: 'object', + additionalProperties: false, + required: ['page', 'pageSize', 'totalPages', 'status', 'data'], + properties: { + page: { + type: 'integer', + minimum: 0, + description: 'One-based page number, or 0 when the search has no matches.' + }, + pageSize: { + type: 'integer', + minimum: 0, + description: 'Number of CVE records returned in this response.' + }, + totalPages: { + type: 'integer', + minimum: 0 + }, + status: { + type: 'string', + enum: ['ok'] + }, + data: { + type: 'object', + additionalProperties: true, + required: ['hits'], + properties: { + took: { + type: 'number', + minimum: 0 + }, + hits: { + $ref: '#/components/schemas/SearchHits' + } + } + } + } + }, + WebSearchResponse: { + type: 'object', + additionalProperties: false, + required: ['searchMetadata', 'resultsTotal', 'data'], + properties: { + searchMetadata: { + type: 'object', + additionalProperties: true, + required: ['searchStatus', 'version', 'errors'], + properties: { + searchStatus: { + type: 'string' + }, + version: { + type: 'string' + }, + errors: { + type: 'array', + items: { + $ref: '#/components/schemas/ApiError' + } + } + } + }, + resultsTotal: { + type: 'integer', + minimum: 0 + }, + data: { + type: 'array', + items: { + type: 'object', + additionalProperties: true + } + } + } + }, SearchRequest: { type: 'object', description: diff --git a/server.js b/server.js index 4c94a56..4680dcc 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,6 @@ const swaggerUi = require('swagger-ui-express'); const openApiSpecification = require('./api-docs/openapi.json'); const http = require('http'); const logger = require('morgan'); -const path = require('path'); const router = require('./routes/index'); const errors = require('./utils/errors'); const requestLogging = require('./utils/requestLogging'); @@ -56,7 +55,6 @@ function createApp() { app.use(requestLogging.assignRequestId); app.use(createRequestLogger()); app.use(cors()); - app.use(express.static(path.join(__dirname, 'public'))); app.use(express.json()); app.get('/api-docs/openapi.json', (req, res) => res.json(openApiSpecification)); diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index d4a90fa..14b8c4e 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "28911974-2b15-4ddf-a88f-3fa6fe001a50", "name": "CVE Search API - Manual Contract Tests", - "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The deprecated /webSearch endpoint is intentionally excluded.", + "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The separate /webSearch contract is intentionally excluded.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ diff --git a/test/postman/README.md b/test/postman/README.md index 4aa8afc..d9b4cf5 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -1,6 +1,6 @@ # Postman Manual Contract Tests -This directory contains an importable Postman collection for manually validating the CVE Search API. It covers every documented `POST /search` request field, representative intentional filter combinations, and the API's deterministic validation errors. The deprecated `/webSearch` endpoint is intentionally excluded. +This directory contains an importable Postman collection for manually validating the CVE Search API. It covers every documented `POST /search` request field, representative intentional filter combinations, and the API's deterministic validation errors. The separate `/webSearch` contract is intentionally excluded. ## Files diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index f58b62f..94b2095 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -1145,7 +1145,7 @@ const collection = { _postman_id: '28911974-2b15-4ddf-a88f-3fa6fe001a50', name: 'CVE Search API - Manual Contract Tests', description: - 'Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The deprecated /webSearch endpoint is intentionally excluded.', + 'Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The separate /webSearch contract is intentionally excluded.', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, variable: [ diff --git a/utils/cvss.js b/utils/cvss.js index d3f2ca5..0641e7d 100644 --- a/utils/cvss.js +++ b/utils/cvss.js @@ -13,12 +13,9 @@ const PARAMS = Object.freeze(Object.keys(VECTOR_FIELDS)); function parseMetricTokens(inputVectorString) { return String(inputVectorString ?? '') .split('/') - .map((token) => token.trim()) + .map((token) => token.trim().toUpperCase()) .filter(Boolean) - .filter((token) => { - const normalized = token.toUpperCase(); - return !normalized.startsWith('CVSS:') && !normalized.startsWith('CVSS#'); - }); + .filter((token) => !token.startsWith('CVSS:') && !token.startsWith('CVSS#')); } function buildPostFilter(params) { @@ -34,7 +31,10 @@ function buildPostFilter(params) { } function vectorContainsEveryToken(vector, tokens) { - return typeof vector === 'string' && tokens.every((token) => vector.includes(token)); + if (typeof vector !== 'string') return false; + + const vectorTokens = new Set(parseMetricTokens(vector)); + return tokens.every((token) => vectorTokens.has(String(token).trim().toUpperCase())); } function metricMatches(metric, fieldNames, tokens) { diff --git a/utils/cvss.test.js b/utils/cvss.test.js index 7d961e8..98b6a6f 100644 --- a/utils/cvss.test.js +++ b/utils/cvss.test.js @@ -36,6 +36,16 @@ describe('CVSS semantic vector matching', () => { expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/AC:L' } }]), filter)).toBe(true); }); + test('matches complete metric tokens without confusing base and modified metrics', () => { + const filter = cvss.buildPostFilter({ + scope: 'ADP_ONLY', + cvssV3_1Metrics: 'AV:N' + }); + + expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:L/MAV:N' } }]), filter)).toBe(false); + expect(cvss.hitMatches(hit([{ cvssV3_1: { vectorString: 'CVSS:3.1/AV:N/MAV:L' } }]), filter)).toBe(true); + }); + test('returns only source fields required by supplied vector filters and scope', () => { const filter = cvss.buildPostFilter({ scope: 'CNA_ONLY', diff --git a/utils/errors.js b/utils/errors.js index d942253..292c161 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -301,7 +301,7 @@ class errors { } webSearchDisabledError() { - return makeError('WEB_SEARCH_DISABLED', 'The /webSearch endpoint is disabled. Use POST /search instead.'); + return makeError('WEB_SEARCH_DISABLED', 'The /webSearch endpoint is disabled by service configuration.'); } openSearchError() { diff --git a/utils/errors.test.js b/utils/errors.test.js index 128e6ad..aae9e04 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -368,7 +368,7 @@ describe('error response helpers', () => { [], { error: 'WEB_SEARCH_DISABLED', - message: 'The /webSearch endpoint is disabled. Use POST /search instead.' + message: 'The /webSearch endpoint is disabled by service configuration.' } ], [ diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 98e373d..8616d73 100644 --- a/utils/searchProvider.js +++ b/utils/searchProvider.js @@ -1,6 +1,7 @@ const { AppConfig, BasicSearchManager, SearchProviderSpec, SearchReader } = require('cve-core'); const constants = require('./constants'); const searchDeadline = require('./searchDeadline'); +const searchReadiness = require('./searchReadiness'); const searchResponseValidator = require('./searchResponseValidator'); class SearchProviderContractError extends Error { @@ -95,8 +96,9 @@ function getBasicSearchManager() { function validateSearchReader(reader) { const hasIndex = typeof reader?._cveIndex === 'string' && reader._cveIndex.trim() !== ''; const hasSearchClient = typeof reader?._client?.search === 'function'; + const hasFieldCapsClient = typeof reader?._client?.fieldCaps === 'function'; - if (!hasIndex || !hasSearchClient) { + if (!hasIndex || !hasSearchClient || !hasFieldCapsClient) { throw new SearchProviderContractError(); } @@ -181,6 +183,25 @@ async function searchOpenSearch(body, { signal } = {}) { async function checkOpenSearchReadiness({ signal } = {}) { searchDeadline.throwIfAborted(signal); const reader = getSearchReader(); + const requestOptions = { + maxRetries: 0, + requestTimeout: getOpenSearchReadinessTimeoutMs() + }; + const fieldCapsResponse = await executeOpenSearchRequest( + () => + reader._client.fieldCaps( + { + index: reader._cveIndex, + fields: searchReadiness.getRequiredFieldNames().join(','), + include_unmapped: true + }, + requestOptions + ), + { signal } + ); + searchReadiness.validateFieldCapabilities(fieldCapsResponse.body); + searchDeadline.throwIfAborted(signal); + const response = await executeOpenSearchRequest( () => reader._client.search( @@ -196,10 +217,7 @@ async function checkOpenSearchReadiness({ signal } = {}) { track_total_hits: false } }, - { - maxRetries: 0, - requestTimeout: getOpenSearchReadinessTimeoutMs() - } + requestOptions ), { signal } ); @@ -210,7 +228,12 @@ async function checkOpenSearchReadiness({ signal } = {}) { } function isSearchProviderContractError(err) { - return err instanceof SearchProviderContractError || err?.code === 'SEARCH_PROVIDER_CONTRACT'; + return ( + err instanceof SearchProviderContractError || + err instanceof searchReadiness.SearchMappingContractError || + err?.code === 'SEARCH_PROVIDER_CONTRACT' || + err?.code === 'SEARCH_MAPPING_CONTRACT' + ); } function getOpenSearchReadinessTimeoutMs() { diff --git a/utils/searchProvider.test.js b/utils/searchProvider.test.js index d18f6e5..dcaada4 100644 --- a/utils/searchProvider.test.js +++ b/utils/searchProvider.test.js @@ -10,11 +10,13 @@ const mockBasicSearchManager = jest.fn(function BasicSearchManager(spec) { }; }); const mockClientSearch = jest.fn(); +const mockClientFieldCaps = jest.fn(); const mockSearchReader = jest.fn(function SearchReader(endpoint, index, options) { this._openSearchDomainEndpoint = endpoint; this._cveIndex = index; this._options = options; this._client = { + fieldCaps: mockClientFieldCaps, search: mockClientSearch, transport: { maxRetries: 3, @@ -41,6 +43,7 @@ jest.mock('cve-core', () => ({ const searchProvider = require('./searchProvider'); const searchDeadline = require('./searchDeadline'); +const searchReadiness = require('./searchReadiness'); function deferred() { const state = {}; @@ -69,12 +72,32 @@ function openSearchResponse(hits = []) { }; } +function fieldCapsResponse() { + return { + body: { + fields: Object.fromEntries( + searchReadiness.FIELD_CAPABILITY_REQUIREMENTS.map((requirement) => [ + requirement.field, + { + [requirement.types[0]]: { + searchable: true, + aggregatable: requirement.aggregatable === true + } + } + ]) + ) + } + }; +} + describe('searchProvider', () => { beforeEach(() => { searchProvider.resetSearchProviderForTests(); mockBasicSearchManager.mockClear(); mockSearchReader.mockClear(); mockClientSearch.mockReset(); + mockClientFieldCaps.mockReset(); + mockClientFieldCaps.mockResolvedValue(fieldCapsResponse()); mockGetDefaultSearchProviderSpec.mockReset(); mockGetDefaultSearchProviderSpec.mockReturnValue({ providerEndpoint: 'https://search.example', @@ -160,6 +183,17 @@ describe('searchProvider', () => { await expect(searchProvider.checkOpenSearchReadiness()).resolves.toBe(true); + expect(mockClientFieldCaps).toHaveBeenCalledWith( + { + index: 'test-index', + fields: searchReadiness.getRequiredFieldNames().join(','), + include_unmapped: true + }, + { + maxRetries: 0, + requestTimeout: 5000 + } + ); expect(mockClientSearch).toHaveBeenCalledWith( { index: 'test-index', @@ -187,11 +221,24 @@ describe('searchProvider', () => { await expect(searchProvider.checkOpenSearchReadiness()).rejects.toBe(unavailable); }); - test('aborts the readiness transport request when its lifecycle is canceled', async () => { + test('fails readiness before searching when required field mappings are unavailable', async () => { + mockClientFieldCaps.mockResolvedValue({ + body: { + fields: {} + } + }); + + await expect(searchProvider.checkOpenSearchReadiness()).rejects.toMatchObject({ + code: 'SEARCH_MAPPING_CONTRACT' + }); + expect(mockClientSearch).not.toHaveBeenCalled(); + }); + + test('aborts the readiness field-capabilities request when its lifecycle is canceled', async () => { const pending = deferred(); const request = pending.promise; request.abort = jest.fn(); - mockClientSearch.mockReturnValue(request); + mockClientFieldCaps.mockReturnValue(request); const controller = new AbortController(); const readiness = searchProvider.checkOpenSearchReadiness({ signal: controller.signal diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 8e24b8d..1a95324 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -59,7 +59,14 @@ function buildOpenSearchBody(searchOptions, queryStringObjects = [], { sourceInc }, track_total_hits: trackTotalHits, size: searchOptions.resultsPerPage, - from: searchOptions.from + from: searchOptions.from, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }; if (sourceIncludes?.length) { @@ -242,36 +249,20 @@ function escapeQueryStringTerm(term, { escapeWildcards = false } = {}) { } function buildQueryStrings(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { - const queryStringObjects = []; - - Object.keys(params).forEach((param) => { - if (param === 'virtualMatchString') return; + if (params?.keywordSearch === undefined) return []; - const fieldSpec = searchScope.resolveFields(param, scope); - const fieldList = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; - if (!fieldList.length) return; - - if (param === 'keywordSearch') { - queryStringObjects.push({ - simple_query_string: { - query: params[param], - default_operator: 'AND', - fields: fieldList - } - }); - return; - } + const fieldList = searchScope.resolveFields('keywordSearch', scope); + if (!fieldList.length) return []; - queryStringObjects.push({ - query_string: { - query: params[param], + return [ + { + simple_query_string: { + query: params.keywordSearch, default_operator: 'AND', fields: fieldList } - }); - }); - - return queryStringObjects; + } + ]; } function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { @@ -369,6 +360,10 @@ function escapeForWildcard(s) { return String(s).replace(/([\\*?])/g, '\\$1'); } +function escapeForRegexp(s) { + return String(s).replace(/([\\.?+*|{}[\]()"#@&<>~])/g, '\\$1'); +} + function parseCveIds(value) { return String(value ?? '') .split(',') @@ -639,8 +634,11 @@ function buildCvssWildcardFilter(inputVectorString, fieldSpec) { return { bool: { filter: tokens.map((t) => ({ - wildcard: { - [keywordField]: { value: `*${escapeForWildcard(t)}*` } + regexp: { + [keywordField]: { + value: `(.*/)?${escapeForRegexp(t)}(/.*)?`, + case_insensitive: true + } } })) } diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index c039a05..335aae7 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -20,6 +20,17 @@ function prefixShould(prefixes) { }; } +function cvssTokenRegexp(field, token) { + return { + regexp: { + [`${field}.keyword`]: { + value: `(.*/)?${token}(/.*)?`, + case_insensitive: true + } + } + }; +} + describe('searchQueryBuilder buildSearchRequest()', () => { test('buildSearchRequest builds filters, query strings, date ranges, and pagination options', () => { const output = queryBuilder.buildSearchRequest({ @@ -225,6 +236,13 @@ describe('searchQueryBuilder buildSearchRequest()', () => { track_total_hits: false, size: 10000, from: 0, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ], _source: [ 'cveMetadata.cveId', 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria', @@ -269,7 +287,14 @@ describe('searchQueryBuilder buildSearchRequest()', () => { }, track_total_hits: true, size: 10, - from: 20 + from: 20, + sort: [ + { + 'cveMetadata.cveId.keyword': { + order: 'asc' + } + } + ] }); }); @@ -433,7 +458,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() builds order-independent wildcard filters for cvssV2 metrics.', () => { + test('getFilterFields() builds token-boundary filters for cvssV2 metrics.', () => { const output = queryBuilder.getFilterFields({ cvssV2Metrics: 'AV:N/AC:L/Au:N' }); @@ -442,27 +467,9 @@ describe('searchQueryBuilder getFilterFields() function', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*AC:L*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV2_0.vectorString.keyword': { - value: '*Au:N*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AC:L'), + cvssTokenRegexp('containers.cna.metrics.cvssV2_0.vectorString', 'AU:N') ] } } @@ -638,7 +645,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() builds order-independent wildcard filters for combined cvssV3 metrics.', () => { + test('getFilterFields() builds token-boundary filters for combined cvssV3 metrics.', () => { const output = queryBuilder.getFilterFields({ cvssV3Metrics: 'CVSS:3.1/AV:N/AC:L' }); @@ -650,40 +657,16 @@ describe('searchQueryBuilder getFilterFields() function', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_0.vectorString.keyword': { - value: '*AC:L*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_0.vectorString', 'AC:L') ] } }, { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV3_1.vectorString.keyword': { - value: '*AC:L*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV3_1.vectorString', 'AC:L') ] } } @@ -694,7 +677,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() escapes wildcard metacharacters in CVSS metric filters.', () => { + test('getFilterFields() escapes regexp metacharacters in CVSS metric filters.', () => { const output = queryBuilder.getFilterFields({ cvssV4Metrics: 'CVSS#4.0/AV:N/AC:*?/UI:P' }); @@ -703,27 +686,9 @@ describe('searchQueryBuilder getFilterFields() function', () => { { bool: { filter: [ - { - wildcard: { - 'containers.cna.metrics.cvssV4_0.vectorString.keyword': { - value: '*AV:N*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV4_0.vectorString.keyword': { - value: '*AC:\\*\\?*' - } - } - }, - { - wildcard: { - 'containers.cna.metrics.cvssV4_0.vectorString.keyword': { - value: '*UI:P*' - } - } - } + cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AV:N'), + cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AC:\\*\\?'), + cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'UI:P') ] } } diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js new file mode 100644 index 0000000..095ccfb --- /dev/null +++ b/utils/searchReadiness.js @@ -0,0 +1,133 @@ +const searchScope = require('./searchScope'); + +const NUMERIC_FIELD_TYPES = Object.freeze([ + 'byte', + 'short', + 'integer', + 'long', + 'half_float', + 'float', + 'double', + 'scaled_float' +]); + +function unique(values) { + return [...new Set(values)]; +} + +function keywordRequirements(params) { + return unique(params.flatMap((param) => searchScope.resolveFields(param))).map((field) => ({ + field: `${field}.keyword`, + types: ['keyword'] + })); +} + +function containerKeywordRequirements(relativePaths) { + return unique(relativePaths.flatMap((path) => searchScope.resolveContainerPaths(path))).map((field) => ({ + field: `${field}.keyword`, + types: ['keyword'] + })); +} + +function containerDateRequirements(relativePaths) { + return unique(relativePaths.flatMap((path) => searchScope.resolveContainerPaths(path))).map((field) => ({ + field, + types: ['date'] + })); +} + +const FIELD_CAPABILITY_REQUIREMENTS = Object.freeze([ + { + field: 'cveMetadata.cveId.keyword', + types: ['keyword'], + aggregatable: true + }, + { field: 'cveMetadata.datePublished', types: ['date'] }, + { field: 'cveMetadata.dateUpdated', types: ['date'] }, + ...keywordRequirements([ + 'noRejected', + 'cvssV3Severity', + 'cvssV3_0Severity', + 'cvssV3_1Severity', + 'cvssV4Severity', + 'cvssV2Metrics', + 'cvssV3Metrics', + 'cvssV3_0Metrics', + 'cvssV3_1Metrics', + 'cvssV4Metrics', + 'cpeName', + 'virtualMatchString', + 'referenceURL', + 'disputed' + ]), + ...unique(searchScope.resolveFields('keywordSearch')).map((field) => ({ + field, + types: ['text', 'keyword'] + })), + ...unique(searchScope.resolveFields('cweId')).map((field) => ({ + field, + types: ['text', 'keyword'] + })), + ...unique(searchScope.resolveFields('cvssV2Severity')).map((field) => ({ + field, + types: NUMERIC_FIELD_TYPES + })), + ...unique(searchScope.resolveFields('isVulnerable')).map((field) => ({ + field, + types: ['boolean'] + })), + ...containerKeywordRequirements([ + 'metrics.other.type', + 'metrics.other.content.options.Exploitation', + 'metrics.other.content.options.Automatable', + 'metrics.other.content.options.Technical Impact', + 'metrics.other.content.role' + ]), + ...containerDateRequirements(['metrics.other.content.timestamp', 'metrics.other.content.dateAdded']) +]); + +class SearchMappingContractError extends Error { + constructor(fields) { + super('Configured CVE index does not expose the field mappings required by the Search API.'); + this.name = 'SearchMappingContractError'; + this.code = 'SEARCH_MAPPING_CONTRACT'; + this.fields = fields; + } +} + +function supportsRequirement(capabilities, requirement) { + if (!capabilities || typeof capabilities !== 'object') return false; + + return requirement.types.some((type) => { + const capability = capabilities[type]; + return ( + capability?.searchable === true && (requirement.aggregatable !== true || capability.aggregatable === true) + ); + }); +} + +function validateFieldCapabilities(body) { + const capabilities = body?.fields; + if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) { + throw new SearchMappingContractError(FIELD_CAPABILITY_REQUIREMENTS.map(({ field }) => field)); + } + + const incompatibleFields = FIELD_CAPABILITY_REQUIREMENTS.filter( + (requirement) => !supportsRequirement(capabilities[requirement.field], requirement) + ).map(({ field }) => field); + + if (incompatibleFields.length) throw new SearchMappingContractError(incompatibleFields); + + return true; +} + +function getRequiredFieldNames() { + return FIELD_CAPABILITY_REQUIREMENTS.map(({ field }) => field); +} + +module.exports = { + FIELD_CAPABILITY_REQUIREMENTS, + SearchMappingContractError, + getRequiredFieldNames, + validateFieldCapabilities +}; diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js new file mode 100644 index 0000000..ebbc643 --- /dev/null +++ b/utils/searchReadiness.test.js @@ -0,0 +1,48 @@ +const searchReadiness = require('./searchReadiness'); + +function validCapabilities() { + return { + fields: Object.fromEntries( + searchReadiness.FIELD_CAPABILITY_REQUIREMENTS.map((requirement) => [ + requirement.field, + { + [requirement.types[0]]: { + searchable: true, + aggregatable: requirement.aggregatable === true + } + } + ]) + ) + }; +} + +describe('search readiness mapping contract', () => { + test('accepts searchable fields with the required types', () => { + expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); + }); + + test('rejects missing fields', () => { + const body = validCapabilities(); + const missingField = searchReadiness.getRequiredFieldNames()[0]; + delete body.fields[missingField]; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test('rejects a non-searchable or incompatible field type', () => { + const body = validCapabilities(); + const requirement = searchReadiness.FIELD_CAPABILITY_REQUIREMENTS.find(({ aggregatable }) => aggregatable); + body.fields[requirement.field] = { + text: { + searchable: true, + aggregatable: false + } + }; + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); +}); From 5e124cc62776e69ce818f838ff8de8b232bc4842 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Mon, 24 Aug 2026 09:36:33 -0400 Subject: [PATCH 23/27] docs: remove webSearch dependency disclosure --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 3d3b8e0..f7f8abb 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,6 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al The controller validates successful `BasicSearchManager` responses before returning HTTP 200. A response without the expected status metadata, total-hit count, or hit array is treated as an invalid upstream response and returns a sanitized HTTP 502. -Known dependency limitation: the current `cve-core` `BasicSearchManager` changes `size` to `from + 1` whenever the requested `size` is smaller than `from`. For example, `{ "from": 100, "size": 1 }` is sent to OpenSearch with `size: 101`. This behavior originates in `cve-core` and remains tracked while `/webSearch` uses `BasicSearchManager`; the Search API controller cannot correct the request after the dependency constructs and executes it. - ## Swagger API docs Swagger API docs are regenerated by `npm run dev` and can be regenerated manually using: From eb0cdc1b60ccb35f7f6d872ad0d2e2eda1398829 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Mon, 24 Aug 2026 09:53:42 -0400 Subject: [PATCH 24/27] fix: preserve requested search page size --- README.md | 2 +- api-docs/openapi.json | 8 +- api-docs/openapi.test.js | 12 +- controllers/searchController.js | 5 +- controllers/searchController.test.js | 14 +- routes/search.integration.test.js | 1 + routes/swagger.js | 9 +- .../CVE-Search-API.postman_collection.json | 126 ++++++++++++++++++ test/postman/buildCollection.js | 2 + 9 files changed, 167 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f7f8abb..2291500 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` search The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. -`/search` orders OpenSearch results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pageSize` is the number of records actually returned, including zero for an out-of-range page. A stable sort does not provide a point-in-time snapshot, so index updates between separate page requests can still shift results until PIT-based pagination is implemented. +`/search` orders OpenSearch results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pageSize` preserves the requested `resultsPerPage` value, while `resultsReturned` reports the number of records actually returned and may be smaller for a partial or out-of-range page. A stable sort does not provide a point-in-time snapshot, so index updates between separate page requests can still shift results until PIT-based pagination is implemented. The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and performance telemetry must be supplied by the deployment infrastructure when those capabilities are enabled. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 283caa7..b8c7b06 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -454,6 +454,7 @@ "required": [ "page", "pageSize", + "resultsReturned", "totalPages", "status", "data" @@ -465,9 +466,14 @@ "description": "One-based page number, or 0 when the search has no matches." }, "pageSize": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of CVE records requested for this page." + }, + "resultsReturned": { "type": "integer", "minimum": 0, - "description": "Number of CVE records returned in this response." + "description": "Number of CVE records actually returned in this response." }, "totalPages": { "type": "integer", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index b3c5d0a..d724de3 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -187,8 +187,16 @@ describe('OpenAPI contract', () => { const schemas = openapi.components.schemas; const searchResponse = schemas.SearchResponse; - expect(searchResponse.required).toStrictEqual(['page', 'pageSize', 'totalPages', 'status', 'data']); - expect(searchResponse.properties.pageSize.description).toContain('returned in this response'); + expect(searchResponse.required).toStrictEqual([ + 'page', + 'pageSize', + 'resultsReturned', + 'totalPages', + 'status', + 'data' + ]); + expect(searchResponse.properties.pageSize.description).toContain('requested for this page'); + expect(searchResponse.properties.resultsReturned.description).toContain('actually returned'); expect(searchResponse.properties.data.properties.hits.$ref).toBe('#/components/schemas/SearchHits'); expect(schemas.SearchHits.properties.hits.items.$ref).toBe('#/components/schemas/SearchHit'); expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); diff --git a/controllers/searchController.js b/controllers/searchController.js index b8d97a8..a8f5a8b 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -218,13 +218,14 @@ async function searchHandler(req, res, next) { //Calculate current page and total pages to append to JSON output const totalValue = resp.data.hits.total.value; const requestedPageSize = pagingOptions.resultsPerPage; - const pageSize = resp.data.hits.hits.length; + const resultsReturned = resp.data.hits.hits.length; const page = totalValue === 0 ? 0 : Math.floor(pagingOptions.from / requestedPageSize) + 1; const totalPages = totalValue === 0 ? 0 : Math.ceil(totalValue / requestedPageSize); return res.status(200).json({ page, - pageSize, + pageSize: requestedPageSize, + resultsReturned, totalPages, ...resp }); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index c7d1702..ad42d84 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -220,7 +220,8 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 3, - pageSize: 1, + pageSize: 10, + resultsReturned: 1, totalPages: 3, ...resp }); @@ -523,6 +524,7 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 1, pageSize: 1, + resultsReturned: 1, totalPages: 1, status: 'ok', data: { @@ -734,6 +736,7 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 1, pageSize: 1, + resultsReturned: 1, totalPages: 1, status: 'ok', data: { @@ -789,6 +792,7 @@ describe('Search controller SEARCH handler', () => { expect(res.json).toHaveBeenCalledWith({ page: 1, pageSize: 1, + resultsReturned: 1, totalPages: 1, status: 'ok', data: { @@ -1034,7 +1038,7 @@ describe('Search controller SEARCH handler', () => { expect(mockApiSearch).not.toHaveBeenCalled(); }); - test('SEARCH returns zeroed pagination metadata when OpenSearch returns zero hits', async () => { + test('SEARCH retains requested page size and reports zero returned results when OpenSearch returns zero hits', async () => { const resp = advancedResponse(0); mockApiSearch.mockResolvedValue(resp); @@ -1049,7 +1053,8 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 0, - pageSize: 0, + pageSize: 20, + resultsReturned: 0, totalPages: 0, ...resp }); @@ -1073,7 +1078,8 @@ describe('Search controller SEARCH handler', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ page: 2, - pageSize: 0, + pageSize: 10, + resultsReturned: 0, totalPages: 1, ...resp }); diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index df381d0..deb1700 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -97,6 +97,7 @@ describe('search route integration', () => { ); expect(response.body.page).toBe(1); expect(response.body.pageSize).toBe(1); + expect(response.body.resultsReturned).toBe(1); expect(response.body.totalPages).toBe(1); }); diff --git a/routes/swagger.js b/routes/swagger.js index 0e0c273..0996449 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -417,7 +417,7 @@ const doc = { SearchResponse: { type: 'object', additionalProperties: false, - required: ['page', 'pageSize', 'totalPages', 'status', 'data'], + required: ['page', 'pageSize', 'resultsReturned', 'totalPages', 'status', 'data'], properties: { page: { type: 'integer', @@ -425,9 +425,14 @@ const doc = { description: 'One-based page number, or 0 when the search has no matches.' }, pageSize: { + type: 'integer', + minimum: 1, + description: 'Maximum number of CVE records requested for this page.' + }, + resultsReturned: { type: 'integer', minimum: 0, - description: 'Number of CVE records returned in this response.' + description: 'Number of CVE records actually returned in this response.' }, totalPages: { type: 'integer', diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 14b8c4e..8d9655a 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -126,10 +126,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", "}" @@ -164,10 +166,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is at least 1', () => pm.expect(payload.data.hits.total.value).to.be.at.least(1));", "}" @@ -208,10 +212,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -258,10 +264,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -308,10 +316,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", @@ -357,10 +367,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", @@ -412,10 +424,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -462,10 +476,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -512,10 +528,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", @@ -561,10 +579,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -611,10 +631,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -661,10 +683,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -717,10 +741,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -767,10 +793,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -817,10 +845,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -867,10 +897,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -917,10 +949,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 2', () => pm.expect(payload.data.hits.total.value).to.eql(2));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -967,10 +1001,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1023,10 +1059,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1073,10 +1111,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1123,10 +1163,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1173,10 +1215,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1223,10 +1267,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1273,10 +1319,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1323,10 +1371,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1373,10 +1423,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1423,10 +1475,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1473,10 +1527,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1523,10 +1579,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1579,10 +1637,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1629,10 +1689,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1679,10 +1741,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1729,10 +1793,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1779,10 +1845,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1829,10 +1897,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1885,10 +1955,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1935,10 +2007,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -1985,10 +2059,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2035,10 +2111,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2085,10 +2163,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2135,10 +2215,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2185,10 +2267,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2241,10 +2325,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2291,10 +2377,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2341,10 +2429,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2391,10 +2481,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2441,10 +2533,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2491,10 +2585,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2541,10 +2637,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2597,10 +2695,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2647,10 +2747,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2697,10 +2799,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2747,10 +2851,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2797,10 +2903,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2853,10 +2961,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2903,10 +3013,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -2953,10 +3065,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -3003,10 +3117,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 0', () => pm.expect(payload.data.hits.total.value).to.eql(0));", "pm.test('returned hit count is 0', () => pm.expect(payload.data.hits.hits.length).to.eql(0));", @@ -3052,10 +3168,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -3102,10 +3220,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -3158,10 +3278,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -3208,10 +3330,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", @@ -3258,10 +3382,12 @@ " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + " pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);", "});", "pm.test('total is 1', () => pm.expect(payload.data.hits.total.value).to.eql(1));", " const returnedIds = payload.data.hits.hits.map((hit) => hit?._source?.cveMetadata?.cveId ?? hit?._id);", diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 94b2095..be6fe13 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -61,10 +61,12 @@ function successTests({ status = 200, exactTotal, minimumTotal, expectedIds = [] " pm.expect(payload.status).to.eql('ok');", " pm.expect(payload.page).to.be.a('number');", " pm.expect(payload.pageSize).to.be.a('number');", + " pm.expect(payload.resultsReturned).to.be.a('number');", " pm.expect(payload.totalPages).to.be.a('number');", " pm.expect(payload.data).to.be.an('object');", " pm.expect(payload.data.hits.total.value).to.be.a('number');", " pm.expect(payload.data.hits.hits).to.be.an('array');", + ' pm.expect(payload.resultsReturned).to.eql(payload.data.hits.hits.length);', '});' ]; From b9145e4dd3dedf1df427542875d2ef1b92309bac Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Mon, 24 Aug 2026 10:25:34 -0400 Subject: [PATCH 25/27] fix: accept alternative readiness field mappings --- README.md | 2 +- api-docs/openapi.json | 4 +- routes/index.js | 2 +- utils/searchReadiness.js | 151 ++++++++++++++++++++++------------ utils/searchReadiness.test.js | 48 ++++++++++- 5 files changed, 149 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 2291500..da17a18 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The Search API will be served at `localhost:3000`. `GET /api/health-check` is a process liveness check. It returns HTTP 200 when the Node.js process is running and deliberately does not contact OpenSearch. Use it where a dependency outage should not restart otherwise healthy API tasks. -`GET /api/ready-check` first verifies that the configured CVE index exposes the field capabilities required by the `/search` contract, then sends a minimal zero-result search. This checks required mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when search requests can be served. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, missing or incompatible field mappings, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. Each OpenSearch readiness request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. +`GET /api/ready-check` first verifies that the configured CVE index exposes compatible field capabilities for each logical `/search` feature, then sends a minimal zero-result search. CNA, ADP, legacy, and CVSS-version fields that implement the same feature are treated as alternatives, while mapped fields must still use compatible searchable types. This checks mappings, connectivity, index availability, and the same search permission used by the API without retrieving CVE records or counting all matches. It returns HTTP 200 when search requests can be served. Configuration errors, connection failures, timeouts, incompatible `cve-core` SearchReader contracts, missing logical capabilities, incompatible mapped fields, and a missing index return a sanitized HTTP 503 `SEARCH_NOT_READY` response with `Retry-After: 5`. Each OpenSearch readiness request is limited to five seconds by default and can be changed with `SearchReadinessTimeoutMs`. An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index b8c7b06..73a4c7b 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -218,7 +218,7 @@ "/api/ready-check": { "get": { "summary": "OpenSearch readiness check", - "description": "Validates the field capabilities required by the search contract, then sends a minimal zero-result search to the configured CVE index. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails.", + "description": "Validates compatible field capabilities for each logical search feature, then sends a minimal zero-result search to the configured CVE index. Alternative CNA, ADP, legacy, and CVSS-version fields may satisfy the same feature. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails.", "operationId": "readinessCheck", "responses": { "200": { @@ -861,4 +861,4 @@ } } } -} \ No newline at end of file +} diff --git a/routes/index.js b/routes/index.js index 1858700..7af0175 100644 --- a/routes/index.js +++ b/routes/index.js @@ -427,7 +427,7 @@ router.get( /* #swagger.operationId = 'readinessCheck' #swagger.summary = "OpenSearch readiness check" -#swagger.description = "Validates the field capabilities required by the search contract, then sends a minimal zero-result search to the configured CVE index. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails." +#swagger.description = "Validates compatible field capabilities for each logical search feature, then sends a minimal zero-result search to the configured CVE index. Alternative CNA, ADP, legacy, and CVSS-version fields may satisfy the same feature. Returns 200 when mappings, index availability, and search permission are valid, or a sanitized 503 response when a readiness check fails." #swagger.responses[200] = { description: 'API is ready to serve search requests', headers: { diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js index 095ccfb..8442075 100644 --- a/utils/searchReadiness.js +++ b/utils/searchReadiness.js @@ -36,56 +36,78 @@ function containerDateRequirements(relativePaths) { })); } -const FIELD_CAPABILITY_REQUIREMENTS = Object.freeze([ - { - field: 'cveMetadata.cveId.keyword', - types: ['keyword'], - aggregatable: true - }, - { field: 'cveMetadata.datePublished', types: ['date'] }, - { field: 'cveMetadata.dateUpdated', types: ['date'] }, - ...keywordRequirements([ - 'noRejected', - 'cvssV3Severity', - 'cvssV3_0Severity', - 'cvssV3_1Severity', - 'cvssV4Severity', - 'cvssV2Metrics', - 'cvssV3Metrics', - 'cvssV3_0Metrics', - 'cvssV3_1Metrics', - 'cvssV4Metrics', - 'cpeName', - 'virtualMatchString', - 'referenceURL', - 'disputed' - ]), - ...unique(searchScope.resolveFields('keywordSearch')).map((field) => ({ - field, - types: ['text', 'keyword'] - })), - ...unique(searchScope.resolveFields('cweId')).map((field) => ({ - field, - types: ['text', 'keyword'] - })), - ...unique(searchScope.resolveFields('cvssV2Severity')).map((field) => ({ - field, - types: NUMERIC_FIELD_TYPES - })), - ...unique(searchScope.resolveFields('isVulnerable')).map((field) => ({ - field, - types: ['boolean'] - })), - ...containerKeywordRequirements([ - 'metrics.other.type', - 'metrics.other.content.options.Exploitation', - 'metrics.other.content.options.Automatable', - 'metrics.other.content.options.Technical Impact', - 'metrics.other.content.role' +function capabilityGroup(name, requirements) { + return Object.freeze({ + name, + requirements: Object.freeze(requirements) + }); +} + +const FIELD_CAPABILITY_GROUPS = Object.freeze([ + capabilityGroup('cveId', [ + { + field: 'cveMetadata.cveId.keyword', + types: ['keyword'], + aggregatable: true + } ]), - ...containerDateRequirements(['metrics.other.content.timestamp', 'metrics.other.content.dateAdded']) + capabilityGroup('datePublished', [{ field: 'cveMetadata.datePublished', types: ['date'] }]), + capabilityGroup('dateUpdated', [{ field: 'cveMetadata.dateUpdated', types: ['date'] }]), + capabilityGroup('noRejected', keywordRequirements(['noRejected'])), + capabilityGroup('cvssV3_0Severity', keywordRequirements(['cvssV3_0Severity'])), + capabilityGroup('cvssV3_1Severity', keywordRequirements(['cvssV3_1Severity'])), + capabilityGroup('cvssV4Severity', keywordRequirements(['cvssV4Severity'])), + capabilityGroup('cvssV2Metrics', keywordRequirements(['cvssV2Metrics'])), + capabilityGroup('cvssV3_0Metrics', keywordRequirements(['cvssV3_0Metrics'])), + capabilityGroup('cvssV3_1Metrics', keywordRequirements(['cvssV3_1Metrics'])), + capabilityGroup('cvssV4Metrics', keywordRequirements(['cvssV4Metrics'])), + capabilityGroup('cpeApplicability', keywordRequirements(['cpeName'])), + capabilityGroup('referenceURL', keywordRequirements(['referenceURL'])), + capabilityGroup('disputed', keywordRequirements(['disputed'])), + capabilityGroup( + 'keywordSearch', + unique(searchScope.resolveFields('keywordSearch')).map((field) => ({ + field, + types: ['text', 'keyword'] + })) + ), + capabilityGroup( + 'cweId', + unique(searchScope.resolveFields('cweId')).map((field) => ({ + field, + types: ['text', 'keyword'] + })) + ), + capabilityGroup( + 'cvssV2Severity', + unique(searchScope.resolveFields('cvssV2Severity')).map((field) => ({ + field, + types: NUMERIC_FIELD_TYPES + })) + ), + capabilityGroup( + 'isVulnerable', + unique(searchScope.resolveFields('isVulnerable')).map((field) => ({ + field, + types: ['boolean'] + })) + ), + capabilityGroup('ssvcType', containerKeywordRequirements(['metrics.other.type'])), + capabilityGroup('ssvcExploitation', containerKeywordRequirements(['metrics.other.content.options.Exploitation'])), + capabilityGroup('ssvcAutomatable', containerKeywordRequirements(['metrics.other.content.options.Automatable'])), + capabilityGroup( + 'ssvcTechnicalImpact', + containerKeywordRequirements(['metrics.other.content.options.Technical Impact']) + ), + capabilityGroup('ssvcRole', containerKeywordRequirements(['metrics.other.content.role'])), + capabilityGroup('ssvcTimestamp', containerDateRequirements(['metrics.other.content.timestamp'])), + capabilityGroup('kevDateAdded', containerDateRequirements(['metrics.other.content.dateAdded'])) ]); +const FIELD_CAPABILITY_REQUIREMENTS = Object.freeze( + FIELD_CAPABILITY_GROUPS.flatMap(({ requirements }) => requirements) +); + class SearchMappingContractError extends Error { constructor(fields) { super('Configured CVE index does not expose the field mappings required by the Search API.'); @@ -106,17 +128,43 @@ function supportsRequirement(capabilities, requirement) { }); } +function hasMappedCapability(capabilities) { + return ( + capabilities && + typeof capabilities === 'object' && + !Array.isArray(capabilities) && + Object.keys(capabilities).some((type) => type !== 'unmapped') + ); +} + function validateFieldCapabilities(body) { const capabilities = body?.fields; if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) { throw new SearchMappingContractError(FIELD_CAPABILITY_REQUIREMENTS.map(({ field }) => field)); } - const incompatibleFields = FIELD_CAPABILITY_REQUIREMENTS.filter( - (requirement) => !supportsRequirement(capabilities[requirement.field], requirement) - ).map(({ field }) => field); + const incompatibleFields = []; + + for (const group of FIELD_CAPABILITY_GROUPS) { + let hasCompatibleAlternative = false; + + for (const requirement of group.requirements) { + const fieldCapabilities = capabilities[requirement.field]; + if (!hasMappedCapability(fieldCapabilities)) continue; + + if (supportsRequirement(fieldCapabilities, requirement)) { + hasCompatibleAlternative = true; + } else { + incompatibleFields.push(requirement.field); + } + } + + if (!hasCompatibleAlternative) { + incompatibleFields.push(...group.requirements.map(({ field }) => field)); + } + } - if (incompatibleFields.length) throw new SearchMappingContractError(incompatibleFields); + if (incompatibleFields.length) throw new SearchMappingContractError(unique(incompatibleFields)); return true; } @@ -126,6 +174,7 @@ function getRequiredFieldNames() { } module.exports = { + FIELD_CAPABILITY_GROUPS, FIELD_CAPABILITY_REQUIREMENTS, SearchMappingContractError, getRequiredFieldNames, diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js index ebbc643..8b02e14 100644 --- a/utils/searchReadiness.test.js +++ b/utils/searchReadiness.test.js @@ -21,10 +21,52 @@ describe('search readiness mapping contract', () => { expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); }); - test('rejects missing fields', () => { + test('rejects a missing capability group', () => { const body = validCapabilities(); - const missingField = searchReadiness.getRequiredFieldNames()[0]; - delete body.fields[missingField]; + const group = searchReadiness.FIELD_CAPABILITY_GROUPS[0]; + group.requirements.forEach(({ field }) => delete body.fields[field]); + + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test('accepts an unmapped optional field when another field provides the capability', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS.find(({ requirements }) => requirements.length > 1); + body.fields[group.requirements[0].field] = { + unmapped: { + searchable: false, + aggregatable: false + } + }; + + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + }); + + test('accepts the optional legacy and container variants absent from the dev index', () => { + const body = validCapabilities(); + [ + 'containers.cna.x_legacyV4Record.CVE_data_meta.STATE.keyword', + 'containers.adp.metrics.cvssV3_0.baseSeverity.keyword', + 'containers.adp.metrics.cvssV2_0.vectorString.keyword', + 'containers.adp.metrics.cvssV3_0.vectorString.keyword', + 'containers.adp.metrics.cvssV2_0.baseScore', + 'containers.cna.metrics.other.content.dateAdded' + ].forEach((field) => delete body.fields[field]); + + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + }); + + test('rejects an incompatible mapped alternative even when the group has a compatible field', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS.find(({ requirements }) => requirements.length > 1); + body.fields[group.requirements[0].field] = { + text: { + searchable: false, + aggregatable: false + } + }; expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( searchReadiness.SearchMappingContractError From c414bfa8b98304bd66c08419361becb6ff2bece2 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Mon, 24 Aug 2026 11:07:09 -0400 Subject: [PATCH 26/27] docs: clarify Postman index requirements --- README.md | 2 +- test/postman/CVE-Search-API.postman_collection.json | 2 +- test/postman/README.md | 7 +++++-- test/postman/buildCollection.js | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da17a18..3f4d5f1 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ npm run coverage npm run quality ``` -An importable Postman collection for manual `/search` contract and filter testing is documented in [test/postman/README.md](test/postman/README.md). +An importable Postman collection for manual `/search` contract and filter testing is documented in [test/postman/README.md](test/postman/README.md). The complete collection targets a fully populated index like those used by shared managed environments; data-dependent positive assertions may fail against an incomplete local index even when the API is operating correctly. ### Operational Logging diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 8d9655a..7624885 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "28911974-2b15-4ddf-a88f-3fa6fe001a50", "name": "CVE Search API - Manual Contract Tests", - "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The separate /webSearch contract is intentionally excluded.", + "description": "Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The complete collection targets a fully populated index like those used by shared managed environments. Data-dependent positive assertions may fail against an incomplete local index even when the API is operating correctly. The separate /webSearch contract is intentionally excluded.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ diff --git a/test/postman/README.md b/test/postman/README.md index d9b4cf5..3cc2da2 100644 --- a/test/postman/README.md +++ b/test/postman/README.md @@ -2,6 +2,9 @@ This directory contains an importable Postman collection for manually validating the CVE Search API. It covers every documented `POST /search` request field, representative intentional filter combinations, and the API's deterministic validation errors. The separate `/webSearch` contract is intentionally excluded. +> [!IMPORTANT] +> The complete collection is designed to run against a fully populated CVE index, such as the indexes used by the shared managed environments. An incomplete local index may not contain the fixture records, container data, mappings, or matching values required by positive assertions. Those data-dependent requests can therefore fail or return zero results even when the API is behaving correctly. The deterministic request-validation folders do not generally depend on indexed CVE data. + ## Files - `CVE-Search-API.postman_collection.json`: generated Postman collection. @@ -44,7 +47,7 @@ The deployment file should resemble: } ``` -Review and approve tests before targeting a shared or production deployment. The included fixtures were calibrated against both the dev CVE index and a smaller 1,111-record local index on August 20, 2026. +Review and approve tests before targeting a shared or production deployment. The included fixtures were calibrated using the shared dev CVE index and were spot-checked against a smaller 1,111-record local index on August 20, 2026. That local spot check does not make the complete collection portable to every partial index; running all positive folders requires the listed records and relevant nested data to be present. | Fixture | Coverage | | ---------------- | --------------------------------------------------------- | @@ -57,7 +60,7 @@ 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. -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. A local index must include the fixtures above to run every positive folder unchanged; validation-error folders do not depend on index contents. +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 be6fe13..7fe3bb2 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -1147,7 +1147,7 @@ const collection = { _postman_id: '28911974-2b15-4ddf-a88f-3fa6fe001a50', name: 'CVE Search API - Manual Contract Tests', description: - 'Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The separate /webSearch contract is intentionally excluded.', + 'Positive filter coverage, intentional combinations, validation failures, and opt-in fault-injection checks for POST /search. The complete collection targets a fully populated index like those used by shared managed environments. Data-dependent positive assertions may fail against an incomplete local index even when the API is operating correctly. The separate /webSearch contract is intentionally excluded.', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, variable: [ From 1347ca9484b0706e674990895d10ac4479829fff Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Mon, 31 Aug 2026 09:59:40 -0400 Subject: [PATCH 27/27] fix: validate CPE version range boundaries --- README.md | 7 ++- api-docs/openapi.json | 20 +++++---- api-docs/openapi.test.js | 12 +++++ controllers/middleware.js | 9 ++++ controllers/middleware.test.js | 17 +++++++ routes/index.js | 3 ++ routes/search.integration.test.js | 20 +++++++++ routes/swagger.js | 19 ++++---- .../CVE-Search-API.postman_collection.json | 44 +++++++++++++++++++ test/postman/buildCollection.js | 5 +++ utils/cpe.js | 14 ++++++ utils/cpe.test.js | 7 +++ utils/errors.js | 7 +++ utils/errors.test.js | 9 ++++ 14 files changed, 175 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3f4d5f1..eb51bd5 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ curl -X POST "http://localhost:3000/search" \ ### Virtual Match String Version Ranges -Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`. +Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. Negative numeric values and the standalone CPE logical `NA` value (`-`) are not accepted as ordered range boundaries. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`. ```bash curl -X POST "http://localhost:3000/search" \ @@ -276,6 +276,11 @@ Each `/search` request owns a lifecycle cancellation signal. When its overall de ### Semantic Post-Filtering +Two separate 10,000-record controls apply to `/search`: + +- Every requested page must fall within the first 10,000 results. Direct OpenSearch filters still report an exact total that can exceed 10,000, but pages beyond that window are rejected. +- Filters that require semantic post-filtering inspect at most 10,000 OpenSearch candidates. Their reported total and pagination describe only matches found within that candidate set. + CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every slash-delimited token supplied to one CVSS vector filter must match a complete token in the same eligible vector; a base token such as `AV:N` does not match a modified token such as `MAV:N`. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 73a4c7b..2a2cffa 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -21,7 +21,7 @@ "CVE Record" ], "summary": "Retrieves all matching CVE records after applying the JSON request body as filters", - "description": "

Expected Behavior

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

", + "description": "

Expected Behavior

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

Result Counts and Pagination

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

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

", "operationId": "search", "responses": { "200": { @@ -436,7 +436,8 @@ "properties": { "value": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "For direct searches, the exact number of matching records, which may be greater than 10,000. For semantic post-filtered searches, the number of matches found within the bounded 10,000-candidate set." } } }, @@ -477,7 +478,8 @@ }, "totalPages": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded 10,000-candidate set." }, "status": { "type": "string", @@ -550,7 +552,7 @@ }, "SearchRequest": { "type": "object", - "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.", + "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed 10,000, although requested pages must remain inside that window. CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most 10,000 OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.", "additionalProperties": false, "properties": { "scope": { @@ -744,20 +746,20 @@ "minimum": 1, "maximum": 500, "default": 20, - "description": "Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first 10000 results." + "description": "Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search." }, "pageNumber": { "type": "integer", "format": "int32", "minimum": 1, "default": 1, - "description": "Specifies the page to return based on resultsPerPage. The requested page must remain within the first 10000 results." + "description": "Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search." }, "versionStart": { "type": "string", "maxLength": 256, "example": "2.2", - "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -771,7 +773,7 @@ "type": "string", "maxLength": 256, "example": "2.6", - "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", @@ -861,4 +863,4 @@ } } } -} +} \ No newline at end of file diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index d724de3..88428cf 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -59,6 +59,9 @@ describe('OpenAPI contract', () => { expect(mediaType.schema).toStrictEqual({ $ref: '#/components/schemas/SearchRequest' }); expect(schema.additionalProperties).toBe(false); expect(schema.description).toContain('the start must be before or equal to the end'); + expect(schema.description).toContain('Direct searches report exact totals'); + expect(schema.description).toContain('semantic post-filtering'); + expect(schema.description).toContain('10,000 OpenSearch candidates'); expect(Object.keys(schema.properties)).toStrictEqual(constants.SEARCH_QUERY_PARAMS); Object.values(schema.properties).forEach((property) => { expect(property.description).toEqual(expect.any(String)); @@ -109,14 +112,18 @@ describe('OpenAPI contract', () => { expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); + expect(schema.properties.resultsPerPage.description).toContain('does not cap the exact total'); + expect(schema.properties.pageNumber.description).toContain('first 10,000 results'); expect(schema.properties.cveId).toBeUndefined(); expect(schema.properties.sourceIdentifier).toBeUndefined(); expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros'); expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); + expect(schema.properties.versionStart.description).toContain('Negative numeric values'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros'); + expect(schema.properties.versionEnd.description).toContain('standalone CPE logical NA value'); expect(schema.properties.versionEnd.description).toContain( 'prerelease qualifiers before an unqualified release' ); @@ -197,7 +204,12 @@ describe('OpenAPI contract', () => { ]); expect(searchResponse.properties.pageSize.description).toContain('requested for this page'); expect(searchResponse.properties.resultsReturned.description).toContain('actually returned'); + expect(searchResponse.properties.totalPages.description).toContain('10,000-candidate set'); expect(searchResponse.properties.data.properties.hits.$ref).toBe('#/components/schemas/SearchHits'); + expect(schemas.SearchHits.properties.total.properties.value.description).toContain( + 'exact number of matching records' + ); + expect(schemas.SearchHits.properties.total.properties.value.description).toContain('10,000-candidate set'); expect(schemas.SearchHits.properties.hits.items.$ref).toBe('#/components/schemas/SearchHit'); expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); diff --git a/controllers/middleware.js b/controllers/middleware.js index 07df429..93aa66b 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -92,6 +92,11 @@ function isSingleNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +function isUnsupportedVersionRangeBoundary(value) { + const normalized = String(value ?? '').trim(); + return normalized === '-' || /^-\d/.test(normalized); +} + function isBoolean(value) { return value === true || value === false; } @@ -252,6 +257,10 @@ function validateParams(req, res, next) { if (hasParam(params, param) && !isSingleNonEmptyString(params[param])) { return res.status(400).json(error.versionRangeValueError(param)); } + + if (hasParam(params, param) && isUnsupportedVersionRangeBoundary(params[param])) { + return res.status(400).json(error.versionRangeBoundaryError(param)); + } } for (const param of versionRangeTypeParams) { diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index a1d5841..2422403 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -308,6 +308,23 @@ describe('middleware validation function', () => { expectValidationError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`); }); + test.each([ + ['versionStart', '-1'], + ['versionEnd', '-1'], + ['versionStart', '-'] + ])('ValidateParams rejects unsupported ordered version boundary %s=%s.', (param, value) => { + validate({ + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + [param]: value, + [`${param}Type`]: 'including' + }); + + expectValidationError( + 'VERSION_RANGE_BOUNDARY', + `The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.` + ); + }); + test.each([ ['versionStartType', 'inclusive'], ['versionEndType', ['excluding']] diff --git a/routes/index.js b/routes/index.js index 7af0175..083db99 100644 --- a/routes/index.js +++ b/routes/index.js @@ -196,6 +196,9 @@ router.post( #swagger.description = "

Expected Behavior

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

+

Result Counts and Pagination

+

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

+

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

#swagger.requestBody = { required: false, content: { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index deb1700..c97c124 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -241,6 +241,26 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('POST /search rejects a negative numeric version bound before invoking OpenSearch', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + versionEnd: '-1', + versionEndType: 'including' + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'VERSION_RANGE_BOUNDARY', + message: + 'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.' + }); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /webSearch normalizes body fields and formats BasicSearchManager responses', async () => { mockBasicSearch.mockResolvedValue(basicResponse({ total: 1, hits: [{ _id: 'CVE-2024-0002' }] })); diff --git a/routes/swagger.js b/routes/swagger.js index 0996449..9092d25 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -8,6 +8,8 @@ const ssvc = require('../utils/ssvc'); const projectRoot = path.join(__dirname, '..'); const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); const routes = [path.join(__dirname, 'index.js')]; +const maxSearchWindowLabel = constants.MAX_SEARCH_WINDOW.toLocaleString('en-US'); +const semanticPostFilterDescription = `CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most ${maxSearchWindowLabel} OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.`; function searchRequestProperty(description, schema) { return { @@ -192,7 +194,7 @@ const searchRequestProperties = { } ), resultsPerPage: searchRequestProperty( - `Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first ${constants.MAX_SEARCH_WINDOW} results.`, + `Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first ${maxSearchWindowLabel} results. This pagination window does not cap the exact total reported by a direct search.`, { type: 'integer', format: 'int32', @@ -202,7 +204,7 @@ const searchRequestProperties = { } ), pageNumber: searchRequestProperty( - `Specifies the page to return based on resultsPerPage. The requested page must remain within the first ${constants.MAX_SEARCH_WINDOW} results.`, + `Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first ${maxSearchWindowLabel} results. This pagination window does not cap the exact total reported by a direct search.`, { type: 'integer', format: 'int32', @@ -211,7 +213,7 @@ const searchRequestProperties = { } ), versionStart: searchRequestProperty( - 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -226,7 +228,7 @@ const searchRequestProperties = { } ), versionEnd: searchRequestProperty( - 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, @@ -402,7 +404,8 @@ const doc = { properties: { value: { type: 'integer', - minimum: 0 + minimum: 0, + description: `For direct searches, the exact number of matching records, which may be greater than ${maxSearchWindowLabel}. For semantic post-filtered searches, the number of matches found within the bounded ${maxSearchWindowLabel}-candidate set.` } } }, @@ -436,7 +439,8 @@ const doc = { }, totalPages: { type: 'integer', - minimum: 0 + minimum: 0, + description: `Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded ${maxSearchWindowLabel}-candidate set.` }, status: { type: 'string', @@ -497,8 +501,7 @@ const doc = { }, SearchRequest: { type: 'object', - description: - 'Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.', + description: `Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed ${maxSearchWindowLabel}, although requested pages must remain inside that window. ${semanticPostFilterDescription}`, additionalProperties: false, properties: searchRequestProperties } diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 7624885..048dad6 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -5760,6 +5760,50 @@ }, "response": [] }, + { + "name": "Negative numeric version range boundary", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_BOUNDARY\",\"message\":\"The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\",\n \"versionEnd\": \"-1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "Invalid ranged virtualMatchString format", "event": [ diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 7fe3bb2..412b5bb 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -1001,6 +1001,11 @@ const cpeAndVersionFailures = folder( { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0', versionStartType: 'before' }, error.versionRangeTypeError('versionStartType') ), + negative( + 'Negative numeric version range boundary', + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', versionEnd: '-1', versionEndType: 'including' }, + error.versionRangeBoundaryError('versionEnd') + ), negative( 'Invalid ranged virtualMatchString format', { virtualMatchString: 'not-a-cpe', versionStart: '1.0', versionStartType: 'including' }, diff --git a/utils/cpe.js b/utils/cpe.js index 7301566..b59719a 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -230,6 +230,10 @@ function tokenizeVersion(value) { .match(/\d+|[a-z]+/g); } +function isNegativeNumericVersion(value) { + return /^-\d/.test(String(value ?? '').trim()); +} + function compareNumericTokens(a, b) { const normalizedA = a.replace(/^0+/, '') || '0'; const normalizedB = b.replace(/^0+/, '') || '0'; @@ -270,6 +274,16 @@ function compareVersionTokenToRelease(token) { } function compareCpeVersions(a, b) { + const negativeA = isNegativeNumericVersion(a); + const negativeB = isNegativeNumericVersion(b); + + if (negativeA !== negativeB) return negativeA ? -1 : 1; + + if (negativeA && negativeB) { + const comparison = compareCpeVersions(String(a).trim().slice(1), String(b).trim().slice(1)); + return comparison === 0 ? 0 : -comparison; + } + const tokensA = tokenizeVersion(a); const tokensB = tokenizeVersion(b); diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 916a0d9..0dbce63 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -169,6 +169,13 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions('1.0', '1.0.1')).toBeLessThan(0); }); + test('compareCpeVersions preserves the sign of negative numeric versions', () => { + expect(cpe.compareCpeVersions('-1', '1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('1', '-1')).toBeGreaterThan(0); + expect(cpe.compareCpeVersions('-2', '-1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('-1.0', '-1')).toBe(0); + }); + test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { const orderedVersions = [ '1.0.0-alpha1', diff --git a/utils/errors.js b/utils/errors.js index 292c161..2b1bfa8 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -96,6 +96,13 @@ class errors { return makeError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`); } + versionRangeBoundaryError(param) { + return makeError( + 'VERSION_RANGE_BOUNDARY', + `The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.` + ); + } + versionRangeTypeError(param) { return makeError('VERSION_RANGE_TYPE', `The ${param} parameter must be including or excluding.`); } diff --git a/utils/errors.test.js b/utils/errors.test.js index aae9e04..fe3e325 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -91,6 +91,15 @@ describe('error response helpers', () => { message: 'The versionStart parameter must be a non-empty string.' } ], + [ + 'versionRangeBoundaryError', + ['versionEnd'], + { + error: 'VERSION_RANGE_BOUNDARY', + message: + 'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.' + } + ], [ 'versionRangeTypeError', ['versionEndType'],