diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index be6b62c..4a65376 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -1,3 +1,4 @@ +export { default as mandatoryTest_6_1_7 } from './mandatoryTests/mandatoryTest_6_1_7.js' export { mandatoryTest_6_1_1, mandatoryTest_6_1_2, @@ -5,7 +6,6 @@ export { mandatoryTest_6_1_4, mandatoryTest_6_1_5, mandatoryTest_6_1_6, - mandatoryTest_6_1_7, mandatoryTest_6_1_8, mandatoryTest_6_1_9, mandatoryTest_6_1_10, diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_7.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_7.js new file mode 100644 index 0000000..b1b2cd6 --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_7.js @@ -0,0 +1,110 @@ +import Ajv from 'ajv/dist/jtd.js' + +const jtdAjv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + properties: { + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + cvss_v2: { + additionalProperties: true, + properties: { + version: { type: 'string' }, + }, + }, + cvss_v3: { + additionalProperties: true, + properties: { + version: { type: 'string' }, + }, + }, + cvss_v4: { + additionalProperties: true, + properties: { + version: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const validate = jtdAjv.compile(inputSchema) + +/** + * + * @param {unknown} doc + */ +export default function mandatoryTest_6_1_7(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + /** @type {Array<{ message: string; instancePath: string }>} */ + const errors = [] + let isValid = true + + if (!validate(doc)) { + return ctx + } + + // 6.1.7 Multiple Scores with same Version per Product + /** @type {Array} */ + const vulnerabilities = doc.vulnerabilities + vulnerabilities.forEach((vulnerability, vulnerabilityIndex) => { + /** @type {Map>} */ + const cvssVersionsByProductName = new Map() + + /** @type {Array} */ + const metrics = vulnerability.metrics + metrics?.forEach((metric, scoreIndex) => { + /** @type {Array} */ + const products = metric.products + products?.forEach((product, productIndex) => { + const versionSet = cvssVersionsByProductName.get(product) ?? new Set() + cvssVersionsByProductName.set(product, versionSet) + + if ( + (metric.content?.cvss_v2?.version !== undefined && + versionSet.has(metric.content?.cvss_v2.version)) || + (metric.content?.cvss_v3?.version !== undefined && + versionSet.has(metric.content?.cvss_v3.version)) || + (metric.content?.cvss_v4?.version !== undefined && + versionSet.has(metric.content?.cvss_v4.version)) + ) { + isValid = false + errors.push({ + message: `product is already included in these cvss-versions: ${Array.from( + versionSet.keys() + ).join(', ')}`, + instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${scoreIndex}/products/${productIndex}`, + }) + } + if (metric.content?.cvss_v2?.version !== undefined) { + versionSet.add(metric.content?.cvss_v2.version) + } + if (metric.content?.cvss_v3?.version !== undefined) { + versionSet.add(metric.content?.cvss_v3.version) + } + if (metric.content?.cvss_v4?.version !== undefined) { + versionSet.add(metric.content?.cvss_v4.version) + } + }) + }) + }) + + return { errors, isValid } +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_7.js b/tests/csaf_2_1/mandatoryTest_6_1_7.js new file mode 100644 index 0000000..e2d5230 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_7.js @@ -0,0 +1,158 @@ +import { expect } from 'chai' +import mandatoryTest_6_1_7 from '../../csaf_2_1/mandatoryTests/mandatoryTest_6_1_7.js' +import minimalDoc from './shared/minimalDoc.js' +import csaf_2_1 from '../../csaf_2_1/schemaTests/csaf_2_1.js' + +const emptyMandatoryTest6_1_7 = { + $schema: minimalDoc.$schema, + document: { + ...minimalDoc.document, + }, + product_tree: { + full_product_names: [ + { + product_id: 'CSAFPID-9080700', + name: 'Product A', + }, + ], + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + cvss_v3: {}, + }, + products: ['CSAFPID-9080700'], + }, + ], + }, + { + metrics: [ + { + content: {}, + products: [], + }, + ], + }, + ], +} + +const invalidMandatoryTest6_1_7 = { + $schema: minimalDoc.$schema, + document: { + ...minimalDoc.document, + }, + product_tree: { + full_product_names: [ + { + product_id: 'CSAFPID-9080700', + name: 'Product A', + }, + ], + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + cvss_v4: { + version: '4.0', + vectorString: + 'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H', + baseScore: 10, + baseSeverity: 'CRITICAL', + }, + }, + products: ['CSAFPID-9080700'], + }, + { + content: { + cvss_v4: { + version: '4.0', + vectorString: + 'CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:H/SI:N/SA:N', + baseScore: 4.9, + baseSeverity: 'MEDIUM', + }, + }, + products: ['CSAFPID-9080700'], + }, + ], + }, + ], +} + +const validMandatoryTest6_1_7 = { + $schema: minimalDoc.$schema, + document: { + ...minimalDoc.document, + }, + product_tree: { + full_product_names: [ + { + product_id: 'CSAFPID-9080700', + name: 'Product A', + }, + ], + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + cvss_v3: { + version: '3.1', + vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + baseScore: 10, + baseSeverity: 'CRITICAL', + }, + }, + products: ['CSAFPID-9080700'], + }, + ], + }, + { + metrics: [ + { + content: { + cvss_v3: { + version: '3.1', + vectorString: 'CVSS:3.1/AV:L/AC:L/PR:H/UI:R/S:U/C:H/I:H/A:H', + baseScore: 6.5, + baseSeverity: 'MEDIUM', + }, + }, + products: ['CSAFPID-9080700'], + }, + ], + }, + ], +} + +describe('mandatory test 6.1.7', function () { + describe('failing examples', function () { + it('test duplicate product id in cvss_v3 ', async function () { + expect(csaf_2_1(invalidMandatoryTest6_1_7).isValid).to.be.true + const result = await mandatoryTest_6_1_7(invalidMandatoryTest6_1_7) + expect(result.errors).to.have.length.greaterThan(0) + }) + }) + + describe('valid examples', function () { + it('test duplicate product id different vulnerabilities', async function () { + expect(csaf_2_1(validMandatoryTest6_1_7).isValid).to.be.true + const result = await mandatoryTest_6_1_7(validMandatoryTest6_1_7) + expect(result.errors.length).to.eq(0) + }) + it('test empty vulnerabilities', async function () { + const result = await mandatoryTest_6_1_7(emptyMandatoryTest6_1_7) + expect(result.errors.length).to.eq(0) + }) + it('test minimal doc', async function () { + expect(csaf_2_1(minimalDoc).isValid).to.be.true + const result = await mandatoryTest_6_1_7(minimalDoc) + expect(result.errors.length).to.eq(0) + }) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 691311a..6431695 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -10,7 +10,6 @@ import * as mandatory from '../../csaf_2_1/mandatoryTests.js' Once all tests are implemented for CSAF 2.1 this should be deleted. */ const excluded = [ - '6.1.7', '6.1.8', '6.1.9', '6.1.10', diff --git a/tests/csaf_2_1/shared/minimalDoc.js b/tests/csaf_2_1/shared/minimalDoc.js new file mode 100644 index 0000000..7eefd2c --- /dev/null +++ b/tests/csaf_2_1/shared/minimalDoc.js @@ -0,0 +1,40 @@ +export default { + $schema: 'https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json', + document: { + category: 'Test Report', + csaf_version: '2.1', + title: 'Minimal valid', + lang: 'en', + distribution: { + tlp: { + label: 'AMBER', + }, + }, + publisher: { + category: 'other', + name: 'Secvisogram Automated Tester', + namespace: 'https://github.com/secvisogram/secvisogram', + }, + references: [ + { + category: 'self', + summary: 'A non-canonical URL.', + url: 'https://example.com/security/data/csaf/2021/my-thing-_10.json', + }, + ], + tracking: { + current_release_date: '2021-01-14T00:00:00.000Z', + id: 'My-Thing-.10', + initial_release_date: '2021-01-14T00:00:00.000Z', + revision_history: [ + { + number: '1', + date: '2021-01-14T00:00:00.000Z', + summary: 'Summary', + }, + ], + status: 'draft', + version: '1', + }, + }, +}