diff --git a/README.md b/README.md index 32d77a87..f2eff1aa 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.58 - Mandatory Test 6.1.59 @@ -442,6 +441,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 ``` [(back to top)](#bsi-csaf-validator-lib) diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index c302572e..454b3ea1 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -64,3 +64,4 @@ 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' 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..69281e7d --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_57.js @@ -0,0 +1,109 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, +}) + +/* + 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) + +/** + * This implements the mandatory test 6.1.57 of the CSAF 2.1 standard. + * + * @param {any} 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 + 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 {any} 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 {any} */ 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 8f223924..6a0f4903 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.58', '6.1.59', '6.2.11',