diff --git a/README.md b/README.md index 8ed48733..b4a2eb27 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,6 @@ The following tests are not yet implemented and therefore missing: - Mandatory Test 6.1.53 - Mandatory Test 6.1.54 - Mandatory Test 6.1.55 -- Mandatory Test 6.1.57 - Mandatory Test 6.1.59 - Mandatory Test 6.1.60.1 - Mandatory Test 6.1.60.2 @@ -462,6 +461,7 @@ export const mandatoryTest_6_1_44: DocumentTest export const mandatoryTest_6_1_45: DocumentTest export const mandatoryTest_6_1_51: DocumentTest export const mandatoryTest_6_1_52: DocumentTest +export const mandatoryTest_6_1_57: DocumentTest export const mandatoryTest_6_1_58: DocumentTest ``` diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index 2d4ebdf5..1666e45c 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -64,4 +64,5 @@ export { mandatoryTest_6_1_44 } from './mandatoryTests/mandatoryTest_6_1_44.js' export { mandatoryTest_6_1_45 } from './mandatoryTests/mandatoryTest_6_1_45.js' export { mandatoryTest_6_1_51 } from './mandatoryTests/mandatoryTest_6_1_51.js' export { mandatoryTest_6_1_52 } from './mandatoryTests/mandatoryTest_6_1_52.js' +export { mandatoryTest_6_1_57 } from './mandatoryTests/mandatoryTest_6_1_57.js' export { mandatoryTest_6_1_58 } from './mandatoryTests/mandatoryTest_6_1_58.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_57.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_57.js new file mode 100644 index 00000000..092d8929 --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_57.js @@ -0,0 +1,116 @@ +import { Ajv } from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + 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, + properties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + branches: { + elements: branchSchema, + }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) +const validateBranch = ajv.compile(branchSchema) + +/** @typedef {import('ajv/dist/jtd.js').JTDDataType} BranchSchema */ + +/** + * This implements the mandatory test 6.1.57 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export function mandatoryTest_6_1_57(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if (!validateInput(doc)) { + return ctx + } + + // Start the recursive check from the root branches + /** @type {Array} */ + const branches = doc.product_tree?.branches ?? [] + branches.forEach((branch, index) => { + checkBranch(branch, `/product_tree/branches/${index}`, [], ctx.errors) + }) + + if (ctx.errors.length > 0) { + ctx.isValid = false + } + + return ctx +} + +/** + * Validates a single branch and its nested branches recursively. + * Checks that no category (except product_family) appears more than once along the path. + * + * @param {BranchSchema} branch + * @param {string} basePath + * @param {string[]} categoriesInPath + * @param {Array<{ instancePath: string; message: string }>} errors + */ +function checkBranch(branch, basePath, categoriesInPath, errors) { + const category = branch.category + + if (category && category !== 'product_family') { + if (categoriesInPath.includes(category)) { + errors.push({ + instancePath: `${basePath}/category`, + message: `Branch category "${category}" appears more than once along the path.`, + }) + } + } + + const newCategories = + category && category !== 'product_family' + ? [...categoriesInPath, category] + : categoriesInPath + + // Recursively check nested branches + if (Array.isArray(branch.branches)) { + branch.branches.forEach( + ( + /** @type {BranchSchema} */ childBranch, + /** @type {number} */ index + ) => { + if (!validateBranch(childBranch)) return + checkBranch( + childBranch, + `${basePath}/branches/${index}`, + newCategories, + errors + ) + } + ) + } +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_57.js b/tests/csaf_2_1/mandatoryTest_6_1_57.js new file mode 100644 index 00000000..853304c5 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_57.js @@ -0,0 +1,49 @@ +import assert from 'node:assert' +import { mandatoryTest_6_1_57 } from '../../csaf_2_1/mandatoryTests.js' + +describe('mandatoryTest_6_1_57', function () { + it('only runs on relevant documents', function () { + assert.equal( + mandatoryTest_6_1_57({ vulnerabilities: 'mydoc' }).errors.length, + 0 + ) + }) + + it('passes when product_tree has no branches', function () { + assert.equal( + mandatoryTest_6_1_57({ + product_tree: { + full_product_names: [ + { + name: 'Example Company Controller A 1.0', + product_id: 'CSAFPID-908070601', + }, + ], + }, + }).errors.length, + 0 + ) + }) + + it('skips recursion when an intermediate branch has invalid branches property', function () { + const result = mandatoryTest_6_1_57({ + product_tree: { + branches: [ + { + category: 'vendor', + name: 'Vendor A', + branches: [ + { + category: 'product_name', + name: 'Product A', + branches: 'not-an-array', + }, + ], + }, + ], + }, + }) + assert.equal(result.errors.length, 0) + assert.equal(result.isValid, true) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 534a078e..cfde90e0 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -28,7 +28,6 @@ const excluded = [ '6.1.54', '6.1.55', '6.1.56', - '6.1.57', '6.1.59', '6.1.60.1', '6.1.60.2',