diff --git a/README.md b/README.md index a936aed..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 | @@ -117,7 +119,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 +134,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. @@ -291,7 +293,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/api-docs/openapi.json b/api-docs/openapi.json index cd18a9d..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": { @@ -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": { @@ -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..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)); @@ -84,6 +85,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..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']; @@ -186,6 +192,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 @@ -358,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 dd70330..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'], @@ -956,6 +993,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/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/package-lock.json b/package-lock.json index 61f936e..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", @@ -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..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", @@ -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", diff --git a/routes/swagger.js b/routes/swagger.js index 3c3e047..025ddd3 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' } @@ -343,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/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/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/errors.js b/utils/errors.js index 921b2d2..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', @@ -184,6 +188,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.`); } 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/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); 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/searchPostFilters.test.js b/utils/searchPostFilters.test.js new file mode 100644 index 0000000..be3329c --- /dev/null +++ b/utils/searchPostFilters.test.js @@ -0,0 +1,140 @@ +const searchPostFilters = require('./searchPostFilters'); + +function response(hits) { + return { + data: { + hits: { + total: { value: hits.length }, + hits + } + } + }; +} + +function cpeHit(id, cpeMatch) { + return { + _id: id, + _source: { + containers: { + cna: { + cpeApplicability: { + nodes: [{ cpeMatch: Array.isArray(cpeMatch) ? cpeMatch : [cpeMatch] }] + } + } + } + } + }; +} + +function filteredHits(hits, postFilters) { + return searchPostFilters.filterSearchHits(response(hits), postFilters).filteredHits; +} + +describe('searchPostFilters CPE semantics', () => { + test('does not combine platform and version matches from different CPE entries', () => { + const candidate = cpeHit('CVE-2026-0250', [ + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }, + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.2', + versionEndExcluding: '6.2.7' + }, + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.1', + versionEndExcluding: '6.1.13' + } + ]); + + expect( + filteredHits([candidate], { + cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + }); + + test('matches CPE string literals without lexical case', () => { + const candidate = cpeHit('CVE-2026-0251', { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }); + + expect( + filteredHits([candidate], { + cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', + isVulnerable: true + }) + ).toStrictEqual([candidate]); + }); + + test('preserves exact version padding while normalizing numeric range segments', () => { + const exactCandidate = cpeHit('CVE-2025-9392', { + criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', + vulnerable: true + }); + const rangeCandidate = cpeHit('CVE-2026-33280', { + criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', + vulnerable: true, + versionEndExcluding: '6.02' + }); + + expect( + filteredHits([exactCandidate], { + cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + expect( + filteredHits([rangeCandidate], { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([rangeCandidate]); + expect( + filteredHits([rangeCandidate], { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', + isVulnerable: true + }) + ).toStrictEqual([]); + }); + + test('preserves CPE NA matches across numeric version ranges', () => { + const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; + const candidates = [ + cpeHit('CVE-2020-17153', { + criteria, + vulnerable: true, + versionStartIncluding: '-' + }), + cpeHit('CVE-2024-38208', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '128.0.2739.42' + }), + cpeHit('CVE-2026-35429', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '148.0.3967.55' + }) + ]; + + expect( + filteredHits(candidates, { + cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', + isVulnerable: true + }).map((hit) => hit._id) + ).toStrictEqual(['CVE-2020-17153', 'CVE-2024-38208', 'CVE-2026-35429']); + }); +}); diff --git a/utils/searchProvider.js b/utils/searchProvider.js index 929ec6b..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/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 22bf955..8e24b8d 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'); @@ -68,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), @@ -102,6 +97,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 +105,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 +628,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; @@ -706,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 acd7477..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', () => { @@ -764,6 +768,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', 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 + ); + }); +});