diff --git a/README.md b/README.md index 1bedef0c..134d9e4e 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,6 @@ The following tests are not yet implemented and therefore missing: - Mandatory Test 6.1.27.13 - Mandatory Test 6.1.47 - Mandatory Test 6.1.48 -- Mandatory Test 6.1.49 - Mandatory Test 6.1.50 - Mandatory Test 6.1.54 - Mandatory Test 6.1.55 @@ -459,6 +458,7 @@ export const mandatoryTest_6_1_43: DocumentTest export const mandatoryTest_6_1_44: DocumentTest export const mandatoryTest_6_1_45: DocumentTest export const mandatoryTest_6_1_46: DocumentTest +export const mandatoryTest_6_1_49: DocumentTest export const mandatoryTest_6_1_51: DocumentTest export const mandatoryTest_6_1_52: DocumentTest export const mandatoryTest_6_1_53: DocumentTest diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index d26ae836..0ae4d620 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -63,6 +63,7 @@ export { mandatoryTest_6_1_43 } from './mandatoryTests/mandatoryTest_6_1_43.js' export { mandatoryTest_6_1_44 } from './mandatoryTests/mandatoryTest_6_1_44.js' export { mandatoryTest_6_1_45 } from './mandatoryTests/mandatoryTest_6_1_45.js' export { mandatoryTest_6_1_46 } from './mandatoryTests/mandatoryTest_6_1_46.js' +export { mandatoryTest_6_1_49 } from './mandatoryTests/mandatoryTest_6_1_49.js' export { mandatoryTest_6_1_51 } from './mandatoryTests/mandatoryTest_6_1_51.js' export { mandatoryTest_6_1_52 } from './mandatoryTests/mandatoryTest_6_1_52.js' export { mandatoryTest_6_1_53 } from './mandatoryTests/mandatoryTest_6_1_53.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_49.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_49.js new file mode 100644 index 00000000..220a41af --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_49.js @@ -0,0 +1,135 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import { compareZonedDateTimes } from '../dateHelper.js' + +const ajv = new Ajv() + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match it normally means that the input + document does not validate against the csaf json schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + tracking: { + additionalProperties: true, + properties: { + revision_history: { + elements: { + additionalProperties: true, + optionalProperties: { + date: { type: 'string' }, + }, + }, + }, + status: { type: 'string' }, + }, + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + content: { + additionalProperties: true, + optionalProperties: { + ssvc_v2: { + additionalProperties: true, + optionalProperties: { + timestamp: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +/** @typedef {import('ajv/dist/jtd.js').JTDDataType} InputSchema */ +/** @typedef {InputSchema['vulnerabilities'][number]} Vulnerability */ +/** @typedef {{ date?: string }} RevisionHistoryItem */ + +const validateInput = ajv.compile(inputSchema) + +/** + * @param {Array} revisionHistory + * @returns {RevisionHistoryItem | undefined} */ +function getNewestRevisionHistoryEntry(revisionHistory) { + // sort the revision history (descending) and save the newest entry + return revisionHistory + .filter((item) => item.date !== undefined) + .sort((a, b) => + compareZonedDateTimes( + /** @type {string} */ (b.date), + /** @type {string} */ (a.date) + ) + )[0] +} + +/** + * This implements the mandatory test 6.1.49 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export function mandatoryTest_6_1_49(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if (!validateInput(doc)) { + return ctx + } + + if ( + doc.document.tracking.status === 'final' || + doc.document.tracking.status === 'interim' + ) { + const revisionHistory = doc.document.tracking.revision_history + const newestRevisionHistoryItem = + getNewestRevisionHistoryEntry(revisionHistory) + if (newestRevisionHistoryItem) { + /** @type {Array} */ + const vulnerabilities = doc.vulnerabilities + vulnerabilities.forEach((vulnerability, vulnerabilityIndex) => { + vulnerability.metrics?.forEach((metric, metricIndex) => { + const ssvcTimestamp = metric.content?.ssvc_v2?.timestamp + if (ssvcTimestamp) { + // compare the ssvcTimestamp with the date of the newest item in the revision history + if ( + compareZonedDateTimes( + ssvcTimestamp, + /** @type {string} */ (newestRevisionHistoryItem?.date) + ) > 0 + ) { + ctx.isValid = false + ctx.errors.push({ + instancePath: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v2/timestamp`, + message: + `The document is in status ${doc.document.tracking.status} but the SSVC timestamp is newer ` + + `than the date of newest item in the revision_history`, + }) + } + } + }) + }) + } + } + + return ctx +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_49.js b/tests/csaf_2_1/mandatoryTest_6_1_49.js new file mode 100644 index 00000000..4a1ea37c --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_49.js @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict' +import { mandatoryTest_6_1_49 } from '../../csaf_2_1/mandatoryTests/mandatoryTest_6_1_49.js' +import { expect } from 'chai' + +describe('mandatoryTest_6_1_49', function () { + it('only runs on relevant documents', function () { + assert.equal(mandatoryTest_6_1_49({ document: 'mydoc' }).isValid, true) + }) + + it('test input schema with empty json object in vulnerabilities', async function () { + const failingInputSchemaTestWithEmptyVulnerability = { + document: { + tracking: { + revision_history: [ + { + date: '2024-01-24T10:00:00.000Z', + }, + ], + status: 'final', + }, + }, + vulnerabilities: [ + {}, // even this vulnerability is empty, the test should not fail due to the inputSchema + { + cve: 'CVE-1900-0001', + metrics: [ + { + content: { + ssvc_v2: { + id: 'CVE-1900-0001', + schemaVersion: '1-0-1', + timestamp: '2024-07-13T10:00:00.000Z', + }, + }, + }, + ], + }, + ], + } + + const result = mandatoryTest_6_1_49( + failingInputSchemaTestWithEmptyVulnerability + ) + expect(result.errors.length).to.eq(1) + }) + it('test input schema with empty/invalid dates in revision_history', async function () { + const failingSchemaTestWithEmptyDates = { + document: { + tracking: { + revision_history: [ + { + number: '1', + }, + { + date: '', + number: '2', + }, + { + date: '1.3.45', + number: '2', + }, + ], + status: 'final', + }, + }, + vulnerabilities: [ + { + metrics: [ + { + content: { + ssvc_v2: { + timestamp: '2024-07-13T10:00:00.000Z', + }, + }, + }, + ], + }, + ], + } + + const result = mandatoryTest_6_1_49(failingSchemaTestWithEmptyDates) + expect(result.errors.length).to.eq(0) + }) + + it('test input schema with empty json object in ssvc_2', async function () { + const failingInputSchemaTestWithEmptyVulnerability = { + document: { + tracking: { + revision_history: [ + { + date: '2024-01-24T10:00:00.000Z', + }, + ], + status: 'final', + }, + }, + vulnerabilities: [ + { + cve: 'CVE-1900-0001', + metrics: [ + { + content: { + epss: {}, // even this ssvc_v2 is here empty, the test should not fail due to the inputSchema + }, + }, + { + content: { + ssvc_v2: { + id: 'CVE-1900-0001', + schemaVersion: '1-0-1', + timestamp: '2024-07-13T10:00:00.000Z', + }, + }, + }, + ], + }, + ], + } + + const result = mandatoryTest_6_1_49( + failingInputSchemaTestWithEmptyVulnerability + ) + expect(result.errors.length).to.eq(1) + }) + + it('test input schema with empty revision history', async function () { + const failingInputSchemaTestWithEmptyVulnerability = { + document: { + tracking: { + revision_history: [], + status: 'final', + }, + }, + vulnerabilities: [ + { + cve: 'CVE-1900-0001', + metrics: [ + { + content: { + ssvc_v2: { + id: 'CVE-1900-0001', + schemaVersion: '1-0-1', + timestamp: '2024-07-13T10:00:00.000Z', + }, + }, + }, + ], + }, + ], + } + + const result = mandatoryTest_6_1_49( + failingInputSchemaTestWithEmptyVulnerability + ) + expect(result.errors.length).to.eq(0) + }) + + it('test input schema with not existing revision history', async function () { + const failingInputSchemaTestWithEmptyVulnerability = { + document: { + tracking: { + status: 'final', + }, + }, + vulnerabilities: [ + { + cve: 'CVE-1900-0001', + metrics: [ + { + content: { + ssvc_v2: { + id: 'CVE-1900-0001', + schemaVersion: '1-0-1', + timestamp: '2024-07-13T10:00:00.000Z', + }, + }, + }, + ], + }, + ], + } + + const result = mandatoryTest_6_1_49( + failingInputSchemaTestWithEmptyVulnerability + ) + expect(result.errors.length).to.eq(0) + }) + + it('test input schema with status interim', async function () { + const failingInputSchemaTestWithEmptyVulnerability = { + document: { + tracking: { + revision_history: [ + { + date: '2024-01-24T10:00:00.000Z', + }, + ], + status: 'interim', + }, + }, + vulnerabilities: [ + {}, // even this vulnerability is empty, the test should not fail due to the inputSchema + { + cve: 'CVE-1900-0001', + metrics: [ + { + content: { + ssvc_v2: { + id: 'CVE-1900-0001', + schemaVersion: '1-0-1', + timestamp: '2024-07-13T10:00:00.000Z', + }, + }, + }, + ], + }, + ], + } + + const result = mandatoryTest_6_1_49( + failingInputSchemaTestWithEmptyVulnerability + ) + expect(result.errors.length).to.eq(1) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 1058d0e3..8fd7f075 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -18,7 +18,6 @@ const excluded = [ '6.1.37', '6.1.47', '6.1.48', - '6.1.49', '6.1.50', '6.1.53', '6.1.54',