diff --git a/README.md b/README.md index 92b52172..c3f1bdd3 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.20 - Recommended Test 6.2.24 - Recommended Test 6.2.26 -- Recommended Test 6.2.31 - Recommended Test 6.2.32 - Recommended Test 6.2.33 - Recommended Test 6.2.34 @@ -493,6 +492,7 @@ export const recommendedTest_6_2_27: DocumentTest export const recommendedTest_6_2_28: DocumentTest export const recommendedTest_6_2_29: DocumentTest export const recommendedTest_6_2_30: DocumentTest +export const recommendedTest_6_2_31: DocumentTest export const recommendedTest_6_2_39_2: DocumentTest export const recommendedTest_6_2_39_3: DocumentTest export const recommendedTest_6_2_39_4: DocumentTest diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 7f91836b..8b9f0463 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -33,6 +33,7 @@ export { recommendedTest_6_2_27 } from './recommendedTests/recommendedTest_6_2_2 export { recommendedTest_6_2_28 } from './recommendedTests/recommendedTest_6_2_28.js' export { recommendedTest_6_2_29 } from './recommendedTests/recommendedTest_6_2_29.js' export { recommendedTest_6_2_30 } from './recommendedTests/recommendedTest_6_2_30.js' +export { recommendedTest_6_2_31 } from './recommendedTests/recommendedTest_6_2_31.js' export { recommendedTest_6_2_38 } from './recommendedTests/recommendedTest_6_2_38.js' export { recommendedTest_6_2_39_2 } from './recommendedTests/recommendedTest_6_2_39_2.js' export { recommendedTest_6_2_39_3 } from './recommendedTests/recommendedTest_6_2_39_3.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_31.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_31.js new file mode 100644 index 00000000..4da8962a --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_31.js @@ -0,0 +1,236 @@ +import { Ajv } from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const productIdentificationHelperSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + serial_numbers: { + elements: { type: 'string' }, + }, + model_numbers: { + elements: { type: 'string' }, + }, + }, +}) + +const productSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_id: { type: 'string' }, + product_identification_helper: productIdentificationHelperSchema, + }, +}) + +const subpathSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + next_product_reference: { type: 'string' }, + }, +}) + +const productPathSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + beginning_product_reference: { type: 'string' }, + full_product_name: productSchema, + subpaths: { + elements: subpathSchema, + }, + }, +}) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product: productSchema, + branches: { + elements: { + additionalProperties: true, + // AJV's JTD does not support recursive schemas. + // Nested branches are validated at runtime in checkBranches() by calling + // validateBranch() on each child branch individually during the recursive traversal. + properties: {}, + }, + }, + }, +}) + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + branches: { + elements: branchSchema, + }, + full_product_names: { + elements: productSchema, + }, + product_paths: { + elements: productPathSchema, + }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) +const validateBranch = ajv.compile(branchSchema) + +/** + * @typedef {import('ajv/dist/core.js').JTDDataType} Branch + * @typedef {import('ajv/dist/core.js').JTDDataType} FullProductName + * @typedef {import('ajv/dist/core.js').JTDDataType} ProductPath + */ + +/** + * This implements the recommended test 6.2.31 of the CSAF 2.1 standard. + * @param {unknown} doc + */ +export function recommendedTest_6_2_31(doc) { + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if (!validateInput(doc)) { + return ctx + } + + /** @type {ProductPath[]} */ + const productPaths = Array.isArray(doc.product_tree?.product_paths) + ? doc.product_tree.product_paths + : [] + + // Start the recursive check from the root branches + checkBranches(doc.product_tree?.branches ?? [], productPaths, ctx) + + checkFullProductNames( + doc.product_tree?.full_product_names ?? [], + productPaths, + ctx + ) + + productPaths.forEach((pp, index) => { + const fpn = pp?.full_product_name + if (!fpn?.product_identification_helper || !fpn.product_id) return + const { serial_numbers, model_numbers } = fpn.product_identification_helper + if ( + (serial_numbers?.length || model_numbers?.length) && + !checkProductPath(productPaths, fpn.product_id) + ) { + ctx.warnings.push({ + instancePath: `/product_tree/product_paths/${index}/full_product_name`, + message: + 'missing product path: product with serial number or model number should be referenced in a product path.', + }) + } + }) + + return ctx +} + +/** + * Check full_product_names for serial_numbers or model_numbers + * @param {FullProductName[]} full_product_names + * @param {ProductPath[]} productPaths + * @param {{ warnings: Array<{ instancePath: string; message: string }> }} ctx + */ +function checkFullProductNames(full_product_names, productPaths, ctx) { + full_product_names.forEach((fullProductName, index) => { + if ( + !fullProductName?.product_identification_helper || + !fullProductName.product_id + ) + return + const { serial_numbers, model_numbers } = + fullProductName.product_identification_helper + + if ( + (serial_numbers?.length || model_numbers?.length) && + !checkProductPath(productPaths, fullProductName.product_id) + ) { + ctx.warnings.push({ + instancePath: `/product_tree/full_product_names/${index}`, + message: + 'missing product path: product with serial number or model number should be referenced in a product path.', + }) + } + }) +} + +/** + * Recursive function to check branches for products with serial_numbers or model_numbers + * but no corresponding product path. + * @param {Branch[]} branches - The current level of branches to process. + * @param {ProductPath[]} productPaths - The product paths array to check against. + * @param {{ warnings: Array<{ instancePath: string; message: string }> }} ctx - The context to store warnings. + * @param {string} [path='/product_tree/branches'] - The current JSON path. + */ +function checkBranches( + branches, + productPaths, + ctx, + path = '/product_tree/branches' +) { + branches?.forEach((branch, branchIndex) => { + // Skip invalid branches + if (!validateBranch(branch)) return + + const currentPath = `${path}/${branchIndex}` + const product = branch.product + + if (product) { + if (product.product_id && product.product_identification_helper) { + const { serial_numbers, model_numbers } = + product.product_identification_helper + + if ( + (serial_numbers?.length || model_numbers?.length) && + !checkProductPath(productPaths, product.product_id) + ) { + ctx.warnings.push({ + instancePath: `${currentPath}/product`, + message: + 'missing product path: product with serial number or model number should be referenced in a product path.', + }) + } + } + } + + // Recursively check nested branches + if (Array.isArray(branch.branches)) { + checkBranches( + branch.branches, + productPaths, + ctx, + `${currentPath}/branches` + ) + } + }) +} + +/** + * Check if there is a valid product path referencing the given productId. + * A product path is valid if it has at least one subpath and the productId + * matches either the beginning_product_reference or any subpaths[].next_product_reference. + * @param {ProductPath[]} productPaths + * @param {string} productId + * @returns {boolean} + */ +function checkProductPath(productPaths, productId) { + return productPaths.some((pp) => { + const subpaths = pp.subpaths + if (!Array.isArray(subpaths) || subpaths.length === 0) { + return false + } + + if (pp.beginning_product_reference === productId) { + return true + } + + return subpaths.some((sp) => sp.next_product_reference === productId) + }) +} diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 1521905f..dd7a9829 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -26,7 +26,6 @@ const excluded = [ '6.2.20', '6.2.24', '6.2.26', - '6.2.31', '6.2.32', '6.2.33', '6.2.34', diff --git a/tests/csaf_2_1/recommendedTest_6_2_31.js b/tests/csaf_2_1/recommendedTest_6_2_31.js new file mode 100644 index 00000000..c0cc889e --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_31.js @@ -0,0 +1,208 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_31 } from '../../csaf_2_1/recommendedTests.js' + +describe('recommendedTest_6_2_31', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_31({ product_tree: 'mydoc' }).warnings.length, + 0 + ) + }) + + it('test input schema without branches', function () { + assert.equal( + recommendedTest_6_2_31({ + document: {}, + product_tree: { + full_product_names: [ + { + name: 'Example Company Controller A 1.0', + product_id: 'CSAFPID-908070601', + product_identification_helper: { + serial_numbers: ['143-D-354'], + }, + }, + ], + }, + }).warnings.length, + 1 + ) + }) + + it('test input schema without full_product_names', function () { + assert.equal( + recommendedTest_6_2_31({ + document: {}, + product_tree: { + full_product_names: [], + branches: [ + { + category: 'product_version', + name: '1.0', + product: { + name: 'Example Company Controller A 1.0', + product_id: 'CSAFPID-908070601', + product_identification_helper: { + serial_numbers: ['143-D-354'], + }, + }, + }, + ], + }, + }).warnings.length, + 1 + ) + }) + + it('test product_path with no subpaths does not count as valid reference', function () { + assert.equal( + recommendedTest_6_2_31({ + document: {}, + product_tree: { + full_product_names: [ + { + product_id: 'CSAFPID-908070601', + product_identification_helper: { + serial_numbers: ['143-D-354'], + }, + }, + ], + product_paths: [ + { + beginning_product_reference: 'CSAFPID-908070601', + subpaths: [], + full_product_name: { + product_id: 'CSAFPID-908070603', + }, + }, + ], + }, + }).warnings.length, + 1 + ) + }) + + it('skips invalid branches and processes valid ones', function () { + assert.equal( + recommendedTest_6_2_31({ + document: {}, + product_tree: { + branches: [ + { + branches: [ + { + product: 'invalid', + }, + { + branches: [{}], + }, + { + product: { + product_id: 'CSAFPID-908070602', + product_identification_helper: { + model_numbers: ['CA-1000'], + }, + }, + }, + ], + }, + ], + }, + }).warnings.length, + 1 + ) + }) + + it('skips full_product_names entry without product_id', function () { + const result = recommendedTest_6_2_31({ + document: {}, + product_tree: { + full_product_names: [ + { + product_identification_helper: { + serial_numbers: ['143-D-354'], + }, + }, + ], + }, + }) + assert.equal(result.warnings.length, 0) + }) + + it('warns with correct instancePath for product_paths[*].full_product_name', function () { + const result = recommendedTest_6_2_31({ + document: {}, + product_tree: { + product_paths: [ + { + beginning_product_reference: 'CSAFPID-908070601', + subpaths: [{ next_product_reference: 'CSAFPID-908070602' }], + full_product_name: { + product_id: 'CSAFPID-908070603', + product_identification_helper: { + serial_numbers: ['143-D-354'], + }, + }, + }, + ], + }, + }) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/product_tree/product_paths/0/full_product_name' + ) + }) + + it('no warning when product_paths full_product_name has empty serial and model numbers', function () { + assert.equal( + recommendedTest_6_2_31({ + document: {}, + product_tree: { + product_paths: [ + { + beginning_product_reference: 'CSAFPID-908070601', + subpaths: [{ next_product_reference: 'CSAFPID-908070602' }], + full_product_name: { + product_id: 'CSAFPID-908070603', + product_identification_helper: { + serial_numbers: [], + model_numbers: [], + }, + }, + }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning when product is referenced as beginning_product_reference', function () { + assert.equal( + recommendedTest_6_2_31({ + document: {}, + product_tree: { + full_product_names: [ + { + product_id: 'CSAFPID-908070601', + product_identification_helper: { + model_numbers: ['CA-1000'], + }, + }, + ], + product_paths: [ + { + beginning_product_reference: 'CSAFPID-908070601', + subpaths: [{ next_product_reference: 'CSAFPID-908070602' }], + full_product_name: { + product_id: 'CSAFPID-908070603', + }, + }, + ], + }, + }).warnings.length, + 0 + ) + }) +})