diff --git a/README.md b/README.md index 32d77a87..718acfbb 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,6 @@ The following tests are not yet implemented and therefore missing: - 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 **Recommended Tests** @@ -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_58: DocumentTest ``` [(back to top)](#bsi-csaf-validator-lib) diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index c302572e..54d48da3 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_58 } from './mandatoryTests/mandatoryTest_6_1_58.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_58.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_58.js new file mode 100644 index 00000000..156b3696 --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_58.js @@ -0,0 +1,130 @@ +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) + +/** + * @typedef {import('ajv/dist/core').JTDDataType} Branch + */ + +/** + * This implements the mandatory test 6.1.58 of the CSAF 2.1 standard. + * + * For each full_product_name_t element under /product_tree/branches, it MUST be + * tested that only one of the branch categories product_version and + * product_version_range is used along the path leading to the full_product_name_t + * element. + * + * @param {any} doc + */ +export function mandatoryTest_6_1_58(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if (!validateInput(doc)) { + return ctx + } + + const branches = doc.product_tree?.branches ?? [] + branches.forEach((branch, index) => { + checkBranch( + branch, + `/product_tree/branches/${index}`, + false, + false, + ctx.errors + ) + }) + + if (ctx.errors.length > 0) { + ctx.isValid = false + } + + return ctx +} + +/** + * Checks that product_version and product_version_range are not both used along the same path + * + * @param {Branch} branch current branch + * @param {string} basePath base instance path for error reporting + * @param {boolean} hasProductVersion - whether product_version appeared in the path so far + * @param {boolean} hasProductVersionRange - whether product_version_range appeared in the path so far + * @param {Array<{ instancePath: string; message: string }>} errors + */ +function checkBranch( + branch, + basePath, + hasProductVersion, + hasProductVersionRange, + errors +) { + const category = branch.category + + const nowHasProductVersion = + hasProductVersion || category === 'product_version' + const nowHasProductVersionRange = + hasProductVersionRange || category === 'product_version_range' + + if (nowHasProductVersion && nowHasProductVersionRange) { + errors.push({ + instancePath: `${basePath}/category`, + message: + 'Both categories "product_version" and "product_version_range" are used along the same path.', + }) + return + } + + // 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}`, + nowHasProductVersion, + nowHasProductVersionRange, + errors + ) + } + ) + } +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_58.js b/tests/csaf_2_1/mandatoryTest_6_1_58.js new file mode 100644 index 00000000..7ab7219f --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_58.js @@ -0,0 +1,49 @@ +import assert from 'node:assert' +import { mandatoryTest_6_1_58 } from '../../csaf_2_1/mandatoryTests.js' + +describe('mandatoryTest_6_1_58', function () { + it('only runs on relevant documents', function () { + assert.equal( + mandatoryTest_6_1_58({ vulnerabilities: 'mydoc' }).errors.length, + 0 + ) + }) + + it('passes when product_tree has no branches', function () { + assert.equal( + mandatoryTest_6_1_58({ + product_tree: { + full_product_names: [ + { + name: 'Example Company Controller A 1.0', + product_id: 'CSAFPID-908070601', + }, + ], + }, + }).errors.length, + 0 + ) + }) + + it('skips recursion when a child branch has invalid branches property', function () { + const result = mandatoryTest_6_1_58({ + product_tree: { + branches: [ + { + category: 'product_version', + name: '1.0', + branches: [ + { + category: 'product_version_range', + name: 'vers:intdot/<1.1', + 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..b8b4c460 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -29,7 +29,6 @@ const excluded = [ '6.1.55', '6.1.56', '6.1.57', - '6.1.58', '6.1.59', '6.2.11', '6.2.19',