From c2f0efe84dd71a4e283a3a026e577c87d6a9324d Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Wed, 17 Sep 2025 13:08:26 +0200 Subject: [PATCH 1/2] feat(CSAF2.1): #447 add mandatory test 6.2.47 --- README.md | 1 + csaf_2_1/recommendedTests.js | 1 + .../recommendedTest_6_2_47.js | 172 ++++++++++++++++++ tests/csaf_2_1/oasis.js | 1 - tests/csaf_2_1/recommendedTest_6_2_47.js | 11 ++ 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 csaf_2_1/recommendedTests/recommendedTest_6_2_47.js create mode 100644 tests/csaf_2_1/recommendedTest_6_2_47.js diff --git a/README.md b/README.md index 7b581815..92bfc196 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,7 @@ export const recommendedTest_6_2_28: DocumentTest export const recommendedTest_6_2_29: DocumentTest export const recommendedTest_6_2_30: DocumentTest export const recommendedTest_6_2_43: DocumentTest +export const recommendedTest_6_2_47: DocumentTest ``` [(back to top)](#bsi-csaf-validator-lib) diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index d90a5174..13c2313d 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -35,3 +35,4 @@ export { recommendedTest_6_2_29 } from './recommendedTests/recommendedTest_6_2_2 export { recommendedTest_6_2_30 } from './recommendedTests/recommendedTest_6_2_30.js' export { recommendedTest_6_2_38 } from './recommendedTests/recommendedTest_6_2_38.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' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js new file mode 100644 index 00000000..5319bf65 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js @@ -0,0 +1,172 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +/** @typedef {import('ajv/dist/jtd.js').JTDDataType} InputSchema */ + +/** @typedef {InputSchema['vulnerabilities'][number]} Vulnerability */ + +/** @typedef {NonNullable[number]} Metric */ + +/** @typedef {NonNullable} MetricContent */ + +const jtdAjv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + optionalProperties: { + references: { + elements: { + additionalProperties: true, + properties: { + category: { type: 'string' }, + url: { type: 'string' }, + }, + }, + }, + + tracking: { + additionalProperties: true, + optionalProperties: { + id: { type: 'string' }, + }, + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + source: { + type: 'string', + }, + content: { + additionalProperties: true, + optionalProperties: { + qualitative_severity_rating: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +/** @typedef {{ url: string; category: string}} Reference */ + +const referenceSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + category: { type: 'string' }, + url: { type: 'string' }, + }, +}) + +const validate = jtdAjv.compile(inputSchema) +const validateReference = ajv.compile(referenceSchema) + +/** + * Get the canonical url from the document + * @return {string} canonical url or empty when no canonical url exists + * @param {Array | undefined} references + * @param {string | undefined} trackingId + */ +function getCanonicalUrl(references, trackingId) { + if (references && trackingId) { + // Find the reference that matches our criteria + /** @type {Reference| undefined} */ + const canonicalUrlReference = references.find( + (reference) => + validateReference(reference) && + reference.category === 'self' && + reference.url.startsWith('https://') && + reference.url.endsWith( + trackingId.toLowerCase().replace(/[^+\-a-z0-9]+/g, '_') + '.json' + ) + ) + + // When we find a matching reference, we know it has the url property + // because validateReference ensures it matches the Reference schema + return canonicalUrlReference?.url ?? '' + } else { + return '' + } +} + +/** + * check whether metric has a qualitative_severity_rating + * and no `source` or `source` that is equal to the canonical URL. + * @param {Metric} metric + * @param {string} canonicalURL + * @return {boolean} + */ +function hasServerRatingAndNoSource(metric, canonicalURL) { + return ( + (!metric.source || metric.source === canonicalURL) && + !!metric?.content?.qualitative_severity_rating + ) +} + +/** + * For each item in `metrics` provided by the issuing party it MUST be tested + * that it does not use the qualitative severity rating. + * This covers all items in `metrics` that do not have a `source` property and those where the `source` is equal to + * the canonical URL. + * +/** + * @param {any} doc + */ +export function recommendedTest_6_2_47(doc) { + /** @type {Array<{ message: string; instancePath: string }>} */ + const warnings = [] + const context = { warnings } + + if (!validate(doc)) { + return context + } + + /** @type {Array} */ + const vulnerabilities = doc.vulnerabilities + const canonicalURL = getCanonicalUrl( + doc.document.references, + doc.document?.tracking?.id + ) + + vulnerabilities.forEach((vulnerabilityItem, vulnerabilityIndex) => { + /** @type {Array | undefined} */ + const metrics = vulnerabilityItem.metrics + /** @type {Array | undefined} */ + const invalidPaths = metrics + ?.map((metric, metricIndex) => + hasServerRatingAndNoSource(metric, canonicalURL) + ? `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/qualitative_severity_rating` + : null + ) + .filter((path) => path !== null) + + if (!!invalidPaths) { + invalidPaths.forEach((path) => { + context.warnings.push({ + message: + 'the metric has a qualitative severity rating and no source property' + + ' or a source property that ist equal to the canonical URL', + instancePath: path, + }) + }) + } + }) + + return context +} diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index fe160623..662aa06d 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -52,7 +52,6 @@ const excluded = [ '6.2.44', '6.2.45', '6.2.46', - '6.2.47', '6.3.12', '6.3.13', '6.3.14', diff --git a/tests/csaf_2_1/recommendedTest_6_2_47.js b/tests/csaf_2_1/recommendedTest_6_2_47.js new file mode 100644 index 00000000..721ab373 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_47.js @@ -0,0 +1,11 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_47 } from '../../csaf_2_1/recommendedTests.js' + +describe('recommendedTest_6_2_47', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_47({ vulnerabilities: 'mydoc' }).warnings.length, + 0 + ) + }) +}) From 0fb89f901e77059926ad8b5923440e78d8fb52a7 Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:49:41 +0100 Subject: [PATCH 2/2] feat(CSAF2.1): #447 add mandatory test 6.2.47 - refactor getCanonicalUrl, improve naming and messages --- .../recommendedTest_6_2_47.js | 62 +++++++------------ lib/optionalTests/optionalTest_6_2_11.js | 21 +------ lib/shared/urlHelper.js | 42 +++++++++++++ tests/csaf_2_1/recommendedTest_6_2_47.js | 33 ++++++++++ tests/urlHelper.js | 60 ++++++++++++++++++ 5 files changed, 161 insertions(+), 57 deletions(-) create mode 100644 lib/shared/urlHelper.js create mode 100644 tests/urlHelper.js diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js index 5319bf65..066c7c1b 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js @@ -1,6 +1,5 @@ import Ajv from 'ajv/dist/jtd.js' - -const ajv = new Ajv() +import { isCanonicalUrl } from '../../lib/shared/urlHelper.js' /** @typedef {import('ajv/dist/jtd.js').JTDDataType} InputSchema */ @@ -10,6 +9,8 @@ const ajv = new Ajv() /** @typedef {NonNullable} MetricContent */ +/** @typedef {{url?: string, category?: string}} Reference */ + const jtdAjv = new Ajv() const inputSchema = /** @type {const} */ ({ @@ -21,7 +22,7 @@ const inputSchema = /** @type {const} */ ({ references: { elements: { additionalProperties: true, - properties: { + optionalProperties: { category: { type: 'string' }, url: { type: 'string' }, }, @@ -64,41 +65,24 @@ const inputSchema = /** @type {const} */ ({ }, }) -/** @typedef {{ url: string; category: string}} Reference */ - -const referenceSchema = /** @type {const} */ ({ - additionalProperties: true, - properties: { - category: { type: 'string' }, - url: { type: 'string' }, - }, -}) - -const validate = jtdAjv.compile(inputSchema) -const validateReference = ajv.compile(referenceSchema) +const validateInput = jtdAjv.compile(inputSchema) /** * Get the canonical url from the document * @return {string} canonical url or empty when no canonical url exists - * @param {Array | undefined} references - * @param {string | undefined} trackingId + * @param {Array<{url?: string, category?: string}>|undefined} references + * @param {string|undefined} trackingId */ function getCanonicalUrl(references, trackingId) { if (references && trackingId) { // Find the reference that matches our criteria /** @type {Reference| undefined} */ - const canonicalUrlReference = references.find( - (reference) => - validateReference(reference) && - reference.category === 'self' && - reference.url.startsWith('https://') && - reference.url.endsWith( - trackingId.toLowerCase().replace(/[^+\-a-z0-9]+/g, '_') + '.json' - ) + const canonicalUrlReference = references.find((reference) => + isCanonicalUrl(reference, trackingId) ) // When we find a matching reference, we know it has the url property - // because validateReference ensures it matches the Reference schema + // because isCanonicalUrl ensures it matches the Reference schema return canonicalUrlReference?.url ?? '' } else { return '' @@ -112,7 +96,7 @@ function getCanonicalUrl(references, trackingId) { * @param {string} canonicalURL * @return {boolean} */ -function hasServerRatingAndNoSource(metric, canonicalURL) { +function hasSeverityRatingAndNoSource(metric, canonicalURL) { return ( (!metric.source || metric.source === canonicalURL) && !!metric?.content?.qualitative_severity_rating @@ -129,18 +113,18 @@ function hasServerRatingAndNoSource(metric, canonicalURL) { * @param {any} doc */ export function recommendedTest_6_2_47(doc) { - /** @type {Array<{ message: string; instancePath: string }>} */ - const warnings = [] - const context = { warnings } - - if (!validate(doc)) { - return context + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + if (!validateInput(doc)) { + return ctx } /** @type {Array} */ const vulnerabilities = doc.vulnerabilities const canonicalURL = getCanonicalUrl( - doc.document.references, + doc.document?.references, doc.document?.tracking?.id ) @@ -150,7 +134,7 @@ export function recommendedTest_6_2_47(doc) { /** @type {Array | undefined} */ const invalidPaths = metrics ?.map((metric, metricIndex) => - hasServerRatingAndNoSource(metric, canonicalURL) + hasSeverityRatingAndNoSource(metric, canonicalURL) ? `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/qualitative_severity_rating` : null ) @@ -158,15 +142,15 @@ export function recommendedTest_6_2_47(doc) { if (!!invalidPaths) { invalidPaths.forEach((path) => { - context.warnings.push({ + ctx.warnings.push({ message: - 'the metric has a qualitative severity rating and no source property' + - ' or a source property that ist equal to the canonical URL', + 'a qualitative severity rating is used by the issuing party (as no "source" is given' + + ' or the source property equals to the canonical URL)', instancePath: path, }) }) } }) - return context + return ctx } diff --git a/lib/optionalTests/optionalTest_6_2_11.js b/lib/optionalTests/optionalTest_6_2_11.js index 022874bc..a6d590b6 100644 --- a/lib/optionalTests/optionalTest_6_2_11.js +++ b/lib/optionalTests/optionalTest_6_2_11.js @@ -1,4 +1,5 @@ import Ajv from 'ajv/dist/jtd.js' +import { isCanonicalUrl } from '../shared/urlHelper.js' const ajv = new Ajv() @@ -26,16 +27,7 @@ const inputSchema = /** @type {const} */ ({ }, }) -const referenceSchema = /** @type {const} */ ({ - additionalProperties: true, - properties: { - category: { type: 'string' }, - url: { type: 'string' }, - }, -}) - const validate = ajv.compile(inputSchema) -const validateReference = ajv.compile(referenceSchema) /** * @param {any} doc @@ -58,15 +50,8 @@ export default function optionalTest_6_2_11(doc) { return ctx } - const hasCanonicalURL = doc.document.references.some( - (r) => - validateReference(r) && - r.category === 'self' && - r.url.startsWith('https://') && - r.url.endsWith( - doc.document.tracking.id.toLowerCase().replace(/[^+\-a-z0-9]+/g, '_') + - '.json' - ) + const hasCanonicalURL = doc.document.references.some((reference) => + isCanonicalUrl(reference, doc.document.tracking.id) ) if (!hasCanonicalURL) { diff --git a/lib/shared/urlHelper.js b/lib/shared/urlHelper.js new file mode 100644 index 00000000..204e40b8 --- /dev/null +++ b/lib/shared/urlHelper.js @@ -0,0 +1,42 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const referenceSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + category: { type: 'string' }, + url: { type: 'string' }, + }, +}) +const validateReference = ajv.compile(referenceSchema) + +/** + * Checks whether a reference contains a canonical URL + * It works for CSAF 2.0 and CSAF 2.1 + * A canonical URL fulfills all the following: + * - It has the category self + * - The url starts with https:// + * - The url ends with the valid filename for the CSAF document + * A filename must apply the following rules + * - The value /trackingId is converted into lower case + * - Any character sequence which is not part of one of the following groups MUST be replaced by a single underscore (_) + * Lower case ASCII letters (0x61 - 0x7A) + * digits (0x30 - 0x39) + * special characters: + (0x2B), - (0x2D) + * - The file extension .json MUST be appended. + * @param {{url?: string, category?: string}} reference + * @param {string} trackingId + * @return {boolean} + */ +export function isCanonicalUrl(reference, trackingId) { + return ( + validateReference(reference) && + reference.category === 'self' && + reference.url !== undefined && + reference.url.startsWith('https://') && + reference.url.endsWith( + trackingId.toLowerCase().replace(/[^+\-a-z0-9]+/g, '_') + '.json' + ) + ) +} diff --git a/tests/csaf_2_1/recommendedTest_6_2_47.js b/tests/csaf_2_1/recommendedTest_6_2_47.js index 721ab373..6f4676e3 100644 --- a/tests/csaf_2_1/recommendedTest_6_2_47.js +++ b/tests/csaf_2_1/recommendedTest_6_2_47.js @@ -8,4 +8,37 @@ describe('recommendedTest_6_2_47', function () { 0 ) }) + + it('runs on references with empty category in reference', function () { + assert.equal( + recommendedTest_6_2_47({ + document: { + references: [ + { + category: 'self', + summary: 'The canonical URL for the CSAF document.', + url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-02.json', + }, + { url: 'https://some.other.url' }, + ], + tracking: { + id: 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-11', + }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + qualitative_severity_rating: 'low', + }, + products: ['CSAFPID-9080700'], + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) }) diff --git a/tests/urlHelper.js b/tests/urlHelper.js new file mode 100644 index 00000000..a4c6a76a --- /dev/null +++ b/tests/urlHelper.js @@ -0,0 +1,60 @@ +import { isCanonicalUrl } from '../lib/shared/urlHelper.js' +import { expect } from 'chai' + +describe('test url helper', function () { + it('test isCanonicalUrl', function () { + expect( + isCanonicalUrl( + { + url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-12.json', + category: 'self', + }, + 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-12' + ), + 'Valid canonical URL' + ).to.be.true + + expect( + isCanonicalUrl( + { + url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-12.json', + category: 'not_self', + }, + 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-12' + ), + 'Invalid canonical URL - category not self' + ).to.be.false + }) + + expect( + isCanonicalUrl( + { + url: 'http://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-12.json', + category: 'self', + }, + 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-12' + ), + 'Invalid canonical URL - url starts not with https://' + ).to.be.false + + expect( + isCanonicalUrl( + { + category: 'self', + }, + 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-12' + ), + 'Invalid canonical URL - no URL ' + ).to.be.false + + expect( + isCanonicalUrl( + { + url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-12_invalid.json', + category: 'self', + }, + 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-12' + ), + 'Valid canonical URL - URL ends not with valid filename' + ).to.be.false +})