From f6bc82728a845bfc9321468ffcb81a1b0d4adb6c Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 28 Jul 2026 11:09:28 -0400 Subject: [PATCH 1/7] fix: align CPE NA range matching with NVD --- README.md | 2 + api-docs/openapi.json | 4 +- api-docs/openapi.test.js | 2 + controllers/searchController.test.js | 42 ++++++++++++++++ routes/swagger.js | 2 +- utils/cpe.js | 7 +++ utils/cpe.test.js | 75 ++++++++++++++++++++++++++++ 7 files changed, 131 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 21080c4..1de97db 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ curl -X POST "http://localhost:3000/search" \ The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. +The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. To match NVD `cpeName` behavior, numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This compatibility rule can produce different range behavior than a literal reading of source CVE Record applicability fields because NVD applies its own CPE enrichment when building CPE Match Criteria. + Set `isVulnerable` to `true` with `cpeName` to require the matching CPE Match Criteria entry itself to be marked vulnerable. A vulnerable flag on an unrelated CPE entry does not satisfy the request. Omitting `isVulnerable`, or setting it to `false`, does not apply a vulnerability restriction. ### Virtual Match String Search diff --git a/api-docs/openapi.json b/api-docs/openapi.json index a860ab1..a2319e0 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -419,7 +419,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "type": "boolean", @@ -503,4 +503,4 @@ } } } -} \ No newline at end of file +} diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 3b205f1..388dda3 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -115,6 +115,8 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('CPE Match Criteria'); expect(cpeName.description).toContain('part, vendor, product, and version'); expect(cpeName.description).toContain('version range fields'); + expect(cpeName.description).toContain('logical value NA'); + expect(cpeName.description).toContain('numeric range boundaries'); expect(cpeName.description).toContain('HTTP 404'); expect(cpeName.description).toContain('virtualMatchString'); expect(cpeName.example).toBe('cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*'); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 1e5fe87..0e326c9 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -554,6 +554,48 @@ describe('Search controller SEARCH handler', () => { }); }); + test('applySearchPostFilters preserves NVD-compatible CPE NA matches from source record 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/routes/swagger.js b/routes/swagger.js index 5a78b14..97b46bb 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -127,7 +127,7 @@ const searchRequestProperties = { example: 'security@example.org' }), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, diff --git a/utils/cpe.js b/utils/cpe.js index 293daf8..1004343 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -1,5 +1,6 @@ const CPE_23_PREFIX = ['cpe', '2.3']; const CPE_COMPONENT_COUNT = 11; +const CPE_NOT_APPLICABLE = '-'; const REQUIRED_CPE_COMPONENT_INDEXES = [0, 1, 2, 3]; function parseCpe23(input) { @@ -336,6 +337,12 @@ function versionRangesOverlap(a, b) { } function rangeBoundaryMatches(assetVersion, cpeMatch) { + if (assetVersion === CPE_NOT_APPLICABLE) { + return ( + cpeMatch.versionStartExcluding !== CPE_NOT_APPLICABLE && cpeMatch.versionEndExcluding !== CPE_NOT_APPLICABLE + ); + } + if ( hasBoundary(cpeMatch.versionStartIncluding) && compareCpeVersions(assetVersion, cpeMatch.versionStartIncluding) < 0 diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 041728e..736fbfd 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -191,6 +191,81 @@ describe('CPE helpers', () => { ).toBe(false); }); + test('cpeMatchAppliesToCpeName treats the CPE NA version as nonnumeric for NVD-compatible ranges', () => { + const cpeName = 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*'; + const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; + + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria, + vulnerable: true, + versionStartIncluding: '-' + }, + cpeName, + undefined, + { requireVulnerable: true } + ) + ).toBe(true); + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '128.0.2739.42' + }, + cpeName, + undefined, + { requireVulnerable: true } + ) + ).toBe(true); + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '148.0.3967.55' + }, + cpeName, + undefined, + { requireVulnerable: true } + ) + ).toBe(true); + }); + + test('cpeMatchAppliesToCpeName still honors exact and exclusive CPE NA criteria', () => { + const cpeName = 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*'; + + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:microsoft:edge:1.0.0:*:*:*:*:android:*:*' + }, + cpeName + ) + ).toBe(false); + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*', + versionStartExcluding: '-' + }, + cpeName + ) + ).toBe(false); + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*', + versionEndExcluding: '-' + }, + cpeName + ) + ).toBe(false); + }); + test('cpeMatchAppliesToCpeName requires vulnerability on the matching entry when requested', () => { const cpeName = 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*'; const matchingCriteria = 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*'; From 85232593b2dee717257dc35e0fa5483f53bff12e Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 28 Jul 2026 11:27:31 -0400 Subject: [PATCH 2/7] fix: order CPE version qualifiers semantically --- README.md | 2 + api-docs/openapi.json | 6 +-- api-docs/openapi.test.js | 5 +++ controllers/middleware.test.js | 27 ++++++++++++ routes/swagger.js | 6 +-- utils/cpe.js | 50 +++++++++++++++++++--- utils/cpe.test.js | 78 ++++++++++++++++++++++++++++++++++ 7 files changed, 163 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1de97db..761b7cf 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ curl -X POST "http://localhost:3000/search" \ Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. +CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. + Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring concrete leading components, such as `part` and `vendor` for normal searches and `part`, `vendor`, and `product` for version range searches, would reduce the most expensive public search shapes. That would intentionally diverge from NVD behavior and should be handled as a policy decision before implementation. `POST /webSearch` is deprecated in favor of `POST /search`. It remains enabled by default during consumer migration and can be disabled without a code change by setting `WebSearchEnabled=false` and restarting the service. When disabled, it returns HTTP `410` with the stable error code `WEB_SEARCH_DISABLED` before validating the request or contacting OpenSearch. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index a2319e0..19a5415 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -419,7 +419,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "type": "boolean", @@ -469,7 +469,7 @@ "type": "string", "maxLength": 256, "example": "2.2", - "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -483,7 +483,7 @@ "type": "string", "maxLength": 256, "example": "2.6", - "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 388dda3..af588a3 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -72,13 +72,18 @@ describe('OpenAPI contract', () => { expect(schema.properties.noRejected.description).toContain('When true'); expect(schema.properties.isVulnerable.description).toContain('same CPE Match Criteria entry'); expect(schema.properties.isVulnerable.description).toContain('False or omitted'); + expect(schema.properties.cpeName.description).toContain('Maven-style ordering'); expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); expect(schema.properties.cveId).toBeUndefined(); expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); + expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); + expect(schema.properties.versionEnd.description).toContain( + 'prerelease qualifiers before an unqualified release' + ); expect(schema.properties.virtualMatchString.description).toContain('CPE Match Criteria'); expect(schema.properties.virtualMatchString.description).toContain('compatible'); expect(schema.properties.virtualMatchString.description).toContain('without a version component'); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index b036f2f..d86a6ce 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -226,6 +226,33 @@ describe('middleware validation function', () => { expectValidationSuccess(); }); + test('ValidateParams accepts a prerelease-to-release version range', () => { + validate({ + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '1.0.0-rc1', + versionStartType: 'including', + versionEnd: '1.0.0', + versionEndType: 'including' + }); + + expectValidationSuccess(); + }); + + test('ValidateParams rejects a release-to-prerelease version range', () => { + validate({ + virtualMatchString: 'cpe:2.3:a:vendor:product', + versionStart: '1.0.0', + versionStartType: 'including', + versionEnd: '1.0.0-rc1', + versionEndType: 'including' + }); + + expectValidationError( + 'VERSION_RANGE_ORDER', + 'The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.' + ); + }); + test('ValidateParams returns an error when version range params are used without virtualMatchString', () => { validate({ versionStart: '2.2', diff --git a/routes/swagger.js b/routes/swagger.js index 97b46bb..dad6758 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -127,7 +127,7 @@ const searchRequestProperties = { example: 'security@example.org' }), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -193,7 +193,7 @@ const searchRequestProperties = { } ), versionStart: searchRequestProperty( - 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -208,7 +208,7 @@ const searchRequestProperties = { } ), versionEnd: searchRequestProperty( - 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, diff --git a/utils/cpe.js b/utils/cpe.js index 1004343..008be28 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -2,6 +2,16 @@ const CPE_23_PREFIX = ['cpe', '2.3']; const CPE_COMPONENT_COUNT = 11; const CPE_NOT_APPLICABLE = '-'; const REQUIRED_CPE_COMPONENT_INDEXES = [0, 1, 2, 3]; +const VERSION_QUALIFIERS = ['alpha', 'beta', 'milestone', 'rc', 'snapshot', '', 'sp']; +const VERSION_QUALIFIER_ALIASES = { + a: 'alpha', + b: 'beta', + cr: 'rc', + final: '', + ga: '', + m: 'milestone', + release: '' +}; function parseCpe23(input) { const value = String(input ?? '') @@ -232,6 +242,33 @@ function compareNumericTokens(a, b) { return normalizedA > normalizedB ? 1 : -1; } +function normalizeVersionQualifier(token) { + return Object.prototype.hasOwnProperty.call(VERSION_QUALIFIER_ALIASES, token) + ? VERSION_QUALIFIER_ALIASES[token] + : token; +} + +function compareVersionQualifiers(a, b) { + const normalizedA = normalizeVersionQualifier(a); + const normalizedB = normalizeVersionQualifier(b); + const rankA = VERSION_QUALIFIERS.indexOf(normalizedA); + const rankB = VERSION_QUALIFIERS.indexOf(normalizedB); + + if (rankA !== -1 || rankB !== -1) { + if (rankA === -1) return 1; + if (rankB === -1) return -1; + return Math.sign(rankA - rankB); + } + + if (normalizedA === normalizedB) return 0; + return normalizedA > normalizedB ? 1 : -1; +} + +function compareVersionTokenToRelease(token) { + if (/^\d+$/.test(token)) return /^0+$/.test(token) ? 0 : 1; + return compareVersionQualifiers(token, ''); +} + function compareCpeVersions(a, b) { const tokensA = tokenizeVersion(a); const tokensB = tokenizeVersion(b); @@ -246,12 +283,14 @@ function compareCpeVersions(a, b) { const tokenB = tokensB[i]; if (tokenA === undefined) { - if (/^0+$/.test(tokenB)) continue; - return -1; + const result = compareVersionTokenToRelease(tokenB); + if (result === 0) continue; + return -result; } if (tokenB === undefined) { - if (/^0+$/.test(tokenA)) continue; - return 1; + const result = compareVersionTokenToRelease(tokenA); + if (result === 0) continue; + return result; } if (tokenA === tokenB) continue; @@ -268,7 +307,8 @@ function compareCpeVersions(a, b) { return aIsNumber ? 1 : -1; } - return tokenA > tokenB ? 1 : -1; + const result = compareVersionQualifiers(tokenA, tokenB); + if (result !== 0) return result; } return 0; diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 736fbfd..d12a6b0 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -166,6 +166,37 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions('1.0', '1.0.1')).toBeLessThan(0); }); + test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { + const orderedVersions = [ + '1.0.0-alpha1', + '1.0.0-alpha2', + '1.0.0-beta1', + '1.0.0-milestone1', + '1.0.0-rc1', + '1.0.0-snapshot', + '1.0.0', + '1.0.0-sp1', + '1.0.0-hf1', + '1.0.0-zz1' + ]; + + for (let index = 1; index < orderedVersions.length; index++) { + expect(cpe.compareCpeVersions(orderedVersions[index - 1], orderedVersions[index])).toBeLessThan(0); + } + }); + + test.each([ + ['1.0.0-a1', '1.0.0-alpha1'], + ['1.0.0-b1', '1.0.0-beta1'], + ['1.0.0-m1', '1.0.0-milestone1'], + ['1.0.0-cr1', '1.0.0-rc1'], + ['1.0.0-GA', '1.0.0'], + ['1.0.0-final', '1.0.0'], + ['1.0.0-release', '1.0.0'] + ])('compareCpeVersions treats %s and %s as equivalent qualifier forms', (left, right) => { + expect(cpe.compareCpeVersions(left, right)).toBe(0); + }); + test('cpeMatchAppliesToCpeName honors inclusive and exclusive version boundaries', () => { const cpeName = 'cpe:2.3:a:vendor:product:2.10:*:*:*:*:*:*:*'; @@ -191,6 +222,19 @@ describe('CPE helpers', () => { ).toBe(false); }); + test('cpeMatchAppliesToCpeName treats prerelease qualifiers as earlier than the unqualified release', () => { + const criteria = { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + versionStartIncluding: '1.0.0-rc1', + versionEndExcluding: '1.0.0-sp1' + }; + + expect(cpe.cpeMatchAppliesToCpeName(criteria, 'cpe:2.3:a:vendor:product:1.0.0:*:*:*:*:*:*:*')).toBe(true); + expect(cpe.cpeMatchAppliesToCpeName(criteria, 'cpe:2.3:a:vendor:product:1.0.0-beta1:*:*:*:*:*:*:*')).toBe( + false + ); + }); + test('cpeMatchAppliesToCpeName treats the CPE NA version as nonnumeric for NVD-compatible ranges', () => { const cpeName = 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*'; const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; @@ -348,6 +392,40 @@ describe('CPE helpers', () => { ).toBe(false); }); + test('cpeMatchOverlapsVirtualMatchStringRange applies qualifier precedence to range overlap', () => { + const matchString = cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'); + + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + versionEndExcluding: '1.0.0' + }, + { + matchString, + versionStart: '1.0.0-rc1', + versionStartType: 'including', + versionEnd: '1.0.0', + versionEndType: 'including' + } + ) + ).toBe(true); + + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + versionEndExcluding: '1.0.0-rc1' + }, + { + matchString, + versionStart: '1.0.0', + versionStartType: 'including' + } + ) + ).toBe(false); + }); + test('cpeMatchOverlapsVirtualMatchStringRange matches upper-bounded wildcard criteria before the excluded boundary', () => { const cpeMatch = { criteria: 'cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*', From 75d897e1fa8936a5533acee9f45b2519c41c7296 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 28 Jul 2026 11:42:30 -0400 Subject: [PATCH 3/7] fix: correlate all CPE name components --- README.md | 2 +- api-docs/openapi.json | 2 +- api-docs/openapi.test.js | 4 +++ controllers/searchController.test.js | 44 ++++++++++++++++++++++++++ routes/swagger.js | 2 +- utils/cpe.js | 6 ++-- utils/cpe.test.js | 47 ++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 761b7cf..4446849 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ curl -X POST "http://localhost:3000/search" \ -d '{"cpeName":"cpe:2.3:a:freetype:freetype:*:*:*:*:*:*:*:*"}' ``` -The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. +The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. Every concrete non-version component in the input, such as edition, language, target software, or target hardware, must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks. Optional input components set to `*` remain broad matches. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. To match NVD `cpeName` behavior, numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This compatibility rule can produce different range behavior than a literal reading of source CVE Record applicability fields because NVD applies its own CPE enrichment when building CPE Match Criteria. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 19a5415..f635444 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -419,7 +419,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "type": "boolean", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index af588a3..a71b4d1 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -73,6 +73,10 @@ describe('OpenAPI contract', () => { expect(schema.properties.isVulnerable.description).toContain('same CPE Match Criteria entry'); expect(schema.properties.isVulnerable.description).toContain('False or omitted'); expect(schema.properties.cpeName.description).toContain('Maven-style ordering'); + expect(schema.properties.cpeName.description).toContain( + 'Every concrete non-version component must be compatible with the same CPE Match Criteria entry' + ); + expect(schema.properties.cpeName.description).toContain('optional * components remain broad matches'); expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 0e326c9..aac0d50 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -554,6 +554,50 @@ describe('Search controller SEARCH handler', () => { }); }); + 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 preserves NVD-compatible CPE NA matches from source record ranges', () => { const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; const candidates = [ diff --git a/routes/swagger.js b/routes/swagger.js index dad6758..0c4f54f 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -127,7 +127,7 @@ const searchRequestProperties = { example: 'security@example.org' }), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, diff --git a/utils/cpe.js b/utils/cpe.js index 008be28..fdecb49 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -315,7 +315,7 @@ function compareCpeVersions(a, b) { } function componentMatches(assetComponent, criteriaComponent) { - return criteriaComponent === '*' || criteriaComponent === assetComponent; + return assetComponent === '*' || criteriaComponent === '*' || criteriaComponent === assetComponent; } function hasBoundary(value) { @@ -421,7 +421,9 @@ function cpeMatchAppliesToCpeName(cpeMatch, cpeName, context, { requireVulnerabl if (!asset.valid || !criteria.valid) return false; if (requireVulnerable && cpeMatch?.vulnerable !== true) return false; - for (const index of [0, 1, 2]) { + for (let index = 0; index < asset.components.length; index++) { + if (index === 3) continue; + if (!componentMatches(asset.components[index], criteria.components[index])) { return false; } diff --git a/utils/cpe.test.js b/utils/cpe.test.js index d12a6b0..612a3fd 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -235,6 +235,53 @@ describe('CPE helpers', () => { ); }); + test('cpeMatchAppliesToCpeName requires every concrete non-version component to match', () => { + const cpeName = 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*'; + const androidRange = { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.1', + versionEndExcluding: '6.1.13' + }; + const macosRange = { + ...androidRange, + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:macos:*:*:*:*:*' + }; + + expect(cpe.cpeMatchAppliesToCpeName(androidRange, cpeName, undefined, { requireVulnerable: true })).toBe(false); + expect(cpe.cpeMatchAppliesToCpeName(macosRange, cpeName, undefined, { requireVulnerable: true })).toBe(true); + }); + + test('cpeMatchAppliesToCpeName rejects an exact version criterion with a different platform component', () => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:android:*:*:*:*:*', + vulnerable: true + }, + 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:macos:*:*:*:*:*', + undefined, + { requireVulnerable: true } + ) + ).toBe(false); + }); + + test('cpeMatchAppliesToCpeName keeps wildcard optional input components broad', () => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:android:*:*:*:*:*', + vulnerable: true, + versionStartIncluding: '6.1', + versionEndExcluding: '6.1.13' + }, + 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.1.1:*:*:*:*:*:*:*', + undefined, + { requireVulnerable: true } + ) + ).toBe(true); + }); + test('cpeMatchAppliesToCpeName treats the CPE NA version as nonnumeric for NVD-compatible ranges', () => { const cpeName = 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*'; const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; From ab6f0c232ca28341a9726a2942f520f79eeab05a Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 28 Jul 2026 11:57:02 -0400 Subject: [PATCH 4/7] fix: make CPE literal matching case-insensitive --- README.md | 4 +-- api-docs/openapi.json | 4 +-- api-docs/openapi.test.js | 3 ++ controllers/searchController.test.js | 36 ++++++++++++++++--- routes/swagger.js | 4 +-- utils/cpe.js | 16 ++++----- utils/cpe.test.js | 53 ++++++++++++++++++++++++++++ utils/searchQueryBuilder.js | 6 ++-- utils/searchQueryBuilder.test.js | 6 ++-- 9 files changed, 110 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4446849..9416e67 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ curl -X POST "http://localhost:3000/search" \ -d '{"cpeName":"cpe:2.3:a:freetype:freetype:*:*:*:*:*:*:*:*"}' ``` -The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. Every concrete non-version component in the input, such as edition, language, target software, or target hardware, must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks. Optional input components set to `*` remain broad matches. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. +The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. In accordance with the CPE 2.3 Name Matching specification, CPE string literal comparisons are insensitive to lexical case, so an input component such as `macos` matches stored criteria containing `macOS`. Every concrete non-version component in the input, such as edition, language, target software, or target hardware, must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks. Optional input components set to `*` remain broad matches. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. To match NVD `cpeName` behavior, numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This compatibility rule can produce different range behavior than a literal reading of source CVE Record applicability fields because NVD applies its own CPE enrichment when building CPE Match Criteria. @@ -158,7 +158,7 @@ Set `isVulnerable` to `true` with `cpeName` to require the matching CPE Match Cr ### Virtual Match String Search -Use `virtualMatchString` when a user needs NVD-style CPE Match Criteria compatibility matching instead of concrete asset matching. Omitted trailing components and `*` components act as broad matches, matching NVD's broad CPE Match String behavior. +Use `virtualMatchString` when a user needs NVD-style CPE Match Criteria compatibility matching instead of concrete asset matching. String literal components are compared without regard to lexical case. Omitted trailing components and `*` components act as broad matches, matching NVD's broad CPE Match String behavior. Partial product example: diff --git a/api-docs/openapi.json b/api-docs/openapi.json index f635444..23fe2da 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -419,7 +419,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "type": "boolean", @@ -497,7 +497,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:o:microsoft:windows_10_1809", - "description": "Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component." + "description": "Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index a71b4d1..26884eb 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -77,6 +77,7 @@ describe('OpenAPI contract', () => { 'Every concrete non-version component must be compatible with the same CPE Match Criteria entry' ); expect(schema.properties.cpeName.description).toContain('optional * components remain broad matches'); + expect(schema.properties.cpeName.description).toContain('insensitive to lexical case'); expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); @@ -90,6 +91,7 @@ describe('OpenAPI contract', () => { ); expect(schema.properties.virtualMatchString.description).toContain('CPE Match Criteria'); expect(schema.properties.virtualMatchString.description).toContain('compatible'); + expect(schema.properties.virtualMatchString.description).toContain('insensitive to lexical case'); expect(schema.properties.virtualMatchString.description).toContain('without a version component'); expect(schema.properties.virtualMatchString.example).toBe('cpe:2.3:o:microsoft:windows_10_1809'); expect(Object.keys(mediaType.examples)).toStrictEqual([ @@ -124,6 +126,7 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('CPE Match Criteria'); expect(cpeName.description).toContain('part, vendor, product, and version'); expect(cpeName.description).toContain('version range fields'); + expect(cpeName.description).toContain('insensitive to lexical case'); expect(cpeName.description).toContain('logical value NA'); expect(cpeName.description).toContain('numeric range boundaries'); expect(cpeName.description).toContain('HTTP 404'); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index aac0d50..440fa66 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -381,7 +381,8 @@ describe('Search controller SEARCH handler', () => { ].map((value) => ({ prefix: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value + value, + case_insensitive: true } } })), @@ -431,7 +432,8 @@ describe('Search controller SEARCH handler', () => { ].map((value) => ({ prefix: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value + value, + case_insensitive: true } } })), @@ -598,6 +600,30 @@ describe('Search controller SEARCH handler', () => { 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 NVD-compatible CPE NA matches from source record ranges', () => { const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; const candidates = [ @@ -1336,7 +1362,8 @@ describe('Search controller SEARCH handler', () => { ].map((value) => ({ prefix: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value + value, + case_insensitive: true } } })), @@ -1364,7 +1391,8 @@ describe('Search controller SEARCH handler', () => { { regexp: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value: 'cpe:2\\.3:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*' + value: 'cpe:2\\.3:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*', + case_insensitive: true } } } diff --git a/routes/swagger.js b/routes/swagger.js index 0c4f54f..4fa72d5 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -127,7 +127,7 @@ const searchRequestProperties = { example: 'security@example.org' }), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -223,7 +223,7 @@ const searchRequestProperties = { } ), virtualMatchString: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component.', + 'Returns CVE records whose CPE Match Criteria are compatible with the provided CPE 2.3 match string. CPE string literal comparisons are insensitive to lexical case. Omitted trailing components and * components act as broad matches. When used with versionStart or versionEnd, provide a partial CPE 2.3 match string without a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.virtualMatchString, diff --git a/utils/cpe.js b/utils/cpe.js index fdecb49..7301566 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -314,8 +314,12 @@ function compareCpeVersions(a, b) { return 0; } +function cpeLiteralsEqual(left, right) { + return String(left).toLowerCase() === String(right).toLowerCase(); +} + function componentMatches(assetComponent, criteriaComponent) { - return assetComponent === '*' || criteriaComponent === '*' || criteriaComponent === assetComponent; + return assetComponent === '*' || criteriaComponent === '*' || cpeLiteralsEqual(criteriaComponent, assetComponent); } function hasBoundary(value) { @@ -430,16 +434,12 @@ function cpeMatchAppliesToCpeName(cpeMatch, cpeName, context, { requireVulnerabl } if (criteria.version !== '*') { - return criteria.version === asset.version; + return cpeLiteralsEqual(criteria.version, asset.version); } return rangeBoundaryMatches(asset.version, cpeMatch); } -function componentMatchesVirtualMatchString(matchComponent, criteriaComponent) { - return matchComponent === '*' || criteriaComponent === '*' || criteriaComponent === matchComponent; -} - function cpeMatchOverlapsVirtualMatchStringRange(cpeMatch, virtualMatchStringRange, context) { const matchString = virtualMatchStringRange?.matchString?.valid ? virtualMatchStringRange.matchString @@ -451,7 +451,7 @@ function cpeMatchOverlapsVirtualMatchStringRange(cpeMatch, virtualMatchStringRan for (let index = 0; index < matchString.components.length; index++) { if (index === 3) continue; - if (!componentMatchesVirtualMatchString(matchString.components[index], criteria.components[index])) { + if (!componentMatches(matchString.components[index], criteria.components[index])) { return false; } } @@ -474,7 +474,7 @@ function cpeMatchMatchesVirtualMatchString(cpeMatch, virtualMatchString, context if (!matchString.valid || !criteria.valid) return false; for (let index = 0; index < matchString.components.length; index++) { - if (!componentMatchesVirtualMatchString(matchString.components[index], criteria.components[index])) { + if (!componentMatches(matchString.components[index], criteria.components[index])) { return false; } } diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 612a3fd..8fcee19 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -252,6 +252,35 @@ describe('CPE helpers', () => { expect(cpe.cpeMatchAppliesToCpeName(macosRange, cpeName, undefined, { requireVulnerable: true })).toBe(true); }); + test('cpeMatchAppliesToCpeName compares concrete string literals without lexical case', () => { + const cpeMatch = { + criteria: 'cpe:2.3:a:Palo_Alto_Networks:GlobalProtect_App:*:*:*:*:*:macOS:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }; + + expect( + cpe.cpeMatchAppliesToCpeName( + cpeMatch, + 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', + undefined, + { requireVulnerable: true } + ) + ).toBe(true); + }); + + test('cpeMatchAppliesToCpeName compares exact criteria versions without lexical case', () => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:vendor:product:1.0.0-RC1:*:*:*:*:*:*:*' + }, + 'cpe:2.3:a:vendor:product:1.0.0-rc1:*:*:*:*:*:*:*' + ) + ).toBe(true); + }); + test('cpeMatchAppliesToCpeName rejects an exact version criterion with a different platform component', () => { expect( cpe.cpeMatchAppliesToCpeName( @@ -519,6 +548,30 @@ describe('CPE helpers', () => { expect(cpe.cpeMatchMatchesVirtualMatchString(windowsX86, 'cpe:2.3:o:microsoft:windows_11_23h2')).toBe(false); }); + test('virtualMatchString comparisons are insensitive to lexical case', () => { + const mixedCaseCriteria = { + criteria: 'cpe:2.3:a:Palo_Alto_Networks:GlobalProtect_App:*:*:*:*:*:macOS:*:*', + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }; + + expect( + cpe.cpeMatchMatchesVirtualMatchString( + mixedCaseCriteria, + 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macos:*:*' + ) + ).toBe(true); + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange(mixedCaseCriteria, { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:palo_alto_networks:globalprotect_app'), + versionStart: '6.0.1', + versionStartType: 'including', + versionEnd: '6.0.2', + versionEndType: 'excluding' + }) + ).toBe(true); + }); + test('hitAppliesToCpeName searches nested cpeApplicability nodes', () => { const hit = { _source: { diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 83104ce..07894bb 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -449,7 +449,8 @@ function buildCpeCriteriaPrefixFilter(field, prefixes) { const prefixFilters = prefixes.map((value) => ({ prefix: { [field]: { - value + value, + case_insensitive: true } } })); @@ -469,7 +470,8 @@ function buildCpeCriteriaRegexpFilter(field, value) { return { regexp: { [field]: { - value + value, + case_insensitive: true } } }; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index d8f9e31..9430664 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -4,7 +4,8 @@ function prefixFilter(value) { return { prefix: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value + value, + case_insensitive: true } } }; @@ -138,7 +139,8 @@ describe('searchQueryBuilder buildSearchRequest()', () => { { regexp: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value: 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:(x86|\\*):[^:]*' + value: 'cpe:2\\.3:(o|\\*):(microsoft|\\*):(windows_10_1809|\\*):[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:(x86|\\*):[^:]*', + case_insensitive: true } } } From 1fa490c3c9ab2220dbbbf6855073864a034a193c Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 28 Jul 2026 12:06:25 -0400 Subject: [PATCH 5/7] test: lock down NVD leading-zero version behavior --- README.md | 2 ++ api-docs/openapi.json | 6 ++-- api-docs/openapi.test.js | 7 ++++ controllers/searchController.test.js | 50 ++++++++++++++++++++++++++++ routes/swagger.js | 6 ++-- utils/cpe.test.js | 42 ++++++++++++++++++++++- 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9416e67..f7605a6 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Version ranges use overlap semantics. A stored wildcard-version CPE Match Criter CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. +As observed in the NVD CVE API on July 28, 2026, numeric segments used in version range comparisons ignore leading zeros. A stored exclusive upper bound of `10.05.1` therefore includes `10.5.0`, and CVE-2026-33280's exclusive `6.02` upper bound includes `6.1` but excludes `6.2`. This normalization applies only to range comparison. Exact CPE Match Criteria preserve leading zeros, so the CVE-2025-9392 versions `1.0.04.001` and `1.0.4.1` are not equivalent. This NVD compatibility behavior is not a universal firmware-versioning rule and can differ from a vendor's actual release sequence. + Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring concrete leading components, such as `part` and `vendor` for normal searches and `part`, `vendor`, and `product` for version range searches, would reduce the most expensive public search shapes. That would intentionally diverge from NVD behavior and should be handled as a policy decision before implementation. `POST /webSearch` is deprecated in favor of `POST /search`. It remains enabled by default during consumer migration and can be disabled without a code change by setting `WebSearchEnabled=false` and restarting the service. When disabled, it returns HTTP `410` with the stable error code `WEB_SEARCH_DISABLED` before validating the request or contacting OpenSearch. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 23fe2da..00cc2d7 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -419,7 +419,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "type": "boolean", @@ -469,7 +469,7 @@ "type": "string", "maxLength": 256, "example": "2.2", - "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -483,7 +483,7 @@ "type": "string", "maxLength": 256, "example": "2.6", - "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 26884eb..d5216d1 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -78,14 +78,19 @@ describe('OpenAPI contract', () => { ); expect(schema.properties.cpeName.description).toContain('optional * components remain broad matches'); expect(schema.properties.cpeName.description).toContain('insensitive to lexical case'); + expect(schema.properties.cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); + expect(schema.properties.cpeName.description).toContain('July 28, 2026'); expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); expect(schema.properties.cveId).toBeUndefined(); expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); + expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros'); + expect(schema.properties.versionStart.description).toContain('July 28, 2026'); expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); + expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros'); expect(schema.properties.versionEnd.description).toContain( 'prerelease qualifiers before an unqualified release' ); @@ -127,6 +132,8 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('part, vendor, product, and version'); expect(cpeName.description).toContain('version range fields'); expect(cpeName.description).toContain('insensitive to lexical case'); + expect(cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); + expect(cpeName.description).toContain('July 28, 2026'); expect(cpeName.description).toContain('logical value NA'); expect(cpeName.description).toContain('numeric range boundaries'); expect(cpeName.description).toContain('HTTP 404'); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 440fa66..4c5bebd 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -624,6 +624,56 @@ describe('Search controller SEARCH handler', () => { 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 NVD-compatible CPE NA matches from source record ranges', () => { const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; const candidates = [ diff --git a/routes/swagger.js b/routes/swagger.js index 4fa72d5..c6dacb3 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -127,7 +127,7 @@ const searchRequestProperties = { example: 'security@example.org' }), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -193,7 +193,7 @@ const searchRequestProperties = { } ), versionStart: searchRequestProperty( - 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -208,7 +208,7 @@ const searchRequestProperties = { } ), versionEnd: searchRequestProperty( - 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 8fcee19..63cd038 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -156,9 +156,12 @@ describe('CPE helpers', () => { expect(cpe.buildCompatibleCriteriaPrefixes(parsed, 3)[0]).toBe('cpe:2.3:a:vendor:product\\:edition:'); }); - test('compareCpeVersions compares numeric version segments semantically', () => { + test('compareCpeVersions compares numeric version segments semantically for range ordering', () => { expect(cpe.compareCpeVersions('2.10', '2.9')).toBeGreaterThan(0); expect(cpe.compareCpeVersions('10.05.1', '10.5.1')).toBe(0); + expect(cpe.compareCpeVersions('10.5.0', '10.05.1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('6.1', '6.02')).toBeLessThan(0); + expect(cpe.compareCpeVersions('6.2', '6.02')).toBe(0); expect(cpe.compareCpeVersions('1', '1.0')).toBe(0); expect(cpe.compareCpeVersions('1.0.0', '1')).toBe(0); expect(cpe.compareCpeVersions('1', '1.0.1')).toBeLessThan(0); @@ -222,6 +225,43 @@ describe('CPE helpers', () => { ).toBe(false); }); + test('cpeMatchAppliesToCpeName ignores leading zeros only when evaluating range boundaries', () => { + const ghostscriptRange = { + criteria: 'cpe:2.3:a:artifex:ghostscript:*:*:*:*:*:*:*:*', + versionEndExcluding: '10.05.1' + }; + const buffaloRange = { + criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', + versionEndExcluding: '6.02' + }; + + expect( + cpe.cpeMatchAppliesToCpeName(ghostscriptRange, 'cpe:2.3:a:artifex:ghostscript:10.5.0:*:*:*:*:*:*:*') + ).toBe(true); + expect( + cpe.cpeMatchAppliesToCpeName(ghostscriptRange, 'cpe:2.3:a:artifex:ghostscript:10.5.1:*:*:*:*:*:*:*') + ).toBe(false); + expect( + cpe.cpeMatchAppliesToCpeName(buffaloRange, 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*') + ).toBe(true); + expect( + cpe.cpeMatchAppliesToCpeName(buffaloRange, 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*') + ).toBe(false); + }); + + test('cpeMatchAppliesToCpeName preserves leading zeros for exact criteria versions', () => { + const exactCriteria = { + criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*' + }; + + expect( + cpe.cpeMatchAppliesToCpeName(exactCriteria, 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*') + ).toBe(true); + expect( + cpe.cpeMatchAppliesToCpeName(exactCriteria, 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*') + ).toBe(false); + }); + test('cpeMatchAppliesToCpeName treats prerelease qualifiers as earlier than the unqualified release', () => { const criteria = { criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', From 0804d39920e47ddc5c810ab194e1506a14ec1808 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 29 Jul 2026 08:20:50 -0400 Subject: [PATCH 6/7] docs: remove NVD alignment language --- README.md | 10 +++++----- api-docs/openapi.json | 6 +++--- api-docs/openapi.test.js | 6 +++--- controllers/middleware.js | 2 +- routes/swagger.js | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f7605a6..9dfcb80 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ String inputs are bounded before query construction: | `versionStart`, `versionEnd` | 256 characters each | | 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 NVD-style CPE matching semantics. +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. Use `cveIds` to request up to 100 CVE records by comma-separated CVE ID. @@ -152,13 +152,13 @@ curl -X POST "http://localhost:3000/search" \ The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. In accordance with the CPE 2.3 Name Matching specification, CPE string literal comparisons are insensitive to lexical case, so an input component such as `macos` matches stored criteria containing `macOS`. Every concrete non-version component in the input, such as edition, language, target software, or target hardware, must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks. Optional input components set to `*` remain broad matches. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. -The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. To match NVD `cpeName` behavior, numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This compatibility rule can produce different range behavior than a literal reading of source CVE Record applicability fields because NVD applies its own CPE enrichment when building CPE Match Criteria. +The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. Numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This rule can produce different range behavior than a literal reading of source CVE Record applicability fields. Set `isVulnerable` to `true` with `cpeName` to require the matching CPE Match Criteria entry itself to be marked vulnerable. A vulnerable flag on an unrelated CPE entry does not satisfy the request. Omitting `isVulnerable`, or setting it to `false`, does not apply a vulnerability restriction. ### Virtual Match String Search -Use `virtualMatchString` when a user needs NVD-style CPE Match Criteria compatibility matching instead of concrete asset matching. String literal components are compared without regard to lexical case. Omitted trailing components and `*` components act as broad matches, matching NVD's broad CPE Match String behavior. +Use `virtualMatchString` when a user needs CPE Match Criteria compatibility matching instead of concrete asset matching. String literal components are compared without regard to lexical case. Omitted trailing components and `*` components act as broad matches. Partial product example: @@ -190,9 +190,9 @@ Version ranges use overlap semantics. A stored wildcard-version CPE Match Criter CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions. -As observed in the NVD CVE API on July 28, 2026, numeric segments used in version range comparisons ignore leading zeros. A stored exclusive upper bound of `10.05.1` therefore includes `10.5.0`, and CVE-2026-33280's exclusive `6.02` upper bound includes `6.1` but excludes `6.2`. This normalization applies only to range comparison. Exact CPE Match Criteria preserve leading zeros, so the CVE-2025-9392 versions `1.0.04.001` and `1.0.4.1` are not equivalent. This NVD compatibility behavior is not a universal firmware-versioning rule and can differ from a vendor's actual release sequence. +Numeric segments used in version range comparisons ignore leading zeros. A stored exclusive upper bound of `10.05.1` therefore includes `10.5.0`, and CVE-2026-33280's exclusive `6.02` upper bound includes `6.1` but excludes `6.2`. This normalization applies only to range comparison. Exact CPE Match Criteria preserve leading zeros, so the CVE-2025-9392 versions `1.0.04.001` and `1.0.4.1` are not equivalent. This comparison rule is not a universal firmware-versioning rule and can differ from a vendor's actual release sequence. -Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring concrete leading components, such as `part` and `vendor` for normal searches and `part`, `vendor`, and `product` for version range searches, would reduce the most expensive public search shapes. That would intentionally diverge from NVD behavior and should be handled as a policy decision before implementation. +Future recommendation: broad `virtualMatchString` values can be expensive because they may require large candidate scans plus semantic CPE post-filtering. Requiring concrete leading components, such as `part` and `vendor` for normal searches and `part`, `vendor`, and `product` for version range searches, would reduce the most expensive public search shapes. Narrowing the accepted searches should be handled as a policy decision before implementation. `POST /webSearch` is deprecated in favor of `POST /search`. It remains enabled by default during consumer migration and can be disabled without a code change by setting `WebSearchEnabled=false` and restarting the service. When disabled, it returns HTTP `410` with the stable error code `WEB_SEARCH_DISABLED` before validating the request or contacting OpenSearch. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 00cc2d7..1c41dd0 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -419,7 +419,7 @@ "type": "string", "maxLength": 2048, "example": "cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*", - "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches." }, "isVulnerable": { "type": "boolean", @@ -469,7 +469,7 @@ "type": "string", "maxLength": 256, "example": "2.2", - "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -483,7 +483,7 @@ "type": "string", "maxLength": 256, "example": "2.6", - "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index d5216d1..7d0ba92 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -79,7 +79,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.cpeName.description).toContain('optional * components remain broad matches'); expect(schema.properties.cpeName.description).toContain('insensitive to lexical case'); expect(schema.properties.cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); - expect(schema.properties.cpeName.description).toContain('July 28, 2026'); + expect(schema.properties.cpeName.description).not.toContain('NVD'); expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); @@ -87,7 +87,7 @@ describe('OpenAPI contract', () => { expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros'); - expect(schema.properties.versionStart.description).toContain('July 28, 2026'); + expect(schema.properties.versionStart.description).not.toContain('NVD'); expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros'); @@ -133,7 +133,7 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('version range fields'); expect(cpeName.description).toContain('insensitive to lexical case'); expect(cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); - expect(cpeName.description).toContain('July 28, 2026'); + expect(cpeName.description).not.toContain('NVD'); expect(cpeName.description).toContain('logical value NA'); expect(cpeName.description).toContain('numeric range boundaries'); expect(cpeName.description).toContain('HTTP 404'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 351e7af..8eedf02 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -258,7 +258,7 @@ function validateParams(req, res, next) { return res.status(400).json(error.missingCpeNameError()); } - //Ensures cpeName follows NVD API requirements for required CPE components + //Ensures cpeName contains the required concrete CPE components if (params?.cpeName && !cpe.isValidCpeName(params.cpeName)) { return res.status(404).json(error.invalidCpeNameError()); } diff --git a/routes/swagger.js b/routes/swagger.js index c6dacb3..ecfe77e 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -127,7 +127,7 @@ const searchRequestProperties = { example: 'security@example.org' }), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; for NVD compatibility, numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. CPE string literal comparisons are insensitive to lexical case. The part, vendor, product, and version components must contain values other than *. Every concrete non-version component must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks; optional * components remain broad matches. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Invalid cpeName values return HTTP 404. Use virtualMatchString for partial CPE Match String searches.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -193,7 +193,7 @@ const searchRequestProperties = { } ), versionStart: searchRequestProperty( - 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -208,7 +208,7 @@ const searchRequestProperties = { } ), versionEnd: searchRequestProperty( - 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros for NVD compatibility, based on behavior observed in the NVD CVE API on July 28, 2026; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, From 370b8c77a8853147062ec81b0d1a5829af7f5924 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 29 Jul 2026 08:38:16 -0400 Subject: [PATCH 7/7] test: use direct CPE behavior descriptions --- api-docs/openapi.test.js | 3 --- controllers/middleware.test.js | 2 +- controllers/searchController.test.js | 2 +- utils/cpe.test.js | 6 +++--- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 7d0ba92..060097e 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -79,7 +79,6 @@ describe('OpenAPI contract', () => { expect(schema.properties.cpeName.description).toContain('optional * components remain broad matches'); expect(schema.properties.cpeName.description).toContain('insensitive to lexical case'); expect(schema.properties.cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); - expect(schema.properties.cpeName.description).not.toContain('NVD'); expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); @@ -87,7 +86,6 @@ describe('OpenAPI contract', () => { expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros'); - expect(schema.properties.versionStart.description).not.toContain('NVD'); expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros'); @@ -133,7 +131,6 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('version range fields'); expect(cpeName.description).toContain('insensitive to lexical case'); expect(cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); - expect(cpeName.description).not.toContain('NVD'); expect(cpeName.description).toContain('logical value NA'); expect(cpeName.description).toContain('numeric range boundaries'); expect(cpeName.description).toContain('HTTP 404'); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index d86a6ce..60ddf3c 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -149,7 +149,7 @@ describe('middleware validation function', () => { ); }); - test('ValidateParams calls next() for broad NVD-style virtualMatchString values', () => { + test('ValidateParams accepts broad virtualMatchString values', () => { validate({ virtualMatchString: 'cpe:2.3:o:*:windows_10_1809' }); diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 4c5bebd..2a116f5 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -674,7 +674,7 @@ describe('Search controller SEARCH handler', () => { expect(excludedRangeResponse.data.hits.total.value).toBe(0); }); - test('applySearchPostFilters preserves NVD-compatible CPE NA matches from source record ranges', () => { + 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', { diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 63cd038..916a0d9 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -112,7 +112,7 @@ describe('CPE helpers', () => { ); }); - test('buildVirtualMatchStringCriteriaRegexp builds an NVD-style partial criteria pattern', () => { + test('buildVirtualMatchStringCriteriaRegexp builds a partial criteria pattern', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:microsoft:windows_10_1809'); expect(cpe.buildVirtualMatchStringCriteriaRegexp(parsed)).toBe( @@ -351,7 +351,7 @@ describe('CPE helpers', () => { ).toBe(true); }); - test('cpeMatchAppliesToCpeName treats the CPE NA version as nonnumeric for NVD-compatible ranges', () => { + test('cpeMatchAppliesToCpeName treats the CPE NA version as nonnumeric for range boundaries', () => { const cpeName = 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*'; const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; @@ -576,7 +576,7 @@ describe('CPE helpers', () => { ).toBe(false); }); - test('cpeMatchMatchesVirtualMatchString matches full and partial NVD-style match strings', () => { + test('cpeMatchMatchesVirtualMatchString matches full and partial match strings', () => { const windowsX86 = { criteria: 'cpe:2.3:o:microsoft:windows_10_1809:-:*:*:*:*:*:x86:*' };