diff --git a/README.md b/README.md index 2f16af0f..f2531c98 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,6 @@ The following tests are not yet implemented and therefore missing: **Recommended Tests** -- Recommended Test 6.2.19 - Recommended Test 6.2.20 - Recommended Test 6.2.24 - Recommended Test 6.2.26 @@ -485,6 +484,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_19: 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/recommendedTests/recommendedTest_6_2_19.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_19.js index 643f159f..ab793be4 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_19.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_19.js @@ -1,8 +1,351 @@ -import { optionalTest_6_2_19 } from '../../optionalTests.js' +import { Ajv } from 'ajv/dist/jtd.js' +import { cvss30, cvss31 } from '../../lib/shared/first.js' +import * as cvss2 from '../../lib/shared/cvss2.js' +import * as cvss3 from '../../lib/shared/cvss3.js' +import * as cvss4 from '../../lib/shared/cvss4.js' + +const ajv = new Ajv() + +const cvssSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + environmentalScore: { type: 'float64' }, + vectorString: { type: 'string' }, + version: { type: 'string' }, + }, +}) + +const metricContentSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + cvss_v4: cvssSchema, + cvss_v3: cvssSchema, + cvss_v2: cvssSchema, + }, +}) + +const metricSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + content: metricContentSchema, + products: { + elements: { type: 'string' }, + }, + }, +}) + +const productStatusSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + fixed: { + elements: { type: 'string' }, + }, + first_fixed: { + elements: { type: 'string' }, + }, + }, +}) + +const vulnerabilitySchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_status: productStatusSchema, + metrics: { + elements: metricSchema, + }, + }, +}) + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: vulnerabilitySchema, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) + +/** + * @typedef {import('ajv/dist/core.js').JTDDataType} Vulnerability + * @typedef {import('ajv/dist/core.js').JTDDataType} Metric + * @typedef {import('ajv/dist/core.js').JTDDataType} MetricContent + * @typedef {import('ajv/dist/core.js').JTDDataType} Cvss + */ /** - * @param {unknown} doc + * @param {any} doc */ export function recommendedTest_6_2_19(doc) { - return optionalTest_6_2_19(doc) + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if (!validateInput(doc)) { + return ctx + } + const /** @type Vulnerability[] */ vulnerabilities = doc.vulnerabilities + vulnerabilities.forEach((vulnerability, vulnerabilityIndex) => { + const fixedProductIDs = new Set([ + ...(vulnerability.product_status?.first_fixed ?? []), + ...(vulnerability.product_status?.fixed ?? []), + ]) + for (const productID of fixedProductIDs) { + const /** @type {Metric[] | undefined} */ metrics = vulnerability.metrics + metrics?.forEach((metric, metricIndex) => { + if (metric.products?.includes(productID)) { + const content = metric.content + if (content !== undefined) { + const cvssTypes = /** @type {Array} */ ([ + 'cvss_v4', + 'cvss_v3', + 'cvss_v2', + ]) + cvssTypes.forEach((cvssType) => { + if ( + content[cvssType] && + checkCVSS(/** @type {Cvss} */ (content[cvssType])) + ) { + ctx.warnings.push({ + instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/${cvssType}`, + message: `environmental score should be 0 since "${productID}" is listed as fixed`, + }) + } + }) + } + } + }) + } + }) + + return ctx +} + +/** + * Check if the cvss object has a valid environmental score. + * @param {Cvss} cvss + * @returns {boolean} + */ +function checkCVSS(cvss) { + const calculatedValue = calculateEnvironmentalScoreFromMetrics({ + version: cvss.version, + vectorString: cvss.vectorString ?? '', + metrics: cvss, + }) + return ( + (typeof cvss.environmentalScore === 'number' && + cvss.environmentalScore > 0) || + (typeof calculatedValue === 'number' && calculatedValue > 0) || + calculatedValue === null + ) +} + +/** + * @param {object} params + * @param {string | undefined} params.version + * @param {string} params.vectorString + * @param {Record} params.metrics + */ +function calculateEnvironmentalScoreFromMetrics({ + version, + vectorString, + metrics, +}) { + const vectorFromVectorString = new Map( + vectorString + .split('/') + .map((e) => { + const [key, value] = e.split(':') + return /** @type {const} */ ([key, value]) + }) + .filter(([, value]) => value) + ) + + if (version === '4.0') { + return calculateMetricScoreForCVSS4( + vectorString, + metrics, + vectorFromVectorString + ) + } else if (version === '3.1' || version === '3.0') { + return calculateMetricScoreForCVSS3( + vectorFromVectorString, + metrics, + version + ) + } else { + return calculateMetricScoreForCVSS2(vectorFromVectorString, metrics) + } +} + +/** + * @param {string} vectorString + * @param {Record} metrics + * @param {Map} vectorFromVectorString + */ +function calculateMetricScoreForCVSS4( + vectorString, + metrics, + vectorFromVectorString +) { + // Extract all metrics from the metrics object and combine with vector string + const metricArray = calculateMetricArray({ + mapping: cvss4Mapping, + metrics, + vector: vectorFromVectorString, + }) + + // Build complete vector string with all metrics including Modified ones + const completeVectorParts = metricArray + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}:${value}`) + + // Keep CVSS version prefix from original vector + const versionPrefix = vectorString.split('/')[0] + const completeVectorString = [versionPrefix, ...completeVectorParts].join('/') + + const scoreObject = cvss4.calculateCvss4_0_Score(completeVectorString) + return scoreObject?.score ?? null +} + +/** + * This function takes a cvss vector and a metric object and extracts all cvss + * @param {Map} vectorFromVectorString + * @param {Record} metrics + * @param {'3.0' | '3.1'} version + * @returns {number|null} + */ +function calculateMetricScoreForCVSS3( + vectorFromVectorString, + metrics, + version +) { + const args = /** + * @type {[ + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * string, + * ]} + */ ( + calculateMetricArray({ + mapping: cvss3Mapping, + metrics, + vector: vectorFromVectorString, + }).map((e) => e[1]) + ) + const metric = (version === '3.1' ? cvss31 : cvss30).calculateCVSSFromMetrics( + ...args + ) + if (!metric.success) return null + return Number(metric.environmentalMetricScore) +} + +/** + * This function takes a cvss vector and a metric object and extracts all cvss + * @param {Map} vectorFromVectorString + * @param {Record} metrics + * @returns {number|null} + */ +function calculateMetricScoreForCVSS2(vectorFromVectorString, metrics) { + const vector = Object.fromEntries( + calculateMetricArray({ + mapping: cvss2Mapping, + metrics, + vector: vectorFromVectorString, + }) + ) + const metric = safelyParseCVSSV2Vector(vector) + if (!metric.success) return null + return metric.environmentalMetricScore +} + +const cvss2Mapping = + /** @type {ReadonlyArray]>} */ ( + cvss2.mapping.map((mapping) => [ + mapping[0], + mapping[1], + Object.fromEntries( + Object.entries(mapping[2]).map(([key, value]) => [key, value.id]) + ), + ]) + ) + +const cvss3Mapping = cvss3.mapping + +const cvss4Mapping = + /** @type {ReadonlyArray]>} */ ( + cvss4.flatMetrics.map((metric) => [ + metric.jsonName, + metric.metricShort, + Object.fromEntries( + metric.options.map((option) => [option.optionValue, option.optionKey]) + ), + ]) + ) + +/** + * This function takes a cvss vector and a metric object and extracts all cvss + * values according to the mapping. It does this by first looking up every property + * in the `vector`. If the property doesn't exist there but in the metrics objects, + * it takes the value from the corresponding metrics object. + * + * @param {object} params + * @param {Map} params.vector + * @param {Record} params.metrics + * @param {ReadonlyArray]>} params.mapping + * @returns an array of pairs where the first element is the metric name (abbreviated) and the + * second is the value (abbreviated). If no value is found the value is `undefined`. + * The order of the array is the same as in the mapping. + */ +function calculateMetricArray({ vector, metrics, mapping }) { + return mapping.map((e) => { + const metricAbbrev = e[1] + const metricPropertyName = e[0] + const /** @type {Record} */ metricValueAbbrevMap = e[2] + const metricValue = /** @type {string} */ (metrics[metricPropertyName]) + return [ + metricAbbrev, + vector.get(metricAbbrev) ?? metricValueAbbrevMap[metricValue], + ] + }) +} + +/** + * @param {Record} vectorString + * @returns {{ success: true; environmentalMetricScore: number } | { success: false; environmentalMetricScore: -1 }} + */ +function safelyParseCVSSV2Vector(vectorString) { + try { + return { + success: true, + environmentalMetricScore: + cvss2.getEnvironmentalScoreFromVectorString(vectorString), + } + } catch { + return { + success: false, + environmentalMetricScore: -1, + } + } } diff --git a/lib/shared/cvss4.js b/lib/shared/cvss4.js index e8f6fc3c..89772ebb 100644 --- a/lib/shared/cvss4.js +++ b/lib/shared/cvss4.js @@ -665,7 +665,7 @@ export const flatMetrics = [ }, { optionName: 'None (N)', - optionValue: 'NONEN', + optionValue: 'NONE', optionKey: 'N', }, ], @@ -763,7 +763,7 @@ export const flatMetrics = [ }, { optionName: 'Negligible (N)', - optionValue: '', + optionValue: 'NEGLIGIBLE', optionKey: 'N', }, ], @@ -1079,7 +1079,7 @@ export class Cvss4JsonWrapper { this.#data[metric.jsonName] = metricOptions?.optionsByKey.get(optionsKey) ?? '' }) - } catch (error) { + } catch { flatMetrics.forEach((metric) => { this.#data[metric.jsonName] = '' }) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 0f97584b..f95abab2 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -22,7 +22,6 @@ const excluded = [ '6.1.60.1', '6.1.60.2', '6.1.60.3', - '6.2.19', '6.2.20', '6.2.24', '6.2.26', diff --git a/tests/csaf_2_1/recommendedTest_6_2_19.js b/tests/csaf_2_1/recommendedTest_6_2_19.js new file mode 100644 index 00000000..58cfdea4 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_19.js @@ -0,0 +1,107 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_19 } from '../../csaf_2_1/recommendedTests.js' + +describe('recommendedTest_6_2_19', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_19({ vulnerabilities: 'mydoc' }).warnings.length, + 0 + ) + }) + + it('warns when cvss_v2 base metrics are missing', function () { + const result = recommendedTest_6_2_19({ + vulnerabilities: [ + { + product_status: { + fixed: ['CSAFPID-0001'], + }, + metrics: [ + { + products: ['CSAFPID-0001'], + content: { + cvss_v2: { + vectorString: '', + }, + }, + }, + ], + }, + ], + }) + assert.equal(result.warnings.length, 1) + }) + + it('warns when cvss_v3 vector is invalid', function () { + const result = recommendedTest_6_2_19({ + vulnerabilities: [ + { + product_status: { + fixed: ['CSAFPID-0001'], + }, + metrics: [ + { + products: ['CSAFPID-0001'], + content: { + cvss_v3: { + version: '3.1', + vectorString: 'INVALID_VECTOR', + }, + }, + }, + ], + }, + ], + }) + // calculatedValue === null → checkCVSS returns true → warning is issued + assert.equal(result.warnings.length, 1) + }) + + it('warns when cvss_v2 vectorString is undefined (missing)', function () { + const result = recommendedTest_6_2_19({ + vulnerabilities: [ + { + product_status: { + fixed: ['CSAFPID-0001'], + }, + metrics: [ + { + products: ['CSAFPID-0001'], + content: { + cvss_v2: { + // kein vectorString → wird zu '' durch ?? '' + }, + }, + }, + ], + }, + ], + }) + // calculatedValue === null → checkCVSS gibt true → Warning + assert.equal(result.warnings.length, 1) + }) + + it('does not warn when cvss_v3 environmental score is 0', function () { + const result = recommendedTest_6_2_19({ + vulnerabilities: [ + { + product_status: { fixed: ['CSAFPID-0001'] }, + metrics: [ + { + products: ['CSAFPID-0001'], + content: { + cvss_v3: { + version: '3.1', + vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N', + environmentalScore: 0, // nicht > 0 + // calculatedValue wird 0 sein → nicht > 0 und nicht null + }, + }, + }, + ], + }, + ], + }) + assert.equal(result.warnings.length, 0) + }) +})