diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index 9b5b19a..18eea4a 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -44,3 +44,4 @@ export { mandatoryTest_6_1_33, } from '../mandatoryTests.js' export { mandatoryTest_6_1_34 } from './mandatoryTests/mandatoryTest_6_1_34.js' +export { mandatoryTest_6_1_35 } from './mandatoryTests/mandatoryTest_6_1_35.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_35.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_35.js new file mode 100644 index 0000000..b74c95f --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_35.js @@ -0,0 +1,209 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +/** + * @typedef {'workaround' + * | 'mitigation' + * | 'vendor_fix' + * | 'optional_patch' + * | 'none_available' + * | 'fix_planned' + * | 'no_fix_planned'} Category + */ + +/** + * This map holds prohibited category combinations. + * See https://github.com/oasis-tcs/csaf/blob/master/csaf_2.1/prose/share/csaf-v2.1-draft.md#324131-vulnerabilities-property---remediations---category- + * + * @type {Map>} + */ +const prohibitionRuleMap = new Map( + /** @satisfies {Array<[Category, Category[]]>} */ ([ + ['workaround', ['optional_patch', 'none_available']], + ['mitigation', ['optional_patch', 'none_available']], + [ + 'vendor_fix', + ['optional_patch', 'none_available', 'fix_planned', 'no_fix_planned'], + ], + [ + 'optional_patch', + [ + 'workaround', + 'mitigation', + 'vendor_fix', + 'none_available', + 'fix_planned', + 'no_fix_planned', + ], + ], + [ + 'none_available', + [ + 'workaround', + 'mitigation', + 'vendor_fix', + 'optional_patch', + 'fix_planned', + 'no_fix_planned', + ], + ], + [ + 'fix_planned', + ['vendor_fix', 'optional_patch', 'none_available', 'no_fix_planned'], + ], + [ + 'no_fix_planned', + ['vendor_fix', 'optional_patch', 'none_available', 'fix_planned'], + ], + ]).map((e) => [e[0], new Set(e[1])]) +) + +const remediationSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + group_ids: { + elements: { + type: 'string', + }, + }, + product_ids: { + elements: { + type: 'string', + }, + }, + category: { type: 'string' }, + }, +}) + +/* + 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, + optionalProperties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + product_groups: { + elements: { + additionalProperties: true, + optionalProperties: { + group_id: { type: 'string' }, + product_ids: { + elements: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + remediations: { + elements: remediationSchema, + }, + }, + }, + }, + }, +}) + +const validate = ajv.compile(inputSchema) + +/** + * This implements the mandatory test of the CSAF 2.1 standard. + * + * @param {any} doc + */ +export function mandatoryTest_6_1_35(doc) { + /* + The `ctx` variable holds the state that is accumulated during the test ran and is + finally returned by the function. + */ + const ctx = { + /** @type {Array<{ instancePath: string; message: string }>} */ + errors: [], + isValid: true, + } + + if (!validate(doc)) { + return ctx + } + + for (const [vulnerabilityIndex, vulnerability] of Object.entries( + doc.vulnerabilities + )) { + /** + * This map holds all discovered product ids and maps them to the set of corresponding + * remediation categories. Later we can check this map to find out if there are any + * contradicting remediations. + * + * @type {Map>} + */ + const productToCategoriesMap = new Map() + + vulnerability.remediations?.forEach((remediation, remediationIndex) => { + const category = remediation.category + if (!category) return + + /** + * This function adds the current category to the given product id in the + * `productMap`. If the product does not yet exist in the map, it is added. + * + * @param {string} id + */ + const collectCategory = (id) => { + productToCategoriesMap.set( + id, + new Set(productToCategoriesMap.get(id)).add(category) + ) + } + + remediation.product_ids?.forEach(collectCategory) + + remediation.group_ids?.forEach((id) => { + const group = doc.product_tree?.product_groups?.find( + (g) => g.group_id === id + ) + if (!group) return + group.product_ids?.forEach(collectCategory) + }) + + for (const [productId, categories] of productToCategoriesMap) { + /** + * This set will hold all already checked categories to avoid double checks + * and doubled error messages. + */ + const checkedCategories = new Set() + + for (const categoryA of categories) { + checkedCategories.add(categoryA) + + for (const categoryB of categories) { + if (checkedCategories.has(categoryB)) continue + + if (prohibitionRuleMap.get(categoryA)?.has(categoryB)) { + ctx.errors.push({ + instancePath: `/vulnerabilities/${vulnerabilityIndex}/remediations/${remediationIndex}`, + message: `contradicting remediation categories for product id "${productId}": ${categoryA}, ${categoryB}`, + }) + ctx.isValid = false + } + } + } + } + }) + } + + return ctx +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_35.js b/tests/csaf_2_1/mandatoryTest_6_1_35.js new file mode 100644 index 0000000..13da145 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_35.js @@ -0,0 +1,35 @@ +import assert from 'node:assert' +import { mandatoryTest_6_1_35 } from '../../csaf_2_1/mandatoryTests.js' + +describe('mandatoryTest_6_1_37', function () { + it('only runs on relevant documents', function () { + assert.equal(mandatoryTest_6_1_35({ document: 'mydoc' }).isValid, true) + }) + + it('skips remediations without valid category', function () { + assert.equal( + mandatoryTest_6_1_35({ + vulnerabilities: [{ remediations: [{}] }], + }).isValid, + true + ) + }) + + it('skips remediation group checks without declared group', function () { + assert.equal( + mandatoryTest_6_1_35({ + vulnerabilities: [ + { + remediations: [ + { + category: 'some_category', + group_ids: ['my_not_existing_group'], + }, + ], + }, + ], + }).isValid, + true + ) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index e3bb154..c5c791f 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -18,7 +18,6 @@ const excluded = [ '6.1.13', '6.1.14', '6.1.16', - '6.1.35', '6.1.36', '6.1.37', '6.1.38',