From 49bd850519ae0194babc99474069cf83a34f6adb Mon Sep 17 00:00:00 2001 From: chirschenberger Date: Tue, 15 Jul 2025 16:48:44 +0200 Subject: [PATCH 1/4] feat: add recommended test 6.2.35 --- README.md | 2 +- csaf_2_1/recommendedTests.js | 1 + .../recommendedTest_6_2_35.js | 100 ++++++++++++++++++ tests/csaf_2_1/oasis.js | 1 - tests/csaf_2_1/optionalTest_6_2_35.js | 51 +++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 csaf_2_1/recommendedTests/recommendedTest_6_2_35.js create mode 100644 tests/csaf_2_1/optionalTest_6_2_35.js diff --git a/README.md b/README.md index ebb45f8d..7e32e7d8 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.32 - Recommended Test 6.2.33 - Recommended Test 6.2.34 -- Recommended Test 6.2.35 - Recommended Test 6.2.36 - Recommended Test 6.2.37 - Recommended Test 6.2.38 @@ -462,6 +461,7 @@ 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_22: DocumentTest +export const recommendedTest_6_2_35: DocumentTest ``` [(back to top)](#bsi-csaf-validator-lib) diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index aeb25c42..2ddb3f5c 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -30,4 +30,5 @@ export { recommendedTest_6_2_22 } from './recommendedTests/recommendedTest_6_2_2 export { recommendedTest_6_2_27 } from './recommendedTests/recommendedTest_6_2_27.js' export { recommendedTest_6_2_28 } from './recommendedTests/recommendedTest_6_2_28.js' export { recommendedTest_6_2_29 } from './recommendedTests/recommendedTest_6_2_29.js' +export { recommendedTest_6_2_35 } from './recommendedTests/recommendedTest_6_2_35.js' export { recommendedTest_6_2_38 } from './recommendedTests/recommendedTest_6_2_38.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js new file mode 100644 index 00000000..f75c90bf --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js @@ -0,0 +1,100 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + distribution: { + additionalProperties: true, + properties: { + tlp: { + additionalProperties: true, + properties: { + label: { type: 'string' }, + }, + }, + }, + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + content: { + additionalProperties: true, + optionalProperties: { + ssvc_v1: { + additionalProperties: true, + optionalProperties: { + selections: { + elements: { + additionalProperties: true, + optionalProperties: { + namespace: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const validate = ajv.compile(inputSchema) + +/** + * This implements the recommended test 6.2.35 of the CSAF 2.1 standard. + * + * @param {any} doc + */ +export function recommendedTest_6_2_35(doc) { + /** @type {Array<{ message: string; instancePath: string }>} */ + const warnings = [] + const context = { warnings } + + if (!validate(doc)) { + return context + } + + /* + * According to https://certcc.github.io/SSVC/data/schema/v1/Decision_Point-1-0-1.schema.json#/$defs/decision_point/properties/namespace + * a private namespace starts with "x_" + * */ + + if (doc.document.distribution.tlp.label !== 'CLEAR') { + return context + } + doc.vulnerabilities?.forEach((vulnerability, vulnerabilityIndex) => { + vulnerability.metrics?.forEach((metric, metricIndex) => { + const selections = metric.content?.ssvc_v1?.selections || [] + selections.forEach((selection, selectionIndex) => { + if (selection.namespace?.startsWith('x_')) { + context.warnings.push({ + message: `The namespace "${selection.namespace}" is a private namespace`, + instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v1/selections/${selectionIndex}/namespace`, + }) + } + }) + }) + }) + + return context +} diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 31ce644c..569a0c9d 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -53,7 +53,6 @@ const excluded = [ '6.2.32', '6.2.33', '6.2.34', - '6.2.35', '6.2.36', '6.2.37', '6.2.39.1', diff --git a/tests/csaf_2_1/optionalTest_6_2_35.js b/tests/csaf_2_1/optionalTest_6_2_35.js new file mode 100644 index 00000000..c07c4cdd --- /dev/null +++ b/tests/csaf_2_1/optionalTest_6_2_35.js @@ -0,0 +1,51 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_35 } from '../../csaf_2_1/recommendedTests.js' + +describe('recommendedTest_6_2_35', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_35({ vulnerabilities: 'mydoc' }).warnings.length, + 0 + ) + }) + it('skips empty objects', function () { + assert.equal( + recommendedTest_6_2_35({ + document: { + distribution: { + tlp: { + label: 'CLEAR', + }, + }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v1: {}, // should be ignored + }, + }, + ], + }, + { + metrics: [ + { + content: { + ssvc_v1: { + selections: [ + { + namespace: 'x_custom', + }, + ], + }, + }, + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) +}) From 490accde2d9230a33e82fed30330b33082341202 Mon Sep 17 00:00:00 2001 From: chirschenberger Date: Thu, 17 Jul 2025 10:56:05 +0200 Subject: [PATCH 2/4] refactor: rename optionalTest to recommendedTest --- .../{optionalTest_6_2_35.js => recommendedTest_6_2_35.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/csaf_2_1/{optionalTest_6_2_35.js => recommendedTest_6_2_35.js} (100%) diff --git a/tests/csaf_2_1/optionalTest_6_2_35.js b/tests/csaf_2_1/recommendedTest_6_2_35.js similarity index 100% rename from tests/csaf_2_1/optionalTest_6_2_35.js rename to tests/csaf_2_1/recommendedTest_6_2_35.js From 7f4e2dd240816db6ad19735a22eafa356ff6129f Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Thu, 11 Jun 2026 15:37:07 +0200 Subject: [PATCH 3/4] feat(CSAF2.1): update recommendedTest_6_2_35 --- .../recommendedTest_6_2_35.js | 19 +++++++++---------- csaf_2_1/shared/ssvcNamespaces.js | 15 +++++++++++++++ tests/csaf_2_1/recommendedTest_6_2_35.js | 6 +++--- 3 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 csaf_2_1/shared/ssvcNamespaces.js diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js index f75c90bf..eabf3d0c 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js @@ -1,4 +1,5 @@ -import Ajv from 'ajv/dist/jtd.js' +import { Ajv } from 'ajv/dist/jtd.js' +import { isUnregisteredNamespace } from '../shared/ssvcNamespaces.js' const ajv = new Ajv() @@ -32,7 +33,7 @@ const inputSchema = /** @type {const} */ ({ content: { additionalProperties: true, optionalProperties: { - ssvc_v1: { + ssvc_v2: { additionalProperties: true, optionalProperties: { selections: { @@ -74,22 +75,20 @@ export function recommendedTest_6_2_35(doc) { return context } - /* - * According to https://certcc.github.io/SSVC/data/schema/v1/Decision_Point-1-0-1.schema.json#/$defs/decision_point/properties/namespace - * a private namespace starts with "x_" - * */ - if (doc.document.distribution.tlp.label !== 'CLEAR') { return context } doc.vulnerabilities?.forEach((vulnerability, vulnerabilityIndex) => { vulnerability.metrics?.forEach((metric, metricIndex) => { - const selections = metric.content?.ssvc_v1?.selections || [] + const selections = metric.content?.ssvc_v2?.selections || [] selections.forEach((selection, selectionIndex) => { - if (selection.namespace?.startsWith('x_')) { + if ( + selection?.namespace && + isUnregisteredNamespace(selection.namespace) + ) { context.warnings.push({ message: `The namespace "${selection.namespace}" is a private namespace`, - instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v1/selections/${selectionIndex}/namespace`, + instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v2/selections/${selectionIndex}/namespace`, }) } }) diff --git a/csaf_2_1/shared/ssvcNamespaces.js b/csaf_2_1/shared/ssvcNamespaces.js new file mode 100644 index 00000000..a301a883 --- /dev/null +++ b/csaf_2_1/shared/ssvcNamespaces.js @@ -0,0 +1,15 @@ +/** + * Returns true if the namespace is a valid unregistered SSVC namespace. + * + * Unregistered namespaces must: + * - start with the `x_` prefix + * - followed by a reverse domain name (alphanumeric, dots, dashes) + * - followed by `#` and a non-empty fragment + * - contain only alphanumeric characters, dots (.), and dashes (-) besides `x_` and `#` + * + * @param {string} namespace - full namespace string + * @returns {boolean} + */ +export function isUnregisteredNamespace(namespace) { + return /^x_[a-zA-Z0-9.\-]+(#[a-zA-Z0-9.\-]+)$/.test(namespace) +} diff --git a/tests/csaf_2_1/recommendedTest_6_2_35.js b/tests/csaf_2_1/recommendedTest_6_2_35.js index c07c4cdd..dde727a0 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_35.js +++ b/tests/csaf_2_1/recommendedTest_6_2_35.js @@ -23,7 +23,7 @@ describe('recommendedTest_6_2_35', function () { metrics: [ { content: { - ssvc_v1: {}, // should be ignored + ssvc_v2: {}, // should be ignored }, }, ], @@ -32,10 +32,10 @@ describe('recommendedTest_6_2_35', function () { metrics: [ { content: { - ssvc_v1: { + ssvc_v2: { selections: [ { - namespace: 'x_custom', + namespace: 'x_com.example#custom', }, ], }, From 6c45fa1ab84958e36b78cc891d136e9ae8ebcf8e Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Thu, 11 Jun 2026 16:35:15 +0200 Subject: [PATCH 4/4] feat(CSAF2.1): update recommendedTest_6_2_35 --- .../recommendedTest_6_2_35.js | 21 ++- csaf_2_1/shared/ssvcNamespaces.js | 46 +++++- tests/csaf_2_1/recommendedTest_6_2_35.js | 134 ++++++++++++++++++ 3 files changed, 193 insertions(+), 8 deletions(-) diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js index eabf3d0c..d3da099c 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_35.js @@ -1,5 +1,8 @@ import { Ajv } from 'ajv/dist/jtd.js' -import { isUnregisteredNamespace } from '../shared/ssvcNamespaces.js' +import { + isSpecialPurposeSsvcNamespace, + isUnregisteredNamespace, +} from '../shared/ssvcNamespaces.js' const ajv = new Ajv() @@ -82,13 +85,17 @@ export function recommendedTest_6_2_35(doc) { vulnerability.metrics?.forEach((metric, metricIndex) => { const selections = metric.content?.ssvc_v2?.selections || [] selections.forEach((selection, selectionIndex) => { - if ( - selection?.namespace && - isUnregisteredNamespace(selection.namespace) - ) { + if (!selection?.namespace) return + const instancePath = `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v2/selections/${selectionIndex}/namespace` + if (isSpecialPurposeSsvcNamespace(selection.namespace)) { context.warnings.push({ - message: `The namespace "${selection.namespace}" is a private namespace`, - instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v2/selections/${selectionIndex}/namespace`, + message: `The namespace "${selection.namespace}" is reserved for special purpose (documentation/testing) and must not be used in production`, + instancePath, + }) + } else if (isUnregisteredNamespace(selection.namespace)) { + context.warnings.push({ + message: `The namespace "${selection.namespace}" is an unregistered namespace`, + instancePath, }) } }) diff --git a/csaf_2_1/shared/ssvcNamespaces.js b/csaf_2_1/shared/ssvcNamespaces.js index a301a883..65762f24 100644 --- a/csaf_2_1/shared/ssvcNamespaces.js +++ b/csaf_2_1/shared/ssvcNamespaces.js @@ -1,3 +1,31 @@ +/** + * SSVC Namespace Specification: https://certcc.github.io/SSVC/reference/code/namespaces/ + * + * Namespaces reserved for special purpose and MUST be treated as per their + * definition (spec section "Reserved Namespace Strings"). + * + * @type {string[]} + */ +export const specialPurposeSsvcNamespaces = ['x_example', 'x_test'] + +/** + * Extracts the base namespace from a full SSVC namespace string. + * Strips everything after the first '#' or '/'. + * + * @param {string} namespace + * @returns {string} + */ +export function getSsvcBaseNamespace(namespace) { + const hashIdx = namespace.indexOf('#') + const slashIdx = namespace.indexOf('/') + + let endIdx = namespace.length + if (hashIdx !== -1) endIdx = Math.min(endIdx, hashIdx) + if (slashIdx !== -1) endIdx = Math.min(endIdx, slashIdx) + + return namespace.substring(0, endIdx) +} + /** * Returns true if the namespace is a valid unregistered SSVC namespace. * @@ -11,5 +39,21 @@ * @returns {boolean} */ export function isUnregisteredNamespace(namespace) { - return /^x_[a-zA-Z0-9.\-]+(#[a-zA-Z0-9.\-]+)$/.test(namespace) + // x_ + one or more labels (dot-separated) + optional #fragment + return /^x_[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(#[a-zA-Z0-9.\-]+)?$/.test( + namespace + ) +} + +/** + * Returns true if the namespace uses a reserved special-purpose prefix + * (x_example or x_test). These namespaces are valid structurally but must + * not be used in production. + * + * @param {string} namespace - full namespace string + * @returns {boolean} + */ +export function isSpecialPurposeSsvcNamespace(namespace) { + const base = getSsvcBaseNamespace(namespace) + return specialPurposeSsvcNamespaces.some((prefix) => base === prefix) } diff --git a/tests/csaf_2_1/recommendedTest_6_2_35.js b/tests/csaf_2_1/recommendedTest_6_2_35.js index dde727a0..2da52cb0 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_35.js +++ b/tests/csaf_2_1/recommendedTest_6_2_35.js @@ -48,4 +48,138 @@ describe('recommendedTest_6_2_35', function () { 1 ) }) + + it('skips selections without a namespace', function () { + assert.equal( + recommendedTest_6_2_35({ + document: { + distribution: { tlp: { label: 'CLEAR' } }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v2: { + selections: [{}, { namespace: '' }], + }, + }, + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warns for special purpose namespaces', function () { + assert.equal( + recommendedTest_6_2_35({ + document: { + distribution: { tlp: { label: 'CLEAR' } }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v2: { + selections: [ + { namespace: 'x_example#test' }, + { namespace: 'x_test#ref' }, + ], + }, + }, + }, + ], + }, + ], + }).warnings.length, + 2 + ) + }) + + it('warns for unregistered namespaces that look like special purpose (x_example.something)', function () { + const result = recommendedTest_6_2_35({ + document: { + distribution: { tlp: { label: 'CLEAR' } }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v2: { + selections: [ + { namespace: 'x_example.unregistered#namespace' }, + { namespace: 'x_test.org#ref' }, + ], + }, + }, + }, + ], + }, + ], + }) + assert.equal(result.warnings.length, 2) + assert.ok( + result.warnings.every((w) => + w.message.includes('unregistered namespace') + ), + 'expected "unregistered namespace" message for x_example.something and x_test.org forms' + ) + }) + + it('warns for single-label unregistered namespace without dot (x_somedomain#fragment)', function () { + const result = recommendedTest_6_2_35({ + document: { + distribution: { tlp: { label: 'CLEAR' } }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v2: { + selections: [{ namespace: 'x_somedomain#fragment' }], + }, + }, + }, + ], + }, + ], + }) + assert.equal(result.warnings.length, 1) + assert.ok( + result.warnings[0].message.includes('unregistered namespace'), + 'expected "unregistered namespace" message for x_somedomain#fragment' + ) + }) + + it('warns for fragment-free unregistered namespace (x_com.example)', function () { + const result = recommendedTest_6_2_35({ + document: { + distribution: { tlp: { label: 'CLEAR' } }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v2: { + selections: [{ namespace: 'x_com.example' }], + }, + }, + }, + ], + }, + ], + }) + assert.equal(result.warnings.length, 1) + assert.ok( + result.warnings[0].message.includes('unregistered namespace'), + 'expected "unregistered namespace" message for x_com.example (no fragment)' + ) + }) })