diff --git a/README.md b/README.md index 1bedef0c..21a24e59 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.24 - Recommended Test 6.2.26 - Recommended Test 6.2.31 @@ -487,6 +486,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_21: 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 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/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/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' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js index b47b588a..7b8a2fa9 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_20.js @@ -1,8 +1,220 @@ -import { optionalTest_6_2_20 } from '../../optionalTests.js' +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) { - return optionalTest_6_2_20(doc) + const ctx = { + warnings: + /** @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( + (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}/${propertyName}`, + message: `property "${propertyName}" is not defined in the schema`, + }) + } + } + + // 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/oasis.js b/tests/csaf_2_1/oasis.js index d13519a2..2d1b6ace 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -33,7 +33,6 @@ const excluded = [ '6.1.61', '6.2.11', '6.2.19', - '6.2.20', '6.2.24', '6.2.26', '6.2.31', 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' + ) + }) +})