diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index 821d3613..a4369765 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -18,7 +18,6 @@ export { mandatoryTest_6_1_27_1, mandatoryTest_6_1_27_2, 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_27_3 } from './mandatoryTests/mandatoryTest_6_1_27_3. 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_6 } from './mandatoryTests/mandatoryTest_6_1_27_6.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..bfb2508d --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_27_8.js @@ -0,0 +1,205 @@ +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' } }, + }, + }, + }, + product_status: { + values: { elements: { type: 'string' } }, + }, + }, + }, + }, + }, + optionalProperties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + product_groups: { + elements: { + additionalProperties: true, + properties: { + 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) { + /* + The `ctx` variable holds the state that is accumulated during the test ran and is + finally returned by the function. + */ + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if (!validate(doc) || doc.document.category !== 'csaf_vex') { + return ctx + } + + /** @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) { + groupProductMap.set(group.group_id, new Set(group.product_ids)) + } + } + + /** @type {Vulnerability[]} */ + const vulnerabilities = doc.vulnerabilities + vulnerabilities.forEach((vulnerability, vulnerabilityIndex) => { + if ( + ['ids', 'cve'].every( + (propertyName) => vulnerability[propertyName] === undefined + ) + ) { + ctx.isValid = false + ctx.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 = vulnerability.product_status + if (productStatus === undefined) return + + const productStatusErrors = checkProductStatus( + productStatus, + coveredProducts, + vulnerabilityIndex + ) + if (productStatusErrors.length > 0) { + ctx.isValid = false + ctx.errors.push(...productStatusErrors) + } + }) + + return ctx +} + +/** + * 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 the errors found for uncovered products. + * @param {Record} productStatus + * @param {Set} coveredProducts + * @param {number} vulnerabilityIndex + * @returns {Array<{ message: string; instancePath: string }>} + */ +function checkProductStatus( + productStatus, + coveredProducts, + vulnerabilityIndex +) { + /** @type {Array<{ message: string; instancePath: string }>} */ + const errors = [] + for (const [statusKey, productIds] of Object.entries(productStatus)) { + productIds.forEach((productId, productIdIndex) => { + if (!coveredProducts.has(productId)) { + 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 errors +} 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..d32133ad --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_27_8.js @@ -0,0 +1,45 @@ +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('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) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 0f97584b..f0a79440 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -81,7 +81,6 @@ const skippedTests = new Set([ 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-02.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-14-32.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-21-17.json', - 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-27-08-02.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', ])