From 06dd09d62e9d7fd8d6f512123f974efb7c8dac96 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 14 Oct 2026 14:39:20 -0400 Subject: [PATCH 1/2] Fix numbered version qualifier aliases for CPE ranges --- README.md | 2 +- api-docs/openapi.json | 6 +- routes/search.integration.test.js | 35 ++ routes/swagger.js | 6 +- .../CVE-Search-API.postman_collection.json | 412 ++++++++++++++++++ test/postman/buildCollection.js | 42 ++ utils/cpe.js | 20 +- utils/cpe.test.js | 96 ++++ 8 files changed, 606 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b03ba5c..ce272d8 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ 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`. Stored intervals with a start after their end, or equal boundaries where either boundary is exclusive, are empty and do not match range searches. -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. +CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha` < `beta` < `milestone` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. The short qualifiers `a`, `b`, and `m` are aliases for `alpha`, `beta`, and `milestone` only when immediately followed by a digit, such as `2.0a1`, `1.0b2`, or `1.0-m1`. Bare trailing letters remain literal qualifiers after the release, so `1.0.2 < 1.0.2a < 1.0.2b` and `1.1.1l < 1.1.1m`. A separator between the letter and number, as in `1.0a.1`, does not activate the short alias. Unknown qualifiers, including `hf`, `dev`, `pre`, and `preview`, 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. Qualifier normalization applies to range comparisons; exact criteria versions remain literal matches. 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. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 994a3f3..9b56e2b 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -792,7 +792,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 value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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 short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." }, "isVulnerable": { "oneOf": [ @@ -853,7 +853,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -867,7 +867,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 5ae6df0..911f94c 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -266,6 +266,41 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test.each([ + ['1.0.2', '1.0.2a', 200], + ['1.1.1l', '1.1.1m', 200], + ['2.0a1', '2.0', 200], + ['1.0b2', '1.0', 200], + ['1.0m3', '1.0', 200], + ['1.0.2a', '1.0.2', 400], + ['1.1.1m', '1.1.1l', 400], + ['2.0', '2.0a1', 400] + ])( + 'validates patch-letter and numbered-alias ranges from %s to %s', + async (versionStart, versionEnd, expectedStatus) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + virtualMatchString: 'cpe:2.3:a:openssl:openssl', + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + } + }); + expect(response.status).toBe(expectedStatus); + if (expectedStatus === 200) { + expect(response.body.pagination.totalResults).toBe(0); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + } else { + expect(response.body.error).toBe('VERSION_RANGE_ORDER'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + } + ); + test('POST /search rejects an inverted virtualMatchString version range before invoking OpenSearch', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/routes/swagger.js b/routes/swagger.js index 069e05e..dba20a1 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -151,7 +151,7 @@ const searchRequestProperties = { } ), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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 short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -214,7 +214,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -229,7 +229,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index c87b1c5..758abd1 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -2697,6 +2697,286 @@ } }, "response": [] + }, + { + "name": "Valid patch-letter or numbered-alias range 1.0.2 to 1.0.2a", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0.2\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0.2a\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid patch-letter or numbered-alias range 1.1.1l to 1.1.1m", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.1.1l\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.1.1m\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid patch-letter or numbered-alias range 2.0a1 to 2.0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0a1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid patch-letter or numbered-alias range 1.0b2 to 1.0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0b2\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid patch-letter or numbered-alias range 1.0m3 to 1.0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0m3\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] } ] }, @@ -6594,6 +6874,138 @@ } }, "response": [] + }, + { + "name": "Inverted patch-letter or numbered-alias range 1.0.2a to 1.0.2", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.0.2a\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.0.2\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted patch-letter or numbered-alias range 1.1.1m to 1.1.1l", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"1.1.1m\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"1.1.1l\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted patch-letter or numbered-alias range 2.0 to 2.0a1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0a1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] } ] }, diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 60feeb1..6915cbf 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -644,6 +644,31 @@ const cpeCoverage = folder('06 - CPE filters', 'Concrete CPE and virtual match-s versionEndType: 'excluding' }, { exactTotal: 1, expectedIds: [ids.cpe] } + ), + ...[ + ['1.0.2', '1.0.2a'], + ['1.1.1l', '1.1.1m'], + ['2.0a1', '2.0'], + ['1.0b2', '1.0'], + ['1.0m3', '1.0'] + ].map(([versionStart, versionEnd]) => + positive( + `Valid patch-letter or numbered-alias range ${versionStart} to ${versionEnd}`, + { + cveIds: 'CVE-1998-0001', + virtualMatchString: 'cpe:2.3:a:openssl:openssl', + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + }, + { + exactTotal: 0, + expectedHitCount: 0, + description: + 'Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.' + } + ) ) ]); @@ -1128,6 +1153,23 @@ const cpeAndVersionFailures = folder( versionEndType: 'including' }, error.versionRangeOrderError() + ), + ...[ + ['1.0.2a', '1.0.2'], + ['1.1.1m', '1.1.1l'], + ['2.0', '2.0a1'] + ].map(([versionStart, versionEnd]) => + negative( + `Inverted patch-letter or numbered-alias range ${versionStart} to ${versionEnd}`, + { + virtualMatchString: 'cpe:2.3:a:openssl:openssl', + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + }, + error.versionRangeOrderError() + ) ) ] ); diff --git a/utils/cpe.js b/utils/cpe.js index 26548a4..7cecff2 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -18,13 +18,11 @@ const REQUIRED_CPE_COMPONENT_INDEXES = [0, 1, 2, 3]; const CPE_PART_VALUES = new Set(['a', 'h', 'o', '*', CPE_NOT_APPLICABLE]); const CONCRETE_CPE_PART_VALUES = new Set(['a', 'h', 'o']); const VERSION_QUALIFIERS = ['alpha', 'beta', 'milestone', 'rc', 'snapshot', '', 'sp']; +const NUMBERED_QUALIFIER_ALIASES = { a: 'alpha', b: 'beta', m: 'milestone' }; const VERSION_QUALIFIER_ALIASES = { - a: 'alpha', - b: 'beta', cr: 'rc', final: '', ga: '', - m: 'milestone', release: '' }; @@ -295,10 +293,20 @@ function buildVirtualMatchStringCriteriaRegexp(input) { } function tokenizeVersion(value) { - return String(value ?? '') + const input = String(value ?? '') .trim() - .toLowerCase() - .match(/\d+|[a-z]+/g); + .toLowerCase(); + const matches = [...input.matchAll(/\d+|[a-z]+/g)]; + if (!matches.length) return null; + + return matches.map((match) => { + const token = match[0]; + // Token positions distinguish a1 from a.1 or a-1 before separators are discarded. + const followedByDigit = /[0-9]/.test(input.charAt(match.index + token.length)); + return followedByDigit && Object.hasOwn(NUMBERED_QUALIFIER_ALIASES, token) + ? NUMBERED_QUALIFIER_ALIASES[token] + : token; + }); } function isNegativeNumericVersion(value) { diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 17e668c..5723ebc 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -247,6 +247,43 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions('-1.0', '-1')).toBe(0); }); + test.each([ + ['1.0.2', '1.0.2a'], + ['1.0.2a', '1.0.2b'], + ['1.0.2b', '1.0.2c'], + ['1.1.1l', '1.1.1m'], + ['1.0.2m', '1.0.2zh'], + ['1.0.2', '1.0.2m'], + ['2.0a1', '2.0'], + ['1.0b2', '1.0'], + ['1.0m3', '1.0'], + ['1.0', '1.0a.1'], + ['1.0', '1.0b-1'], + ['1.0', '1.0m.3'] + ])('orders %s before %s without treating bare letters as prerelease aliases', (earlier, later) => { + expect(cpe.compareCpeVersions(earlier, later)).toBeLessThan(0); + expect(cpe.compareCpeVersions(later, earlier)).toBeGreaterThan(0); + }); + + test('orders the full patch-letter sequence consistently', () => { + const versions = [ + '1.0.2', + ...'abcdefghijklmnopqrstuvwxyz'.split('').map((letter) => `1.0.2${letter}`), + '1.0.2zh' + ]; + for (let left = 0; left < versions.length; left++) { + expect(cpe.compareCpeVersions(versions[left], versions[left].toUpperCase())).toBe(0); + for (let right = left + 1; right < versions.length; right++) { + expect(cpe.compareCpeVersions(versions[left], versions[right])).toBeLessThan(0); + expect(cpe.compareCpeVersions(versions[right], versions[left])).toBeGreaterThan(0); + } + } + }); + + test.each(['dev', 'pre', 'preview'])('preserves the existing policy for unknown %s qualifiers', (qualifier) => { + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}`, '2.0.0')).toBeGreaterThan(0); + }); + test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { const orderedVersions = [ '1.0.0-alpha1', @@ -363,6 +400,38 @@ describe('CPE helpers', () => { ); }); + test.each([ + ['1.0.2a', { versionStartIncluding: '1.0.2' }, true], + ['1.0.2b', { versionStartIncluding: '1.0.2' }, true], + ['1.0.2m', { versionStartIncluding: '1.0.2' }, true], + ['1.1.1l', { versionEndExcluding: '1.1.1m' }, true], + ['1.1.1m', { versionEndExcluding: '1.1.1m' }, false], + ['1.1.1m', { versionEndIncluding: '1.1.1m' }, true], + ['1.1.1m', { versionStartExcluding: '1.1.1m' }, false], + ['1.1.1n', { versionStartExcluding: '1.1.1m' }, true] + ])('applies patch-letter bounds to asset %s with %j', (version, bounds, expected) => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*', + ...bounds + }, + `cpe:2.3:a:openssl:openssl:${version}:*:*:*:*:*:*:*` + ) + ).toBe(expected); + }); + + test('keeps exact criteria versions literal when a numbered qualifier is an alias', () => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:vendor:product:2.0a1:*:*:*:*:*:*:*' + }, + 'cpe:2.3:a:vendor:product:2.0alpha1:*:*:*:*:*:*:*' + ) + ).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 = { @@ -657,6 +726,33 @@ describe('CPE helpers', () => { ).toBe(false); }); + test.each([ + ['1.1.1l', 'including', true], + ['1.1.1m', 'including', false], + ['1.1.1m', 'excluding', false], + ['1.1.1n', 'including', false] + ])( + 'compares a requested %s %s start against an exclusive patch-letter upper bound', + (versionStart, versionStartType, expected) => { + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*', + versionStartIncluding: '1.1.1', + versionEndExcluding: '1.1.1m' + }, + { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:openssl:openssl'), + versionStart, + versionStartType, + versionEnd: '1.1.1n', + versionEndType: 'including' + } + ) + ).toBe(expected); + } + ); + test('cpeMatchOverlapsVirtualMatchStringRange matches upper-bounded wildcard criteria before the excluded boundary', () => { const cpeMatch = { criteria: 'cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*', From 87e314bdf2c9868e7673bec1d8c198c93ad286bc Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 14 Oct 2026 14:48:48 -0400 Subject: [PATCH 2/2] Define distinct dev pre and preview prerelease ordering --- README.md | 2 +- api-docs/openapi.json | 6 +- routes/search.integration.test.js | 11 +- routes/swagger.js | 6 +- .../CVE-Search-API.postman_collection.json | 372 +++++++++++++++++- test/postman/buildCollection.js | 15 +- utils/cpe.js | 3 +- utils/cpe.test.js | 63 ++- 8 files changed, 450 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ce272d8..a61fa83 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ 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`. Stored intervals with a start after their end, or equal boundaries where either boundary is exclusive, are empty and do not match range searches. -CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha` < `beta` < `milestone` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. The short qualifiers `a`, `b`, and `m` are aliases for `alpha`, `beta`, and `milestone` only when immediately followed by a digit, such as `2.0a1`, `1.0b2`, or `1.0-m1`. Bare trailing letters remain literal qualifiers after the release, so `1.0.2 < 1.0.2a < 1.0.2b` and `1.1.1l < 1.1.1m`. A separator between the letter and number, as in `1.0a.1`, does not activate the short alias. Unknown qualifiers, including `hf`, `dev`, `pre`, and `preview`, 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. Qualifier normalization applies to range comparisons; exact criteria versions remain literal matches. +CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use an API-defined extension of Maven-style qualifier precedence: `dev` < `alpha` < `beta` < `milestone` < `pre` < `preview` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. The short qualifiers `a`, `b`, and `m` are aliases for `alpha`, `beta`, and `milestone` only when immediately followed by a digit, such as `2.0a1`, `1.0b2`, or `1.0-m1`. Bare trailing letters remain literal qualifiers after the release, so `1.0.2 < 1.0.2a < 1.0.2b` and `1.1.1l < 1.1.1m`. A separator between the letter and number, as in `1.0a.1`, does not activate the short alias. The stages `pre`, `preview`, and `rc` are distinct, not aliases: `2.0.0-pre1 < 2.0.0-preview1 < 2.0.0-rc1 < 2.0.0`. Development versions precede alpha versions, so `2.0.0-dev1 < 2.0.0-alpha1`. These additional stages are an API policy rather than strict Maven behavior. 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. Qualifier normalization applies to range comparisons; exact criteria versions remain literal matches. 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. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 9b56e2b..6bfa56e 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -792,7 +792,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 value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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 short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search." }, "isVulnerable": { "oneOf": [ @@ -853,7 +853,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -867,7 +867,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 911f94c..ddd7628 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -272,11 +272,18 @@ describe('search route integration', () => { ['2.0a1', '2.0', 200], ['1.0b2', '1.0', 200], ['1.0m3', '1.0', 200], + ['2.0.0-dev1', '2.0.0-alpha1', 200], + ['2.0.0-pre1', '2.0.0-preview1', 200], + ['2.0.0-preview1', '2.0.0-rc1', 200], + ['2.0.0-preview1', '2.0.0', 200], ['1.0.2a', '1.0.2', 400], ['1.1.1m', '1.1.1l', 400], - ['2.0', '2.0a1', 400] + ['2.0', '2.0a1', 400], + ['2.0.0', '2.0.0-dev1', 400], + ['2.0.0-preview1', '2.0.0-pre1', 400], + ['2.0.0-rc1', '2.0.0-preview1', 400] ])( - 'validates patch-letter and numbered-alias ranges from %s to %s', + 'validates qualifier and patch-letter ranges from %s to %s', async (versionStart, versionEnd, expectedStatus) => { mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); const response = await request(createApp(), { diff --git a/routes/swagger.js b/routes/swagger.js index dba20a1..d57e9ea 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -151,7 +151,7 @@ const searchRequestProperties = { } ), cpeName: searchRequestProperty( - 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. 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 short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The value must begin with cpe:2.3 and contain concrete part, vendor, product, and version components; part must be a, h, or o. Unescaped * or ? characters anywhere in those required components, including versions such as 2.6.*, return HTTP 400. Backslash-escaped literal characters remain allowed. Empty components, malformed escaping, and unsupported part values return HTTP 400. CPE string literal comparisons are insensitive to lexical case. 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 response includes a CPE_COMPONENTS_DEFAULTED warning when omitted trailing components are treated as wildcards. The concrete version can match stored criteria with the same version or criteria with a wildcard version and version range fields. The requested version need not appear literally in a record when it falls within a range. The API does not verify that a software version actually exists; open-ended criteria can match hypothetical versions. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. The version - represents the CPE logical value NA, not an unknown or unrestricted version; numeric range boundaries on wildcard-version criteria do not exclude it under the API matching policy. Use virtualMatchString for partial CPE Match String searches or all versions of a product. For a version interval, omit the virtualMatchString version component and supply versionStart/versionStartType and/or versionEnd/versionEndType. A cpeName version ending in punctuation, such as 2.6., is not a version-prefix search.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, @@ -214,7 +214,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -229,7 +229,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. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The labels dev, pre, and preview remain unknown qualifiers. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering extended by the API policy, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The short qualifiers a, b, and m expand only when immediately followed by a digit, such as 2.0a1. Bare trailing letters sort after the release in case-insensitive lexical order, so 1.0.2 precedes 1.0.2a and 1.1.1l precedes 1.1.1m. A separator between a short letter and its number does not activate the alias. The prerelease order is dev, alpha, beta, milestone, pre, preview, rc, snapshot. The labels pre, preview, and rc are distinct, not aliases. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 758abd1..38624f6 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -2699,7 +2699,7 @@ "response": [] }, { - "name": "Valid patch-letter or numbered-alias range 1.0.2 to 1.0.2a", + "name": "Valid qualifier or patch-letter range 1.0.2 to 1.0.2a", "event": [ { "listen": "test", @@ -2755,7 +2755,7 @@ "response": [] }, { - "name": "Valid patch-letter or numbered-alias range 1.1.1l to 1.1.1m", + "name": "Valid qualifier or patch-letter range 1.1.1l to 1.1.1m", "event": [ { "listen": "test", @@ -2811,7 +2811,7 @@ "response": [] }, { - "name": "Valid patch-letter or numbered-alias range 2.0a1 to 2.0", + "name": "Valid qualifier or patch-letter range 2.0a1 to 2.0", "event": [ { "listen": "test", @@ -2867,7 +2867,7 @@ "response": [] }, { - "name": "Valid patch-letter or numbered-alias range 1.0b2 to 1.0", + "name": "Valid qualifier or patch-letter range 1.0b2 to 1.0", "event": [ { "listen": "test", @@ -2923,7 +2923,7 @@ "response": [] }, { - "name": "Valid patch-letter or numbered-alias range 1.0m3 to 1.0", + "name": "Valid qualifier or patch-letter range 1.0m3 to 1.0", "event": [ { "listen": "test", @@ -2977,6 +2977,230 @@ } }, "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-dev1 to 2.0.0-alpha1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-dev1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-alpha1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-pre1 to 2.0.0-preview1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-pre1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-preview1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-preview1 to 2.0.0-rc1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-preview1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-rc1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Valid qualifier or patch-letter range 2.0.0-preview1 to 2.0.0", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 0', () => pm.expect(payload.pagination.totalResults).to.eql(0));", + "pm.test('returned record count is 0', () => pm.expect(payload.records.length).to.eql(0));", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "description": "Uses a pre-1999 CVE ID to verify range acceptance without depending on indexed OpenSSL applicability data. OpenSearch must still be available.", + "body": { + "mode": "raw", + "raw": "{\n \"cveIds\": \"CVE-1998-0001\",\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-preview1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] } ] }, @@ -6876,7 +7100,7 @@ "response": [] }, { - "name": "Inverted patch-letter or numbered-alias range 1.0.2a to 1.0.2", + "name": "Inverted qualifier or patch-letter range 1.0.2a to 1.0.2", "event": [ { "listen": "test", @@ -6920,7 +7144,7 @@ "response": [] }, { - "name": "Inverted patch-letter or numbered-alias range 1.1.1m to 1.1.1l", + "name": "Inverted qualifier or patch-letter range 1.1.1m to 1.1.1l", "event": [ { "listen": "test", @@ -6964,7 +7188,7 @@ "response": [] }, { - "name": "Inverted patch-letter or numbered-alias range 2.0 to 2.0a1", + "name": "Inverted qualifier or patch-letter range 2.0 to 2.0a1", "event": [ { "listen": "test", @@ -7006,6 +7230,138 @@ } }, "response": [] + }, + { + "name": "Inverted qualifier or patch-letter range 2.0.0 to 2.0.0-dev1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-dev1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted qualifier or patch-letter range 2.0.0-preview1 to 2.0.0-pre1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-preview1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-pre1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Inverted qualifier or patch-letter range 2.0.0-rc1 to 2.0.0-preview1", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:a:openssl:openssl\",\n \"versionStart\": \"2.0.0-rc1\",\n \"versionStartType\": \"including\",\n \"versionEnd\": \"2.0.0-preview1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] } ] }, diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 6915cbf..08082c9 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -650,10 +650,14 @@ const cpeCoverage = folder('06 - CPE filters', 'Concrete CPE and virtual match-s ['1.1.1l', '1.1.1m'], ['2.0a1', '2.0'], ['1.0b2', '1.0'], - ['1.0m3', '1.0'] + ['1.0m3', '1.0'], + ['2.0.0-dev1', '2.0.0-alpha1'], + ['2.0.0-pre1', '2.0.0-preview1'], + ['2.0.0-preview1', '2.0.0-rc1'], + ['2.0.0-preview1', '2.0.0'] ].map(([versionStart, versionEnd]) => positive( - `Valid patch-letter or numbered-alias range ${versionStart} to ${versionEnd}`, + `Valid qualifier or patch-letter range ${versionStart} to ${versionEnd}`, { cveIds: 'CVE-1998-0001', virtualMatchString: 'cpe:2.3:a:openssl:openssl', @@ -1157,10 +1161,13 @@ const cpeAndVersionFailures = folder( ...[ ['1.0.2a', '1.0.2'], ['1.1.1m', '1.1.1l'], - ['2.0', '2.0a1'] + ['2.0', '2.0a1'], + ['2.0.0', '2.0.0-dev1'], + ['2.0.0-preview1', '2.0.0-pre1'], + ['2.0.0-rc1', '2.0.0-preview1'] ].map(([versionStart, versionEnd]) => negative( - `Inverted patch-letter or numbered-alias range ${versionStart} to ${versionEnd}`, + `Inverted qualifier or patch-letter range ${versionStart} to ${versionEnd}`, { virtualMatchString: 'cpe:2.3:a:openssl:openssl', versionStart, diff --git a/utils/cpe.js b/utils/cpe.js index 7cecff2..527ab59 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -17,7 +17,8 @@ const CPE_NOT_APPLICABLE = '-'; const REQUIRED_CPE_COMPONENT_INDEXES = [0, 1, 2, 3]; const CPE_PART_VALUES = new Set(['a', 'h', 'o', '*', CPE_NOT_APPLICABLE]); const CONCRETE_CPE_PART_VALUES = new Set(['a', 'h', 'o']); -const VERSION_QUALIFIERS = ['alpha', 'beta', 'milestone', 'rc', 'snapshot', '', 'sp']; +// API policy extends Maven-style precedence with distinct dev, pre, and preview stages. +const VERSION_QUALIFIERS = ['dev', 'alpha', 'beta', 'milestone', 'pre', 'preview', 'rc', 'snapshot', '', 'sp']; const NUMBERED_QUALIFIER_ALIASES = { a: 'alpha', b: 'beta', m: 'milestone' }; const VERSION_QUALIFIER_ALIASES = { cr: 'rc', diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 5723ebc..489b35a 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -280,16 +280,25 @@ describe('CPE helpers', () => { } }); - test.each(['dev', 'pre', 'preview'])('preserves the existing policy for unknown %s qualifiers', (qualifier) => { + test.each(['dev', 'pre', 'preview'])('orders %s before release and compares its numeric suffix', (qualifier) => { + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}`, '2.0.0')).toBeLessThan(0); + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}2`, `2.0.0-${qualifier}10`)).toBeLessThan(0); + expect(cpe.compareCpeVersions(`2.0.0-${qualifier}1`, `2.0.0-${qualifier.toUpperCase()}1`)).toBe(0); + }); + + test.each(['devtools', 'prerelease', 'previewish'])('keeps the unknown qualifier %s after release', (qualifier) => { expect(cpe.compareCpeVersions(`2.0.0-${qualifier}`, '2.0.0')).toBeGreaterThan(0); }); - test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { + test('compareCpeVersions orders distinct prerelease stages under the API policy', () => { const orderedVersions = [ + '1.0.0-dev1', '1.0.0-alpha1', '1.0.0-alpha2', '1.0.0-beta1', '1.0.0-milestone1', + '1.0.0-pre1', + '1.0.0-preview1', '1.0.0-rc1', '1.0.0-snapshot', '1.0.0', @@ -298,8 +307,11 @@ describe('CPE helpers', () => { '1.0.0-zz1' ]; - for (let index = 1; index < orderedVersions.length; index++) { - expect(cpe.compareCpeVersions(orderedVersions[index - 1], orderedVersions[index])).toBeLessThan(0); + for (let left = 0; left < orderedVersions.length; left++) { + for (let right = left + 1; right < orderedVersions.length; right++) { + expect(cpe.compareCpeVersions(orderedVersions[left], orderedVersions[right])).toBeLessThan(0); + expect(cpe.compareCpeVersions(orderedVersions[right], orderedVersions[left])).toBeGreaterThan(0); + } } }); @@ -408,8 +420,12 @@ describe('CPE helpers', () => { ['1.1.1m', { versionEndExcluding: '1.1.1m' }, false], ['1.1.1m', { versionEndIncluding: '1.1.1m' }, true], ['1.1.1m', { versionStartExcluding: '1.1.1m' }, false], - ['1.1.1n', { versionStartExcluding: '1.1.1m' }, true] - ])('applies patch-letter bounds to asset %s with %j', (version, bounds, expected) => { + ['1.1.1n', { versionStartExcluding: '1.1.1m' }, true], + ['2.0.0-dev1', { versionEndExcluding: '2.0.0' }, true], + ['2.0.0-pre1', { versionEndExcluding: '2.0.0-preview1' }, true], + ['2.0.0-preview1', { versionStartIncluding: '2.0.0-pre1', versionEndExcluding: '2.0.0-rc1' }, true], + ['2.0.0-rc1', { versionEndExcluding: '2.0.0-preview1' }, false] + ])('applies version bounds to asset %s with %j', (version, bounds, expected) => { expect( cpe.cpeMatchAppliesToCpeName( { @@ -432,6 +448,17 @@ describe('CPE helpers', () => { ).toBe(false); }); + test.each(['2.0.0-preview1', '2.0.0-rc1'])('does not equate exact pre1 criteria with %s', (version) => { + expect( + cpe.cpeMatchAppliesToCpeName( + { + criteria: 'cpe:2.3:a:vendor:product:2.0.0-pre1:*:*:*:*:*:*:*' + }, + `cpe:2.3:a:vendor:product:${version}:*:*:*:*:*:*:*` + ) + ).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 = { @@ -726,6 +753,30 @@ describe('CPE helpers', () => { ).toBe(false); }); + test.each([ + ['2.0.0-dev1', '2.0.0-alpha1', false], + ['2.0.0-pre1', '2.0.0-preview1', true], + ['2.0.0-preview1', '2.0.0-rc1', true], + ['2.0.0-rc1', '2.0.0', false] + ])('evaluates distinct prerelease interval %s through %s', (versionStart, versionEnd, expected) => { + expect( + cpe.cpeMatchOverlapsVirtualMatchStringRange( + { + criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + versionStartIncluding: '2.0.0-pre1', + versionEndExcluding: '2.0.0-rc1' + }, + { + matchString: cpe.parseVirtualMatchString('cpe:2.3:a:vendor:product'), + versionStart, + versionStartType: 'including', + versionEnd, + versionEndType: 'including' + } + ) + ).toBe(expected); + }); + test.each([ ['1.1.1l', 'including', true], ['1.1.1m', 'including', false],