From 6a1c00fa9efb18cca8788288d943f68dcb0bb8cc Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Wed, 22 Apr 2026 13:30:04 +0200 Subject: [PATCH] fix(CSAF2.1): change relationships to product_paths --- .../mandatoryTests/mandatoryTest_6_1_25.js | 191 ++++++++++++++++++ tests/csaf_2_1/mandatoryTest_6_1_25.js | 107 ++++++++++ 2 files changed, 298 insertions(+) create mode 100644 csaf_2_1/mandatoryTests/mandatoryTest_6_1_25.js create mode 100644 tests/csaf_2_1/mandatoryTest_6_1_25.js diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_25.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_25.js new file mode 100644 index 00000000..508a3d7d --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_25.js @@ -0,0 +1,191 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const productIdentificationHelperSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + hashes: { + elements: { + additionalProperties: true, + optionalProperties: { + file_hashes: { + elements: { + additionalProperties: true, + optionalProperties: { + algorithm: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, +}) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + product: { + additionalProperties: true, + optionalProperties: { + product_identification_helper: productIdentificationHelperSchema, + }, + }, + }, +}) + +const validateBranch = ajv.compile(branchSchema) + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_identification_helper: productIdentificationHelperSchema, + }, +}) + +/* + 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: { + branches: { + elements: branchSchema, + }, + full_product_names: { + elements: fullProductNameSchema, + }, + product_paths: { + elements: { + additionalProperties: true, + optionalProperties: { + full_product_name: fullProductNameSchema, + }, + }, + }, + }, + }, + }, +}) + +const validate = ajv.compile(inputSchema) + +/** + * @typedef {import('ajv/dist/core').JTDDataType} Branch + * @typedef {import('ajv/dist/core').JTDDataType} FullProductName + */ + +/** + * This implements the mandatory test 6.1.25 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export default function mandatoryTest_6_1_25(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if (!validate(doc)) { + return ctx + } + + doc.product_tree?.branches?.forEach((branch, index) => { + checkBranch(`/product_tree/branches/${index}`, branch) + }) + + doc.product_tree?.full_product_names?.forEach((fullProductName, index) => { + checkFullProductName( + `/product_tree/full_product_names/${index}`, + fullProductName + ) + }) + + doc.product_tree?.product_paths?.forEach((productPath, index) => { + const fullProductName = productPath.full_product_name + if (fullProductName) { + checkFullProductName( + `/product_tree/product_paths/${index}/full_product_name`, + fullProductName + ) + } + }) + + return ctx + + /** + * Check for duplicate hash algorithms in the file_hashes of a full product name. + * + * @param {string} prefix + * @param {FullProductName} fullProductName + */ + function checkFullProductName(prefix, fullProductName) { + fullProductName.product_identification_helper?.hashes?.forEach( + (hash, hashIndex) => { + checkDuplicateHashAlgorithms( + hash, + `${prefix}/product_identification_helper/hashes/${hashIndex}` + ) + } + ) + } + + /** + * Check for duplicate hash algorithms in the file_hashes of a branch and its children. + * + * @param {string} prefix + * @param {Branch} branch + */ + function checkBranch(prefix, branch) { + branch.product?.product_identification_helper?.hashes?.forEach( + (hash, hashIndex) => { + checkDuplicateHashAlgorithms( + hash, + `${prefix}/product/product_identification_helper/hashes/${hashIndex}` + ) + } + ) + branch.branches?.forEach((childBranch, index) => { + if (validateBranch(childBranch)) { + checkBranch(`${prefix}/branches/${index}`, childBranch) + } + }) + } + + /** + * Check a single hash entry for duplicate algorithm values in file_hashes. + * + * @param {{ file_hashes?: Array<{ algorithm?: string }> }} hash + * @param {string} hashPrefix e.g. ".../hashes/0" + */ + function checkDuplicateHashAlgorithms(hash, hashPrefix) { + if (!Array.isArray(hash.file_hashes)) return + /** @type {Set} */ + const algorithmSet = new Set() + hash.file_hashes.forEach((fileHash, fileHashIndex) => { + if (fileHash.algorithm == null) return + if (algorithmSet.has(fileHash.algorithm)) { + ctx.isValid = false + ctx.errors.push({ + instancePath: `${hashPrefix}/file_hashes/${fileHashIndex}`, + message: `there is already a hash with the algorithm ${fileHash.algorithm}`, + }) + } + algorithmSet.add(fileHash.algorithm) + }) + } +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_25.js b/tests/csaf_2_1/mandatoryTest_6_1_25.js new file mode 100644 index 00000000..c1734fc6 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_25.js @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' + +import mandatoryTest_6_1_25 from '../../csaf_2_1/mandatoryTests/mandatoryTest_6_1_25.js' + +describe('mandatoryTest_6_1_25', function () { + it('only runs on relevant documents', function () { + assert.equal(mandatoryTest_6_1_25({ product_tree: 'mydoc' }).isValid, true) + }) + + it('detects duplicate hash algorithms in product_paths', function () { + const result = mandatoryTest_6_1_25({ + product_tree: { + product_paths: [ + { + full_product_name: { + name: 'Product A', + product_id: 'CSAFPID-9080700', + product_identification_helper: { + hashes: [ + { + file_hashes: [ + { algorithm: 'md5', value: 'aabbcc' }, + { algorithm: 'md5', value: 'ddeeff' }, + ], + filename: 'product_a.so', + }, + ], + }, + }, + }, + ], + }, + }) + assert.equal(result.isValid, false) + }) + + it('detects duplicate hash algorithms in nested branches', function () { + const result = mandatoryTest_6_1_25({ + product_tree: { + branches: [ + { + branches: [ + { + product: { + product_identification_helper: { + hashes: [ + { + file_hashes: [ + { algorithm: 'sha256', value: 'aabbcc' }, + { algorithm: 'sha256', value: 'ddeeff' }, + ], + filename: 'product_a.so', + }, + ], + }, + }, + }, + ], + }, + ], + }, + }) + assert.equal(result.isValid, false) + }) + + it('passes when file_hashes is not an array', function () { + assert.equal( + mandatoryTest_6_1_25({ + product_tree: { + full_product_names: [ + { + product_identification_helper: { + hashes: [ + { + filename: 'product_a.so', + // file_hashes is missing entirely + }, + ], + }, + }, + ], + }, + }).isValid, + true + ) + }) + + it('skips file_hashes entries without an algorithm', function () { + const result = mandatoryTest_6_1_25({ + product_tree: { + full_product_names: [ + { + product_identification_helper: { + hashes: [ + { + file_hashes: [{ value: 'aabbcc' }, { value: 'ddeeff' }], + filename: 'product_a.so', + }, + ], + }, + }, + ], + }, + }) + assert.equal(result.isValid, true) + }) +})