From d1d1ff3f10626cfed5ec7c0e5b32863b2a1ab3c0 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Tue, 28 Apr 2026 09:27:21 +0200 Subject: [PATCH 1/5] fix(CSAF2.1): change schema for recommended test 6.2.1 --- .../recommendedTests/recommendedTest_6_2_1.js | 365 +++++++++++++++++- tests/csaf_2_1/recommendedTest_6_2_1.js | 199 ++++++++++ 2 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 tests/csaf_2_1/recommendedTest_6_2_1.js diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index 8bc9a647..d8b6617f 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -1,8 +1,367 @@ -import { optionalTest_6_2_1 } from '../../optionalTests.js' +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + product_tree: { + additionalProperties: true, + + optionalProperties: { + branches: { + elements: { + additionalProperties: true, + + properties: {}, + }, + }, + + full_product_names: { + elements: { + additionalProperties: true, + + properties: {}, + }, + }, + + product_paths: { + elements: { + additionalProperties: true, + + properties: {}, + }, + }, + }, + }, + }, + + optionalProperties: { + document: { + additionalProperties: true, + + optionalProperties: { + category: { type: 'string' }, + }, + }, + }, +}) +const validate = ajv.compile(inputSchema) + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + product_id: { type: 'string' }, + }, +}) +const validateFullProductName = ajv.compile(fullProductNameSchema) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product: fullProductNameSchema, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, +}) +const validateBranch = ajv.compile(branchSchema) + +const productPathSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + full_product_name: fullProductNameSchema, + }, +}) +const validateProductPath = ajv.compile(productPathSchema) /** - * @param {unknown} doc + * @param {any} doc */ export function recommendedTest_6_2_1(doc) { - return optionalTest_6_2_1(doc) + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if ( + !validate(doc) || + doc.document?.category === 'csaf_informational_advisory' + ) { + return ctx + } + + /** + * @param {object} params + * @param {string} params.path + * @param {unknown[]} params.branches + */ + function checkBranches({ path, branches }) { + branches.forEach((branch, branchIndex) => { + if (validateBranch(branch)) { + if ( + typeof branch.product?.product_id === 'string' && + !isReferenced(doc, branch.product.product_id) + ) { + ctx.warnings.push({ + instancePath: `${path}/${branchIndex}/product/product_id`, + message: 'is not referenced', + }) + } + + if (Array.isArray(branch.branches)) { + checkBranches({ + path: `${path}/${branchIndex}/branches`, + branches: branch.branches, + }) + } + } + }) + } + + checkBranches({ + path: '/product_tree/branches', + branches: doc.product_tree?.branches ?? [], + }) + + doc.product_tree.full_product_names?.forEach( + (fullProductName, fullProductNameIndex) => { + if (!validateFullProductName(fullProductName)) return + if (!isReferenced(doc, fullProductName.product_id)) { + ctx.warnings.push({ + instancePath: `/product_tree/full_product_names/${fullProductNameIndex}/product_id`, + message: 'is not referenced', + }) + } + } + ) + + doc.product_tree.product_paths?.forEach((productPath, productPathIndex) => { + if (!validateProductPath(productPath)) return + if (!isReferenced(doc, productPath.full_product_name.product_id)) { + ctx.warnings.push({ + instancePath: `/product_tree/product_paths/${productPathIndex}/full_product_name/product_id`, + message: 'is not referenced', + }) + } + }) + + return ctx +} + +const containsProductGroupsSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + product_tree: { + additionalProperties: true, + + properties: { + product_groups: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, +}) + +const containsProductPathsWithReferencesSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + product_tree: { + additionalProperties: true, + + properties: { + product_paths: { + elements: { + additionalProperties: true, + + optionalProperties: { + beginning_product_reference: { type: 'string' }, + subpaths: { + elements: { + additionalProperties: true, + optionalProperties: { + next_product_reference: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const containsVulnerabilitiesWithReferencesSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_status: { + additionalProperties: true, + + optionalProperties: { + first_affected: { elements: { type: 'string' } }, + first_fixed: { elements: { type: 'string' } }, + fixed: { elements: { type: 'string' } }, + known_affected: { elements: { type: 'string' } }, + known_not_affected: { elements: { type: 'string' } }, + last_affected: { elements: { type: 'string' } }, + recommended: { elements: { type: 'string' } }, + under_investigation: { elements: { type: 'string' } }, + unknown: { elements: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +}) + +const containsVulnerabilitiesWithOptionalReferencesSchema = + /** @type {const} */ ({ + additionalProperties: true, + + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + + optionalProperties: { + remediations: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + scores: { + elements: { + additionalProperties: true, + + optionalProperties: { + products: { + elements: { type: 'string' }, + }, + }, + }, + }, + threats: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + +const validateContainsProductGroups = ajv.compile(containsProductGroupsSchema) +const validateContainsProductPathsWithReferences = ajv.compile( + containsProductPathsWithReferencesSchema +) +const validateContainsVulnerabilitiesWithReferences = ajv.compile( + containsVulnerabilitiesWithReferencesSchema +) +const validateContainsVulnerabilitiesWithOptionalReferences = ajv.compile( + containsVulnerabilitiesWithOptionalReferencesSchema +) + +/** + * @param {unknown} doc + * @param {string} productId + */ +function isReferenced(doc, productId) { + let referenced = false + + if (!referenced && validateContainsProductGroups(doc)) { + referenced = doc.product_tree.product_groups.some((group) => { + return group.product_ids?.includes(productId) ?? false + }) + } + + if (!referenced && validateContainsProductPathsWithReferences(doc)) { + referenced = doc.product_tree.product_paths.some((productPath) => { + return ( + productPath.beginning_product_reference === productId || + productPath.subpaths?.some( + (subpath) => subpath.next_product_reference === productId + ) + ) + }) + } + + if (!referenced && validateContainsVulnerabilitiesWithReferences(doc)) { + referenced = doc.vulnerabilities.some((vulnerability) => { + const keys = /** @type {const} */ ([ + 'first_affected', + 'first_fixed', + 'fixed', + 'known_affected', + 'known_not_affected', + 'last_affected', + 'recommended', + 'under_investigation', + 'unknown', + ]) + return keys.some( + (key) => + vulnerability.product_status?.[key]?.includes(productId) ?? false + ) + }) + } + + if ( + !referenced && + validateContainsVulnerabilitiesWithOptionalReferences(doc) + ) { + referenced = doc.vulnerabilities.some((vulnerability) => { + return ( + vulnerability.remediations?.some((remediation) => + remediation.product_ids?.includes(productId) + ) || + vulnerability.scores?.some((score) => + score.products?.includes(productId) + ) || + vulnerability.threats?.some((threat) => + threat.product_ids?.includes(productId) + ) || + false + ) + }) + } + + return referenced } diff --git a/tests/csaf_2_1/recommendedTest_6_2_1.js b/tests/csaf_2_1/recommendedTest_6_2_1.js new file mode 100644 index 00000000..66396db9 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_1.js @@ -0,0 +1,199 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_1 } from '../../csaf_2_1/recommendedTests.js' + +const baseDoc = { + product_tree: { + full_product_names: [{ product_id: 'CSAFPID-0001', name: 'Product A' }], + }, +} + +describe('recommendedTest_6_2_1', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_1({ vulnerabilities: 'mydoc' }).warnings.length, + 0 + ) + }) + + it('skips documents with category csaf_informational_advisory', function () { + assert.equal( + recommendedTest_6_2_1({ + document: { category: 'csaf_informational_advisory' }, + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.remediations.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + remediations: [ + { + category: 'vendor_fix', + details: 'Update.', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warns if product_id appears only in unrelated vulnerability fields', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + remediations: [ + { + category: 'vendor_fix', + details: 'Update.', + product_ids: ['CSAFPID-9999'], + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.scores.products', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + scores: [ + { + products: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.threats.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + threats: [ + { + category: 'exploit_status', + details: 'Exploits available.', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('skips full_product_name entries that have no product_id field', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [{ name: 'Product A without ID' }], + }, + }).warnings.length, + 0 + ) + }) + + it('skips product_path entries that have no full_product_name field', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + product_paths: [{ beginning_product_reference: 'CSAFPID-0001' }], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in product_tree.product_groups.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + ], + product_groups: [ + { + group_id: 'CSAFGID-0001', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in product_paths.subpaths.next_product_reference', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + { product_id: 'CSAFPID-0002', name: 'Product B' }, + ], + product_paths: [ + { + full_product_name: { + product_id: 'CSAFPID-0003', + name: 'Product A on Product B', + }, + beginning_product_reference: 'CSAFPID-0001', + subpaths: [{ next_product_reference: 'CSAFPID-0002' }], + }, + ], + }, + vulnerabilities: [ + { + product_status: { + known_affected: ['CSAFPID-0001', 'CSAFPID-0002', 'CSAFPID-0003'], + }, + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warns if product_id is not referenced and product_group has no product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + ], + product_groups: [ + { + group_id: 'CSAFGID-0001', + }, + ], + }, + }).warnings.length, + 1 + ) + }) +}) From 1324a082919c6e90d537c3560e76dd8a581e5881 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Tue, 28 Apr 2026 09:44:51 +0200 Subject: [PATCH 2/5] fix(CSAF2.1): change schema 2.0 to 2.1 --- .../recommendedTests/recommendedTest_6_2_1.js | 34 +++++++++++++-- tests/csaf_2_1/recommendedTest_6_2_1.js | 42 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index d8b6617f..dfed08c5 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -260,7 +260,7 @@ const containsVulnerabilitiesWithOptionalReferencesSchema = }, }, }, - scores: { + metrics: { elements: { additionalProperties: true, @@ -271,6 +271,28 @@ const containsVulnerabilitiesWithOptionalReferencesSchema = }, }, }, + flags: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + first_known_exploitation_dates: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, threats: { elements: { additionalProperties: true, @@ -352,8 +374,14 @@ function isReferenced(doc, productId) { vulnerability.remediations?.some((remediation) => remediation.product_ids?.includes(productId) ) || - vulnerability.scores?.some((score) => - score.products?.includes(productId) + vulnerability.metrics?.some((metric) => + metric.products?.includes(productId) + ) || + vulnerability.flags?.some((flag) => + flag.product_ids?.includes(productId) + ) || + vulnerability.first_known_exploitation_dates?.some((entry) => + entry.product_ids?.includes(productId) ) || vulnerability.threats?.some((threat) => threat.product_ids?.includes(productId) diff --git a/tests/csaf_2_1/recommendedTest_6_2_1.js b/tests/csaf_2_1/recommendedTest_6_2_1.js index 66396db9..ec28e81b 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_1.js +++ b/tests/csaf_2_1/recommendedTest_6_2_1.js @@ -69,13 +69,13 @@ describe('recommendedTest_6_2_1', function () { ) }) - it('no warning if product_id is referenced in vulnerabilities.scores.products', function () { + it('no warning if product_id is referenced in vulnerabilities.metrics.products', function () { assert.equal( recommendedTest_6_2_1({ ...baseDoc, vulnerabilities: [ { - scores: [ + metrics: [ { products: ['CSAFPID-0001'], }, @@ -87,6 +87,44 @@ describe('recommendedTest_6_2_1', function () { ) }) + it('no warning if product_id is referenced in vulnerabilities.flags.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + flags: [ + { + label: 'component_not_present', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.first_known_exploitation_dates.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + first_known_exploitation_dates: [ + { + date: '2024-01-01T00:00:00Z', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + it('no warning if product_id is referenced in vulnerabilities.threats.product_ids', function () { assert.equal( recommendedTest_6_2_1({ From f9bb8a84d28012e65bcda3552d5bdb1e943fd90a Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Thu, 28 May 2026 11:38:45 +0200 Subject: [PATCH 3/5] doc(CSAF2.1): collect all ids as set --- .../recommendedTests/recommendedTest_6_2_1.js | 457 ++++++++++++------ tests/csaf_2_1/recommendedTest_6_2_1.js | 255 +++++++--- 2 files changed, 491 insertions(+), 221 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index dfed08c5..8840440c 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -4,59 +4,47 @@ const ajv = new Ajv() const inputSchema = /** @type {const} */ ({ additionalProperties: true, - properties: { product_tree: { additionalProperties: true, - optionalProperties: { branches: { elements: { additionalProperties: true, - properties: {}, }, }, - full_product_names: { elements: { additionalProperties: true, - properties: {}, }, }, - product_paths: { elements: { additionalProperties: true, - properties: {}, }, }, }, }, }, - optionalProperties: { document: { additionalProperties: true, - optionalProperties: { category: { type: 'string' }, }, }, }, }) -const validate = ajv.compile(inputSchema) const fullProductNameSchema = /** @type {const} */ ({ additionalProperties: true, - properties: { product_id: { type: 'string' }, }, }) -const validateFullProductName = ajv.compile(fullProductNameSchema) const branchSchema = /** @type {const} */ ({ additionalProperties: true, @@ -70,18 +58,27 @@ const branchSchema = /** @type {const} */ ({ }, }, }) -const validateBranch = ajv.compile(branchSchema) const productPathSchema = /** @type {const} */ ({ additionalProperties: true, - properties: { full_product_name: fullProductNameSchema, }, }) + +const validate = ajv.compile(inputSchema) +const validateFullProductName = ajv.compile(fullProductNameSchema) +const validateBranch = ajv.compile(branchSchema) const validateProductPath = ajv.compile(productPathSchema) /** + * @typedef {import('ajv/dist/core.js').JTDDataType} Branch + * @typedef {import('ajv/dist/core.js').JTDDataType} FullProductName + * @typedef {import('ajv/dist/core.js').JTDDataType} ProductGroups + */ + +/** + * This implements the recommended test 6.2.1 of the CSAF 2.1 standard. * @param {any} doc */ export function recommendedTest_6_2_1(doc) { @@ -97,17 +94,19 @@ export function recommendedTest_6_2_1(doc) { return ctx } + const referencedProductIds = collectReferencedProductIds(doc) + /** * @param {object} params * @param {string} params.path - * @param {unknown[]} params.branches + * @param {Branch[]} params.branches */ function checkBranches({ path, branches }) { branches.forEach((branch, branchIndex) => { if (validateBranch(branch)) { if ( typeof branch.product?.product_id === 'string' && - !isReferenced(doc, branch.product.product_id) + !referencedProductIds.has(branch.product.product_id) ) { ctx.warnings.push({ instancePath: `${path}/${branchIndex}/product/product_id`, @@ -132,41 +131,40 @@ export function recommendedTest_6_2_1(doc) { doc.product_tree.full_product_names?.forEach( (fullProductName, fullProductNameIndex) => { - if (!validateFullProductName(fullProductName)) return - if (!isReferenced(doc, fullProductName.product_id)) { - ctx.warnings.push({ - instancePath: `/product_tree/full_product_names/${fullProductNameIndex}/product_id`, - message: 'is not referenced', - }) + if (validateFullProductName(fullProductName)) { + if (!referencedProductIds.has(fullProductName.product_id)) { + ctx.warnings.push({ + instancePath: `/product_tree/full_product_names/${fullProductNameIndex}/product_id`, + message: 'is not referenced', + }) + } } } ) doc.product_tree.product_paths?.forEach((productPath, productPathIndex) => { - if (!validateProductPath(productPath)) return - if (!isReferenced(doc, productPath.full_product_name.product_id)) { - ctx.warnings.push({ - instancePath: `/product_tree/product_paths/${productPathIndex}/full_product_name/product_id`, - message: 'is not referenced', - }) + if (validateProductPath(productPath)) { + if (!referencedProductIds.has(productPath.full_product_name.product_id)) { + ctx.warnings.push({ + instancePath: `/product_tree/product_paths/${productPathIndex}/full_product_name/product_id`, + message: 'is not referenced', + }) + } } }) return ctx } -const containsProductGroupsSchema = /** @type {const} */ ({ +const productGroupsSchema = /** @type {const} */ ({ additionalProperties: true, - properties: { product_tree: { additionalProperties: true, - properties: { product_groups: { elements: { additionalProperties: true, - optionalProperties: { product_ids: { elements: { type: 'string' }, @@ -179,18 +177,15 @@ const containsProductGroupsSchema = /** @type {const} */ ({ }, }) -const containsProductPathsWithReferencesSchema = /** @type {const} */ ({ +const productPathRefsSchema = /** @type {const} */ ({ additionalProperties: true, - properties: { product_tree: { additionalProperties: true, - properties: { product_paths: { elements: { additionalProperties: true, - optionalProperties: { beginning_product_reference: { type: 'string' }, subpaths: { @@ -209,18 +204,15 @@ const containsProductPathsWithReferencesSchema = /** @type {const} */ ({ }, }) -const containsVulnerabilitiesWithReferencesSchema = /** @type {const} */ ({ +const vulnStatusSchema = /** @type {const} */ ({ additionalProperties: true, - properties: { vulnerabilities: { elements: { additionalProperties: true, - optionalProperties: { product_status: { additionalProperties: true, - optionalProperties: { first_affected: { elements: { type: 'string' } }, first_fixed: { elements: { type: 'string' } }, @@ -239,68 +231,117 @@ const containsVulnerabilitiesWithReferencesSchema = /** @type {const} */ ({ }, }) -const containsVulnerabilitiesWithOptionalReferencesSchema = - /** @type {const} */ ({ - additionalProperties: true, - - properties: { - vulnerabilities: { - elements: { - additionalProperties: true, - - optionalProperties: { - remediations: { - elements: { - additionalProperties: true, - - optionalProperties: { - product_ids: { - elements: { type: 'string' }, - }, +const vulnOptionalRefsSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + remediations: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, }, }, }, - metrics: { - elements: { - additionalProperties: true, - - optionalProperties: { - products: { - elements: { type: 'string' }, - }, + }, + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + products: { + elements: { type: 'string' }, }, }, }, - flags: { - elements: { - additionalProperties: true, - - optionalProperties: { - product_ids: { - elements: { type: 'string' }, - }, + }, + flags: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, }, }, }, - first_known_exploitation_dates: { - elements: { - additionalProperties: true, - - optionalProperties: { - product_ids: { - elements: { type: 'string' }, - }, + }, + first_known_exploitation_dates: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, }, }, }, - threats: { - elements: { - additionalProperties: true, + }, + threats: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, +}) - optionalProperties: { - product_ids: { - elements: { type: 'string' }, - }, +const noteWithProductIdsSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, +}) + +const docNotesSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + notes: { elements: noteWithProductIdsSchema }, + }, + }, + }, +}) + +const vulnNotesSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + notes: { elements: noteWithProductIdsSchema }, + }, + }, + }, + }, +}) + +const vulnInvolvementsSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + involvements: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, }, }, }, @@ -308,88 +349,186 @@ const containsVulnerabilitiesWithOptionalReferencesSchema = }, }, }, - }) + }, +}) -const validateContainsProductGroups = ajv.compile(containsProductGroupsSchema) -const validateContainsProductPathsWithReferences = ajv.compile( - containsProductPathsWithReferencesSchema -) -const validateContainsVulnerabilitiesWithReferences = ajv.compile( - containsVulnerabilitiesWithReferencesSchema -) -const validateContainsVulnerabilitiesWithOptionalReferences = ajv.compile( - containsVulnerabilitiesWithOptionalReferencesSchema -) +const hasDocNotes = ajv.compile(docNotesSchema) +const hasProductGroups = ajv.compile(productGroupsSchema) +const hasProductPathRefs = ajv.compile(productPathRefsSchema) +const hasVulnStatus = ajv.compile(vulnStatusSchema) +const hasVulnOptionalRefs = ajv.compile(vulnOptionalRefsSchema) +const hasVulnNotes = ajv.compile(vulnNotesSchema) +const hasVulnInvolvements = ajv.compile(vulnInvolvementsSchema) /** + * Collects all product IDs in a set that are referenced anywhere in the document + * * @param {unknown} doc - * @param {string} productId + * @returns {Set} */ -function isReferenced(doc, productId) { - let referenced = false +function collectReferencedProductIds(doc) { + /** @type {Set} */ + const ids = new Set() + collectDocumentNotes(doc, ids) + collectProductGroups(doc, ids) + collectProductPathRefs(doc, ids) + collectVulnerabilityStatus(doc, ids) + collectVulnerabilityOptionalRefs(doc, ids) + collectVulnerabilityNotes(doc, ids) + collectVulnerabilityInvolvements(doc, ids) + return ids +} - if (!referenced && validateContainsProductGroups(doc)) { - referenced = doc.product_tree.product_groups.some((group) => { - return group.product_ids?.includes(productId) ?? false - }) +/** + * Collects all product IDs that are referenced in document notes + * @param {unknown} doc + * @param {Set} ids + */ +function collectDocumentNotes(doc, ids) { + if (hasDocNotes(doc)) { + for (const note of doc.document.notes) { + for (const id of note.product_ids ?? []) { + ids.add(id) + } + } } +} - if (!referenced && validateContainsProductPathsWithReferences(doc)) { - referenced = doc.product_tree.product_paths.some((productPath) => { - return ( - productPath.beginning_product_reference === productId || - productPath.subpaths?.some( - (subpath) => subpath.next_product_reference === productId - ) - ) - }) +/** + * Collects all product IDs that are referenced in product groups + * @param {unknown} doc + * @param {Set} ids + */ +function collectProductGroups(doc, ids) { + if (hasProductGroups(doc)) { + for (const group of doc.product_tree.product_groups) { + if (group.product_ids) { + for (const id of group.product_ids) ids.add(id) + } + } } +} - if (!referenced && validateContainsVulnerabilitiesWithReferences(doc)) { - referenced = doc.vulnerabilities.some((vulnerability) => { - const keys = /** @type {const} */ ([ - 'first_affected', - 'first_fixed', - 'fixed', - 'known_affected', - 'known_not_affected', - 'last_affected', - 'recommended', - 'under_investigation', - 'unknown', - ]) - return keys.some( - (key) => - vulnerability.product_status?.[key]?.includes(productId) ?? false - ) - }) +/** + * Collects all product IDs that are referenced in product paths + * @param {unknown} doc + * @param {Set} ids + */ +function collectProductPathRefs(doc, ids) { + if (hasProductPathRefs(doc)) { + for (const productPath of doc.product_tree.product_paths) { + if (typeof productPath.beginning_product_reference === 'string') { + ids.add(productPath.beginning_product_reference) + } + if (productPath.subpaths) { + for (const subpath of productPath.subpaths) { + if (typeof subpath.next_product_reference === 'string') { + ids.add(subpath.next_product_reference) + } + } + } + } } +} - if ( - !referenced && - validateContainsVulnerabilitiesWithOptionalReferences(doc) - ) { - referenced = doc.vulnerabilities.some((vulnerability) => { - return ( - vulnerability.remediations?.some((remediation) => - remediation.product_ids?.includes(productId) - ) || - vulnerability.metrics?.some((metric) => - metric.products?.includes(productId) - ) || - vulnerability.flags?.some((flag) => - flag.product_ids?.includes(productId) - ) || - vulnerability.first_known_exploitation_dates?.some((entry) => - entry.product_ids?.includes(productId) - ) || - vulnerability.threats?.some((threat) => - threat.product_ids?.includes(productId) - ) || - false - ) - }) +/** + * Collects all product IDs that are referenced in the product status of vulnerabilities + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityStatus(doc, ids) { + if (hasVulnStatus(doc)) { + const keys = /** @type {const} */ ([ + 'first_affected', + 'first_fixed', + 'fixed', + 'known_affected', + 'known_not_affected', + 'last_affected', + 'recommended', + 'under_investigation', + 'unknown', + ]) + for (const vulnerability of doc.vulnerabilities) { + for (const key of keys) { + const list = vulnerability.product_status?.[key] + if (list) { + for (const id of list) { + ids.add(id) + } + } + } + } + } +} + +/** + * Collects all product IDs that are referenced in optional references of vulnerabilities + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityOptionalRefs(doc, ids) { + if (hasVulnOptionalRefs(doc)) { + for (const vulnerability of doc.vulnerabilities) { + for (const remediation of vulnerability.remediations ?? []) { + for (const id of remediation.product_ids ?? []) { + ids.add(id) + } + } + for (const metric of vulnerability.metrics ?? []) { + for (const id of metric.products ?? []) { + ids.add(id) + } + } + for (const flag of vulnerability.flags ?? []) { + for (const id of flag.product_ids ?? []) { + ids.add(id) + } + } + for (const entry of vulnerability.first_known_exploitation_dates ?? []) { + for (const id of entry.product_ids ?? []) { + ids.add(id) + } + } + for (const threat of vulnerability.threats ?? []) { + for (const id of threat.product_ids ?? []) { + ids.add(id) + } + } + } + } +} + +/** + * Collects all product IDs that are referenced in vulnerability notes + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityNotes(doc, ids) { + if (hasVulnNotes(doc)) { + for (const vulnerability of doc.vulnerabilities) { + for (const note of vulnerability.notes ?? []) { + for (const id of note.product_ids ?? []) { + ids.add(id) + } + } + } } +} - return referenced +/** + * Collects all product IDs that are referenced in vulnerability involvements + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityInvolvements(doc, ids) { + if (hasVulnInvolvements(doc)) { + for (const vulnerability of doc.vulnerabilities) { + for (const involvement of vulnerability.involvements ?? []) { + for (const id of involvement.product_ids ?? []) { + ids.add(id) + } + } + } + } } diff --git a/tests/csaf_2_1/recommendedTest_6_2_1.js b/tests/csaf_2_1/recommendedTest_6_2_1.js index ec28e81b..9d129da9 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_1.js +++ b/tests/csaf_2_1/recommendedTest_6_2_1.js @@ -29,6 +29,90 @@ describe('recommendedTest_6_2_1', function () { ) }) + // collectDocumentNotes + it('no warning if product_id is referenced in document.notes[].product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + document: { + notes: [{ product_ids: ['CSAFPID-0001'] }], + }, + }).warnings.length, + 0 + ) + }) + + it('warning if note has no product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + document: { + notes: [{ category: 'general', text: 'note without product_ids' }], + }, + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in product_tree.product_groups.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + ], + product_groups: [ + { + group_id: 'CSAFGID-0001', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in product_paths.beginning_product_reference', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + product_paths: [{ beginning_product_reference: 'CSAFPID-0001' }], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in product_paths.subpath.next_product_reference', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + product_paths: [ + { subpaths: [{ next_product_reference: 'CSAFPID-0002' }] }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities[].product_status.unknown', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + product_status: { + unknown: ['CSAFPID-0001'], + }, + }, + ], + }).warnings.length, + 0 + ) + }) + it('no warning if product_id is referenced in vulnerabilities.remediations.product_ids', function () { assert.equal( recommendedTest_6_2_1({ @@ -37,8 +121,6 @@ describe('recommendedTest_6_2_1', function () { { remediations: [ { - category: 'vendor_fix', - details: 'Update.', product_ids: ['CSAFPID-0001'], }, ], @@ -49,7 +131,7 @@ describe('recommendedTest_6_2_1', function () { ) }) - it('warns if product_id appears only in unrelated vulnerability fields', function () { + it('warns if product_id is unreferenced and remediation has no product_ids field', function () { assert.equal( recommendedTest_6_2_1({ ...baseDoc, @@ -59,7 +141,6 @@ describe('recommendedTest_6_2_1', function () { { category: 'vendor_fix', details: 'Update.', - product_ids: ['CSAFPID-9999'], }, ], }, @@ -87,6 +168,24 @@ describe('recommendedTest_6_2_1', function () { ) }) + it('warning if product_id is unreferenced and metric has no products field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + metrics: [ + { + content: {}, + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + it('no warning if product_id is referenced in vulnerabilities.flags.product_ids', function () { assert.equal( recommendedTest_6_2_1({ @@ -95,7 +194,6 @@ describe('recommendedTest_6_2_1', function () { { flags: [ { - label: 'component_not_present', product_ids: ['CSAFPID-0001'], }, ], @@ -106,6 +204,24 @@ describe('recommendedTest_6_2_1', function () { ) }) + it('warning if product_id is unreferenced and flag has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + flags: [ + { + label: 'component_not_present', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + it('no warning if product_id is referenced in vulnerabilities.first_known_exploitation_dates.product_ids', function () { assert.equal( recommendedTest_6_2_1({ @@ -114,7 +230,6 @@ describe('recommendedTest_6_2_1', function () { { first_known_exploitation_dates: [ { - date: '2024-01-01T00:00:00Z', product_ids: ['CSAFPID-0001'], }, ], @@ -125,6 +240,24 @@ describe('recommendedTest_6_2_1', function () { ) }) + it('warning if product_id is unreferenced and first_known_exploitation_date has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + first_known_exploitation_dates: [ + { + date: '2024-01-01T00:00:00Z', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + it('no warning if product_id is referenced in vulnerabilities.threats.product_ids', function () { assert.equal( recommendedTest_6_2_1({ @@ -133,8 +266,6 @@ describe('recommendedTest_6_2_1', function () { { threats: [ { - category: 'exploit_status', - details: 'Exploits available.', product_ids: ['CSAFPID-0001'], }, ], @@ -145,71 +276,71 @@ describe('recommendedTest_6_2_1', function () { ) }) - it('skips full_product_name entries that have no product_id field', function () { + it('warning if product_id is unreferenced and threat has no product_ids field', function () { assert.equal( recommendedTest_6_2_1({ - product_tree: { - full_product_names: [{ name: 'Product A without ID' }], - }, + ...baseDoc, + vulnerabilities: [ + { + threats: [ + { + category: 'exploit_status', + }, + ], + }, + ], }).warnings.length, - 0 + 1 ) }) - it('skips product_path entries that have no full_product_name field', function () { + it('no warning if product_id is referenced in vulnerabilities[].notes[].product_ids', function () { assert.equal( recommendedTest_6_2_1({ - product_tree: { - product_paths: [{ beginning_product_reference: 'CSAFPID-0001' }], - }, + ...baseDoc, + vulnerabilities: [ + { + notes: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], }).warnings.length, 0 ) }) - it('no warning if product_id is referenced in product_tree.product_groups.product_ids', function () { + it('warning if product_id is unreferenced and vulnerability note has no product_ids field', function () { assert.equal( recommendedTest_6_2_1({ - product_tree: { - full_product_names: [ - { product_id: 'CSAFPID-0001', name: 'Product A' }, - ], - product_groups: [ - { - group_id: 'CSAFGID-0001', - product_ids: ['CSAFPID-0001'], - }, - ], - }, + ...baseDoc, + vulnerabilities: [ + { + notes: [ + { + category: 'general', + }, + ], + }, + ], }).warnings.length, - 0 + 1 ) }) - it('no warning if product_id is referenced in product_paths.subpaths.next_product_reference', function () { + it('no warning if product_id is referenced in vulnerabilities[].involvements[].product_ids', function () { assert.equal( recommendedTest_6_2_1({ - product_tree: { - full_product_names: [ - { product_id: 'CSAFPID-0001', name: 'Product A' }, - { product_id: 'CSAFPID-0002', name: 'Product B' }, - ], - product_paths: [ - { - full_product_name: { - product_id: 'CSAFPID-0003', - name: 'Product A on Product B', - }, - beginning_product_reference: 'CSAFPID-0001', - subpaths: [{ next_product_reference: 'CSAFPID-0002' }], - }, - ], - }, + ...baseDoc, vulnerabilities: [ { - product_status: { - known_affected: ['CSAFPID-0001', 'CSAFPID-0002', 'CSAFPID-0003'], - }, + involvements: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], }, ], }).warnings.length, @@ -217,21 +348,21 @@ describe('recommendedTest_6_2_1', function () { ) }) - it('warns if product_id is not referenced and product_group has no product_ids', function () { + it('warning if product_id is unreferenced and involvement has no product_ids field', function () { assert.equal( recommendedTest_6_2_1({ - product_tree: { - full_product_names: [ - { product_id: 'CSAFPID-0001', name: 'Product A' }, - ], - product_groups: [ - { - group_id: 'CSAFGID-0001', - }, - ], - }, + ...baseDoc, + vulnerabilities: [ + { + involvements: [ + { + party: 'vendor', + }, + ], + }, + ], }).warnings.length, - 1 + 0 ) }) }) From 9b1e016380308bc2d82c844ea41fc95f15e48942 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Thu, 28 May 2026 11:46:24 +0200 Subject: [PATCH 4/5] doc(CSAF2.1): fix --- csaf_2_1/recommendedTests/recommendedTest_6_2_1.js | 2 +- tests/csaf_2_1/recommendedTest_6_2_1.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index 8840440c..34f0fda9 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -1,4 +1,4 @@ -import Ajv from 'ajv/dist/jtd.js' +import { Ajv } from 'ajv/dist/jtd.js' const ajv = new Ajv() diff --git a/tests/csaf_2_1/recommendedTest_6_2_1.js b/tests/csaf_2_1/recommendedTest_6_2_1.js index 9d129da9..630b08aa 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_1.js +++ b/tests/csaf_2_1/recommendedTest_6_2_1.js @@ -362,7 +362,7 @@ describe('recommendedTest_6_2_1', function () { }, ], }).warnings.length, - 0 + 1 ) }) }) From 5f231f0cf3c5f1f3171d3471ee45caaeba61d908 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Thu, 28 May 2026 12:17:06 +0200 Subject: [PATCH 5/5] doc(CSAF2.1): get rid of the any type --- .../recommendedTests/recommendedTest_6_2_1.js | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index 34f0fda9..3f562005 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -73,8 +73,15 @@ const validateProductPath = ajv.compile(productPathSchema) /** * @typedef {import('ajv/dist/core.js').JTDDataType} Branch - * @typedef {import('ajv/dist/core.js').JTDDataType} FullProductName - * @typedef {import('ajv/dist/core.js').JTDDataType} ProductGroups + * @typedef {import('ajv/dist/core.js').JTDDataType} Note + * @typedef {import('ajv/dist/core.js').JTDDataType['product_tree']['product_groups'][number]} ProductGroup + * @typedef {import('ajv/dist/core.js').JTDDataType['product_tree']['product_paths'][number]} ProductPathRef + * @typedef {NonNullable['vulnerabilities'][number]['remediations']>[number]} Remediation + * @typedef {NonNullable['vulnerabilities'][number]['metrics']>[number]} Metric + * @typedef {NonNullable['vulnerabilities'][number]['flags']>[number]} Flag + * @typedef {NonNullable['vulnerabilities'][number]['first_known_exploitation_dates']>[number]} ExploitationDate + * @typedef {NonNullable['vulnerabilities'][number]['threats']>[number]} Threat + * @typedef {NonNullable['vulnerabilities'][number]['involvements']>[number]} Involvement */ /** @@ -294,7 +301,7 @@ const vulnOptionalRefsSchema = /** @type {const} */ ({ }, }) -const noteWithProductIdsSchema = /** @type {const} */ ({ +const noteSchema = /** @type {const} */ ({ additionalProperties: true, optionalProperties: { product_ids: { @@ -309,7 +316,7 @@ const docNotesSchema = /** @type {const} */ ({ document: { additionalProperties: true, properties: { - notes: { elements: noteWithProductIdsSchema }, + notes: { elements: noteSchema }, }, }, }, @@ -322,7 +329,7 @@ const vulnNotesSchema = /** @type {const} */ ({ elements: { additionalProperties: true, optionalProperties: { - notes: { elements: noteWithProductIdsSchema }, + notes: { elements: noteSchema }, }, }, }, @@ -386,7 +393,7 @@ function collectReferencedProductIds(doc) { */ function collectDocumentNotes(doc, ids) { if (hasDocNotes(doc)) { - for (const note of doc.document.notes) { + for (const /** @type {Note} */ note of doc.document.notes) { for (const id of note.product_ids ?? []) { ids.add(id) } @@ -401,7 +408,8 @@ function collectDocumentNotes(doc, ids) { */ function collectProductGroups(doc, ids) { if (hasProductGroups(doc)) { - for (const group of doc.product_tree.product_groups) { + for (const /** @type {ProductGroup} */ group of doc.product_tree + .product_groups) { if (group.product_ids) { for (const id of group.product_ids) ids.add(id) } @@ -416,7 +424,8 @@ function collectProductGroups(doc, ids) { */ function collectProductPathRefs(doc, ids) { if (hasProductPathRefs(doc)) { - for (const productPath of doc.product_tree.product_paths) { + for (const /** @type {ProductPathRef} */ productPath of doc.product_tree + .product_paths) { if (typeof productPath.beginning_product_reference === 'string') { ids.add(productPath.beginning_product_reference) } @@ -470,27 +479,29 @@ function collectVulnerabilityStatus(doc, ids) { function collectVulnerabilityOptionalRefs(doc, ids) { if (hasVulnOptionalRefs(doc)) { for (const vulnerability of doc.vulnerabilities) { - for (const remediation of vulnerability.remediations ?? []) { + for (const /** @type {Remediation} */ remediation of vulnerability.remediations ?? + []) { for (const id of remediation.product_ids ?? []) { ids.add(id) } } - for (const metric of vulnerability.metrics ?? []) { + for (const /** @type {Metric} */ metric of vulnerability.metrics ?? []) { for (const id of metric.products ?? []) { ids.add(id) } } - for (const flag of vulnerability.flags ?? []) { + for (const /** @type {Flag} */ flag of vulnerability.flags ?? []) { for (const id of flag.product_ids ?? []) { ids.add(id) } } - for (const entry of vulnerability.first_known_exploitation_dates ?? []) { + for (const /** @type {ExploitationDate} */ entry of vulnerability.first_known_exploitation_dates ?? + []) { for (const id of entry.product_ids ?? []) { ids.add(id) } } - for (const threat of vulnerability.threats ?? []) { + for (const /** @type {Threat} */ threat of vulnerability.threats ?? []) { for (const id of threat.product_ids ?? []) { ids.add(id) } @@ -507,7 +518,7 @@ function collectVulnerabilityOptionalRefs(doc, ids) { function collectVulnerabilityNotes(doc, ids) { if (hasVulnNotes(doc)) { for (const vulnerability of doc.vulnerabilities) { - for (const note of vulnerability.notes ?? []) { + for (const /** @type {Note} */ note of vulnerability.notes ?? []) { for (const id of note.product_ids ?? []) { ids.add(id) } @@ -524,7 +535,8 @@ function collectVulnerabilityNotes(doc, ids) { function collectVulnerabilityInvolvements(doc, ids) { if (hasVulnInvolvements(doc)) { for (const vulnerability of doc.vulnerabilities) { - for (const involvement of vulnerability.involvements ?? []) { + for (const /** @type {Involvement} */ involvement of vulnerability.involvements ?? + []) { for (const id of involvement.product_ids ?? []) { ids.add(id) }