diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index 60b53fd3..bc409301 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -20,7 +20,6 @@ export { mandatoryTest_6_1_27_2, mandatoryTest_6_1_27_6, mandatoryTest_6_1_27_7, - mandatoryTest_6_1_27_8, mandatoryTest_6_1_27_9, mandatoryTest_6_1_27_10, mandatoryTest_6_1_28, @@ -42,6 +41,7 @@ export { mandatoryTest_6_1_13 } from './mandatoryTests/mandatoryTest_6_1_13.js' export { mandatoryTest_6_1_27_3 } from './mandatoryTests/mandatoryTest_6_1_27_3.js' export { mandatoryTest_6_1_27_4 } from './mandatoryTests/mandatoryTest_6_1_27_4.js' export { mandatoryTest_6_1_27_5 } from './mandatoryTests/mandatoryTest_6_1_27_5.js' +export { mandatoryTest_6_1_27_8 } from './mandatoryTests/mandatoryTest_6_1_27_8.js' export { mandatoryTest_6_1_27_11 } from './mandatoryTests/mandatoryTest_6_1_27_11.js' export { mandatoryTest_6_1_27_12 } from './mandatoryTests/mandatoryTest_6_1_27_12.js' export { mandatoryTest_6_1_27_14 } from './mandatoryTests/mandatoryTest_6_1_27_14.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_27_8.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_27_8.js new file mode 100644 index 00000000..7f474c17 --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_27_8.js @@ -0,0 +1,206 @@ +import { Ajv } from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match it normally means that the input + document does not validate against the csaf json schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + category: { + type: 'string', + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + cve: { type: 'string' }, + ids: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { elements: { type: 'string' } }, + group_ids: { elements: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + optionalProperties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + product_groups: { + elements: { + additionalProperties: true, + optionalProperties: { + group_id: { type: 'string' }, + product_ids: { elements: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +}) + +const validate = ajv.compile(inputSchema) + +/** @typedef {import('ajv/dist/jtd.js').JTDDataType} InputDoc */ +/** @typedef {InputDoc['vulnerabilities'][number]} Vulnerability */ +/** @typedef {NonNullable[number]} VulnerabilityId */ +/** @typedef {NonNullable['product_groups']>[number]} ProductGroup */ + +/** + * This implements the mandatory test 6.1.27.8 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export function mandatoryTest_6_1_27_8(doc) { + /** @type {Array<{ message: string; instancePath: string }>} */ + const errors = [] + let isValid = true + + if (!validate(doc) || doc.document.category !== 'csaf_vex') { + return { errors, isValid } + } + + /** @type {Map>} */ + const groupProductMap = new Map() + /** @type {ProductGroup[] | undefined} */ + const productGroups = doc.product_tree?.product_groups + if (Array.isArray(productGroups)) { + for (const group of productGroups) { + if ( + typeof group.group_id === 'string' && + Array.isArray(group.product_ids) + ) { + groupProductMap.set(group.group_id, new Set(group.product_ids)) + } + } + } + + /** @type {Vulnerability[]} */ + const vulnerabilities = doc.vulnerabilities + if (Array.isArray(vulnerabilities)) { + vulnerabilities.forEach((vulnerability, vulnerabilityIndex) => { + if (['ids', 'cve'].every((p) => vulnerability[p] === undefined)) { + isValid = false + errors.push({ + instancePath: `/vulnerabilities/${vulnerabilityIndex}`, + message: + 'Neither a CVE nor a general vulnerability id (ids) is given for this vulnerability.', + }) + return + } + + if (vulnerability.cve !== undefined) return + + if (!Array.isArray(vulnerability.ids)) return + + const allScoped = vulnerability.ids.every( + (id) => + (Array.isArray(id.product_ids) && id.product_ids.length > 0) || + (Array.isArray(id.group_ids) && id.group_ids.length > 0) + ) + if (!allScoped) return + + const coveredProducts = getAllCoveredProducts( + vulnerability.ids, + groupProductMap + ) + + const productStatus = + /** @type {Record | null | undefined} */ ( + vulnerability.product_status + ) + if (productStatus == null || typeof productStatus !== 'object') return + + const hadErrors = checkProductStatus( + productStatus, + coveredProducts, + vulnerabilityIndex, + errors + ) + if (hadErrors) { + isValid = false + } + }) + } + + return { errors, isValid } +} + +/** + * Collects all product ids covered by the given ids entries, + * resolving group_ids via groupProductMap. + * @param {VulnerabilityId[]} ids + * @param {Map>} groupProductMap + * @returns {Set} + */ +function getAllCoveredProducts(ids, groupProductMap) { + const coveredProducts = new Set() + for (const id of ids) { + if (Array.isArray(id.product_ids)) { + for (const pid of id.product_ids) { + coveredProducts.add(pid) + } + } + if (Array.isArray(id.group_ids)) { + for (const gid of id.group_ids) { + const members = groupProductMap.get(gid) + if (members) { + for (const pid of members) { + coveredProducts.add(pid) + } + } + } + } + } + return coveredProducts +} + +/** + * Checks that every product referenced in product_status is covered. + * Returns true if any uncovered products were found. + * @param {Record} productStatus + * @param {Set} coveredProducts + * @param {number} vulnerabilityIndex + * @param {Array<{ message: string; instancePath: string }>} errors + * @returns {boolean} + */ +function checkProductStatus( + productStatus, + coveredProducts, + vulnerabilityIndex, + errors +) { + let hadErrors = false + for (const [statusKey, productIds] of Object.entries(productStatus)) { + if (!Array.isArray(productIds)) continue + productIds.forEach((productId, productIdIndex) => { + if (typeof productId !== 'string') return + if (!coveredProducts.has(productId)) { + hadErrors = true + errors.push({ + instancePath: `/vulnerabilities/${vulnerabilityIndex}/product_status/${statusKey}/${productIdIndex}`, + message: + `product id \`${productId}\` does not have a vulnerability id assigned` + + ` nor a CVE or general vulnerability id is given`, + }) + } + }) + } + return hadErrors +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_27_8.js b/tests/csaf_2_1/mandatoryTest_6_1_27_8.js new file mode 100644 index 00000000..79a65395 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_27_8.js @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { mandatoryTest_6_1_27_8 } from '../../csaf_2_1/mandatoryTests/mandatoryTest_6_1_27_8.js' + +describe('mandatoryTest_6_1_27_8', function () { + it('only runs on relevant documents (skips non-object document)', function () { + assert.equal(mandatoryTest_6_1_27_8({ document: 'mydoc' }).isValid, true) + }) + + it('returns valid for documents with irrelevant category', function () { + assert.equal( + mandatoryTest_6_1_27_8({ + document: { category: 'csaf_base' }, + vulnerabilities: [{ title: 'no cve, no ids' }], + }).isValid, + true + ) + }) + + it('reports uncovered products when vulnerability has empty ids array', function () { + const result = mandatoryTest_6_1_27_8({ + document: { category: 'csaf_vex' }, + vulnerabilities: [ + { + ids: [], + product_status: { known_affected: ['PROD_A'] }, + }, + ], + }) + assert.equal(result.isValid, false) + assert.equal(result.errors.length, 1) + assert.equal( + result.errors[0].instancePath, + '/vulnerabilities/0/product_status/known_affected/0' + ) + }) + + it('returns valid when product_status is absent (nothing to check)', function () { + const result = mandatoryTest_6_1_27_8({ + document: { category: 'csaf_vex' }, + vulnerabilities: [ + { + ids: [ + { + system_name: 'Tracking System', + text: 'TRACK-001', + product_ids: ['PROD_A'], + }, + ], + }, + ], + }) + assert.equal(result.isValid, true) + assert.equal(result.errors.length, 0) + }) + + it('skips non-array product_status entries (e.g. a string value)', function () { + const result = mandatoryTest_6_1_27_8({ + document: { category: 'csaf_vex' }, + vulnerabilities: [ + { + ids: [ + { + system_name: 'Tracking System', + text: 'TRACK-001', + product_ids: ['PROD_A'], + }, + ], + product_status: { known_affected: 'not-an-array' }, + }, + ], + }) + assert.equal(result.isValid, true) + assert.equal(result.errors.length, 0) + }) + + it('skips non-string product ids in product_status', function () { + const result = mandatoryTest_6_1_27_8({ + document: { category: 'csaf_vex' }, + vulnerabilities: [ + { + ids: [ + { + system_name: 'Tracking System', + text: 'TRACK-001', + product_ids: ['PROD_A'], + }, + ], + product_status: { known_affected: [42, null, 'PROD_B'] }, + }, + ], + }) + assert.equal(result.errors.length, 1) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 51e4d5bc..c0448b30 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -86,7 +86,6 @@ const skippedTests = new Set([ 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-01-03.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-01.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-02.json', - 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-27-08-02.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-04-03.json', 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-13.json', 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-02.json',