From ec2662979bdafbb92bdfc8b4a157b129e9f79445 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Mon, 6 Oct 2025 14:39:48 +0200 Subject: [PATCH 1/7] feat(CSAF2.1): #302 add recommendedTest_6_2_20.js --- README.md | 2 +- csaf_2_1/csafAjv.js | 6 ++--- csaf_2_1/csafAjv/cvss-v2.0.js | 1 + csaf_2_1/csafAjv/cvss-v3.0.js | 1 + csaf_2_1/csafAjv/cvss-v3.1.js | 1 + csaf_2_1/csafAjv/cvss-v4.0.js | 7 ++++++ .../recommendedTest_6_2_20.js | 25 +++++++++++++++++-- tests/csaf_2_1/oasis.js | 1 - 8 files changed, 37 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f4f9297..dbbe55f7 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.11 - Recommended Test 6.2.19 -- Recommended Test 6.2.20 - Recommended Test 6.2.21 - Recommended Test 6.2.24 - Recommended Test 6.2.25 @@ -459,6 +458,7 @@ export const recommendedTest_6_2_15: DocumentTest export const recommendedTest_6_2_16: DocumentTest export const recommendedTest_6_2_17: DocumentTest export const recommendedTest_6_2_18: DocumentTest +export const recommendedTest_6_2_20: DocumentTest export const recommendedTest_6_2_22: DocumentTest export const recommendedTest_6_2_23: DocumentTest ``` diff --git a/csaf_2_1/csafAjv.js b/csaf_2_1/csafAjv.js index 833f8bb0..f37d48f2 100644 --- a/csaf_2_1/csafAjv.js +++ b/csaf_2_1/csafAjv.js @@ -1,8 +1,8 @@ import addFormats from 'ajv-formats' import Ajv2020 from 'ajv/dist/2020.js' -import cvss_v2_0 from '../schemas/cvss-v2.0.js' -import cvss_v3_0 from '../schemas/cvss-v3.0.js' -import cvss_v3_1 from '../schemas/cvss-v3.1.js' +import cvss_v2_0 from './csafAjv/cvss-v2.0.js' +import cvss_v3_0 from './csafAjv/cvss-v3.0.js' +import cvss_v3_1 from './csafAjv/cvss-v3.1.js' import cvss_v4_0 from './csafAjv/cvss-v4.0.js' import meta from './csafAjv/meta.js' import formatAssertion from './csafAjv/format-assertion.js' diff --git a/csaf_2_1/csafAjv/cvss-v2.0.js b/csaf_2_1/csafAjv/cvss-v2.0.js index cf791137..4833f1ce 100644 --- a/csaf_2_1/csafAjv/cvss-v2.0.js +++ b/csaf_2_1/csafAjv/cvss-v2.0.js @@ -24,6 +24,7 @@ export default { title: 'JSON Schema for Common Vulnerability Scoring System version 2.0', $id: 'https://www.first.org/cvss/cvss-v2.0.json?20170531', type: 'object', + additionalProperties: false, $defs: { accessVectorType: { type: 'string', diff --git a/csaf_2_1/csafAjv/cvss-v3.0.js b/csaf_2_1/csafAjv/cvss-v3.0.js index c46f4ef2..f7c4e7cf 100644 --- a/csaf_2_1/csafAjv/cvss-v3.0.js +++ b/csaf_2_1/csafAjv/cvss-v3.0.js @@ -24,6 +24,7 @@ export default { title: 'JSON Schema for Common Vulnerability Scoring System version 3.0', $id: 'https://www.first.org/cvss/cvss-v3.0.json?20170531', type: 'object', + additionalProperties: false, $defs: { attackVectorType: { type: 'string', diff --git a/csaf_2_1/csafAjv/cvss-v3.1.js b/csaf_2_1/csafAjv/cvss-v3.1.js index d4b86cee..d6d0eefc 100644 --- a/csaf_2_1/csafAjv/cvss-v3.1.js +++ b/csaf_2_1/csafAjv/cvss-v3.1.js @@ -25,6 +25,7 @@ export default { title: 'JSON Schema for Common Vulnerability Scoring System version 3.1', $id: 'https://www.first.org/cvss/cvss-v3.1.json?20190610', type: 'object', + additionalProperties: false, $defs: { attackVectorType: { type: 'string', diff --git a/csaf_2_1/csafAjv/cvss-v4.0.js b/csaf_2_1/csafAjv/cvss-v4.0.js index 4bf575fc..feef2db5 100644 --- a/csaf_2_1/csafAjv/cvss-v4.0.js +++ b/csaf_2_1/csafAjv/cvss-v4.0.js @@ -25,6 +25,7 @@ export default { title: 'JSON Schema for Common Vulnerability Scoring System version 4.0', $id: 'https://www.first.org/cvss/cvss-v4.0.json?20240216', type: 'object', + additionalProperties: false, definitions: { attackVectorType: { type: 'string', @@ -190,6 +191,12 @@ export default { pattern: '^CVSS:4[.]0/AV:[NALP]/AC:[LH]/AT:[NP]/PR:[NLH]/UI:[NPA]/VC:[HLN]/VI:[HLN]/VA:[HLN]/SC:[HLN]/SI:[HLN]/SA:[HLN](/E:[XAPU])?(/CR:[XHML])?(/IR:[XHML])?(/AR:[XHML])?(/MAV:[XNALP])?(/MAC:[XLH])?(/MAT:[XNP])?(/MPR:[XNLH])?(/MUI:[XNPA])?(/MVC:[XNLH])?(/MVI:[XNLH])?(/MVA:[XNLH])?(/MSC:[XNLH])?(/MSI:[XNLHS])?(/MSA:[XNLHS])?(/S:[XNP])?(/AU:[XNY])?(/R:[XAUI])?(/V:[XDC])?(/RE:[XLMH])?(/U:(X|Clear|Green|Amber|Red))?$', }, + baseScore: { $ref: '#/definitions/noneScoreType' }, + baseSeverity: { $ref: '#/definitions/noneSeverityType' }, + threatScore: { $ref: '#/definitions/noneScoreType' }, + threatSeverity: { $ref: '#/definitions/noneSeverityType' }, + environmentalScore: { $ref: '#/definitions/noneScoreType' }, + environmentalSeverity: { $ref: '#/definitions/noneSeverityType' }, attackVector: { $ref: '#/definitions/attackVectorType' }, attackComplexity: { $ref: '#/definitions/attackComplexityType' }, attackRequirements: { $ref: '#/definitions/attackRequirementsType' }, diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js index b47b588a..b60f5372 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js @@ -1,8 +1,29 @@ -import { optionalTest_6_2_20 } from '../../optionalTests.js' +import schema from '../schemaTests/csaf_2_1_strict/schema.js' +import csafAjv from '../csafAjv.js' + +const validateStrictSchema = csafAjv.compile(schema) /** * @param {unknown} doc */ export function recommendedTest_6_2_20(doc) { - return optionalTest_6_2_20(doc) + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if (!validateStrictSchema(doc)) { + const additionalPropertiesErrors = + validateStrictSchema.errors?.filter( + (e) => e.keyword === 'additionalProperties' + ) ?? [] + for (const error of additionalPropertiesErrors) { + ctx.warnings.push({ + instancePath: error.instancePath, + message: error.message ?? '', + }) + } + } + + return ctx } diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 0e9d2e60..100890b1 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -34,7 +34,6 @@ const excluded = [ '6.1.56', '6.2.11', '6.2.19', - '6.2.20', '6.2.21', '6.2.24', '6.2.25', From 029cc48d65fcafcbee6e9088172a1d59ba2b7b2f Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Tue, 24 Mar 2026 17:02:28 +0100 Subject: [PATCH 2/7] fix(CSAF2.1): change additionalProperties to unevaluatedProperties --- csaf_2_1/csafAjv/cvss-v4.0.1.js | 6 +----- csaf_2_1/recommendedTests/recommendedTest_6_2_20.js | 10 +++++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/csaf_2_1/csafAjv/cvss-v4.0.1.js b/csaf_2_1/csafAjv/cvss-v4.0.1.js index 3fb46aea..84da9da7 100644 --- a/csaf_2_1/csafAjv/cvss-v4.0.1.js +++ b/csaf_2_1/csafAjv/cvss-v4.0.1.js @@ -26,7 +26,7 @@ export default { 'JSON Schema for Common Vulnerability Scoring System version 4.0, Revision 1', $id: 'https://www.first.org/cvss/cvss-v4.0.1.json?20250704', type: 'object', - additionalProperties: false, + unevaluatedProperties: false, definitions: { attackVectorType: { type: 'string', @@ -194,10 +194,6 @@ export default { }, baseScore: { $ref: '#/definitions/noneScoreType' }, baseSeverity: { $ref: '#/definitions/noneSeverityType' }, - threatScore: { $ref: '#/definitions/noneScoreType' }, - threatSeverity: { $ref: '#/definitions/noneSeverityType' }, - environmentalScore: { $ref: '#/definitions/noneScoreType' }, - environmentalSeverity: { $ref: '#/definitions/noneSeverityType' }, attackVector: { $ref: '#/definitions/attackVectorType' }, attackComplexity: { $ref: '#/definitions/attackComplexityType' }, attackRequirements: { $ref: '#/definitions/attackRequirementsType' }, diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js index b60f5372..e2240232 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js @@ -15,12 +15,16 @@ export function recommendedTest_6_2_20(doc) { if (!validateStrictSchema(doc)) { const additionalPropertiesErrors = validateStrictSchema.errors?.filter( - (e) => e.keyword === 'additionalProperties' + (e) => + e.keyword === 'additionalProperties' || + e.keyword === 'unevaluatedProperties' ) ?? [] for (const error of additionalPropertiesErrors) { + const propertyName = + error.params.additionalProperty ?? error.params.unevaluatedProperty ctx.warnings.push({ - instancePath: error.instancePath, - message: error.message ?? '', + instancePath: `${error.instancePath}/${propertyName}`, + message: `property "${propertyName}" is not defined in the schema`, }) } } From edde8756158acb2d861a6808d983fa0b08b9d69c Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Tue, 24 Mar 2026 17:08:47 +0100 Subject: [PATCH 3/7] fix(CSAF2.1): change additionalProperties to unevaluatedProperties --- tests/csaf_2_1/oasis.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 47e0b21e..4ff31671 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -33,7 +33,6 @@ const excluded = [ '6.1.59', '6.2.11', '6.2.19', - '6.2.21', '6.2.24', '6.2.26', '6.2.31', From b70d9e0d94a853f41ce6f5844bbb9ab52991395a Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Fri, 29 May 2026 10:19:12 +0200 Subject: [PATCH 4/7] feat(CSAF2.1): fix imports --- csaf_2_1/csafAjv.js | 6 +++--- csaf_2_1/recommendedTests.js | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/csaf_2_1/csafAjv.js b/csaf_2_1/csafAjv.js index 44da8f36..deb59d05 100644 --- a/csaf_2_1/csafAjv.js +++ b/csaf_2_1/csafAjv.js @@ -1,8 +1,8 @@ import addFormats from 'ajv-formats' import { Ajv2020 } from 'ajv/dist/2020.js' -import cvss_v2_0 from '../schemas/cvss-v2.0.js' -import cvss_v3_0 from '../schemas/cvss-v3.0.js' -import cvss_v3_1 from '../schemas/cvss-v3.1.js' +import cvss_v2_0 from './csafAjv/cvss-v2.0.js' +import cvss_v3_0 from './csafAjv/cvss-v3.0.js' +import cvss_v3_1 from './csafAjv/cvss-v3.1.js' import cvss_v4_0_0 from './csafAjv/cvss-v4.0.0.js' import extension_content from './csafAjv/extension-content.js' import content_schema from './csafAjv/content_schema.js' diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 46213ccb..b058567b 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -25,6 +25,7 @@ export { recommendedTest_6_2_16 } from './recommendedTests/recommendedTest_6_2_1 export { recommendedTest_6_2_17 } from './recommendedTests/recommendedTest_6_2_17.js' export { recommendedTest_6_2_18 } from './recommendedTests/recommendedTest_6_2_18.js' export { recommendedTest_6_2_19 } from './recommendedTests/recommendedTest_6_2_19.js' +export { recommendedTest_6_2_20 } from './recommendedTests/recommendedTest_6_2_20.js' export { recommendedTest_6_2_21 } from './recommendedTests/recommendedTest_6_2_21.js' export { recommendedTest_6_2_22 } from './recommendedTests/recommendedTest_6_2_22.js' export { recommendedTest_6_2_23 } from './recommendedTests/recommendedTest_6_2_23.js' From 913814fe7b881ad11d143ac0c2513967f6251458 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Fri, 29 May 2026 14:06:53 +0200 Subject: [PATCH 5/7] feat(CSAF2.1): extend the test to include x_extensions --- .../recommendedTest_6_2_20.js | 187 ++++++++++++++++++ tests/csaf_2_1/recommendedTest_6_2_20.js | 136 +++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 tests/csaf_2_1/recommendedTest_6_2_20.js diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js index e2240232..7b8a2fa9 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js @@ -1,9 +1,97 @@ import schema from '../schemaTests/csaf_2_1_strict/schema.js' import csafAjv from '../csafAjv.js' +import { Ajv } from 'ajv/dist/jtd.js' +const ajv = new Ajv() const validateStrictSchema = csafAjv.compile(schema) +const extensionSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + $schema: { type: 'string' }, + category: { type: 'string' }, + }, +}) + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + x_extensions: { elements: extensionSchema }, + }, +}) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product: fullProductNameSchema, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, +}) + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + x_extensions: { elements: extensionSchema }, + document: { + additionalProperties: true, + optionalProperties: { + x_extensions: { elements: extensionSchema }, + }, + }, + product_tree: { + additionalProperties: true, + optionalProperties: { + full_product_names: { elements: fullProductNameSchema }, + branches: { elements: branchSchema }, + product_paths: { + elements: { + additionalProperties: true, + optionalProperties: { + full_product_name: fullProductNameSchema, + }, + }, + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + x_extensions: { elements: extensionSchema }, + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + content: { + additionalProperties: true, + optionalProperties: { + x_extensions: { elements: extensionSchema }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) + +/** + * @typedef {import('ajv/dist/core.js').JTDDataType} Branch + * @typedef {import('ajv/dist/core.js').JTDDataType} ExtensionSchema + */ + /** + * This implements the recommended test 6.2.20 of the CSAF 2.1 standard. + * * @param {unknown} doc */ export function recommendedTest_6_2_20(doc) { @@ -12,6 +100,7 @@ export function recommendedTest_6_2_20(doc) { /** @type {Array<{ instancePath: string; message: string }>} */ ([]), } + // Part 1: strict schema check – report any property not defined in the CSAF schema if (!validateStrictSchema(doc)) { const additionalPropertiesErrors = validateStrictSchema.errors?.filter( @@ -29,5 +118,103 @@ export function recommendedTest_6_2_20(doc) { } } + // Part 2: warn about unsupported CSAF Extensions + if (!validateInput(doc)) return ctx + + if (doc.x_extensions) { + checkExtensions(ctx.warnings, doc.x_extensions, '/x_extensions') + } + + if (doc.document?.x_extensions) { + checkExtensions( + ctx.warnings, + doc.document.x_extensions, + '/document/x_extensions' + ) + } + + doc.product_tree?.full_product_names?.forEach((fpn, j) => { + if (fpn.x_extensions) { + checkExtensions( + ctx.warnings, + fpn.x_extensions, + `/product_tree/full_product_names/${j}/x_extensions` + ) + } + }) + + if (doc.product_tree?.branches) { + checkBranchExtensions( + ctx.warnings, + doc.product_tree.branches, + '/product_tree/branches' + ) + } + + doc.product_tree?.product_paths?.forEach((pp, j) => { + if (pp.full_product_name?.x_extensions) { + checkExtensions( + ctx.warnings, + pp.full_product_name.x_extensions, + `/product_tree/product_paths/${j}/full_product_name/x_extensions` + ) + } + }) + + doc.vulnerabilities?.forEach((vuln, j) => { + if (vuln.x_extensions) { + checkExtensions( + ctx.warnings, + vuln.x_extensions, + `/vulnerabilities/${j}/x_extensions` + ) + } + vuln.metrics?.forEach((metric, k) => { + if (metric.content?.x_extensions) { + checkExtensions( + ctx.warnings, + metric.content.x_extensions, + `/vulnerabilities/${j}/metrics/${k}/content/x_extensions` + ) + } + }) + }) + return ctx } + +/** + * Checks an array of extensions for unsupported schemas + * @param {Array<{ instancePath: string; message: string }>} warnings + * @param {ExtensionSchema[]} extensions + * @param {string} basePath + */ +function checkExtensions(warnings, extensions, basePath) { + extensions.forEach((ext, i) => { + warnings.push({ + instancePath: `${basePath}/${i}/$schema`, + message: `unsupported CSAF Extension of schema "${ext.$schema}"`, + }) + }) +} + +/** + * Recursively checks branches for unsupported extensions + * @param {Array<{ instancePath: string; message: string }>} warnings + * @param {Branch[]} branches + * @param {string} path + */ +function checkBranchExtensions(warnings, branches, path) { + branches.forEach((branch, i) => { + if (Array.isArray(branch?.product?.x_extensions)) { + checkExtensions( + warnings, + branch.product.x_extensions, + `${path}/${i}/product/x_extensions` + ) + } + if (Array.isArray(branch?.branches)) { + checkBranchExtensions(warnings, branch.branches, `${path}/${i}/branches`) + } + }) +} diff --git a/tests/csaf_2_1/recommendedTest_6_2_20.js b/tests/csaf_2_1/recommendedTest_6_2_20.js new file mode 100644 index 00000000..2f27f9b8 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_20.js @@ -0,0 +1,136 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_20 } from '../../csaf_2_1/recommendedTests.js' + +const SCHEMA_URL = 'https://example.com/my-extension/schema/1.0.0.json' + +describe('recommendedTest_6_2_20', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_20({ vulnerabilities: 'mydoc' }).warnings.length, + 0 + ) + }) + + it('returns no warning when $schema is not a string (root x_extensions)', function () { + const doc = { + x_extensions: [{ category: { type: 'string' } }], + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 0) + }) + + it('warns for x_extensions at root level', function () { + const doc = { + x_extensions: [{ $schema: SCHEMA_URL }], + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal(result.warnings[0].instancePath, '/x_extensions/0/$schema') + }) + + it('warns for x_extensions in document', function () { + const doc = { + document: { + x_extensions: [{ $schema: SCHEMA_URL }], + }, + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/document/x_extensions/0/$schema' + ) + }) + + it('warns for x_extensions in product_tree/full_product_names', function () { + const doc = { + product_tree: { + full_product_names: [{ x_extensions: [{ $schema: SCHEMA_URL }] }], + }, + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/product_tree/full_product_names/0/x_extensions/0/$schema' + ) + }) + + it('warns for x_extensions in product_tree/branches', function () { + const doc = { + product_tree: { + branches: [{ product: { x_extensions: [{ $schema: SCHEMA_URL }] } }], + }, + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/product_tree/branches/0/product/x_extensions/0/$schema' + ) + }) + + it('warns for x_extensions in nested product_tree/branches', function () { + const doc = { + product_tree: { + branches: [ + { + branches: [ + { product: { x_extensions: [{ $schema: SCHEMA_URL }] } }, + ], + }, + ], + }, + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/product_tree/branches/0/branches/0/product/x_extensions/0/$schema' + ) + }) + + it('warns for x_extensions in product_tree/product_paths', function () { + const doc = { + product_tree: { + product_paths: [ + { full_product_name: { x_extensions: [{ $schema: SCHEMA_URL }] } }, + ], + }, + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/product_tree/product_paths/0/full_product_name/x_extensions/0/$schema' + ) + }) + + it('warns for x_extensions in vulnerabilities', function () { + const doc = { + vulnerabilities: [{ x_extensions: [{ $schema: SCHEMA_URL }] }], + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/vulnerabilities/0/x_extensions/0/$schema' + ) + }) + + it('warns for x_extensions in vulnerabilities/metrics/content', function () { + const doc = { + vulnerabilities: [ + { + metrics: [{ content: { x_extensions: [{ $schema: SCHEMA_URL }] } }], + }, + ], + } + const result = recommendedTest_6_2_20(doc) + assert.equal(result.warnings.length, 1) + assert.equal( + result.warnings[0].instancePath, + '/vulnerabilities/0/metrics/0/content/x_extensions/0/$schema' + ) + }) +}) From 9576ee8ab303928a536b0a1fee41808dd4d6c126 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Thu, 13 Aug 2026 16:18:16 +0200 Subject: [PATCH 6/7] feat(csaf2.1): update to vitest --- tests/csaf_2_1/recommendedTest_6_2_20.js | 49 ++++++++++-------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/tests/csaf_2_1/recommendedTest_6_2_20.js b/tests/csaf_2_1/recommendedTest_6_2_20.js index 2f27f9b8..51fe286b 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_20.js +++ b/tests/csaf_2_1/recommendedTest_6_2_20.js @@ -1,14 +1,12 @@ -import assert from 'node:assert' import { recommendedTest_6_2_20 } from '../../csaf_2_1/recommendedTests.js' const SCHEMA_URL = 'https://example.com/my-extension/schema/1.0.0.json' describe('recommendedTest_6_2_20', function () { it('only runs on relevant documents', function () { - assert.equal( - recommendedTest_6_2_20({ vulnerabilities: 'mydoc' }).warnings.length, - 0 - ) + expect( + recommendedTest_6_2_20({ vulnerabilities: 'mydoc' }).warnings.length + ).to.equal(0) }) it('returns no warning when $schema is not a string (root x_extensions)', function () { @@ -16,7 +14,7 @@ describe('recommendedTest_6_2_20', function () { x_extensions: [{ category: { type: 'string' } }], } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 0) + expect(result.warnings.length).to.equal(0) }) it('warns for x_extensions at root level', function () { @@ -24,8 +22,8 @@ describe('recommendedTest_6_2_20', function () { x_extensions: [{ $schema: SCHEMA_URL }], } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal(result.warnings[0].instancePath, '/x_extensions/0/$schema') + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal('/x_extensions/0/$schema') }) it('warns for x_extensions in document', function () { @@ -35,9 +33,8 @@ describe('recommendedTest_6_2_20', function () { }, } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/document/x_extensions/0/$schema' ) }) @@ -49,9 +46,8 @@ describe('recommendedTest_6_2_20', function () { }, } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/product_tree/full_product_names/0/x_extensions/0/$schema' ) }) @@ -63,9 +59,8 @@ describe('recommendedTest_6_2_20', function () { }, } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/product_tree/branches/0/product/x_extensions/0/$schema' ) }) @@ -83,9 +78,8 @@ describe('recommendedTest_6_2_20', function () { }, } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/product_tree/branches/0/branches/0/product/x_extensions/0/$schema' ) }) @@ -99,9 +93,8 @@ describe('recommendedTest_6_2_20', function () { }, } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/product_tree/product_paths/0/full_product_name/x_extensions/0/$schema' ) }) @@ -111,9 +104,8 @@ describe('recommendedTest_6_2_20', function () { vulnerabilities: [{ x_extensions: [{ $schema: SCHEMA_URL }] }], } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/vulnerabilities/0/x_extensions/0/$schema' ) }) @@ -127,9 +119,8 @@ describe('recommendedTest_6_2_20', function () { ], } const result = recommendedTest_6_2_20(doc) - assert.equal(result.warnings.length, 1) - assert.equal( - result.warnings[0].instancePath, + expect(result.warnings.length).to.equal(1) + expect(result.warnings[0].instancePath).to.equal( '/vulnerabilities/0/metrics/0/content/x_extensions/0/$schema' ) }) From b7136d0e3955432884b6e657cd798d5462243c06 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Wed, 2 Sep 2026 15:20:45 +0200 Subject: [PATCH 7/7] refactor(csaf2.1): recommendedTest_6_2_20.js --- csaf_2_1/csafAjv.js | 2 + .../extensionSchemas/documentation-11.js | 54 +++++ .../extensionSchemas/documentation-12.js | 68 ++++++ .../extensionSchemas/documentation-13.js | 129 +++++++++++ csaf_2_1/csafAjv/extensionSchemas/index.js | 100 +++++++++ .../recommendedTest_6_2_20.js | 206 +++--------------- tests/csaf_2_1/recommendedTest_6_2_20.js | 47 ++-- 7 files changed, 402 insertions(+), 204 deletions(-) create mode 100644 csaf_2_1/csafAjv/extensionSchemas/documentation-11.js create mode 100644 csaf_2_1/csafAjv/extensionSchemas/documentation-12.js create mode 100644 csaf_2_1/csafAjv/extensionSchemas/documentation-13.js create mode 100644 csaf_2_1/csafAjv/extensionSchemas/index.js diff --git a/csaf_2_1/csafAjv.js b/csaf_2_1/csafAjv.js index 4c122c60..6f7df1ec 100644 --- a/csaf_2_1/csafAjv.js +++ b/csaf_2_1/csafAjv.js @@ -11,6 +11,7 @@ import cvss_meta from './csafAjv/cvss_meta.js' import meta_format_assertion from './csafAjv/meta-format-assertion.js' import draft_07_schema from './csafAjv/draft-07-schema.js' import selectionList_2_0_0Schema from './csafAjv/SelectionList_2_0_0.schema.js' +import { registerExtensionSchemas } from './csafAjv/extensionSchemas/index.js' import { validateTimestamp } from './dateHelper.js' @@ -45,6 +46,7 @@ csafAjv.addSchema( selectionList_2_0_0Schema, 'https://certcc.github.io/SSVC/data/schema/v2/SelectionList_2_0_0.schema.json' ) +registerExtensionSchemas(csafAjv) csafAjv.addFormat('date-time', { type: 'string', diff --git a/csaf_2_1/csafAjv/extensionSchemas/documentation-11.js b/csaf_2_1/csafAjv/extensionSchemas/documentation-11.js new file mode 100644 index 00000000..6f5d2538 --- /dev/null +++ b/csaf_2_1/csafAjv/extensionSchemas/documentation-11.js @@ -0,0 +1,54 @@ +// https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-11/documentation-11-content_1.0.0.json +// Copied from ../../../csaf/csaf_2.1/test/extension/data/valid/documentation-11/documentation-11-content_1.0.0.json +export default { + $schema: + 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/extension-metaschema.json', + $id: 'https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-11/documentation-11-content_1.0.0.json', + title: 'CSAF Extension: Documentation 11', + description: + 'Representation of a valid extension used for documentation purposes.', + type: 'object', + required: ['$schema', 'category', 'critical', 'content'], + properties: { + $schema: { + title: 'CSAF Extension Schema', + description: + 'Contains the URL of the CSAF Extension JSON schema which the JSON object promises to be valid for.', + type: 'string', + format: 'uri', + const: + 'https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-11/documentation-11-content_1.0.0.json', + }, + category: { + title: 'Extension Category', + description: 'Holds the category of the extension content.', + type: 'string', + enum: ['essential', 'significant', 'supplementary'], + }, + content: { + title: 'Content', + description: 'Contains the additional information in its properties.', + type: 'object', + minProperties: 1, + properties: { + documentation: { + title: 'Documentation content', + description: + 'Contains a constant string to clarify that this is for documentation purposes only and not to be used in production.', + type: 'string', + const: + 'This extension is for documentation and test purposed only. It is valid. It is not allowed to be used in a production CSAF.', + }, + }, + unevaluatedProperties: false, + }, + critical: { + title: 'Critical', + description: + 'Determines whether using the extension would fail a mandatory test.', + type: 'boolean', + const: false, + }, + }, + additionalProperties: false, +} diff --git a/csaf_2_1/csafAjv/extensionSchemas/documentation-12.js b/csaf_2_1/csafAjv/extensionSchemas/documentation-12.js new file mode 100644 index 00000000..33f8eb88 --- /dev/null +++ b/csaf_2_1/csafAjv/extensionSchemas/documentation-12.js @@ -0,0 +1,68 @@ +// https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-12/documentation-12-content_1.0.0.json +// Copied from ../../../csaf/csaf_2.1/test/extension/data/valid/documentation-12/documentation-12-content_1.0.0.json +export default { + $schema: + 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/extension-metaschema.json', + $id: 'https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-12/documentation-12-content_1.0.0.json', + title: 'CSAF Extension: Documentation 12', + description: + 'Representation of a valid extension used for documentation purposes.', + type: 'object', + required: ['$schema', 'category', 'critical', 'content'], + properties: { + $schema: { + title: 'CSAF Extension Schema', + description: + 'Contains the URL of the CSAF Extension JSON schema which the JSON object promises to be valid for.', + type: 'string', + format: 'uri', + const: + 'https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-12/documentation-12-content_1.0.0.json', + }, + category: { + title: 'Extension Category', + description: 'Holds the category of the extension content.', + type: 'string', + enum: ['essential', 'significant', 'supplementary'], + }, + content: { + title: 'Content', + description: 'Contains the additional information in its properties.', + type: 'object', + minProperties: 1, + required: ['documentation'], + properties: { + documentation: { + title: 'Documentation content', + description: + 'Contains a constant string to clarify that this is for documentation purposes only and not to be used in production.', + type: 'string', + const: + 'This extension is for documentation and test purposed only. It is valid. It is not allowed to be used in a production CSAF.', + }, + notes: { + title: 'Notes', + description: + 'Contains a list of notes to convey a more complex structure.', + type: 'array', + minItems: 1, + items: { + title: 'Entry', + description: 'Contains a single entry.', + type: 'string', + minLength: 1, + }, + }, + }, + unevaluatedProperties: false, + }, + critical: { + title: 'Critical', + description: + 'Determines whether using the extension would fail a mandatory test.', + type: 'boolean', + const: false, + }, + }, + additionalProperties: false, +} diff --git a/csaf_2_1/csafAjv/extensionSchemas/documentation-13.js b/csaf_2_1/csafAjv/extensionSchemas/documentation-13.js new file mode 100644 index 00000000..343e23f8 --- /dev/null +++ b/csaf_2_1/csafAjv/extensionSchemas/documentation-13.js @@ -0,0 +1,129 @@ +// https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-13/documentation-13-content_1.0.0.json +// Copied from ../../../csaf/csaf_2.1/test/extension/data/valid/documentation-13/documentation-13-content_1.0.0.json +export default { + $schema: + 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/extension-metaschema.json', + $id: 'https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-13/documentation-13-content_1.0.0.json', + title: 'CSAF Extension: Documentation 13', + description: + 'Representation of a valid extension for configurations used for documentation purposes.', + type: 'object', + $defs: { + configuration_id_t: { + title: 'Reference token for configuration instance', + description: + 'Token required to identify a configuration so that it can be referred to from other parts in the document. There is no predefined or required format for the configuration_id as long as it uniquely identifies a configuration in the context of the current document.', + type: 'string', + minLength: 1, + examples: ['CSAFCID-0001', 'CSAFCID-0002'], + }, + configuration_t: { + title: 'Configuration', + description: + 'Contains the description of a configuration and assigns it a configuration id.', + type: 'object', + required: ['configuration_id', 'details'], + properties: { + configuration_id: { $ref: '#/$defs/configuration_id_t' }, + details: { + title: 'Details of the Configuration', + description: + 'Contains all details as human-readable description on how to identify the configuration.', + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + }, + relationship_t: { + title: 'Relationship', + description: + 'Establishes a link between an existing full_product_name_t element and a configuration, allowing the document producer to define a combination that form a new full_product_name entry.', + type: 'object', + required: [ + 'configuration_reference', + 'full_product_name', + 'product_reference', + ], + properties: { + configuration_reference: { + title: 'Configuration Reference', + description: + 'Holds a Configuration ID that refers to the Configuration element, which is referenced as the second element of the relationship.', + $ref: '#/$defs/configuration_id_t', + }, + full_product_name: { + $ref: 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/csaf.json#/$defs/full_product_name_t', + }, + product_reference: { + title: 'Product reference', + description: + 'Holds a Product ID that refers to the Full Product Name element, which is referenced as the first element of the relationship.', + $ref: 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/csaf.json#/$defs/product_id_t', + }, + }, + additionalProperties: false, + }, + }, + required: ['$schema', 'category', 'critical', 'content'], + properties: { + $schema: { + title: 'CSAF Extension Schema', + description: + 'Contains the URL of the CSAF Extension JSON schema which the JSON object promises to be valid for.', + type: 'string', + format: 'uri', + const: + 'https://raw.githubusercontent.com/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/documentation-13/documentation-13-content_1.0.0.json', + }, + category: { + title: 'Extension Category', + description: 'Holds the category of the extension content.', + type: 'string', + enum: ['essential', 'significant', 'supplementary'], + }, + content: { + title: 'Content', + description: 'Contains the additional information in its properties.', + type: 'object', + minProperties: 1, + properties: { + configuration_tree: { + title: 'Configuration Tree', + description: + 'Contains configurations and their bindings to products.', + type: 'object', + required: ['configurations', 'relationships'], + properties: { + configurations: { + title: 'List of Configurations', + description: 'Contains a list of configuration elements.', + type: 'array', + minItems: 1, + uniqueItems: true, + items: { $ref: '#/$defs/configuration_t' }, + }, + relationships: { + title: 'List of Relationships', + description: + 'Contains a list of product-configuration combinations.', + type: 'array', + minItems: 1, + uniqueItems: true, + items: { $ref: '#/$defs/relationship_t' }, + }, + }, + }, + }, + unevaluatedProperties: false, + }, + critical: { + title: 'Critical', + description: + 'Determines whether using the extension would fail a mandatory test.', + type: 'boolean', + const: true, + }, + }, + additionalProperties: false, +} diff --git a/csaf_2_1/csafAjv/extensionSchemas/index.js b/csaf_2_1/csafAjv/extensionSchemas/index.js new file mode 100644 index 00000000..b6c4fa53 --- /dev/null +++ b/csaf_2_1/csafAjv/extensionSchemas/index.js @@ -0,0 +1,100 @@ +import documentation11 from './documentation-11.js' +import documentation12 from './documentation-12.js' +import documentation13 from './documentation-13.js' + +/** + * @typedef {object} ExtensionSchemaEntry + * @property {import('ajv').AnySchemaObject} schema + * @property {'official' | 'registered' | 'experimental'} class Extension + * class as defined in spec section 2.4.1 ("Classes"). + * @property {boolean} deprecated Whether this extension is on the OASIS CSAF + * TC's list of deprecated extensions (spec section 2.4.2 "Lists"). Can + * still be used, but support for it will be removed in the near future. + * @property {boolean} denyListed Whether this extension is on the OASIS CSAF + * TC's list of deny-listed extensions (spec section 2.4.2 "Lists"). MUST + * NOT be used. + */ + +/** + * Allow list of CSAF Extension Schemas that this validator explicitly + * implements and supports at runtime. + * + * Per CSAF 2.1 spec chapter 8 ("Safety, Security, and Data Protection + * Considerations"), CSAF validators SHOULD NOT automatically retrieve JSON + * schemas from a URL declared in a CSAF document, and SHOULD instead keep a + * local copy of all schemas necessary to fulfill their tasks. Therefore, no + * schema referenced by an `x_extensions[].$schema` value is ever fetched over + * the network - only the schemas listed here are used to validate mandatory + * test 6.1.60.2. + * + * The `class`/`deprecated`/`denyListed` fields mirror the OASIS CSAF TC's + * lists described in spec section 2.4.2 ("Lists"). Those lists are not + * (yet) available as real, importable data in this repository - the entries + * below are currently limited to the OASIS documentation-11/12/13 test + * fixtures, which are themselves explicitly marked as "not allowed to be + * used in a production CSAF" and are therefore classified as `experimental`. + * The fields are still modeled explicitly so that real official/registered/ + * deprecated/deny-listed extensions can be added later without having to + * change any consumer of this module. + * + * To add support for a new CSAF Extension Schema: + * 1. Add a new module next to this file that exports the schema (its `$id` + * MUST match the `$schema` value that CSAF documents will declare). + * 2. Import it here and add an entry to `extensionSchemas` below with its + * class and deprecated/deny-listed status. + * + * @type {ExtensionSchemaEntry[]} + */ +export const extensionSchemas = [ + { + schema: documentation11, + class: 'experimental', + deprecated: false, + denyListed: false, + }, + { + schema: documentation12, + class: 'experimental', + deprecated: false, + denyListed: false, + }, + { + schema: documentation13, + class: 'experimental', + deprecated: false, + denyListed: false, + }, +] + +/** + * Registers all allow-listed extension schemas on the given Ajv instance so + * that `ajv.getSchema(schemaUrl)` resolves them without any network access. + * + * @param {import('ajv').default} ajv + */ +export function registerExtensionSchemas(ajv) { + for (const { schema } of extensionSchemas) { + ajv.addSchema(schema, schema.$id) + } +} + +/** + * Classifies a CSAF Extension by its declared `$schema` URL, used to + * differentiate the warning raised for extensions that are not in the allow + * list above (see mandatoryTest_6_1_60_2). + * + * For allow-listed schemas, this returns the explicit class recorded in + * `extensionSchemas`. For any other schema, the class cannot be determined + * reliably: this validator does not have access to the OASIS CSAF TC's real + * lists of official/registered/deprecated/deny-listed extensions (spec + * section 2.4.2 "Lists"), which are not available as data in this + * repository, so guessing from the URL alone (e.g. by host name) would be + * misleading. `'unknown'` is returned in that case instead. + * + * @param {string} schemaUrl + * @returns {'official' | 'registered' | 'experimental' | 'unknown'} + */ +export function classifyExtensionSchema(schemaUrl) { + const entry = extensionSchemas.find((e) => e.schema.$id === schemaUrl) + return entry?.class ?? 'unknown' +} diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js index 7b8a2fa9..bc2d0ccf 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js @@ -1,100 +1,26 @@ import schema from '../schemaTests/csaf_2_1_strict/schema.js' import csafAjv from '../csafAjv.js' -import { Ajv } from 'ajv/dist/jtd.js' +import { walkPath } from '../../lib/walkPaths.js' +import { classifyExtensionSchema } from '../csafAjv/extensionSchemas/index.js' -const ajv = new Ajv() const validateStrictSchema = csafAjv.compile(schema) -const extensionSchema = /** @type {const} */ ({ - additionalProperties: true, - optionalProperties: { - $schema: { type: 'string' }, - category: { type: 'string' }, - }, -}) - -const fullProductNameSchema = /** @type {const} */ ({ - additionalProperties: true, - optionalProperties: { - x_extensions: { elements: extensionSchema }, - }, -}) - -const branchSchema = /** @type {const} */ ({ - additionalProperties: true, - optionalProperties: { - product: fullProductNameSchema, - branches: { - elements: { - additionalProperties: true, - properties: {}, - }, - }, - }, -}) - -const inputSchema = /** @type {const} */ ({ - additionalProperties: true, - optionalProperties: { - x_extensions: { elements: extensionSchema }, - document: { - additionalProperties: true, - optionalProperties: { - x_extensions: { elements: extensionSchema }, - }, - }, - product_tree: { - additionalProperties: true, - optionalProperties: { - full_product_names: { elements: fullProductNameSchema }, - branches: { elements: branchSchema }, - product_paths: { - elements: { - additionalProperties: true, - optionalProperties: { - full_product_name: fullProductNameSchema, - }, - }, - }, - }, - }, - vulnerabilities: { - elements: { - additionalProperties: true, - optionalProperties: { - x_extensions: { elements: extensionSchema }, - metrics: { - elements: { - additionalProperties: true, - optionalProperties: { - content: { - additionalProperties: true, - optionalProperties: { - x_extensions: { elements: extensionSchema }, - }, - }, - }, - }, - }, - }, - }, - }, - }, -}) - -const validateInput = ajv.compile(inputSchema) - -/** - * @typedef {import('ajv/dist/core.js').JTDDataType} Branch - * @typedef {import('ajv/dist/core.js').JTDDataType} ExtensionSchema - */ +const X_EXTENSIONS_PATHS = /** @type {const} */ ([ + '/document/x_extensions[]', + '/product_tree/branches[*]/product/x_extensions[]', + '/product_tree/full_product_names[]/x_extensions[]', + '/product_tree/product_paths[]/full_product_name/x_extensions[]', + '/vulnerabilities[]/metrics[]/content/x_extensions[]', + '/vulnerabilities[]/x_extensions[]', + '/x_extensions[]', +]) /** * This implements the recommended test 6.2.20 of the CSAF 2.1 standard. * * @param {unknown} doc */ -export function recommendedTest_6_2_20(doc) { +export async function recommendedTest_6_2_20(doc) { const ctx = { warnings: /** @type {Array<{ instancePath: string; message: string }>} */ ([]), @@ -119,102 +45,26 @@ export function recommendedTest_6_2_20(doc) { } // Part 2: warn about unsupported CSAF Extensions - if (!validateInput(doc)) return ctx + for (const path of X_EXTENSIONS_PATHS) { + await walkPath(doc, path, async (instancePath, value) => { + const schemaUrl = + value && typeof value === 'object' && '$schema' in value + ? value.$schema + : undefined - if (doc.x_extensions) { - checkExtensions(ctx.warnings, doc.x_extensions, '/x_extensions') - } + if (typeof schemaUrl !== 'string') return - if (doc.document?.x_extensions) { - checkExtensions( - ctx.warnings, - doc.document.x_extensions, - '/document/x_extensions' - ) - } - - doc.product_tree?.full_product_names?.forEach((fpn, j) => { - if (fpn.x_extensions) { - checkExtensions( - ctx.warnings, - fpn.x_extensions, - `/product_tree/full_product_names/${j}/x_extensions` - ) - } - }) - - if (doc.product_tree?.branches) { - checkBranchExtensions( - ctx.warnings, - doc.product_tree.branches, - '/product_tree/branches' - ) - } - - doc.product_tree?.product_paths?.forEach((pp, j) => { - if (pp.full_product_name?.x_extensions) { - checkExtensions( - ctx.warnings, - pp.full_product_name.x_extensions, - `/product_tree/product_paths/${j}/full_product_name/x_extensions` - ) - } - }) + const isSupported = typeof csafAjv.getSchema(schemaUrl) === 'function' + if (isSupported) return - doc.vulnerabilities?.forEach((vuln, j) => { - if (vuln.x_extensions) { - checkExtensions( - ctx.warnings, - vuln.x_extensions, - `/vulnerabilities/${j}/x_extensions` - ) - } - vuln.metrics?.forEach((metric, k) => { - if (metric.content?.x_extensions) { - checkExtensions( - ctx.warnings, - metric.content.x_extensions, - `/vulnerabilities/${j}/metrics/${k}/content/x_extensions` - ) - } + ctx.warnings.push({ + instancePath: `${instancePath}/$schema`, + message: `unsupported CSAF Extension of schema "${schemaUrl}" (class: ${classifyExtensionSchema( + schemaUrl + )})`, + }) }) - }) + } return ctx } - -/** - * Checks an array of extensions for unsupported schemas - * @param {Array<{ instancePath: string; message: string }>} warnings - * @param {ExtensionSchema[]} extensions - * @param {string} basePath - */ -function checkExtensions(warnings, extensions, basePath) { - extensions.forEach((ext, i) => { - warnings.push({ - instancePath: `${basePath}/${i}/$schema`, - message: `unsupported CSAF Extension of schema "${ext.$schema}"`, - }) - }) -} - -/** - * Recursively checks branches for unsupported extensions - * @param {Array<{ instancePath: string; message: string }>} warnings - * @param {Branch[]} branches - * @param {string} path - */ -function checkBranchExtensions(warnings, branches, path) { - branches.forEach((branch, i) => { - if (Array.isArray(branch?.product?.x_extensions)) { - checkExtensions( - warnings, - branch.product.x_extensions, - `${path}/${i}/product/x_extensions` - ) - } - if (Array.isArray(branch?.branches)) { - checkBranchExtensions(warnings, branch.branches, `${path}/${i}/branches`) - } - }) -} diff --git a/tests/csaf_2_1/recommendedTest_6_2_20.js b/tests/csaf_2_1/recommendedTest_6_2_20.js index 51fe286b..ff86a39e 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_20.js +++ b/tests/csaf_2_1/recommendedTest_6_2_20.js @@ -3,69 +3,64 @@ import { recommendedTest_6_2_20 } from '../../csaf_2_1/recommendedTests.js' const SCHEMA_URL = 'https://example.com/my-extension/schema/1.0.0.json' describe('recommendedTest_6_2_20', function () { - it('only runs on relevant documents', function () { + it('only runs on relevant documents', async function () { expect( - recommendedTest_6_2_20({ vulnerabilities: 'mydoc' }).warnings.length + (await recommendedTest_6_2_20({ vulnerabilities: 'mydoc' })).warnings + .length ).to.equal(0) }) - it('returns no warning when $schema is not a string (root x_extensions)', function () { - const doc = { - x_extensions: [{ category: { type: 'string' } }], - } - const result = recommendedTest_6_2_20(doc) - expect(result.warnings.length).to.equal(0) - }) - - it('warns for x_extensions at root level', function () { + it('warns for x_extensions at root level', async function () { const doc = { x_extensions: [{ $schema: SCHEMA_URL }], } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal('/x_extensions/0/$schema') + expect(result.warnings[0].message).to.contain(SCHEMA_URL) + expect(result.warnings[0].message).to.contain('class: unknown') }) - it('warns for x_extensions in document', function () { + it('warns for x_extensions in document', async function () { const doc = { document: { x_extensions: [{ $schema: SCHEMA_URL }], }, } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/document/x_extensions/0/$schema' ) }) - it('warns for x_extensions in product_tree/full_product_names', function () { + it('warns for x_extensions in product_tree/full_product_names', async function () { const doc = { product_tree: { full_product_names: [{ x_extensions: [{ $schema: SCHEMA_URL }] }], }, } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/product_tree/full_product_names/0/x_extensions/0/$schema' ) }) - it('warns for x_extensions in product_tree/branches', function () { + it('warns for x_extensions in product_tree/branches', async function () { const doc = { product_tree: { branches: [{ product: { x_extensions: [{ $schema: SCHEMA_URL }] } }], }, } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/product_tree/branches/0/product/x_extensions/0/$schema' ) }) - it('warns for x_extensions in nested product_tree/branches', function () { + it('warns for x_extensions in nested product_tree/branches', async function () { const doc = { product_tree: { branches: [ @@ -77,14 +72,14 @@ describe('recommendedTest_6_2_20', function () { ], }, } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/product_tree/branches/0/branches/0/product/x_extensions/0/$schema' ) }) - it('warns for x_extensions in product_tree/product_paths', function () { + it('warns for x_extensions in product_tree/product_paths', async function () { const doc = { product_tree: { product_paths: [ @@ -92,25 +87,25 @@ describe('recommendedTest_6_2_20', function () { ], }, } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/product_tree/product_paths/0/full_product_name/x_extensions/0/$schema' ) }) - it('warns for x_extensions in vulnerabilities', function () { + it('warns for x_extensions in vulnerabilities', async function () { const doc = { vulnerabilities: [{ x_extensions: [{ $schema: SCHEMA_URL }] }], } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/vulnerabilities/0/x_extensions/0/$schema' ) }) - it('warns for x_extensions in vulnerabilities/metrics/content', function () { + it('warns for x_extensions in vulnerabilities/metrics/content', async function () { const doc = { vulnerabilities: [ { @@ -118,7 +113,7 @@ describe('recommendedTest_6_2_20', function () { }, ], } - const result = recommendedTest_6_2_20(doc) + const result = await recommendedTest_6_2_20(doc) expect(result.warnings.length).to.equal(1) expect(result.warnings[0].instancePath).to.equal( '/vulnerabilities/0/metrics/0/content/x_extensions/0/$schema'