From 79d41d1b9f566f9462519f2ceec6203828a3430f Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 13 Aug 2026 19:19:23 -0400 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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', () => {