From 240fbf04df89db3b3151094dee2dcb9fd95fcb77 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Mon, 22 Jun 2026 14:00:56 +0200 Subject: [PATCH 1/3] feat(CSAF2.1): add recommendedTest_6_2_42.js --- README.md | 2 +- csaf_2_1/recommendedTests.js | 1 + .../recommendedTest_6_2_42.js | 300 ++++++++++++++++++ tests/csaf_2_1/oasis.js | 1 - tests/csaf_2_1/recommendedTest_6_2_42.js | 221 +++++++++++++ 5 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 csaf_2_1/recommendedTests/recommendedTest_6_2_42.js create mode 100644 tests/csaf_2_1/recommendedTest_6_2_42.js diff --git a/README.md b/README.md index afdaa0c6..f69c4a45 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.39.3 - Recommended Test 6.2.39.4 - Recommended Test 6.2.39.5 -- Recommended Test 6.2.42 - Recommended Test 6.2.44 - Recommended Test 6.2.45 - Recommended Test 6.2.46 @@ -500,6 +499,7 @@ export const recommendedTest_6_2_39_3: DocumentTest export const recommendedTest_6_2_39_4: DocumentTest export const recommendedTest_6_2_40: DocumentTest export const recommendedTest_6_2_41: DocumentTest +export const recommendedTest_6_2_42: DocumentTest export const recommendedTest_6_2_43: DocumentTest export const recommendedTest_6_2_47: DocumentTest export const recommendedTest_6_2_48: DocumentTest diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 46213ccb..380004a4 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -39,6 +39,7 @@ export { recommendedTest_6_2_39_3 } from './recommendedTests/recommendedTest_6_2 export { recommendedTest_6_2_39_4 } from './recommendedTests/recommendedTest_6_2_39_4.js' export { recommendedTest_6_2_40 } from './recommendedTests/recommendedTest_6_2_40.js' export { recommendedTest_6_2_41 } from './recommendedTests/recommendedTest_6_2_41.js' +export { recommendedTest_6_2_42 } from './recommendedTests/recommendedTest_6_2_42.js' export { recommendedTest_6_2_43 } from './recommendedTests/recommendedTest_6_2_43.js' export { recommendedTest_6_2_47 } from './recommendedTests/recommendedTest_6_2_47.js' export { recommendedTest_6_2_48 } from './recommendedTests/recommendedTest_6_2_48.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js new file mode 100644 index 00000000..02d3e164 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js @@ -0,0 +1,300 @@ +import { PackageURL } from 'packageurl-js' +import { Ajv } from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_identification_helper: { + additionalProperties: true, + optionalProperties: { + cpe: { type: 'string' }, + purls: { elements: { type: 'string' } }, + }, + }, + }, +}) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + category: { type: 'string' }, + name: { type: 'string' }, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + product: fullProductNameSchema, + }, +}) + +const validateBranch = ajv.compile(branchSchema) + +/* + 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, + }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) + +/** + * @typedef {import('ajv/dist/core.js').JTDDataType} Branch + * @typedef {import('ajv/dist/core.js').JTDDataType} FullProductName + * @typedef {NonNullable} ProductIdentificationHelper + * @typedef {{ category: string; name: string }} BranchPathEntry + */ + +/** @type {Record} */ +const CATEGORY_TO_CPE_INDEX = { + vendor: 3, + product_name: 4, + product_version: 5, + patch_level: 6, + service_pack: 6, + language: 8, + platform: 10, + architecture: 11, +} + +/** @type {Record} */ +const CPE_INDEX_TO_CATEGORIES = { + 3: ['vendor'], + 4: ['product_name'], + 5: ['product_version'], + 6: ['patch_level', 'service_pack'], + 7: [], // index 7 = edition: no CSAF branch category corresponds to this CPE component + 8: ['language'], + 9: [], // index 9 = sw_edition: no CSAF branch category corresponds to this CPE component + 10: ['platform'], + 11: ['architecture'], + 12: [], // index 12 = other: no CSAF branch category corresponds to this CPE component +} + +/** @type {Record} */ +const CATEGORY_TO_PURL_COMPONENT = { + vendor: 'namespace', + product_name: 'name', + product_version: 'version', +} + +/** + * This implements the recommended test 6.2.42 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export function recommendedTest_6_2_42(doc) { + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if (!validateInput(doc)) { + return ctx + } + + doc.product_tree?.branches?.forEach((branch, index) => { + checkBranch(branch, `/product_tree/branches/${index}`, [], ctx.warnings) + }) + + return ctx +} + +/** + * @param {Branch} branch + * @param {string} basePath + * @param {BranchPathEntry[]} categorizedStrings + * @param {Array<{ instancePath: string; message: string }>} warnings + */ +function checkBranch(branch, basePath, categorizedStrings, warnings) { + if (!validateBranch(branch)) return + + const { category, name } = branch + const currentCategorizedStrings = + category && name + ? [...categorizedStrings, { category, name }] + : categorizedStrings + + const pih /** @type {ProductIdentificationHelper} */ = + branch.product?.product_identification_helper + if (pih) { + const pihPath = `${basePath}/product/product_identification_helper` + + if (typeof pih.cpe === 'string') { + checkCpe(pih.cpe, currentCategorizedStrings, `${pihPath}/cpe`, warnings) + } + + pih.purls?.forEach((purlStr, purlIndex) => { + if (typeof purlStr === 'string') { + checkPurl( + purlStr, + currentCategorizedStrings, + `${pihPath}/purls/${purlIndex}`, + warnings + ) + } + }) + } + + branch.branches?.forEach((childBranch, childIndex) => { + checkBranch( + childBranch, + `${basePath}/branches/${childIndex}`, + currentCategorizedStrings, + warnings + ) + }) +} + +/** + * @param {string} cpe + * @param {BranchPathEntry[]} categorizedStrings + * @param {string} instancePath + * @param {Array<{ instancePath: string; message: string }>} warnings + */ +function checkCpe(cpe, categorizedStrings, instancePath, warnings) { + if (!cpe.startsWith('cpe:2.3:')) return + + const parts = cpe.split(':') + if (parts.length !== 13) return + + const presentCategories = new Set(categorizedStrings.map((b) => b.category)) + + for (const { category, name } of categorizedStrings) { + const cpeIndex = CATEGORY_TO_CPE_INDEX[category] + if (cpeIndex === undefined) continue + + const cpeValue = parts[cpeIndex] + + if (isCpeNotSet(cpeValue)) { + warnings.push({ + instancePath, + message: `CPE counterpart for branch category "${category}" is not set but branch name is "${name}"`, + }) + continue + } + + if (containsWildcard(cpeValue) && !containsWildcard(name)) { + warnings.push({ + instancePath, + message: `CPE counterpart for branch category "${category}" contains a wildcard but branch name "${name}" does not indicate one`, + }) + continue + } + + if (category === 'product_version') { + if (normalizeCpeValue(cpeValue) !== normalizeCpeValue(name)) { + warnings.push({ + instancePath, + message: `CPE version "${cpeValue}" does not match branch version "${name}"`, + }) + } + } + } + + for (const [indexStr, categories] of Object.entries( + CPE_INDEX_TO_CATEGORIES + )) { + const index = Number(indexStr) + const cpeValue = parts[index] + + if (!isCpeNotSet(cpeValue)) { + const hasCorrespondingBranch = categories.some((cat) => + presentCategories.has(cat) + ) + if (!hasCorrespondingBranch) { + warnings.push({ + instancePath, + message: + `CPE has extra information at index ${index} ("${cpeValue}") with no corresponding branch ` + + `category (${categories.join(' / ')})`, + }) + } + } + } +} + +/** + * @param {string} purlStr + * @param {BranchPathEntry[]} categorizedStrings + * @param {string} instancePath + * @param {Array<{ instancePath: string; message: string }>} warnings + */ +function checkPurl(purlStr, categorizedStrings, instancePath, warnings) { + let purl + try { + purl = PackageURL.fromString(purlStr) + } catch { + return + } + + for (const { category, name } of categorizedStrings) { + const purlComponent = CATEGORY_TO_PURL_COMPONENT[category] + if (!purlComponent) continue + + const purlValue = /** @type {string | null | undefined} */ ( + purl[/** @type {keyof PackageURL} */ (purlComponent)] + ) + + if (!purlValue) { + warnings.push({ + instancePath, + message: `PURL "${purlComponent}" is not set but branch category "${category}" has value "${name}"`, + }) + continue + } + + if (containsWildcard(purlValue) && !containsWildcard(name)) { + warnings.push({ + instancePath, + message: `PURL "${purlComponent}" contains a wildcard but branch name "${name}" does not indicate one`, + }) + continue + } + + if (category === 'product_version' && purlValue !== name) { + warnings.push({ + instancePath, + message: `PURL version "${purlValue}" does not match branch version "${name}"`, + }) + } + } +} + +/** + * @param {string} value + */ +function isCpeNotSet(value) { + return value === '*' || value === '-' +} + +/** + * @param {string} value + */ +function containsWildcard(value) { + return /(? Date: Tue, 23 Jun 2026 09:10:58 +0200 Subject: [PATCH 2/3] feat(CSAF2.1): fix recommendedTest_6_2_42.js --- .../recommendedTest_6_2_42.js | 69 +++++++++++++------ tests/csaf_2_1/recommendedTest_6_2_42.js | 4 +- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js index 02d3e164..fa6375b5 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js @@ -122,8 +122,15 @@ export function recommendedTest_6_2_42(doc) { * @param {string} basePath * @param {BranchPathEntry[]} categorizedStrings * @param {Array<{ instancePath: string; message: string }>} warnings + * @param {boolean} partialPath */ -function checkBranch(branch, basePath, categorizedStrings, warnings) { +function checkBranch( + branch, + basePath, + categorizedStrings, + warnings, + partialPath = false +) { if (!validateBranch(branch)) return const { category, name } = branch @@ -131,6 +138,7 @@ function checkBranch(branch, basePath, categorizedStrings, warnings) { category && name ? [...categorizedStrings, { category, name }] : categorizedStrings + const currentPartialPath = partialPath || !category || !name const pih /** @type {ProductIdentificationHelper} */ = branch.product?.product_identification_helper @@ -138,7 +146,13 @@ function checkBranch(branch, basePath, categorizedStrings, warnings) { const pihPath = `${basePath}/product/product_identification_helper` if (typeof pih.cpe === 'string') { - checkCpe(pih.cpe, currentCategorizedStrings, `${pihPath}/cpe`, warnings) + checkCpe( + pih.cpe, + currentCategorizedStrings, + `${pihPath}/cpe`, + warnings, + currentPartialPath + ) } pih.purls?.forEach((purlStr, purlIndex) => { @@ -158,7 +172,8 @@ function checkBranch(branch, basePath, categorizedStrings, warnings) { childBranch, `${basePath}/branches/${childIndex}`, currentCategorizedStrings, - warnings + warnings, + currentPartialPath ) }) } @@ -168,8 +183,15 @@ function checkBranch(branch, basePath, categorizedStrings, warnings) { * @param {BranchPathEntry[]} categorizedStrings * @param {string} instancePath * @param {Array<{ instancePath: string; message: string }>} warnings + * @param {boolean} partialPath */ -function checkCpe(cpe, categorizedStrings, instancePath, warnings) { +function checkCpe( + cpe, + categorizedStrings, + instancePath, + warnings, + partialPath = false +) { if (!cpe.startsWith('cpe:2.3:')) return const parts = cpe.split(':') @@ -209,23 +231,28 @@ function checkCpe(cpe, categorizedStrings, instancePath, warnings) { } } - for (const [indexStr, categories] of Object.entries( - CPE_INDEX_TO_CATEGORIES - )) { - const index = Number(indexStr) - const cpeValue = parts[index] - - if (!isCpeNotSet(cpeValue)) { - const hasCorrespondingBranch = categories.some((cat) => - presentCategories.has(cat) - ) - if (!hasCorrespondingBranch) { - warnings.push({ - instancePath, - message: - `CPE has extra information at index ${index} ("${cpeValue}") with no corresponding branch ` + - `category (${categories.join(' / ')})`, - }) + const hasUnmappedCategory = categorizedStrings.some( + (b) => CATEGORY_TO_CPE_INDEX[b.category] === undefined + ) + if (!partialPath && !hasUnmappedCategory) { + for (const [indexStr, categories] of Object.entries( + CPE_INDEX_TO_CATEGORIES + )) { + const index = Number(indexStr) + const cpeValue = parts[index] + + if (!isCpeNotSet(cpeValue)) { + const hasCorrespondingBranch = categories.some((cat) => + presentCategories.has(cat) + ) + if (!hasCorrespondingBranch) { + warnings.push({ + instancePath, + message: + `CPE has extra information at index ${index} ("${cpeValue}") with no corresponding branch ` + + `category (${categories.join(' / ')})`, + }) + } } } } diff --git a/tests/csaf_2_1/recommendedTest_6_2_42.js b/tests/csaf_2_1/recommendedTest_6_2_42.js index a93b2edd..15387d07 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_42.js +++ b/tests/csaf_2_1/recommendedTest_6_2_42.js @@ -167,7 +167,7 @@ describe('recommendedTest_6_2_42', function () { recommendedTest_6_2_42( docWithBranches({ purls: ['pkg:generic/example/product_a@2.2.*'] }) ).warnings.length, - 2 + 1 ) }) @@ -189,7 +189,7 @@ describe('recommendedTest_6_2_42', function () { cpe: 'cpe:2.3:a:example:product_a:2.2.*:*:*:*:*:*:*:*', }) ).warnings.length, - 2 + 1 ) }) From 603ef4f4f011c3d186e93b161a72fb43f6bfadf8 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Tue, 23 Jun 2026 09:30:32 +0200 Subject: [PATCH 3/3] feat(CSAF2.1): fix recommendedTest_6_2_42.js --- csaf_2_1/recommendedTests/recommendedTest_6_2_42.js | 9 ++++----- tests/csaf_2_1/recommendedTest_6_2_42.js | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js index fa6375b5..5ad87aa4 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_42.js @@ -231,10 +231,7 @@ function checkCpe( } } - const hasUnmappedCategory = categorizedStrings.some( - (b) => CATEGORY_TO_CPE_INDEX[b.category] === undefined - ) - if (!partialPath && !hasUnmappedCategory) { + if (!partialPath) { for (const [indexStr, categories] of Object.entries( CPE_INDEX_TO_CATEGORIES )) { @@ -246,11 +243,13 @@ function checkCpe( presentCategories.has(cat) ) if (!hasCorrespondingBranch) { + const categoryHint = + categories.length > 0 ? ` (${categories.join(' / ')})` : '' warnings.push({ instancePath, message: `CPE has extra information at index ${index} ("${cpeValue}") with no corresponding branch ` + - `category (${categories.join(' / ')})`, + `category${categoryHint}`, }) } } diff --git a/tests/csaf_2_1/recommendedTest_6_2_42.js b/tests/csaf_2_1/recommendedTest_6_2_42.js index 15387d07..2b6ff427 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_42.js +++ b/tests/csaf_2_1/recommendedTest_6_2_42.js @@ -109,7 +109,7 @@ describe('recommendedTest_6_2_42', function () { product_id: 'CSAFPID-0002', name: 'My Family 2.2.0', product_identification_helper: { - cpe: 'cpe:2.3:a:example:product_a:2.2.0:*:*:*:*:*:*:*', + cpe: 'cpe:2.3:a:*:*:2.2.0:*:*:*:*:*:*:*', }, }, },