diff --git a/README.md b/README.md index fd53f3c..cf89c6b 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,12 @@ 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`. + +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. + +`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: | Filters | Data used for matching | @@ -138,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 | @@ -161,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 43f0bec..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." + "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", @@ -708,72 +708,76 @@ "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", "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/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 bc06ade..6b11084 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -3,11 +3,12 @@ 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']; -const cvssV3_4SeverityEnum = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +const cvssV3_4SeverityEnum = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; const severityValidators = [ { @@ -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', @@ -67,6 +68,7 @@ const versionRangeTypeParams = ['versionStartType', 'versionEndType']; const nonStringSearchParams = [ 'keywordExactMatch', 'cveIds', + 'cweIds', 'isVulnerable', 'noRejected', 'knownExploited', @@ -79,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'; @@ -128,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) { @@ -228,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()); } @@ -240,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()); } @@ -368,6 +380,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/controllers/middleware.test.js b/controllers/middleware.test.js index 037e98a..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', 'a'.repeat(constants.SEARCH_STRING_LENGTH_LIMITS.cweId)] - ])('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-'; @@ -667,7 +699,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 +718,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 +737,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 +756,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/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/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", diff --git a/routes/index.js b/routes/index.js index 7e5a5a5..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', @@ -215,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 63c3958..0ba7ee9 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -48,6 +48,129 @@ 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', + '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[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) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { scope, cweIds: ' 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'], + ['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', @@ -124,6 +247,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..9efb150 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 { @@ -67,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.', + 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( @@ -83,35 +84,35 @@ 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( - `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 c87b1c5..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,7 @@ "response": [] }, { - "name": "CVE ID length", + "name": "Invalid CWE IDs 79", "event": [ { "listen": "test", @@ -4436,7 +4548,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\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", + "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 +4571,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "raw": "{\n \"cweIds\": \"79\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVE ID count limit", + "name": "Invalid CWE IDs CWE-0", "event": [ { "listen": "test", @@ -4480,7 +4592,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\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", + "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 +4615,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cveIds\": \"CVE-2024-1000,CVE-2024-1001,CVE-2024-1002,CVE-2024-1003,CVE-2024-1004,CVE-2024-1005,CVE-2024-1006,CVE-2024-1007,CVE-2024-1008,CVE-2024-1009,CVE-2024-1010,CVE-2024-1011,CVE-2024-1012,CVE-2024-1013,CVE-2024-1014,CVE-2024-1015,CVE-2024-1016,CVE-2024-1017,CVE-2024-1018,CVE-2024-1019,CVE-2024-1020,CVE-2024-1021,CVE-2024-1022,CVE-2024-1023,CVE-2024-1024,CVE-2024-1025,CVE-2024-1026,CVE-2024-1027,CVE-2024-1028,CVE-2024-1029,CVE-2024-1030,CVE-2024-1031,CVE-2024-1032,CVE-2024-1033,CVE-2024-1034,CVE-2024-1035,CVE-2024-1036,CVE-2024-1037,CVE-2024-1038,CVE-2024-1039,CVE-2024-1040,CVE-2024-1041,CVE-2024-1042,CVE-2024-1043,CVE-2024-1044,CVE-2024-1045,CVE-2024-1046,CVE-2024-1047,CVE-2024-1048,CVE-2024-1049,CVE-2024-1050,CVE-2024-1051,CVE-2024-1052,CVE-2024-1053,CVE-2024-1054,CVE-2024-1055,CVE-2024-1056,CVE-2024-1057,CVE-2024-1058,CVE-2024-1059,CVE-2024-1060,CVE-2024-1061,CVE-2024-1062,CVE-2024-1063,CVE-2024-1064,CVE-2024-1065,CVE-2024-1066,CVE-2024-1067,CVE-2024-1068,CVE-2024-1069,CVE-2024-1070,CVE-2024-1071,CVE-2024-1072,CVE-2024-1073,CVE-2024-1074,CVE-2024-1075,CVE-2024-1076,CVE-2024-1077,CVE-2024-1078,CVE-2024-1079,CVE-2024-1080,CVE-2024-1081,CVE-2024-1082,CVE-2024-1083,CVE-2024-1084,CVE-2024-1085,CVE-2024-1086,CVE-2024-1087,CVE-2024-1088,CVE-2024-1089,CVE-2024-1090,CVE-2024-1091,CVE-2024-1092,CVE-2024-1093,CVE-2024-1094,CVE-2024-1095,CVE-2024-1096,CVE-2024-1097,CVE-2024-1098,CVE-2024-1099,CVE-2024-1100\"\n}", + "raw": "{\n \"cweIds\": \"CWE-0\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "resultsPerPage must be positive", + "name": "Invalid CWE IDs CWE-079", "event": [ { "listen": "test", @@ -4524,7 +4636,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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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);", "});", @@ -4547,14 +4659,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"resultsPerPage\": -1\n}", + "raw": "{\n \"cweIds\": \"CWE-079\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "resultsPerPage maximum", + "name": "Invalid CWE IDs CWE-79,,CWE-89", "event": [ { "listen": "test", @@ -4568,7 +4680,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\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", + "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);", "});", @@ -4591,14 +4703,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"resultsPerPage\": 501\n}", + "raw": "{\n \"cweIds\": \"CWE-79,,CWE-89\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "pageNumber must be positive", + "name": "Invalid CWE IDs CWE-*", "event": [ { "listen": "test", @@ -4612,7 +4724,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\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", + "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);", "});", @@ -4635,14 +4747,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"pageNumber\": 0\n}", + "raw": "{\n \"cweIds\": \"CWE-*\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "Pagination window limit", + "name": "Invalid CWE IDs CWE-79,invalid", "event": [ { "listen": "test", @@ -4656,7 +4768,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\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", + "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);", "});", @@ -4679,14 +4791,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", + "raw": "{\n \"cweIds\": \"CWE-79,invalid\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector total length", + "name": "Invalid CWE IDs CWE-79", "event": [ { "listen": "test", @@ -4700,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\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "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);", "});", @@ -4723,14 +4835,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV3Metrics\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "raw": "{\n \"cweIds\": [\n \"CWE-79\"\n ]\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector token count", + "name": "Invalid CWE IDs CWE-79,", "event": [ { "listen": "test", @@ -4744,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\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "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);", "});", @@ -4767,14 +4879,14 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV4Metrics\": \"T0:A/T1:A/T2:A/T3:A/T4:A/T5:A/T6:A/T7:A/T8:A/T9:A/T10:A/T11:A/T12:A/T13:A/T14:A/T15:A/T16:A/T17:A/T18:A/T19:A/T20:A/T21:A/T22:A/T23:A/T24:A/T25:A/T26:A/T27:A/T28:A/T29:A/T30:A/T31:A/T32:A\"\n}", + "raw": "{\n \"cweIds\": \"CWE-79,\"\n}", "options": { "raw": { "language": "json" } } } }, "response": [] }, { - "name": "CVSS vector token length", + "name": "Former cweId field is unsupported", "event": [ { "listen": "test", @@ -4788,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\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "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);", "});", @@ -4811,7 +4923,667 @@ }, "body": { "mode": "raw", - "raw": "{\n \"cvssV2Metrics\": \"AV:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "raw": "{\n \"cweId\": \"CWE-79\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CWE ID item length", + "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_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);", + "});", + "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-11111111111111111111111111111\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CWE ID count limit", + "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_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);", + "});", + "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-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" } } + } + }, + "response": [] + }, + { + "name": "CVE ID length", + "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\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", + "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 \"cveIds\": \"CVE-2024-11111111111111111111111111111111111111111111111111111111\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVE ID count limit", + "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\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", + "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 \"cveIds\": \"CVE-2024-1000,CVE-2024-1001,CVE-2024-1002,CVE-2024-1003,CVE-2024-1004,CVE-2024-1005,CVE-2024-1006,CVE-2024-1007,CVE-2024-1008,CVE-2024-1009,CVE-2024-1010,CVE-2024-1011,CVE-2024-1012,CVE-2024-1013,CVE-2024-1014,CVE-2024-1015,CVE-2024-1016,CVE-2024-1017,CVE-2024-1018,CVE-2024-1019,CVE-2024-1020,CVE-2024-1021,CVE-2024-1022,CVE-2024-1023,CVE-2024-1024,CVE-2024-1025,CVE-2024-1026,CVE-2024-1027,CVE-2024-1028,CVE-2024-1029,CVE-2024-1030,CVE-2024-1031,CVE-2024-1032,CVE-2024-1033,CVE-2024-1034,CVE-2024-1035,CVE-2024-1036,CVE-2024-1037,CVE-2024-1038,CVE-2024-1039,CVE-2024-1040,CVE-2024-1041,CVE-2024-1042,CVE-2024-1043,CVE-2024-1044,CVE-2024-1045,CVE-2024-1046,CVE-2024-1047,CVE-2024-1048,CVE-2024-1049,CVE-2024-1050,CVE-2024-1051,CVE-2024-1052,CVE-2024-1053,CVE-2024-1054,CVE-2024-1055,CVE-2024-1056,CVE-2024-1057,CVE-2024-1058,CVE-2024-1059,CVE-2024-1060,CVE-2024-1061,CVE-2024-1062,CVE-2024-1063,CVE-2024-1064,CVE-2024-1065,CVE-2024-1066,CVE-2024-1067,CVE-2024-1068,CVE-2024-1069,CVE-2024-1070,CVE-2024-1071,CVE-2024-1072,CVE-2024-1073,CVE-2024-1074,CVE-2024-1075,CVE-2024-1076,CVE-2024-1077,CVE-2024-1078,CVE-2024-1079,CVE-2024-1080,CVE-2024-1081,CVE-2024-1082,CVE-2024-1083,CVE-2024-1084,CVE-2024-1085,CVE-2024-1086,CVE-2024-1087,CVE-2024-1088,CVE-2024-1089,CVE-2024-1090,CVE-2024-1091,CVE-2024-1092,CVE-2024-1093,CVE-2024-1094,CVE-2024-1095,CVE-2024-1096,CVE-2024-1097,CVE-2024-1098,CVE-2024-1099,CVE-2024-1100\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "resultsPerPage must be positive", + "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\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": -1\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "resultsPerPage maximum", + "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\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", + "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 \"resultsPerPage\": 501\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "pageNumber must be positive", + "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\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", + "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 \"pageNumber\": 0\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Pagination window limit", + "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\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", + "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 \"resultsPerPage\": 500,\n \"pageNumber\": 21\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector total length", + "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_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "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\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector token count", + "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_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "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\": \"T0:A/T1:A/T2:A/T3:A/T4:A/T5:A/T6:A/T7:A/T8:A/T9:A/T10:A/T11:A/T12:A/T13:A/T14:A/T15:A/T16:A/T17:A/T18:A/T19:A/T20:A/T21:A/T22:A/T23:A/T24:A/T25:A/T26:A/T27:A/T28:A/T29:A/T30:A/T31:A/T32:A\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "CVSS vector token length", + "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_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", + "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:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "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" } } } }, @@ -5146,7 +5918,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 +5962,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 +6006,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 +6050,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/test/postman/buildCollection.js b/test/postman/buildCollection.js index 60feeb1..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,6 +925,24 @@ 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-*', '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( 'CVE ID count limit', @@ -953,6 +981,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/constants.js b/utils/constants.js index 83f7b4d..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,8 +46,8 @@ 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 - * cvssV2Severity: NONE, LOW, MEDIUM, HIGH, CRITICAL + * 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 * cvssV3_1Severity: 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/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 cdbaa2e..6710d26 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -241,6 +241,27 @@ class errors { ); } + cweIdsFormatError() { + return makeError( + '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.` + ); + } + cvssV2SeverityError(severity) { return makeError( 'INVALID_CVSSV2_SEVERITY', @@ -251,28 +272,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.` ); } @@ -301,6 +322,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/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.' } ], [ diff --git a/utils/searchQueryBuilder.js b/utils/searchQueryBuilder.js index 1a95324..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); @@ -340,8 +345,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; } @@ -364,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()) @@ -372,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 { @@ -382,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]; @@ -622,11 +641,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..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 + } } }, { @@ -674,7 +706,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 +736,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 +808,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']); 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'] },