diff --git a/README.md b/README.md index 21080c4..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. @@ -150,13 +150,15 @@ 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`. 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. 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. 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: @@ -186,7 +188,11 @@ 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`. -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. +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. + +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. 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 a860ab1..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. 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. 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. 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. 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", @@ -497,10 +497,10 @@ "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." } } } } } -} \ No newline at end of file +} diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 3b205f1..060097e 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -72,15 +72,29 @@ 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.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.cpeName.description).toContain('insensitive to lexical case'); + expect(schema.properties.cpeName.description).toContain('exact CPE Match Criteria preserve leading zeros'); 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('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' + ); 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([ @@ -115,6 +129,10 @@ 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('exact CPE Match Criteria preserve leading zeros'); + 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/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/controllers/middleware.test.js b/controllers/middleware.test.js index b036f2f..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' }); @@ -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/controllers/searchController.test.js b/controllers/searchController.test.js index 1e5fe87..2a116f5 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 } } })), @@ -554,6 +556,166 @@ 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 matches CPE string literals without lexical case', () => { + const candidate = cpeHit('CVE-2026-0251', { + criteria: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:*:*:*:*:*:macOS:*:*', + vulnerable: true, + versionStartIncluding: '6.0', + versionEndExcluding: '6.1' + }); + + const response = controller.applySearchPostFilters( + advancedResponse(1, [candidate]), + { + cpeName: 'cpe:2.3:a:palo_alto_networks:globalprotect_app:6.0.1:*:*:*:*:macos:*:*', + isVulnerable: true + }, + { + from: 0, + resultsPerPage: 20 + } + ); + + expect(response.data.hits.total.value).toBe(1); + expect(response.data.hits.hits).toStrictEqual([candidate]); + }); + + test('applySearchPostFilters preserves exact version padding while normalizing numeric range segments', () => { + const exactCandidate = cpeHit('CVE-2025-9392', { + criteria: 'cpe:2.3:o:linksys:re6250_firmware:1.0.04.001:*:*:*:*:*:*:*', + vulnerable: true + }); + const rangeCandidate = cpeHit('CVE-2026-33280', { + criteria: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:*:*:*:*:*:*:*:*', + vulnerable: true, + versionEndExcluding: '6.02' + }); + + const exactResponse = controller.applySearchPostFilters( + advancedResponse(1, [exactCandidate]), + { + cpeName: 'cpe:2.3:o:linksys:re6250_firmware:1.0.4.1:*:*:*:*:*:*:*', + isVulnerable: true + }, + { + from: 0, + resultsPerPage: 20 + } + ); + const includedRangeResponse = controller.applySearchPostFilters( + advancedResponse(1, [rangeCandidate]), + { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.1:*:*:*:*:*:*:*', + isVulnerable: true + }, + { + from: 0, + resultsPerPage: 20 + } + ); + const excludedRangeResponse = controller.applySearchPostFilters( + advancedResponse(1, [rangeCandidate]), + { + cpeName: 'cpe:2.3:o:buffalo:wsr3600be4-kh_firmware:6.2:*:*:*:*:*:*:*', + isVulnerable: true + }, + { + from: 0, + resultsPerPage: 20 + } + ); + + expect(exactResponse.data.hits.total.value).toBe(0); + expect(includedRangeResponse.data.hits.hits).toStrictEqual([rangeCandidate]); + expect(excludedRangeResponse.data.hits.total.value).toBe(0); + }); + + test('applySearchPostFilters preserves CPE NA matches across numeric version ranges', () => { + const criteria = 'cpe:2.3:a:microsoft:edge:*:*:*:*:*:android:*:*'; + const candidates = [ + cpeHit('CVE-2020-17153', { + criteria, + vulnerable: true, + versionStartIncluding: '-' + }), + cpeHit('CVE-2024-38208', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '128.0.2739.42' + }), + cpeHit('CVE-2026-35429', { + criteria, + vulnerable: true, + versionStartIncluding: '1.0.0', + versionEndExcluding: '148.0.3967.55' + }) + ]; + + const response = controller.applySearchPostFilters( + advancedResponse(candidates.length, candidates), + { + cpeName: 'cpe:2.3:a:microsoft:edge:-:*:*:*:*:android:*:*', + isVulnerable: true + }, + { + from: 0, + resultsPerPage: 20 + } + ); + + expect(response.data.hits.total.value).toBe(3); + expect(response.data.hits.hits.map((hit) => hit._id)).toStrictEqual([ + 'CVE-2020-17153', + 'CVE-2024-38208', + 'CVE-2026-35429' + ]); + }); + test('SEARCH applies semantic virtualMatchString version range filtering before building response metadata', async () => { const applicable = cpeHit('CVE-2024-0001', { criteria: 'cpe:2.3:o:linux:linux_kernel:2.5:*:*:*:*:*:*:*' @@ -1250,7 +1412,8 @@ describe('Search controller SEARCH handler', () => { ].map((value) => ({ prefix: { 'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': { - value + value, + case_insensitive: true } } })), @@ -1278,7 +1441,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 5a78b14..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. 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. 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. 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. 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, @@ -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 293daf8..7301566 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -1,6 +1,17 @@ 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 ?? '') @@ -231,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); @@ -245,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; @@ -267,14 +307,19 @@ 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; } +function cpeLiteralsEqual(left, right) { + return String(left).toLowerCase() === String(right).toLowerCase(); +} + function componentMatches(assetComponent, criteriaComponent) { - return criteriaComponent === '*' || criteriaComponent === assetComponent; + return assetComponent === '*' || criteriaComponent === '*' || cpeLiteralsEqual(criteriaComponent, assetComponent); } function hasBoundary(value) { @@ -336,6 +381,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 @@ -374,23 +425,21 @@ 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; } } 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 @@ -402,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; } } @@ -425,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 041728e..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( @@ -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); @@ -166,6 +169,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 +225,207 @@ 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:*:*:*:*:*:*:*:*', + 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 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 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( + { + 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 range boundaries', () => { + 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:*:*:*:*:*:*:*'; @@ -273,6 +508,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:*:*:*:*:*:*:*:*', @@ -307,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:*' }; @@ -319,6 +588,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 } } }