diff --git a/README.md b/README.md index 999bc58..7a9bf8d 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ When filtering with `cpeName`, provide a CPE 2.3 name beginning with `cpe:2.3` a ### CPE Name Search -Use `cpeName` when a user has a concrete asset CPE Name and needs CVE records whose CPE Match Criteria apply to that asset version. The `part`, `vendor`, `product`, and `version` components must all be concrete values, not `*`. +Use `cpeName` when a user has a concrete asset CPE Name and needs CVE records whose CPE Match Criteria apply to that asset version. The `part`, `vendor`, `product`, and `version` components must all be concrete values. Unescaped `*` or `?` characters anywhere in those required components return HTTP 400 (`INVALID_CPE_NAME`), including versions such as `2.6.*`, `2.6*`, `2.*`, and `*.*`. Backslash-escaped literal characters remain allowed. Optional components can still be `*` or omitted. Valid `cpeName` example: @@ -174,7 +174,23 @@ curl -X POST "http://localhost:3000/search" \ The valid query can match stored CPE Match Criteria that use either a concrete version or a wildcard version plus range fields such as `versionStartIncluding`, `versionStartExcluding`, `versionEndIncluding`, or `versionEndExcluding`. In accordance with the CPE 2.3 Name Matching specification, CPE string literal comparisons are insensitive to lexical case, so an input component such as `macos` matches stored criteria containing `macOS`. Every concrete non-version component in the input, such as edition, language, target software, or target hardware, must be compatible with the same CPE Match Criteria entry that satisfies the version and vulnerability checks. Optional input components set to `*` remain broad matches. Use `virtualMatchString` instead of `cpeName` when searching for partial CPE Match String values such as all versions of a product. -The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies. Numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. This rule can produce different range behavior than a literal reading of source CVE Record applicability fields. +The requested version does not need to appear literally in a returned record when it falls within a stored range. The API evaluates those criteria; it does not verify that a software version actually exists. An open-ended range can therefore match a hypothetical version such as `1001.1`. + +For a version interval, use `virtualMatchString` without a version component and supply explicit version boundaries. For example, to search for criteria overlapping Linux versions from `2.6` inclusive to `2.7` exclusive: + +```json +{ + "virtualMatchString": "cpe:2.3:o:linux:linux_kernel", + "versionStart": "2.6", + "versionStartType": "including", + "versionEnd": "2.7", + "versionEndType": "excluding" +} +``` + +An embedded wildcard such as `2.6.*` is not a supported `cpeName` version-family search. A literal version ending in punctuation, such as `2.6.`, is still accepted but is not a version-prefix search either. + +The version component `-` is the CPE logical value `NA`, meaning that no meaningful version applies, not that the version is unknown or unrestricted. Numeric version range boundaries on wildcard-version criteria do not exclude an asset whose version is `-`; an exact concrete criteria version still does not match `-`, and an exclusive `-` boundary still excludes it. The CPE specification defines `NA`; this treatment of numeric range boundaries is an API matching policy. This rule can produce different range behavior than a literal reading of source CVE Record applicability fields. Do not substitute `-` for `*` to search all versions; use `virtualMatchString` with an omitted version or a whole-component `*` instead. Set `isVulnerable` to `true` with `cpeName` to require the matching CPE Match Criteria entry itself to be marked vulnerable. A vulnerable flag on an unrelated CPE entry does not satisfy the request. Omitting `isVulnerable`, or setting it to `false`, does not apply a vulnerability restriction. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 8dd63a8..055e990 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -719,7 +719,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. 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. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Use virtualMatchString for partial CPE Match String searches." + "description": "Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The 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." }, "isVulnerable": { "oneOf": [ diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index 12e4b51..6699f2c 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -176,6 +176,11 @@ describe('OpenAPI contract', () => { expect(cpeName.description).toContain('logical value NA'); expect(cpeName.description).toContain('numeric range boundaries'); expect(cpeName.description).toContain('HTTP 400'); + expect(cpeName.description).toContain('Unescaped * or ?'); + expect(cpeName.description).toContain('Backslash-escaped literal characters remain allowed'); + expect(cpeName.description).toContain('does not verify that a software version actually exists'); + expect(cpeName.description).toContain('not an unknown or unrestricted version'); + expect(cpeName.description).toContain('versionStart/versionStartType'); expect(cpeName.description).toContain('virtualMatchString'); expect(cpeName.example).toBe('cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*'); expect(openapi.paths['/search'].post.responses['404']).toBeUndefined(); diff --git a/routes/cpeName.test.js b/routes/cpeName.test.js index 18d4b13..aaad5e3 100644 --- a/routes/cpeName.test.js +++ b/routes/cpeName.test.js @@ -21,43 +21,49 @@ describe('POST /search cpeName route behavior', () => { jest.clearAllMocks(); }); - test('rejects cpeName with wildcard version before the controller runs', async () => { - const cpeName = 'cpe:2.3:a:artifex:ghostscript:*:*:*:*:*:*:*:*'; - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - cpeName - } - }); - - expect(response.status).toBe(400); - expect(response.body).toStrictEqual({ - error: 'INVALID_CPE_NAME', - message: - 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' - }); - expect(controller.SEARCH).not.toHaveBeenCalled(); - }); + test.each(['*', '2.6.*', '2.6*', '2.*', '*.*', '0.*', '10.*', '100.*', '2.6.?'])( + 'rejects cpeName version %s before the controller runs', + async (version) => { + const cpeName = `cpe:2.3:o:linux:linux_kernel:${version}:*:*:*:*:*:*:*`; + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + cpeName + } + }); - test('allows valid cpeName through to the controller', async () => { - const cpeName = 'cpe:2.3:a:artifex:ghostscript:10.05.1:*:*:*:*:*:*:*'; - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - cpeName - } - }); - - expect(response.status).toBe(200); - expect(controller.SEARCH).toHaveBeenCalledTimes(1); - expect(response.body).toStrictEqual({ - route: 'search', - params: { - scope: 'FULL', - cpeName - } - }); - }); + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'INVALID_CPE_NAME', + message: + 'The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.' + }); + expect(controller.SEARCH).not.toHaveBeenCalled(); + } + ); + + test.each(['10.05.1', '-', '2.6.', '1001.1', '5.1991234123412341234abcdefasdfasdfasdf', String.raw`2.6.\*`])( + 'allows cpeName version %s through to the controller', + async (version) => { + const cpeName = `cpe:2.3:a:artifex:ghostscript:${version}:*:*:*:*:*:*:*`; + const response = await request(app, { + method: 'POST', + path: '/search', + body: { + cpeName + } + }); + + expect(response.status).toBe(200); + expect(controller.SEARCH).toHaveBeenCalledTimes(1); + expect(response.body).toStrictEqual({ + route: 'search', + params: { + scope: 'FULL', + cpeName + } + }); + } + ); }); diff --git a/routes/swagger.js b/routes/swagger.js index 8f44195..ff79ea9 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. 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. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. The version - represents the CPE logical value NA; numeric range boundaries on wildcard-version criteria do not exclude it. Use virtualMatchString for partial CPE Match String searches.', + 'Returns CVE records whose CPE Match Criteria apply to the provided CPE 2.3 asset name. The 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.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cpeName, diff --git a/utils/cpe.js b/utils/cpe.js index f6a950c..ca814b9 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -57,6 +57,22 @@ function hasUnescapedWhitespace(value) { return false; } +function hasUnescapedWildcard(value) { + let escaped = false; + + for (const character of value) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '*' || character === '?') { + return true; + } + } + + return false; +} + function parseCpeComponents(input, minimumComponentCount) { const value = normalizeCpeInput(input); const parts = splitOnUnescapedColon(value); @@ -140,7 +156,7 @@ function parseCpeName(input) { const hasRequiredComponents = REQUIRED_CPE_COMPONENT_INDEXES.every((index) => { const component = parsed.components[index]; if (index === 0) return CONCRETE_CPE_PART_VALUES.has(component.toLowerCase()); - return component && component !== '*'; + return component && !hasUnescapedWildcard(component); }); if (!hasRequiredComponents) { diff --git a/utils/cpe.test.js b/utils/cpe.test.js index a9ba8d2..311e12c 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -38,6 +38,25 @@ describe('CPE helpers', () => { 'cpe:2.3:a:*:product:1.0:*:*:*:*:*:*:*', 'cpe:2.3:a:vendor:*:1.0:*:*:*:*:*:*:*', 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', + 'cpe:2.3:a:ven*dor:product:1.0', + 'cpe:2.3:a:ven?dor:product:1.0', + 'cpe:2.3:a:vendor:prod*uct:1.0', + 'cpe:2.3:a:vendor:prod?uct:1.0', + ...[ + '2.6.*', + '2.6*', + '2.*', + '*.*', + '0.*', + '10.*', + '100.*', + '?', + '2.6.?', + '*2.6', + String.raw`2.6\\*`, + String.raw`2.6\\?`, + String.raw`2.6\**` + ].map((version) => `cpe:2.3:o:linux:linux_kernel:${version}:*:*:*:*:*:*:*`), 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*:extra' ])('parseCpeName rejects invalid cpeName value %s', (value) => { expect(cpe.parseCpeName(value).valid).toBe(false); @@ -61,6 +80,17 @@ describe('CPE helpers', () => { expect(parsed.product).toBe('product\\:edition'); }); + test.each([ + String.raw`cpe:2.3:a:ven\*dor:prod\?uct:1.0`, + String.raw`cpe:2.3:a:vendor:product:2.6.\*`, + String.raw`cpe:2.3:a:vendor:product:2.6.\\\?` + ])('parseCpeName preserves escaped literal wildcard characters in %s', (value) => { + const parsed = cpe.parseCpeName(value); + + expect(parsed.valid).toBe(true); + expect(parsed.components.slice(0, 4)).toStrictEqual(value.split(':').slice(2)); + }); + test('parseVirtualMatchString accepts partial CPE 2.3 match strings without a version component', () => { const parsed = cpe.parseVirtualMatchString('cpe:2.3:o:linux:linux_kernel'); @@ -248,6 +278,16 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions(left, right)).toBe(0); }); + test('cpeMatchAppliesToCpeName rejects a wildcard version instead of comparing it as a concrete version', () => { + const criteria = { + criteria: 'cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*', + versionEndExcluding: '5.19' + }; + + expect(cpe.cpeMatchAppliesToCpeName(criteria, 'cpe:2.3:o:linux:linux_kernel:2.6.*:*:*:*:*:*:*:*')).toBe(false); + expect(cpe.cpeMatchAppliesToCpeName(criteria, 'cpe:2.3:o:linux:linux_kernel:2.6.12:*:*:*:*:*:*:*')).toBe(true); + }); + test('cpeMatchAppliesToCpeName honors inclusive and exclusive version boundaries', () => { const cpeName = 'cpe:2.3:a:vendor:product:2.10:*:*:*:*:*:*:*';