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..066c7c1b --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_47.js @@ -0,0 +1,156 @@ +import Ajv from 'ajv/dist/jtd.js' +import { isCanonicalUrl } from '../../lib/shared/urlHelper.js' + +/** @typedef {import('ajv/dist/jtd.js').JTDDataType} InputSchema */ + +/** @typedef {InputSchema['vulnerabilities'][number]} Vulnerability */ + +/** @typedef {NonNullable[number]} Metric */ + +/** @typedef {NonNullable} MetricContent */ + +/** @typedef {{url?: string, category?: string}} Reference */ + +const jtdAjv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + optionalProperties: { + references: { + elements: { + additionalProperties: true, + optionalProperties: { + 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', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +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<{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) => + isCanonicalUrl(reference, trackingId) + ) + + // When we find a matching reference, we know it has the url property + // because isCanonicalUrl 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 hasSeverityRatingAndNoSource(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) { + 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?.tracking?.id + ) + + vulnerabilities.forEach((vulnerabilityItem, vulnerabilityIndex) => { + /** @type {Array | undefined} */ + const metrics = vulnerabilityItem.metrics + /** @type {Array | undefined} */ + const invalidPaths = metrics + ?.map((metric, metricIndex) => + hasSeverityRatingAndNoSource(metric, canonicalURL) + ? `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/qualitative_severity_rating` + : null + ) + .filter((path) => path !== null) + + if (!!invalidPaths) { + invalidPaths.forEach((path) => { + ctx.warnings.push({ + message: + '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 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/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..6f4676e3 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_47.js @@ -0,0 +1,44 @@ +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 + ) + }) + + 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 +})