From 13edfd7073ca04833158078cd22e3f230efe9127 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 8 Oct 2026 09:22:49 -0400 Subject: [PATCH 1/5] Patch actionable dependency vulnerabilities --- package-lock.json | 38 +++++++++++++++++++++----------------- package.json | 8 ++++---- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70c8592..045fcd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "morgan": "~1.12.0", + "morgan": "~1.12.1", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -1510,9 +1510,9 @@ } }, "node_modules/adm-zip": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", - "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", "license": "MIT", "engines": { "node": ">=14.0" @@ -1617,13 +1617,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -1852,9 +1852,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4809,9 +4809,9 @@ } }, "node_modules/morgan": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", - "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -5330,9 +5330,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -5340,6 +5340,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/package.json b/package.json index abbe9f6..8507465 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dotenv": "^6.1.0", "express": "^4.22.2", "express-validator": "^7.2.1", - "morgan": "~1.12.0", + "morgan": "~1.12.1", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -32,9 +32,9 @@ "swagger-autogen": "^2.23.7" }, "overrides": { - "adm-zip": "0.6.0", - "axios": "1.18.0", - "brace-expansion": "5.0.9", + "adm-zip": "0.6.1", + "axios": "1.20.0", + "brace-expansion": "5.0.12", "js-yaml": "4.3.2", "lodash": "4.18.1", "lodash.unset": "4.18.0", From 270e91b0633505318dcc6418e0a57ed01bfd27f5 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:46:57 -0400 Subject: [PATCH 2/5] Accept NONE severity for CVSS v3 and v4 filters --- README.md | 2 + api-docs/openapi.json | 12 ++++-- controllers/middleware.js | 2 +- controllers/middleware.test.js | 8 ++-- routes/search.integration.test.js | 41 +++++++++++++++++++ routes/swagger.js | 16 ++++---- .../CVE-Search-API.postman_collection.json | 8 ++-- utils/constants.js | 2 +- utils/errors.js | 8 ++-- utils/errors.test.js | 8 ++-- 10 files changed, 77 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index fd53f3c..cfe9192 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ Use the optional `scope` field to control which parts of each record participate Date filters use the API's full UTC timestamp format and inclusive boundaries. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end. +CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`, case-insensitively. They match the stored `baseSeverity` label in scope-selected CNA/ADP metrics; the API does not recalculate the label from scores. CVSS v2 instead maps `LOW`, `MEDIUM`, and `HIGH` to base-score ranges and does not accept `NONE` or `CRITICAL`. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 43f0bec..b6fd69e 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -708,42 +708,46 @@ "cvssV3Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive." }, "cvssV3_0Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 metric. Values are case-insensitive." }, "cvssV3_1Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.1 metric. Values are case-insensitive." }, "cvssV4Severity": { "type": "string", "enum": [ + "NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL" ], - "description": "Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive." + "description": "Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v4.0 metric. Values are case-insensitive." }, "cvssV2Metrics": { "type": "string", diff --git a/controllers/middleware.js b/controllers/middleware.js index bc06ade..0c7f415 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -7,7 +7,7 @@ const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; -const cvssV3_4SeverityEnum = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +const cvssV3_4SeverityEnum = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; const severityValidators = [ { diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 037e98a..ebfc338 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -667,7 +667,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV3_SEVERITY', - 'Test is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); @@ -686,7 +686,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV3_0_SEVERITY', - 'Test is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); @@ -705,7 +705,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV3_1_SEVERITY', - 'Test is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); @@ -724,7 +724,7 @@ describe('middleware validation function', () => { expectValidationError( 'INVALID_CVSSV4_SEVERITY', - 'Test is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'Test is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' ); }); diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 63c3958..8d8d400 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -124,6 +124,47 @@ describe('search route integration', () => { }); }); + test.each(['cvssV3Severity', 'cvssV3_0Severity', 'cvssV3_1Severity', 'cvssV4Severity'])( + 'POST /search accepts NONE for %s in every scope', + async (param) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); + const metricNames = { + cvssV3Severity: ['cvssV3_0', 'cvssV3_1'], + cvssV3_0Severity: ['cvssV3_0'], + cvssV3_1Severity: ['cvssV3_1'], + cvssV4Severity: ['cvssV4_0'] + }; + for (const scope of ['FULL', 'CNA_ONLY', 'ADP_ONLY']) { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { [param]: 'none', scope } + }); + expect(response.status).toBe(200); + const containers = scope === 'FULL' ? ['cna', 'adp'] : [scope === 'CNA_ONLY' ? 'cna' : 'adp']; + const clauses = containers.flatMap((container) => + metricNames[param].map((metric) => ({ + term: { [`containers.${container}.metrics.${metric}.baseSeverity.keyword`]: 'NONE' } + })) + ); + expect(mockSearchOpenSearch.mock.lastCall[0].query.bool.filter).toStrictEqual([ + clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } } + ]); + } + } + ); + + test('POST /search rejects NONE for CVSS v2', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cvssV2Severity: 'NONE' } + }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('INVALID_CVSSV2_SEVERITY'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /search builds a scope-aware literal reference URL substring filter', async () => { mockSearchOpenSearch.mockResolvedValue(advancedResponse(0, []).data); diff --git a/routes/swagger.js b/routes/swagger.js index 2dcc8c8..ee4778d 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -83,31 +83,31 @@ const searchRequestProperties = { } ), cvssV3Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 or v3.1 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_0Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.0 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.0 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV3_1Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v3.1 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v3.1 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV4Severity: searchRequestProperty( - 'Returns CVE records with the provided qualitative severity in a scope-selected CVSS v4.0 metric. Values are case-insensitive.', + 'Returns CVE records with the provided stored baseSeverity label in a scope-selected CVSS v4.0 metric. Values are case-insensitive.', { type: 'string', - enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + enum: ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] } ), cvssV2Metrics: searchRequestProperty( diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index c87b1c5..8feef83 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -5146,7 +5146,7 @@ "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\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5190,7 +5190,7 @@ "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\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5234,7 +5234,7 @@ "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\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -5278,7 +5278,7 @@ "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\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.\"};", + "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", diff --git a/utils/constants.js b/utils/constants.js index 83f7b4d..5d81011 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -45,7 +45,7 @@ const cpeMatchFields = { * cveIds: comma-separated list of CVE-year-XXXX identifiers * noRejected: no-value flag that excludes rejected CVE records * cweId: CWE-XXX - * cvssV2Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL + * cvssV2Severity: LOW, MEDIUM, HIGH * cvssV3Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL * cvssV3_0Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL * cvssV3_1Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL diff --git a/utils/errors.js b/utils/errors.js index cdbaa2e..ef12f58 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -251,28 +251,28 @@ class errors { cvssV3SeverityError(severity) { return makeError( 'INVALID_CVSSV3_SEVERITY', - `${severity} is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } cvssV3_0SeverityError(severity) { return makeError( 'INVALID_CVSSV3_0_SEVERITY', - `${severity} is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } cvssV3_1SeverityError(severity) { return makeError( 'INVALID_CVSSV3_1_SEVERITY', - `${severity} is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } cvssV4SeverityError(severity) { return makeError( 'INVALID_CVSSV4_SEVERITY', - `${severity} is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.` + `${severity} is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.` ); } diff --git a/utils/errors.test.js b/utils/errors.test.js index 6c7bfbb..9e1b0c2 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -245,7 +245,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV3_SEVERITY', message: - 'bad is not an allowed input for the cvssV3Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ @@ -254,7 +254,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV3_0_SEVERITY', message: - 'bad is not an allowed input for the cvssV3_0Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ @@ -263,7 +263,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV3_1_SEVERITY', message: - 'bad is not an allowed input for the cvssV3_1Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ @@ -272,7 +272,7 @@ describe('error response helpers', () => { { error: 'INVALID_CVSSV4_SEVERITY', message: - 'bad is not an allowed input for the cvssV4Severity. Please use LOW, MEDIUM, HIGH, or CRITICAL.' + 'bad is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.' } ], [ From e420fece037619e142a410fcb46824955903479b Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:54:49 -0400 Subject: [PATCH 3/5] Validate version-specific CVSS metric filters --- README.md | 2 + api-docs/openapi.json | 10 +- controllers/middleware.js | 7 +- routes/search.integration.test.js | 16 ++ routes/swagger.js | 11 +- .../CVE-Search-API.postman_collection.json | 176 ++++++++++++++++++ test/postman/buildCollection.js | 26 +++ utils/cvss.js | 145 ++++++++++++++- utils/cvss.test.js | 68 +++++++ utils/errors.js | 4 + utils/searchQueryBuilder.js | 13 +- utils/searchQueryBuilder.test.js | 17 +- 12 files changed, 462 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index cfe9192..8ccac2a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Date filters use the API's full UTC timestamp format and inclusive boundaries. W CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`, case-insensitively. They match the stored `baseSeverity` label in scope-selected CNA/ADP metrics; the API does not recalculate the label from scores. CVSS v2 instead maps `LOW`, `MEDIUM`, and `HIGH` to base-score ranges and does not accept `NONE` or `CRITICAL`. +CVSS metric filters accept partial vectors, such as `AV:N/AC:L`, with case-insensitive metric names and values from the selected CVSS version, including optional metrics. A `CVSS:2.0`, `CVSS:3.0`, `CVSS:3.1`, or `CVSS:4.0` prefix is optional but must match the requested filter. For `cvssV3Metrics`, omitting the prefix searches both v3.0 and v3.1; providing one restricts matching to that version. Unknown metrics or values, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 `CVSS_METRICS_FORMAT`. Repeated identical tokens are accepted. Every supplied token must occur in the same eligible stored vector; filters do not calculate a CVSS score. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index b6fd69e..741a5df 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -753,31 +753,31 @@ "type": "string", "maxLength": 256, "example": "AV:N/AC:L/Au:N/C:P/I:P/A:P", - "description": "Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v2.0 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Without a prefix, both versions are searched; CVSS:3.0 or CVSS:3.1 restricts matching to that version. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_0Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV3_1Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "description": "Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cvssV4Metrics": { "type": "string", "maxLength": 256, "example": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", - "description": "Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." + "description": "Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to 256 characters, 32 slash-delimited metric tokens, and 32 characters per metric token." }, "cpeName": { "type": "string", diff --git a/controllers/middleware.js b/controllers/middleware.js index 0c7f415..504aac4 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -3,6 +3,7 @@ const constants = require('../utils/constants'); const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); const ssvc = require('../utils/ssvc'); +const cvss = require('../utils/cvss'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -43,7 +44,7 @@ const ssvcEnumValidators = [ { param: 'ssvcTechnicalImpact', allowed: ssvc.TECHNICAL_IMPACT_VALUES } ]; -const cvssMetricsParams = ['cvssV2Metrics', 'cvssV3Metrics', 'cvssV3_0Metrics', 'cvssV3_1Metrics', 'cvssV4Metrics']; +const cvssMetricsParams = cvss.PARAMS; const dateParams = [ 'lastModStartDate', @@ -368,6 +369,10 @@ function validateParams(req, res, next) { if (params?.[param] && hasInvalidCvssMetricsShape(params[param])) { return res.status(400).json(error.cvssMetricsLimitError(param)); } + if (hasParam(params, param)) { + const parsed = cvss.parseMetricFilter(param, params[param]); + if (!parsed.valid) return res.status(400).json(error.cvssMetricsFormatError(param, parsed.reason)); + } } //Ensures date params are valid timestamps matching yyyy-MM-ddTHH:mm:ss.SSS'Z' and real calendar dates. diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index 8d8d400..d977715 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,6 +48,22 @@ describe('search route integration', () => { mockBasicSearch.mockReset(); }); + test.each([ + ['cvssV2Metrics', 'AU:Z'], + ['cvssV3Metrics', 'CVSS:4.0/AV:N'], + ['cvssV3_0Metrics', 'CVSS:3.1/AV:N'], + ['cvssV3_1Metrics', 'AV:N/AV:L'], + ['cvssV4Metrics', 'FOO:N'], + ['cvssV4Metrics', 'CVSS:4.0'], + ['cvssV4Metrics', 'AV:N/'], + ['cvssV4Metrics', 'CVSS#4.0/AV:N'] + ])('rejects invalid %s vectors before OpenSearch: %s', async (param, value) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { [param]: value } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('CVSS_METRICS_FORMAT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { const record = { dataType: 'CVE_RECORD', diff --git a/routes/swagger.js b/routes/swagger.js index ee4778d..567d3a8 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -11,6 +11,7 @@ const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); const routes = [path.join(__dirname, 'index.js')]; const maxSearchWindowLabel = constants.MAX_SEARCH_WINDOW.toLocaleString('en-US'); const semanticPostFilterDescription = `CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most ${maxSearchWindowLabel} search candidates, so reported totals and pages include only matches found within that candidate set.`; +const cvssMetricsDescription = `Partial vectors and optional metrics are accepted. Names and values are case-insensitive and must be valid for the selected CVSS version. An optional CVSS:version prefix must match that version. Unknown tokens, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 CVSS_METRICS_FORMAT. Repeated identical tokens are accepted. Every token must match the same eligible vector. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`; function searchRequestProperty(description, schema) { return { @@ -111,7 +112,7 @@ const searchRequestProperties = { } ), cvssV2Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v2 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v2.0 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -119,7 +120,7 @@ const searchRequestProperties = { } ), cvssV3Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 or v3.1 vector containing every supplied slash-delimited metric token. Without a prefix, both versions are searched; CVSS:3.0 or CVSS:3.1 restricts matching to that version. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -127,7 +128,7 @@ const searchRequestProperties = { } ), cvssV3_0Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.0 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -135,7 +136,7 @@ const searchRequestProperties = { } ), cvssV3_1Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v3.1 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, @@ -143,7 +144,7 @@ const searchRequestProperties = { } ), cvssV4Metrics: searchRequestProperty( - `Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. Values are limited to ${constants.MAX_CVSS_METRICS_LENGTH} characters, ${constants.MAX_CVSS_METRIC_TOKENS} slash-delimited metric tokens, and ${constants.MAX_CVSS_METRIC_TOKEN_LENGTH} characters per metric token.`, + `Returns CVE records with a scope-selected CVSS v4.0 vector containing every supplied slash-delimited metric token. ${cvssMetricsDescription}`, { type: 'string', maxLength: constants.MAX_CVSS_METRICS_LENGTH, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 8feef83..52a47b7 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -4816,6 +4816,182 @@ } }, "response": [] + }, + { + "name": "CVSS vector version mismatch", + "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\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3Metrics parameter is invalid. The CVSS version prefix must match the requested filter.\"};", + "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 \"cvssV3Metrics\": \"CVSS:4.0/AV:N\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector unknown metric", + "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\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV4Metrics parameter is invalid. A metric name or value is not supported by the requested CVSS version.\"};", + "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 \"cvssV4Metrics\": \"FOO:N\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector empty segment", + "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\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3_1Metrics parameter is invalid. Supply one or more slash-delimited metric:value tokens without empty segments.\"};", + "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 \"cvssV3_1Metrics\": \"AV:N/\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector conflicting metric values", + "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\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV2Metrics parameter is invalid. A metric cannot have conflicting values in the same filter.\"};", + "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 \"cvssV2Metrics\": \"AV:N/AV:L\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] } ] }, diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 60feeb1..5d6699a 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -953,6 +953,32 @@ const limitFailures = folder( 'CVSS vector token length', { cvssV2Metrics: `AV:${'A'.repeat(constants.MAX_CVSS_METRIC_TOKEN_LENGTH + 1)}` }, error.cvssMetricsLimitError('cvssV2Metrics') + ), + negative( + 'CVSS vector version mismatch', + { cvssV3Metrics: 'CVSS:4.0/AV:N' }, + error.cvssMetricsFormatError('cvssV3Metrics', 'The CVSS version prefix must match the requested filter.') + ), + negative( + 'CVSS vector unknown metric', + { cvssV4Metrics: 'FOO:N' }, + error.cvssMetricsFormatError( + 'cvssV4Metrics', + 'A metric name or value is not supported by the requested CVSS version.' + ) + ), + negative( + 'CVSS vector empty segment', + { cvssV3_1Metrics: 'AV:N/' }, + error.cvssMetricsFormatError( + 'cvssV3_1Metrics', + 'Supply one or more slash-delimited metric:value tokens without empty segments.' + ) + ), + negative( + 'CVSS vector conflicting metric values', + { cvssV2Metrics: 'AV:N/AV:L' }, + error.cvssMetricsFormatError('cvssV2Metrics', 'A metric cannot have conflicting values in the same filter.') ) ] ); diff --git a/utils/cvss.js b/utils/cvss.js index 0641e7d..7aa9cf6 100644 --- a/utils/cvss.js +++ b/utils/cvss.js @@ -10,6 +10,135 @@ const VECTOR_FIELDS = Object.freeze({ }); const PARAMS = Object.freeze(Object.keys(VECTOR_FIELDS)); +// Metric vocabularies from FIRST's CVSS v2.0, v3.x, and v4.0 vector specifications. +// Search filters may supply a subset of metrics, unlike complete scoring vectors. +const V3_METRIC_VALUES = { + AV: ['N', 'A', 'L', 'P'], + AC: ['L', 'H'], + PR: ['N', 'L', 'H'], + UI: ['N', 'R'], + S: ['U', 'C'], + C: ['N', 'L', 'H'], + I: ['N', 'L', 'H'], + A: ['N', 'L', 'H'], + E: ['X', 'U', 'P', 'F', 'H'], + RL: ['X', 'O', 'T', 'W', 'U'], + RC: ['X', 'U', 'R', 'C'], + CR: ['X', 'L', 'M', 'H'], + IR: ['X', 'L', 'M', 'H'], + AR: ['X', 'L', 'M', 'H'], + MAV: ['X', 'N', 'A', 'L', 'P'], + MAC: ['X', 'L', 'H'], + MPR: ['X', 'N', 'L', 'H'], + MUI: ['X', 'N', 'R'], + MS: ['X', 'U', 'C'], + MC: ['X', 'N', 'L', 'H'], + MI: ['X', 'N', 'L', 'H'], + MA: ['X', 'N', 'L', 'H'] +}; +const METRIC_VALUES = { + cvssV2_0: { + AV: ['N', 'A', 'L'], + AC: ['L', 'M', 'H'], + AU: ['M', 'S', 'N'], + C: ['N', 'P', 'C'], + I: ['N', 'P', 'C'], + A: ['N', 'P', 'C'], + E: ['U', 'POC', 'F', 'H', 'ND'], + RL: ['OF', 'TF', 'W', 'U', 'ND'], + RC: ['UC', 'UR', 'C', 'ND'], + CDP: ['N', 'L', 'LM', 'MH', 'H', 'ND'], + TD: ['N', 'L', 'M', 'H', 'ND'], + CR: ['L', 'M', 'H', 'ND'], + IR: ['L', 'M', 'H', 'ND'], + AR: ['L', 'M', 'H', 'ND'] + }, + cvssV3_0: V3_METRIC_VALUES, + cvssV3_1: V3_METRIC_VALUES, + cvssV4_0: { + AV: ['N', 'A', 'L', 'P'], + AC: ['L', 'H'], + AT: ['N', 'P'], + PR: ['N', 'L', 'H'], + UI: ['N', 'P', 'A'], + VC: ['H', 'L', 'N'], + VI: ['H', 'L', 'N'], + VA: ['H', 'L', 'N'], + SC: ['H', 'L', 'N'], + SI: ['H', 'L', 'N'], + SA: ['H', 'L', 'N'], + E: ['X', 'A', 'P', 'U'], + CR: ['X', 'H', 'M', 'L'], + IR: ['X', 'H', 'M', 'L'], + AR: ['X', 'H', 'M', 'L'], + MAV: ['X', 'N', 'A', 'L', 'P'], + MAC: ['X', 'L', 'H'], + MAT: ['X', 'N', 'P'], + MPR: ['X', 'N', 'L', 'H'], + MUI: ['X', 'N', 'P', 'A'], + MVC: ['X', 'N', 'L', 'H'], + MVI: ['X', 'N', 'L', 'H'], + MVA: ['X', 'N', 'L', 'H'], + MSC: ['X', 'N', 'L', 'H'], + MSI: ['X', 'N', 'L', 'H', 'S'], + MSA: ['X', 'N', 'L', 'H', 'S'], + S: ['X', 'N', 'P'], + AU: ['X', 'N', 'Y'], + R: ['X', 'A', 'U', 'I'], + V: ['X', 'D', 'C'], + RE: ['X', 'L', 'M', 'H'], + U: ['X', 'CLEAR', 'GREEN', 'AMBER', 'RED'] + } +}; + +function parseMetricFilter(param, input) { + if (!PARAMS.includes(param) || typeof input !== 'string') { + return { valid: false, reason: 'A supported CVSS field and a string value are required.' }; + } + const tokens = input + .trim() + .toUpperCase() + .split('/') + .map((token) => token.trim()); + let fieldNames = VECTOR_FIELDS[param]; + if (tokens[0].startsWith('CVSS')) { + const prefix = /^CVSS:(2\.0|3\.0|3\.1|4\.0)$/.exec(tokens.shift()); + const field = prefix && `cvssV${prefix[1].replace('.', '_')}`; + if (!field || !fieldNames.includes(field)) { + return { valid: false, reason: 'The CVSS version prefix must match the requested filter.' }; + } + fieldNames = [field]; + } + if (!tokens.length || tokens.some((token) => !/^[A-Z]+:[A-Z]+$/.test(token))) { + return { + valid: false, + reason: 'Supply one or more slash-delimited metric:value tokens without empty segments.' + }; + } + const seen = new Map(); + for (const token of tokens) { + const [name, value] = token.split(':'); + if ( + !fieldNames.every( + (field) => Object.hasOwn(METRIC_VALUES[field], name) && METRIC_VALUES[field][name].includes(value) + ) + ) { + return { valid: false, reason: 'A metric name or value is not supported by the requested CVSS version.' }; + } + if (seen.has(name) && seen.get(name) !== value) { + return { valid: false, reason: 'A metric cannot have conflicting values in the same filter.' }; + } + seen.set(name, value); + } + return { valid: true, fieldNames, tokens: [...new Set(tokens)] }; +} + +function requireMetricFilter(param, input) { + const parsed = parseMetricFilter(param, input); + if (!parsed.valid) throw new TypeError(parsed.reason); + return parsed; +} + function parseMetricTokens(inputVectorString) { return String(inputVectorString ?? '') .split('/') @@ -20,7 +149,10 @@ function parseMetricTokens(inputVectorString) { function buildPostFilter(params) { const vectors = Object.fromEntries( - PARAMS.filter((param) => params?.[param] !== undefined).map((param) => [param, params[param]]) + PARAMS.filter((param) => params?.[param] !== undefined).map((param) => [ + param, + requireMetricFilter(param, params[param]) + ]) ); if (!Object.keys(vectors).length) return null; @@ -44,14 +176,13 @@ function metricMatches(metric, fieldNames, tokens) { function hitMatches(hit, filter) { const metrics = searchContainerMetrics.getMetrics(hit?._source, filter.scope); - return Object.entries(filter.vectors).every(([param, inputVectorString]) => { - const tokens = parseMetricTokens(inputVectorString); - return tokens.length > 0 && metrics.some((metric) => metricMatches(metric, VECTOR_FIELDS[param], tokens)); - }); + return Object.values(filter.vectors).every(({ fieldNames, tokens }) => + metrics.some((metric) => metricMatches(metric, fieldNames, tokens)) + ); } function getCandidateSourceFields(filter) { - const fieldNames = new Set(Object.keys(filter.vectors).flatMap((param) => VECTOR_FIELDS[param])); + const fieldNames = new Set(Object.values(filter.vectors).flatMap((vector) => vector.fieldNames)); return [...fieldNames].flatMap((fieldName) => searchScope.resolveContainerPaths(`metrics.${fieldName}.vectorString`, filter.scope) @@ -65,5 +196,7 @@ module.exports = { getCandidateSourceFields, hitMatches, parseMetricTokens, + parseMetricFilter, + requireMetricFilter, vectorContainsEveryToken }; diff --git a/utils/cvss.test.js b/utils/cvss.test.js index 98b6a6f..5c20608 100644 --- a/utils/cvss.test.js +++ b/utils/cvss.test.js @@ -1,5 +1,60 @@ const cvss = require('./cvss'); +describe('CVSS filter validation', () => { + test.each([ + ['cvssV2Metrics', ' av:n / au:s / E:POC / RL:OF / CDP:LM / TD:ND '], + ['cvssV2Metrics', 'CVSS:2.0/AV:N'], + ['cvssV3Metrics', 'AV:N/AC:L'], + ['cvssV3_0Metrics', 'cvss:3.0/av:n/mav:x/rc:r'], + ['cvssV3_1Metrics', 'CVSS:3.1/AV:N/MUI:R/CR:H'], + ['cvssV4Metrics', 'CVSS:4.0/AT:P/UI:A/MSI:S/S:P/U:Green/RE:M/AU:Y'] + ])('accepts valid partial vectors and optional metrics: %s %s', (param, value) => { + expect(cvss.parseMetricFilter(param, value).valid).toBe(true); + }); + + test.each([ + ['unknown', 'AV:N'], + ['cvssV2Metrics', null], + ['cvssV2Metrics', 'CVSS:3.1/AV:N'], + ['cvssV3_0Metrics', 'CVSS:3.1/AV:N'], + ['cvssV3_1Metrics', 'CVSS:3.0/AV:N'], + ['cvssV3Metrics', 'CVSS:4.0/AV:N'], + ['cvssV4Metrics', 'CVSS:5.0/AV:N'], + ['cvssV4Metrics', 'CVSS#4.0/AV:N'], + ['cvssV4Metrics', 'CVSS:4.0'], + ['cvssV4Metrics', ''], + ['cvssV4Metrics', 'AV:N/'], + ['cvssV4Metrics', '/AV:N'], + ['cvssV4Metrics', 'AV:N//AC:L'], + ['cvssV4Metrics', 'AV:N/AV:L'], + ['cvssV4Metrics', 'AV:N/CVSS:4.0'], + ['cvssV4Metrics', 'FOO:N'], + ['cvssV4Metrics', 'AV:Z'], + ['cvssV4Metrics', 'AC:*?'], + ['cvssV4Metrics', 'C:H'], + ['cvssV3_1Metrics', 'AT:P'], + ['cvssV2Metrics', 'PR:N'], + ['cvssV2Metrics', 'AV:P'], + ['cvssV2Metrics', 'C:H'], + ['cvssV3Metrics', 'UI:P'], + ['cvssV4Metrics', 'UI:R'], + ['cvssV4Metrics', 'MSC:S'], + ['cvssV3Metrics', 'AV'], + ['cvssV3Metrics', 'AV:N:L'] + ])('rejects malformed or version-incompatible filters: %s %s', (param, value) => { + expect(cvss.parseMetricFilter(param, value)).toMatchObject({ valid: false, reason: expect.any(String) }); + expect(() => cvss.requireMetricFilter(param, value)).toThrow(TypeError); + }); + + test('normalizes case and harmless repeated identical tokens', () => { + expect(cvss.parseMetricFilter('cvssV3Metrics', 'cvss:3.1/av:n/AV:N')).toStrictEqual({ + valid: true, + fieldNames: ['cvssV3_1'], + tokens: ['AV:N'] + }); + }); +}); + function hit(metrics) { return { _source: { @@ -11,6 +66,19 @@ function hit(metrics) { } describe('CVSS semantic vector matching', () => { + test.each(['3.0', '3.1'])( + 'an explicit %s prefix restricts semantic matching and candidate source fields', + (version) => { + const field = `cvssV${version.replace('.', '_')}`; + const other = version === '3.0' ? 'cvssV3_1' : 'cvssV3_0'; + const filter = cvss.buildPostFilter({ scope: 'ADP_ONLY', cvssV3Metrics: `CVSS:${version}/AV:N` }); + expect(cvss.hitMatches(hit([{ [field]: { vectorString: `CVSS:${version}/AV:N` } }]), filter)).toBe(true); + expect(cvss.hitMatches(hit([{ [other]: { vectorString: 'AV:N' } }]), filter)).toBe(false); + expect(cvss.getCandidateSourceFields(filter)).toStrictEqual([ + `containers.adp.metrics.${field}.vectorString` + ]); + } + ); test('requires every token to occur in one eligible vector', () => { const filter = cvss.buildPostFilter({ scope: 'ADP_ONLY', diff --git a/utils/errors.js b/utils/errors.js index ef12f58..a8b6272 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -301,6 +301,10 @@ class errors { ); } + cvssMetricsFormatError(param, reason) { + return makeError('CVSS_METRICS_FORMAT', `The ${param} parameter is invalid. ${reason}`); + } + dateTimeError(dateTime) { return makeError( 'DATE_TIME_FORMAT', diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 1a95324..0866048 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -340,8 +340,11 @@ function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { } if (cvssVectorParams.has(param)) { - const filter = buildCvssWildcardFilter(params[param], fieldSpec); - if (filter) fieldsToReturn.push(filter); + const { tokens, fieldNames } = cvss.requireMetricFilter(param, params[param]); + const fields = fieldNames.flatMap((name) => + searchScope.resolveContainerPaths(`metrics.${name}.vectorString`, scope) + ); + fieldsToReturn.push(buildCvssWildcardFilter(tokens, fields)); return; } @@ -622,11 +625,7 @@ function buildHasAnyRangeFieldFilter(rangeFields) { }; } -function buildCvssWildcardFilter(inputVectorString, fieldSpec) { - const tokens = cvss.parseMetricTokens(inputVectorString); - - if (!tokens.length) return null; - +function buildCvssWildcardFilter(tokens, fieldSpec) { const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; const perFieldFilter = (fieldPath) => { diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index fd628fc..a45bbfd 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -674,7 +674,7 @@ describe('searchQueryBuilder getFilterFields() function', () => { test('getFilterFields() builds token-boundary filters for combined cvssV3 metrics.', () => { const output = queryBuilder.getFilterFields({ - cvssV3Metrics: 'CVSS:3.1/AV:N/AC:L' + cvssV3Metrics: 'AV:N/AC:L' }); expect(output).toStrictEqual([ @@ -704,18 +704,17 @@ describe('searchQueryBuilder getFilterFields() function', () => { ]); }); - test('getFilterFields() escapes regexp metacharacters in CVSS metric filters.', () => { - const output = queryBuilder.getFilterFields({ - cvssV4Metrics: 'CVSS#4.0/AV:N/AC:*?/UI:P' - }); + test('getFilterFields() rejects regexp metacharacters in CVSS metric filters.', () => { + expect(() => queryBuilder.getFilterFields({ cvssV4Metrics: 'CVSS:4.0/AC:*?' })).toThrow(TypeError); + }); + test.each(['3.0', '3.1'])('combined v3 candidate queries honor the explicit %s prefix', (version) => { + const output = queryBuilder.getFilterFields({ cvssV3Metrics: `CVSS:${version}/AV:N` }); expect(output).toStrictEqual([ { bool: { filter: [ - cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AV:N'), - cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'AC:\\*\\?'), - cvssTokenRegexp('containers.cna.metrics.cvssV4_0.vectorString', 'UI:P') + cvssTokenRegexp(`containers.cna.metrics.cvssV${version.replace('.', '_')}.vectorString`, 'AV:N') ] } } @@ -777,7 +776,7 @@ describe('scope-aware search filters', () => { expect(output.postFilters.cvss).toStrictEqual({ scope: 'ADP_ONLY', vectors: { - cvssV3_1Metrics: 'CVSS:3.1/AV:N/AC:L' + cvssV3_1Metrics: { valid: true, fieldNames: ['cvssV3_1'], tokens: ['AV:N', 'AC:L'] } } }); expect(body._source).toStrictEqual(['cveMetadata.cveId', 'containers.adp.metrics.cvssV3_1.vectorString']); From 54e816729ba6f82d355e197c3a7c10c046f9ae7d Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 13 Oct 2026 10:56:12 -0400 Subject: [PATCH 4/5] Validate and normalize single CWE identifiers --- README.md | 2 + api-docs/openapi.json | 2 +- controllers/middleware.js | 4 + controllers/middleware.test.js | 2 +- routes/index.js | 5 +- routes/search.integration.test.js | 28 +++ routes/swagger.js | 2 +- .../CVE-Search-API.postman_collection.json | 220 ++++++++++++++++++ test/postman/buildCollection.js | 3 + utils/errors.js | 7 + 10 files changed, 270 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8ccac2a..8e8e4b4 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MED CVSS metric filters accept partial vectors, such as `AV:N/AC:L`, with case-insensitive metric names and values from the selected CVSS version, including optional metrics. A `CVSS:2.0`, `CVSS:3.0`, `CVSS:3.1`, or `CVSS:4.0` prefix is optional but must match the requested filter. For `cvssV3Metrics`, omitting the prefix searches both v3.0 and v3.1; providing one restricts matching to that version. Unknown metrics or values, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 `CVSS_METRICS_FORMAT`. Repeated identical tokens are accepted. Every supplied token must occur in the same eligible stored vector; filters do not calculate a CVSS score. +`cweId` accepts one ID in the form `CWE-`, without leading zeros, such as `CWE-79`. It is case-insensitive and normalized to uppercase. Bare numbers, comma-separated lists, wildcard patterns, and other malformed IDs return HTTP 400 `CWE_ID_FORMAT`; arrays are also unsupported. The API validates the format, not membership in the CWE catalog. A valid ID with no matching indexed records returns HTTP 200 with zero results. + Scope applies to filter matching as follows: | Filters | Data used for matching | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 741a5df..8caf19a 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -694,7 +694,7 @@ "type": "string", "maxLength": 32, "example": "CWE-79", - "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID." + "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog." }, "cvssV2Severity": { "type": "string", diff --git a/controllers/middleware.js b/controllers/middleware.js index 504aac4..97254f9 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -198,6 +198,10 @@ function validateParams(req, res, next) { } } + if (hasParam(params, 'cweId') && !/^CWE-[1-9][0-9]*$/i.test(params.cweId.trim())) { + return res.status(400).json(error.cweIdFormatError()); + } + const parsedCpeName = params.cpeName ? cpe.parseCpeName(params.cpeName) : null; if (parsedCpeName && !parsedCpeName.valid) { return res.status(400).json(error.invalidCpeNameError(parsedCpeName.reason)); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index ebfc338..f135fd1 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -536,7 +536,7 @@ describe('middleware validation function', () => { test.each([ ['keywordSearch', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch)], - ['cweId', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId)] + ['cweId', `CWE-${'1'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId - 4)}`] ])('ValidateParams accepts %s at its maximum length.', (param, value) => { validate({ [param]: value diff --git a/routes/index.js b/routes/index.js index 7e5a5a5..27cf895 100644 --- a/routes/index.js +++ b/routes/index.js @@ -157,8 +157,9 @@ function requireWebSearchEnabled(req, res, next) { function normalizeSearchParams(req, res, next) { const searchParams = { ...(req.body ?? {}) }; - const upperCaseEnums = [ + const upperCaseFields = [ 'scope', + 'cweId', 'cvssV2Severity', 'cvssV3Severity', 'cvssV3_0Severity', @@ -175,7 +176,7 @@ function normalizeSearchParams(req, res, next) { } searchParams.scope = searchScope.normalizeScope(searchParams.scope); - upperCaseEnums.forEach((param) => { + upperCaseFields.forEach((param) => { if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toUpperCase(); }); lowerCaseEnums.forEach((param) => { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index d977715..d0378ed 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,6 +48,34 @@ describe('search route integration', () => { mockBasicSearch.mockReset(); }); + test.each(['79', 'CWE-0', 'CWE-079', 'CWE--1', 'CWE-79.1', 'CWE-79,CWE-89', 'CWE-*', 'CWE-79 extra'])( + 'rejects malformed CWE IDs before searching: %s', + async (cweId) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { cweId } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('CWE_ID_FORMAT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + + test.each(['FULL', 'CNA_ONLY', 'ADP_ONLY'])( + 'normalizes CWE IDs in %s without requiring catalog membership', + async (scope) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { scope, cweId: ' cwe-99999999 ' } + }); + expect(response.status).toBe(200); + expect(response.body.pagination.totalResults).toBe(0); + const query = JSON.stringify(mockSearchOpenSearch.mock.calls[0][0].query); + expect(query).toContain('CWE-99999999'); + expect(query.includes('containers.cna.')).toBe(scope !== 'ADP_ONLY'); + expect(query.includes('containers.adp.')).toBe(scope !== 'CNA_ONLY'); + } + ); + test.each([ ['cvssV2Metrics', 'AU:Z'], ['cvssV3Metrics', 'CVSS:4.0/AV:N'], diff --git a/routes/swagger.js b/routes/swagger.js index 567d3a8..f4061a3 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -69,7 +69,7 @@ const searchRequestProperties = { coercibleBooleanSchema() ), cweId: searchRequestProperty( - 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID.', + 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 52a47b7..5646043 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -4421,6 +4421,226 @@ }, "response": [] }, + { + "name": "Invalid CWE ID 79", + "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "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 \"cweId\": \"79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-0", + "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "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 \"cweId\": \"CWE-0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-079", + "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "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 \"cweId\": \"CWE-079\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-79,CWE-89", + "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "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 \"cweId\": \"CWE-79,CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE ID CWE-*", + "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "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 \"cweId\": \"CWE-*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "CVE ID length", "event": [ diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 5d6699a..4455443 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -915,6 +915,9 @@ const limitFailures = folder( error.searchFieldMinimumLengthError('referenceURL', 3) ), negative('CVE ID format', { cveIds: 'not-a-cve' }, error.cveIdsFormatError()), + ...['79', 'CWE-0', 'CWE-079', 'CWE-79,CWE-89', 'CWE-*'].map((cweId) => + negative(`Invalid CWE ID ${cweId}`, { cweId }, error.cweIdFormatError()) + ), negative('CVE ID length', { cveIds: `CVE-2024-${'1'.repeat(56)}` }, error.cveIdLengthError()), negative( 'CVE ID count limit', diff --git a/utils/errors.js b/utils/errors.js index a8b6272..b0ab053 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -241,6 +241,13 @@ class errors { ); } + cweIdFormatError() { + return makeError( + 'CWE_ID_FORMAT', + 'The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).' + ); + } + cvssV2SeverityError(severity) { return makeError( 'INVALID_CVSSV2_SEVERITY', From a2a78751580ce77ec937d38b2f338aab64c07c75 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 14 Oct 2026 13:59:38 -0400 Subject: [PATCH 5/5] Support exact scoped searches for multiple CWE IDs --- README.md | 6 +- api-docs/openapi.json | 10 +- api-docs/openapi.test.js | 6 + controllers/middleware.js | 25 +- controllers/middleware.test.js | 52 ++- controllers/searchController.test.js | 6 +- routes/index.js | 8 +- routes/search.integration.test.js | 91 +++- routes/swagger.js | 8 +- .../CVE-Search-API.postman_collection.json | 418 +++++++++++++++++- test/postman/buildCollection.js | 39 +- utils/constants.js | 10 +- utils/errors.js | 20 +- utils/searchQueryBuilder.js | 20 +- utils/searchQueryBuilder.test.js | 52 ++- utils/searchReadiness.js | 8 +- utils/searchReadiness.test.js | 20 + utils/searchScope.js | 2 +- 18 files changed, 703 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 8e8e4b4..cf89c6b 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MED CVSS metric filters accept partial vectors, such as `AV:N/AC:L`, with case-insensitive metric names and values from the selected CVSS version, including optional metrics. A `CVSS:2.0`, `CVSS:3.0`, `CVSS:3.1`, or `CVSS:4.0` prefix is optional but must match the requested filter. For `cvssV3Metrics`, omitting the prefix searches both v3.0 and v3.1; providing one restricts matching to that version. Unknown metrics or values, empty segments, prefix-only values, and conflicting repeated metrics return HTTP 400 `CVSS_METRICS_FORMAT`. Repeated identical tokens are accepted. Every supplied token must occur in the same eligible stored vector; filters do not calculate a CVSS score. -`cweId` accepts one ID in the form `CWE-`, without leading zeros, such as `CWE-79`. It is case-insensitive and normalized to uppercase. Bare numbers, comma-separated lists, wildcard patterns, and other malformed IDs return HTTP 400 `CWE_ID_FORMAT`; arrays are also unsupported. The API validates the format, not membership in the CWE catalog. A valid ID with no matching indexed records returns HTTP 200 with zero results. +`cweIds` accepts one or more comma-separated IDs, such as `"CWE-79, cwe-89"`, following the same input pattern as `cveIds`. Each ID must use `CWE-`, without leading zeros. Values are case-insensitive, whitespace around each ID is trimmed, and duplicate IDs do not change results. A record matches when any requested ID occurs in the scope-selected CNA or ADP containers; the CWE filter is combined with other filters using AND. Matching uses complete identifiers rather than analyzed text. Arrays, bare numbers, empty entries, wildcard patterns, and other malformed IDs return HTTP 400 `CWE_IDS_FORMAT`; any invalid entry rejects the whole request. Limits apply before deduplication: at most 100 IDs, 32 characters per ID, and 4,096 characters overall. The API validates format, not membership in the CWE catalog. Valid IDs without indexed matches contribute no results and do not prevent other IDs from matching. The former `cweId` request field is no longer supported. Scope applies to filter matching as follows: @@ -144,7 +144,7 @@ Scope applies to filter matching as follows: | `cveIds`, `pubStartDate`, `pubEndDate`, `lastModStartDate`, `lastModEndDate` | Base record metadata in every scope | | `noRejected` | Base record state in every scope, plus the legacy CNA state when CNA data is selected | | `keywordSearch`, `keywordExactMatch` | Description text in the selected CNA and/or ADP containers | -| `cweId`, all CVSS severity and metric filters | Problem types or metrics in the selected CNA and/or ADP containers | +| `cweIds`, all CVSS severity and metric filters | Problem types or metrics in the selected CNA and/or ADP containers | | All SSVC filters, `knownExploited`, KEV date filters, `referenceURL`, `disputed` | Structured metrics, references, or tags in the selected CNA and/or ADP containers | | `cpeName`, `isVulnerable`, `virtualMatchString`, and version range filters | CNA CPE applicability data; incompatible with `ADP_ONLY` | | `resultsPerPage`, `pageNumber` | Response controls; they do not select record data | @@ -167,7 +167,7 @@ String inputs are bounded before query construction: | ---------------------------- | ---------------------------------------------------------------------: | | `keywordSearch` | 512 characters and 64 whitespace-delimited terms | | `cveIds` | 4,096 characters overall, 100 IDs, and 64 characters per ID | -| `cweId` | 32 characters | +| `cweIds` | 4,096 characters overall, 100 IDs, and 32 characters per ID | | `cpeName` | 2,048 characters | | `virtualMatchString` | 2,048 characters | | `versionStart`, `versionEnd` | 256 characters each | diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 8caf19a..ba475c9 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -137,7 +137,7 @@ "keywordExactMatch": true, "cveIds": "CVE-2024-1234,CVE-2024-5678", "noRejected": true, - "cweId": "CWE-79", + "cweIds": "CWE-79,CWE-89", "cvssV2Severity": "HIGH", "cvssV3Severity": "CRITICAL", "cvssV3_0Severity": "CRITICAL", @@ -690,11 +690,11 @@ "default": false, "description": "When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, - "cweId": { + "cweIds": { "type": "string", - "maxLength": 32, - "example": "CWE-79", - "description": "Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog." + "maxLength": 4096, + "example": "CWE-79,CWE-89", + "description": "Returns CVE records whose scope-selected containers include any ID in the comma-separated list of CWE IDs. Each ID must use CWE-, without leading zeros. Input is case-insensitive, surrounding whitespace is trimmed per ID, and duplicate IDs do not change results. Matching uses complete identifiers, with OR between IDs and AND with other filters. Limited to 100 CWE IDs, 32 characters per ID, and 4096 characters overall. Arrays, empty entries, and malformed IDs return HTTP 400 CWE_IDS_FORMAT; one invalid entry rejects the whole request. The API validates format, not membership in the CWE catalog. The former cweId field is unsupported." }, "cvssV2Severity": { "type": "string", diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index be8da04..a88fbf6 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -74,6 +74,12 @@ describe('OpenAPI contract', () => { expect(schema.properties.cveIds.description).toContain(`${constants.MAX_CVE_ID_LENGTH} characters per ID`); expect(schema.properties.cveIds.description).toContain(`before ${constants.FIRST_CVE_RECORD_YEAR}`); expect(schema.properties.cveIds.description).toContain('non-fatal response warning'); + expect(schema.properties.cweIds.type).toBe('string'); + expect(schema.properties.cweIds.description).toContain('comma-separated list of CWE IDs'); + expect(schema.properties.cweIds.description).toContain(`${constants.MAX_CWE_IDS} CWE IDs`); + expect(schema.properties.cweIds.description).toContain(`${constants.MAX_CWE_ID_LENGTH} characters per ID`); + expect(schema.properties.cweIds.description).toContain('OR between IDs and AND with other filters'); + expect(schema.properties.cweId).toBeUndefined(); expect(schema.properties.keywordSearch.description).toContain( `${constants.MAX_KEYWORD_SEARCH_TOKENS} whitespace-delimited terms` ); diff --git a/controllers/middleware.js b/controllers/middleware.js index 97254f9..6b11084 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -68,6 +68,7 @@ const versionRangeTypeParams = ['versionStartType', 'versionEndType']; const nonStringSearchParams = [ 'keywordExactMatch', 'cveIds', + 'cweIds', 'isVulnerable', 'noRejected', 'knownExploited', @@ -80,6 +81,7 @@ const nonEmptyStringParams = constants.SEARCH_QUERY_PARAMS.filter((param) => !no const dateTimeFormat = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/; const cveIdFormat = /^CVE-\d{4}-\d{4,}$/i; +const cweIdFormat = /^CWE-[1-9][0-9]*$/i; function isTrueParam(value) { return value === true || String(value).toLowerCase() === 'true'; @@ -129,13 +131,13 @@ function countWhitespaceDelimitedTokens(value) { return value.trim().split(/\s+/).length; } -function parseCveIds(value) { +function parseIds(value, format) { if (typeof value !== 'string') return null; - const cveIds = value.split(',').map((id) => id.trim()); - if (!cveIds.length || cveIds.some((id) => !id || !cveIdFormat.test(id))) return null; + const ids = value.split(',').map((id) => id.trim()); + if (!ids.length || ids.some((id) => !id || !format.test(id))) return null; - return cveIds; + return ids; } function getRequestedResultsPerPage(params) { @@ -198,10 +200,6 @@ function validateParams(req, res, next) { } } - if (hasParam(params, 'cweId') && !/^CWE-[1-9][0-9]*$/i.test(params.cweId.trim())) { - return res.status(400).json(error.cweIdFormatError()); - } - const parsedCpeName = params.cpeName ? cpe.parseCpeName(params.cpeName) : null; if (parsedCpeName && !parsedCpeName.valid) { return res.status(400).json(error.invalidCpeNameError(parsedCpeName.reason)); @@ -233,7 +231,7 @@ function validateParams(req, res, next) { } if (hasParam(params, 'cveIds')) { - const cveIds = parseCveIds(params.cveIds); + const cveIds = parseIds(params.cveIds, cveIdFormat); if (!cveIds) { return res.status(400).json(error.cveIdsFormatError()); } @@ -245,6 +243,15 @@ function validateParams(req, res, next) { } } + if (hasParam(params, 'cweIds')) { + const cweIds = parseIds(params.cweIds, cweIdFormat); + if (!cweIds) return res.status(400).json(error.cweIdsFormatError()); + if (cweIds.some((id) => id.length > constants.MAX_CWE_ID_LENGTH)) { + return res.status(400).json(error.cweIdLengthError()); + } + if (cweIds.length > constants.MAX_CWE_IDS) return res.status(400).json(error.cweIdsLimitError()); + } + if (hasParam(params, 'noRejected') && ![true, false, ''].includes(params.noRejected)) { return res.status(400).json(error.noRejectedValueError()); } diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index f135fd1..27ea2a5 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -450,7 +450,7 @@ describe('middleware validation function', () => { ['keywordExactMatch', 'false'], ['cveIds', 'CVE-2024-0001,CVE-2024-0002'], ['noRejected', true], - ['cweId', 'CWE-79'], + ['cweIds', 'CWE-79, cwe-89'], ['cvssV2Severity', 'LOW'], ['cvssV2Metrics', 'AV:N/AC:L/Au:N/C:P/I:P/A:C'], ['cvssV3Severity', 'MEDIUM'], @@ -481,6 +481,7 @@ describe('middleware validation function', () => { test.each([ ['cveId', 'CVE-2024-0001'], + ['cweId', 'CWE-79'], ['sourceIdentifier', 'CISA'] ])('ValidateParams returns an error when unsupported %s is provided.', (param, value) => { validate({ @@ -534,16 +535,16 @@ describe('middleware validation function', () => { } ); - test.each([ - ['keywordSearch', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch)], - ['cweId', `CWE-${'1'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId - 4)}`] - ])('ValidateParams accepts %s at its maximum length.', (param, value) => { - validate({ - [param]: value - }); + test.each([['keywordSearch', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.keywordSearch)]])( + 'ValidateParams accepts %s at its maximum length.', + (param, value) => { + validate({ + [param]: value + }); - expectValidationSuccess(); - }); + expectValidationSuccess(); + } + ); test('ValidateParams accepts cveIds and each CVE ID at their maximum lengths.', () => { const idPrefix = 'CVE-2024-'; @@ -558,6 +559,37 @@ describe('middleware validation function', () => { expectValidationSuccess(); }); + test('ValidateParams accepts cweIds at the total length, item length, and item count limits', () => { + const id = `CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 4)}`; + const cweIds = Array(constants.MAX_CWE_IDS) + .fill(id) + .join(',') + .padEnd(constants.SEARCH_STRING_LENGTH_LIMITS.cweIds, ' '); + expect(cweIds).toHaveLength(constants.SEARCH_STRING_LENGTH_LIMITS.cweIds); + validate({ cweIds }); + expectValidationSuccess(); + }); + + test('ValidateParams rejects a CWE ID that exceeds the item length limit', () => { + validate({ cweIds: `CWE-79,CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 3)}` }); + expectValidationError( + 'CWE_ID_LENGTH', + `Each CWE ID in the cweIds field must be ${constants.MAX_CWE_ID_LENGTH} characters or fewer.` + ); + }); + + test('ValidateParams counts all supplied CWE IDs before deduplication', () => { + validate({ + cweIds: Array(constants.MAX_CWE_IDS + 1) + .fill('CWE-79') + .join(',') + }); + expectValidationError( + 'CWE_IDS_LIMIT', + `The cweIds parameter cannot contain more than ${constants.MAX_CWE_IDS} CWE IDs.` + ); + }); + test('ValidateParams returns an error when an individual CVE ID exceeds its maximum length.', () => { const idPrefix = 'CVE-2024-'; diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index 8319ec4..01123ae 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -466,7 +466,7 @@ describe('Search controller SEARCH handler', () => { { query: { scope: 'CNA_ONLY', - cweId: 'CWE-79', + cweIds: 'CWE-79', cpeName: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*', isVulnerable: 'true' } @@ -480,8 +480,8 @@ describe('Search controller SEARCH handler', () => { expect(filters).toHaveLength(3); expect(filters[0]).toStrictEqual({ - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { value: 'CWE-79', case_insensitive: true } } }); expect(filters[1].bool.minimum_should_match).toBe(1); diff --git a/routes/index.js b/routes/index.js index 27cf895..5ee3cd7 100644 --- a/routes/index.js +++ b/routes/index.js @@ -16,6 +16,7 @@ const SEARCH_STRING_BODY_FIELDS = constants.SEARCH_QUERY_PARAMS.filter( ![ 'keywordExactMatch', 'cveIds', + 'cweIds', 'isVulnerable', 'noRejected', 'knownExploited', @@ -157,9 +158,8 @@ function requireWebSearchEnabled(req, res, next) { function normalizeSearchParams(req, res, next) { const searchParams = { ...(req.body ?? {}) }; - const upperCaseFields = [ + const upperCaseEnums = [ 'scope', - 'cweId', 'cvssV2Severity', 'cvssV3Severity', 'cvssV3_0Severity', @@ -176,7 +176,7 @@ function normalizeSearchParams(req, res, next) { } searchParams.scope = searchScope.normalizeScope(searchParams.scope); - upperCaseFields.forEach((param) => { + upperCaseEnums.forEach((param) => { if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toUpperCase(); }); lowerCaseEnums.forEach((param) => { @@ -216,7 +216,7 @@ router.post( keywordExactMatch: true, cveIds: "CVE-2024-1234,CVE-2024-5678", noRejected: true, - cweId: "CWE-79", + cweIds: "CWE-79,CWE-89", cvssV2Severity: "HIGH", cvssV3Severity: "CRITICAL", cvssV3_0Severity: "CRITICAL", diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index d0378ed..0ba7ee9 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,16 +48,95 @@ describe('search route integration', () => { mockBasicSearch.mockReset(); }); - test.each(['79', 'CWE-0', 'CWE-079', 'CWE--1', 'CWE-79.1', 'CWE-79,CWE-89', 'CWE-*', 'CWE-79 extra'])( - 'rejects malformed CWE IDs before searching: %s', - async (cweId) => { - const response = await request(createApp(), { method: 'POST', path: '/search', body: { cweId } }); + test.each( + [ + '79', + 'CWE-0', + 'CWE-079', + 'CWE--1', + 'CWE-79.1', + 'CWE-79,,CWE-89', + 'CWE-*', + 'CWE-79 extra', + 'CWE-79,invalid', + ',CWE-79', + 'CWE-79,', + '', + ' ', + ['CWE-79', 'CWE-89'], + null, + 79, + true, + {} + ].map((value) => [value]) + )('rejects malformed CWE IDs before searching: %s', async (cweIds) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { cweIds } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('CWE_IDS_FORMAT'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + [ + { + cweIds: Array(constants.MAX_CWE_IDS + 1) + .fill('CWE-79') + .join(',') + }, + 'CWE_IDS_LIMIT' + ], + [{ cweIds: `CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 3)}` }, 'CWE_ID_LENGTH'], + [{ cweIds: 'CWE-79'.padEnd(constants.SEARCH_STRING_LENGTH_LIMITS.cweIds + 1, ' ') }, 'SEARCH_FIELD_LENGTH'] + ])('rejects oversized CWE input with %s', async (body, code) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body }); + expect(response.status).toBe(400); + expect(response.body.error).toBe(code); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([{ cweId: 'CWE-79' }, { cweId: 'CWE-79', cweIds: 'CWE-89' }])( + 'rejects the removed cweId parameter: %j', + async (body) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body }); expect(response.status).toBe(400); - expect(response.body.error).toBe('CWE_ID_FORMAT'); + expect(response.body[0].error).toBe('INVALID_PARAM'); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); } ); + test.each(['FULL', 'CNA_ONLY', 'ADP_ONLY'])( + 'matches any requested CWE ID within %s and combines other filters with AND', + async (scope) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { scope, cweIds: ' cwe-79, CWE-89, CWE-79 ', cveIds: 'CVE-2024-0001', noRejected: true } + }); + expect(response.status).toBe(200); + const filters = mockSearchOpenSearch.mock.calls[0][0].query.bool.filter; + expect(filters).toContainEqual({ terms: { 'cveMetadata.cveId.keyword': ['CVE-2024-0001'] } }); + const cweFilter = filters.find((filter) => filter.bool?.minimum_should_match === 1); + const containers = scope === 'FULL' ? ['cna', 'adp'] : [scope === 'CNA_ONLY' ? 'cna' : 'adp']; + expect(cweFilter).toStrictEqual({ + bool: { + minimum_should_match: 1, + should: containers.flatMap((container) => + ['CWE-79', 'CWE-89'].map((value) => ({ + term: { + [`containers.${container}.problemTypes.descriptions.cweId.keyword`]: { + value, + case_insensitive: true + } + } + })) + ) + } + }); + expect(filters).toHaveLength(3); + } + ); + test.each(['FULL', 'CNA_ONLY', 'ADP_ONLY'])( 'normalizes CWE IDs in %s without requiring catalog membership', async (scope) => { @@ -65,7 +144,7 @@ describe('search route integration', () => { const response = await request(createApp(), { method: 'POST', path: '/search', - body: { scope, cweId: ' cwe-99999999 ' } + body: { scope, cweIds: ' cwe-99999999 ' } }); expect(response.status).toBe(200); expect(response.body.pagination.totalResults).toBe(0); diff --git a/routes/swagger.js b/routes/swagger.js index f4061a3..9efb150 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -68,12 +68,12 @@ const searchRequestProperties = { 'When true, excludes CVE records with REJECT, REJECTED, or Rejected status. JSON booleans and the lowercase strings "true" and "false" are accepted.', coercibleBooleanSchema() ), - cweId: searchRequestProperty( - 'Returns CVE records whose scope-selected containers include the provided Common Weakness Enumeration ID. Accepts one ID in the format CWE-, without leading zeros, such as CWE-79. Values are case-insensitive and normalized to uppercase. Malformed IDs return HTTP 400 CWE_ID_FORMAT; the API does not check whether an ID exists in the CWE catalog.', + cweIds: searchRequestProperty( + `Returns CVE records whose scope-selected containers include any ID in the comma-separated list of CWE IDs. Each ID must use CWE-, without leading zeros. Input is case-insensitive, surrounding whitespace is trimmed per ID, and duplicate IDs do not change results. Matching uses complete identifiers, with OR between IDs and AND with other filters. Limited to ${constants.MAX_CWE_IDS} CWE IDs, ${constants.MAX_CWE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cweIds} characters overall. Arrays, empty entries, and malformed IDs return HTTP 400 CWE_IDS_FORMAT; one invalid entry rejects the whole request. The API validates format, not membership in the CWE catalog. The former cweId field is unsupported.`, { type: 'string', - maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweId, - example: 'CWE-79' + maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cweIds, + example: 'CWE-79,CWE-89' } ), cvssV2Severity: searchRequestProperty( diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 5646043..3000add 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -816,7 +816,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -872,7 +872,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"CWE-367\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -1165,7 +1165,7 @@ "response": [] }, { - "name": "cweId", + "name": "cweIds", "event": [ { "listen": "test", @@ -1214,7 +1214,119 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweId\": \"CWE-89\"\n}", + "raw": "{\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Multiple CWE IDs use OR and accept case and whitespace", + "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 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "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 \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \" CWE-79, cwe-89, CWE-89 \"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "An unmatched CWE ID does not discard another match", + "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 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + " const returnedIds = payload.records.map((record) => record?.cveMetadata?.cveId);", + "pm.test('expected CVE IDs are returned', () => pm.expect(returnedIds).to.include.members([\"CVE-2024-5981\"]));", + "}" + ] + } + } + ], + "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 \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"cweIds\": \"CWE-99999999,CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -3779,7 +3891,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweId\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", + "raw": "{\n \"scope\": \"CNA_ONLY\",\n \"cveIds\": \"CVE-2024-5981\",\n \"noRejected\": true,\n \"keywordSearch\": \"sql injection\",\n \"keywordExactMatch\": true,\n \"cweIds\": \"CWE-89\",\n \"cvssV3_1Severity\": \"MEDIUM\",\n \"cvssV3_1Metrics\": \"AV:N/AC:L/PR:L\",\n \"pubStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"pubEndDate\": \"2024-12-31T23:59:59.999Z\",\n \"lastModStartDate\": \"2024-01-01T00:00:00.000Z\",\n \"lastModEndDate\": \"2030-12-31T23:59:59.999Z\",\n \"resultsPerPage\": 1,\n \"pageNumber\": 1\n}", "options": { "raw": { "language": "json" } } } }, @@ -3835,7 +3947,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweId\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", + "raw": "{\n \"scope\": \"ADP_ONLY\",\n \"cveIds\": \"CVE-2022-31640\",\n \"cweIds\": \"CWE-367\",\n \"cvssV3_1Severity\": \"HIGH\",\n \"cvssV3_1Metrics\": \"AV:L/AC:H/PR:L\",\n \"ssvcExploitation\": \"NONE\",\n \"ssvcAutomatable\": \"NO\",\n \"ssvcTechnicalImpact\": \"TOTAL\",\n \"ssvcRole\": \"CISA Coordinator\",\n \"referenceURL\": \"support.hp.com/us-en/document/ish_6662920\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -4422,7 +4534,271 @@ "response": [] }, { - "name": "Invalid CWE ID 79", + "name": "Invalid CWE IDs 79", + "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\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-0", + "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\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-0\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-079", + "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\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-079\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,,CWE-89", + "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\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-79,,CWE-89\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-*", + "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\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-*\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79,invalid", + "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\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", + "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 \"cweIds\": \"CWE-79,invalid\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid CWE IDs CWE-79", "event": [ { "listen": "test", @@ -4436,7 +4812,7 @@ "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4459,14 +4835,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"79\"\n}", + "raw": "{\n \"cweIds\": [\n \"CWE-79\"\n ]\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-0", + "name": "Invalid CWE IDs CWE-79,", "event": [ { "listen": "test", @@ -4480,7 +4856,7 @@ "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4503,14 +4879,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-0\"\n}", + "raw": "{\n \"cweIds\": \"CWE-79,\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-079", + "name": "Former cweId field is unsupported", "event": [ { "listen": "test", @@ -4524,7 +4900,7 @@ "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"cweId is not supported by the Search API.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4547,14 +4923,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-079\"\n}", + "raw": "{\n \"cweId\": \"CWE-79\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-79,CWE-89", + "name": "CWE ID item length", "event": [ { "listen": "test", @@ -4568,7 +4944,7 @@ "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_ID_LENGTH\",\"message\":\"Each CWE ID in the cweIds field must be 32 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4591,14 +4967,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-79,CWE-89\"\n}", + "raw": "{\n \"cweIds\": \"CWE-11111111111111111111111111111\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Invalid CWE ID CWE-*", + "name": "CWE ID count limit", "event": [ { "listen": "test", @@ -4612,7 +4988,7 @@ "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\":\"CWE_ID_FORMAT\",\"message\":\"The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).\"};", + "const expectedError = {\"error\":\"CWE_IDS_LIMIT\",\"message\":\"The cweIds parameter cannot contain more than 100 CWE IDs.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -4635,7 +5011,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cweId\": \"CWE-*\"\n}", + "raw": "{\n \"cweIds\": \"CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79,CWE-79\"\n}", "options": { "raw": { "language": "json" } } } }, diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 4455443..2a7e0ad 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -346,7 +346,7 @@ const scopeCoverage = folder('02 - Scope coverage', 'Base, CNA, and ADP matching ), positive( 'CNA_ONLY CWE match', - { scope: 'CNA_ONLY', cveIds: ids.primary, cweId: 'CWE-89' }, + { scope: 'CNA_ONLY', cveIds: ids.primary, cweIds: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] @@ -354,7 +354,7 @@ const scopeCoverage = folder('02 - Scope coverage', 'Base, CNA, and ADP matching ), positive( 'ADP_ONLY CWE match', - { scope: 'ADP_ONLY', cveIds: ids.adp, cweId: 'CWE-367' }, + { scope: 'ADP_ONLY', cveIds: ids.adp, cweIds: 'CWE-367' }, { exactTotal: 1, expectedIds: [ids.adp] @@ -400,7 +400,17 @@ const keywordAndRecordFilters = folder( { cveIds: `${ids.primary},${ids.rejected}`, noRejected: false, resultsPerPage: 2 }, { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } ), - positive('cweId', { cveIds: ids.primary, cweId: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] }) + positive('cweIds', { cveIds: ids.primary, cweIds: 'CWE-89' }, { exactTotal: 1, expectedIds: [ids.primary] }), + positive( + 'Multiple CWE IDs use OR and accept case and whitespace', + { scope: 'CNA_ONLY', cveIds: ids.primary, cweIds: ' CWE-79, cwe-89, CWE-89 ' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ), + positive( + 'An unmatched CWE ID does not discard another match', + { scope: 'CNA_ONLY', cveIds: ids.primary, cweIds: 'CWE-99999999,CWE-89' }, + { exactTotal: 1, expectedIds: [ids.primary] } + ) ] ); @@ -819,7 +829,7 @@ const intentionalCombinations = folder( noRejected: true, keywordSearch: 'sql injection', keywordExactMatch: true, - cweId: 'CWE-89', + cweIds: 'CWE-89', cvssV3_1Severity: 'MEDIUM', cvssV3_1Metrics: 'AV:N/AC:L/PR:L', pubStartDate: '2024-01-01T00:00:00.000Z', @@ -836,7 +846,7 @@ const intentionalCombinations = folder( { scope: 'ADP_ONLY', cveIds: ids.adp, - cweId: 'CWE-367', + cweIds: 'CWE-367', cvssV3_1Severity: 'HIGH', cvssV3_1Metrics: 'AV:L/AC:H/PR:L', ssvcExploitation: 'NONE', @@ -915,8 +925,23 @@ const limitFailures = folder( error.searchFieldMinimumLengthError('referenceURL', 3) ), negative('CVE ID format', { cveIds: 'not-a-cve' }, error.cveIdsFormatError()), - ...['79', 'CWE-0', 'CWE-079', 'CWE-79,CWE-89', 'CWE-*'].map((cweId) => - negative(`Invalid CWE ID ${cweId}`, { cweId }, error.cweIdFormatError()) + ...['79', 'CWE-0', 'CWE-079', 'CWE-79,,CWE-89', 'CWE-*', 'CWE-79,invalid', ['CWE-79'], 'CWE-79,'].map( + (cweIds) => negative(`Invalid CWE IDs ${cweIds}`, { cweIds }, error.cweIdsFormatError()) + ), + negative('Former cweId field is unsupported', { cweId: 'CWE-79' }, error.invalidParamError('cweId')), + negative( + 'CWE ID item length', + { cweIds: `CWE-${'1'.repeat(constants.MAX_CWE_ID_LENGTH - 3)}` }, + error.cweIdLengthError() + ), + negative( + 'CWE ID count limit', + { + cweIds: Array(constants.MAX_CWE_IDS + 1) + .fill('CWE-79') + .join(',') + }, + error.cweIdsLimitError() ), negative('CVE ID length', { cveIds: `CVE-2024-${'1'.repeat(56)}` }, error.cveIdLengthError()), negative( diff --git a/utils/constants.js b/utils/constants.js index 5d81011..fcca22e 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -6,6 +6,8 @@ const MAX_CVSS_METRIC_TOKENS = 32; const MAX_CVSS_METRIC_TOKEN_LENGTH = 32; const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; +const MAX_CWE_IDS = 100; +const MAX_CWE_ID_LENGTH = 32; const FIRST_CVE_RECORD_YEAR = 1999; const MAX_KEYWORD_SEARCH_TOKENS = 64; const MIN_REFERENCE_URL_LENGTH = 3; @@ -13,7 +15,7 @@ const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, cveIds: 4096, - cweId: 32, + cweIds: 4096, cpeName: 2048, versionStart: 256, versionEnd: 256, @@ -44,7 +46,7 @@ const cpeMatchFields = { * keywordExactMatch: list of terms, must be an exact match to terms in a description * cveIds: comma-separated list of CVE-year-XXXX identifiers * noRejected: no-value flag that excludes rejected CVE records - * cweId: CWE-XXX + * cweIds: comma-separated list of CWE-XXX identifiers * cvssV2Severity: LOW, MEDIUM, HIGH * cvssV3Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL * cvssV3_0Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL @@ -85,7 +87,7 @@ const searchQueryParams = [ 'keywordExactMatch', 'cveIds', 'noRejected', - 'cweId', + 'cweIds', 'cvssV2Severity', 'cvssV3Severity', 'cvssV3_0Severity', @@ -130,6 +132,8 @@ module.exports = { MAX_CVSS_METRIC_TOKEN_LENGTH, MAX_CVE_IDS, MAX_CVE_ID_LENGTH, + MAX_CWE_IDS, + MAX_CWE_ID_LENGTH, FIRST_CVE_RECORD_YEAR, MAX_KEYWORD_SEARCH_TOKENS, MIN_REFERENCE_URL_LENGTH, diff --git a/utils/errors.js b/utils/errors.js index b0ab053..6710d26 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -241,10 +241,24 @@ class errors { ); } - cweIdFormatError() { + cweIdsFormatError() { return makeError( - 'CWE_ID_FORMAT', - 'The cweId parameter must be one CWE ID in the format CWE-, without leading zeros (for example, CWE-79).' + 'CWE_IDS_FORMAT', + 'The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).' + ); + } + + cweIdLengthError() { + return makeError( + 'CWE_ID_LENGTH', + `Each CWE ID in the cweIds field must be ${constants.MAX_CWE_ID_LENGTH} characters or fewer.` + ); + } + + cweIdsLimitError() { + return makeError( + 'CWE_IDS_LIMIT', + `The cweIds parameter cannot contain more than ${constants.MAX_CWE_IDS} CWE IDs.` ); } diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 0866048..f1c035d 100644 --- a/utils/searchQueryBuilder.js +++ b/utils/searchQueryBuilder.js @@ -298,6 +298,11 @@ function getFilterFields(params, scope = searchScope.SEARCH_SCOPES.CNA_ONLY) { return; } + if (param === 'cweIds') { + fieldsToReturn.push(buildCweIdsFilter(params[param], fieldSpec)); + return; + } + if (param === 'cpeName') { const filter = buildCpeNameFilter(params[param]); if (filter) fieldsToReturn.push(filter); @@ -367,7 +372,7 @@ function escapeForRegexp(s) { return String(s).replace(/([\\.?+*|{}[\]()"#@&<>~])/g, '\\$1'); } -function parseCveIds(value) { +function parseIds(value) { return String(value ?? '') .split(',') .map((id) => id.trim().toUpperCase()) @@ -375,7 +380,7 @@ function parseCveIds(value) { } function buildCveIdsFilter(value, field) { - const cveIds = [...new Set(parseCveIds(value))]; + const cveIds = [...new Set(parseIds(value))]; if (!cveIds.length) return null; return { @@ -385,6 +390,17 @@ function buildCveIdsFilter(value, field) { }; } +function buildCweIdsFilter(value, fields) { + const ids = [...new Set(parseIds(value))]; + const clauses = fields.flatMap((field) => + ids.map((id) => ({ + term: { [`${field}.keyword`]: { value: id, case_insensitive: true } } + })) + ); + + return clauses.length === 1 ? clauses[0] : { bool: { should: clauses, minimum_should_match: 1 } }; +} + function buildNoRejectedFilter(fieldSpec) { const fields = Array.isArray(fieldSpec) ? fieldSpec : [fieldSpec]; diff --git a/utils/searchQueryBuilder.test.js b/utils/searchQueryBuilder.test.js index a45bbfd..e578b07 100644 --- a/utils/searchQueryBuilder.test.js +++ b/utils/searchQueryBuilder.test.js @@ -286,7 +286,7 @@ describe('searchQueryBuilder buildSearchRequest()', () => { const output = queryBuilder.buildSearchRequest({ scope: 'CNA_ONLY', keywordSearch: 'remote code', - cweId: 'CWE-79', + cweIds: 'CWE-79', resultsPerPage: 10, pageNumber: 3 }); @@ -305,8 +305,11 @@ describe('searchQueryBuilder buildSearchRequest()', () => { ], filter: [ { - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { + value: 'CWE-79', + case_insensitive: true + } } } ] @@ -375,15 +378,41 @@ describe('searchQueryBuilder buildQueryStrings() function', () => { }); describe('searchQueryBuilder getFilterFields() function', () => { - test('getFilterFields() function correctly builds array of one matchPhrase object when one parameter is passed.', () => { + test.each([ + ['FULL', ['cna', 'adp']], + ['CNA_ONLY', ['cna']], + ['ADP_ONLY', ['adp']] + ])('builds exact OR clauses for deduplicated CWE IDs in %s', (scope, containers) => { + expect(queryBuilder.getFilterFields({ cweIds: ' cwe-79, CWE-89, CWE-79 ' }, scope)).toStrictEqual([ + { + bool: { + minimum_should_match: 1, + should: containers.flatMap((container) => + ['CWE-79', 'CWE-89'].map((value) => ({ + term: { + [`containers.${container}.problemTypes.descriptions.cweId.keyword`]: { + value, + case_insensitive: true + } + } + })) + ) + } + } + ]); + }); + test('getFilterFields() builds one case-insensitive exact CWE identifier query', () => { const testParams = { - cweId: 'CWE-79' + cweIds: 'CWE-79' }; const testMatchPhrase = [ { - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { + value: 'CWE-79', + case_insensitive: true + } } } ]; @@ -395,14 +424,17 @@ describe('searchQueryBuilder getFilterFields() function', () => { test('getFilterFields() function correctly builds default and severity filter objects when multiple parameters are passed.', () => { const testParams = { - cweId: 'CWE-79', + cweIds: 'CWE-79', cvssV4Severity: 'HIGH' }; const testMatchPhrase = [ { - match_phrase: { - 'containers.cna.problemTypes.descriptions.cweId': 'CWE-79' + term: { + 'containers.cna.problemTypes.descriptions.cweId.keyword': { + value: 'CWE-79', + case_insensitive: true + } } }, { diff --git a/utils/searchReadiness.js b/utils/searchReadiness.js index d65e456..fb28d56 100644 --- a/utils/searchReadiness.js +++ b/utils/searchReadiness.js @@ -71,13 +71,7 @@ const FIELD_CAPABILITY_GROUPS = Object.freeze([ types: ['text', 'keyword'] })) ), - capabilityGroup( - 'cweId', - unique(searchScope.resolveFields('cweId')).map((field) => ({ - field, - types: ['text', 'keyword'] - })) - ), + capabilityGroup('cweIds', keywordRequirements(['cweIds'])), capabilityGroup( 'cvssV2Severity', unique(searchScope.resolveFields('cvssV2Severity')).map((field) => ({ diff --git a/utils/searchReadiness.test.js b/utils/searchReadiness.test.js index 3cc3944..63b8891 100644 --- a/utils/searchReadiness.test.js +++ b/utils/searchReadiness.test.js @@ -17,6 +17,26 @@ function validCapabilities() { } describe('search readiness mapping contract', () => { + test('requires searchable keyword CWE fields instead of analyzed text fields', () => { + const body = validCapabilities(); + const group = searchReadiness.FIELD_CAPABILITY_GROUPS.find(({ name }) => name === 'cweIds'); + group.requirements.forEach(({ field }) => { + delete body.fields[field]; + body.fields[field.replace(/\.keyword$/, '')] = { text: { searchable: true, aggregatable: false } }; + }); + expect(() => searchReadiness.validateFieldCapabilities(body)).toThrow( + searchReadiness.SearchMappingContractError + ); + }); + + test.each(['cna', 'adp'])( + 'accepts a missing optional %s CWE field when the other container provides keyword support', + (container) => { + const body = validCapabilities(); + delete body.fields[`containers.${container}.problemTypes.descriptions.cweId.keyword`]; + expect(searchReadiness.validateFieldCapabilities(body)).toBe(true); + } + ); test('accepts searchable fields with the required types', () => { expect(searchReadiness.validateFieldCapabilities(validCapabilities())).toBe(true); }); diff --git a/utils/searchScope.js b/utils/searchScope.js index c25d1d4..0dfe919 100644 --- a/utils/searchScope.js +++ b/utils/searchScope.js @@ -18,7 +18,7 @@ const FIELD_PATHS = Object.freeze({ base: ['cveMetadata.state'], cna: ['containers.cna.x_legacyV4Record.CVE_data_meta.STATE'] }, - cweId: { + cweIds: { cna: ['containers.cna.problemTypes.descriptions.cweId'], adp: ['containers.adp.problemTypes.descriptions.cweId'] },