diff --git a/README.md b/README.md index 647244ed..3acba5d5 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,6 @@ The following tests are not yet implemented and therefore missing: **Recommended Tests** - Recommended Test 6.2.20 -- Recommended Test 6.2.24 - Recommended Test 6.2.31 - Recommended Test 6.2.38 - Recommended Test 6.2.39.1 @@ -502,6 +501,7 @@ 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 +export const recommendedTest_6_2_24: DocumentTest export const recommendedTest_6_2_25: DocumentTest export const recommendedTest_6_2_26: DocumentTest export const recommendedTest_6_2_27: DocumentTest diff --git a/csaf_2_1/dateHelper.js b/csaf_2_1/dateHelper.js index 037d1dd0..69f7acc1 100644 --- a/csaf_2_1/dateHelper.js +++ b/csaf_2_1/dateHelper.js @@ -17,6 +17,22 @@ export const compareZonedDateTimes = (a, b) => { } } +/** + * Convert a local date in string representation, i.e. a date accurate to the + * day like 'yyyy-mm-dd', to a zoned instant string at the start of that day + * in UTC. This is needed because `compareZonedDateTimes` relies on + * `Temporal.Instant.from`, which cannot parse plain (offset-less) dates. + * + * @param {string} localDateString + * @returns {string} the UTC instant representing the start of the given local date + */ +export const localDateToTimeZonedDate = (localDateString) => { + return Temporal.PlainDate.from(localDateString) + .toZonedDateTime('UTC') + .toInstant() + .toString() +} + /** * This regex validates a date against RFC 3339 section 5.6. * See: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 4e74f84a..32d41eb1 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -28,6 +28,7 @@ export { recommendedTest_6_2_19 } from './recommendedTests/recommendedTest_6_2_1 export { recommendedTest_6_2_21 } from './recommendedTests/recommendedTest_6_2_21.js' export { recommendedTest_6_2_22 } from './recommendedTests/recommendedTest_6_2_22.js' export { recommendedTest_6_2_23 } from './recommendedTests/recommendedTest_6_2_23.js' +export { recommendedTest_6_2_24 } from './recommendedTests/recommendedTest_6_2_24.js' export { recommendedTest_6_2_25 } from './recommendedTests/recommendedTest_6_2_25.js' export { recommendedTest_6_2_26 } from './recommendedTests/recommendedTest_6_2_26.js' export { recommendedTest_6_2_27 } from './recommendedTests/recommendedTest_6_2_27.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_24.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_24.js new file mode 100644 index 00000000..20d4d6d7 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_24.js @@ -0,0 +1,144 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import { cwecMap } from '../../lib/cwec.js' +import { + compareZonedDateTimes, + localDateToTimeZonedDate, +} 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: { + current_release_date: { type: 'string' }, + }, + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + cwes: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) + +const cweSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + id: { type: 'string' }, + version: { type: 'string' }, + name: { type: 'string' }, + }, +}) + +const validateCWE = ajv.compile(cweSchema) + +/** @type {Promise> | undefined} */ +let sortedCwecReleases + +/** + * Loads the release dates of all known CWE versions once (loading all catalogues is expensive) + * and returns them sorted descending by date (newest first). Versions with the same date + * keep the order of the `cwecMap`, which lists newer versions first. + * + * @returns {Promise>} + */ +function getSortedCwecReleases() { + sortedCwecReleases ??= (async () => { + /** @type {Array<[string, string]>} */ + const releases = [] + for (const [version, load] of cwecMap) { + const date = (await load()).default.date + releases.push([version, localDateToTimeZonedDate(date)]) + } + return releases.sort((a, b) => compareZonedDateTimes(b[1], a[1])) + })() + return sortedCwecReleases +} + +/** + * This implements the recommended test 6.2.24 of the CSAF 2.1 standard. + * + * @param {any} doc + */ +export async function recommendedTest_6_2_24(doc) { + /** @type {Array<{ message: string; instancePath: string }>} */ + const warnings = [] + const context = { warnings } + + if (!validateInput(doc)) { + return context + } + + const currentReleaseDate = doc.document.tracking.current_release_date + const cwecArray = await getSortedCwecReleases() + + for (let i = 0; i < doc.vulnerabilities.length; ++i) { + const vulnerability = doc.vulnerabilities[i] + if (vulnerability.cwes) { + for (let j = 0; j < vulnerability.cwes.length; ++j) { + const cwe = vulnerability.cwes.at(j) + if (validateCWE(cwe)) { + const idx = cwecArray.findIndex((row) => row[0] === cwe.version) + if (idx === -1) continue + const zonedCweReleaseDate = cwecArray[idx][1] + // Case 1: Check if the used cwe version was already published + // at the current release date of the CSAF document + if ( + compareZonedDateTimes(currentReleaseDate, zonedCweReleaseDate) < 0 + ) { + context.warnings.push({ + instancePath: `/vulnerabilities/${i}/cwes/${j}/version`, + message: `at release date ${currentReleaseDate} the used version ${cwe.version} was not published yet`, + }) + // Case 2: Check if a newer cwe version than the used one had already been published + // at the current release date of the CSAF document + } else { + for (let k = idx - 1; k >= 0; k--) { + const zonedCweReleaseDateNewer = cwecArray[k][1] + + if ( + compareZonedDateTimes( + currentReleaseDate, + zonedCweReleaseDateNewer + ) > 0 + ) { + context.warnings.push({ + instancePath: `/vulnerabilities/${i}/cwes/${j}/version`, + message: + `the used cwe version ${cwe.version} ` + + `was not the newest one available at release date ${currentReleaseDate}`, + }) + break + } + } + } + } + } + } + } + return context +} diff --git a/lib/cwec.js b/lib/cwec.js index 06b1b7b0..7fd56985 100644 --- a/lib/cwec.js +++ b/lib/cwec.js @@ -12,7 +12,7 @@ /** * @typedef {object} CweCatalogue - * @property {string} [date] + * @property {string} date * @property {CweWeakness[]} weaknesses */ diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index a65b2ea6..164b80cf 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -17,7 +17,6 @@ const excluded = [ '6.1.60.2', '6.1.60.3', '6.2.20', - '6.2.24', '6.2.31', '6.2.39.1', '6.2.46', diff --git a/tests/csaf_2_1/recommendedTest_6_2_24.js b/tests/csaf_2_1/recommendedTest_6_2_24.js new file mode 100644 index 00000000..263aea75 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_24.js @@ -0,0 +1,91 @@ +import { recommendedTest_6_2_24 } from '../../csaf_2_1/recommendedTests.js' + +describe('recommendedTest_6_2_24', function () { + it('only runs on relevant documents', async function () { + expect( + (await recommendedTest_6_2_24({ vulnerabilities: 'mydoc' })).warnings + .length + ).toBe(0) + }) + + it('skips cwe entries with an unknown version (not in cwecMap)', async function () { + expect( + ( + await recommendedTest_6_2_24({ + document: { + tracking: { + current_release_date: '2024-01-21T10:00:00.000Z', + }, + }, + vulnerabilities: [ + { + cwes: [ + { + id: 'CWE-256', + name: 'Plaintext Storage of a Password', + version: '99.99', + }, + ], + }, + ], + }) + ).warnings.length + ).toBe(0) + }) + + it('skips empty objects', async function () { + expect( + ( + await recommendedTest_6_2_24({ + document: { + tracking: { + current_release_date: '2024-01-21T10:00:00.000Z', + }, + }, + vulnerabilities: [ + { + cwes: [ + { + id: 'CWE-256', + name: 'Plaintext Storage of a Password', + version: '4.12', + }, + ], + }, + {}, // should be ignored + ], + }) + ).warnings.length + ).toBe(1) + }) + + it('skips cwe entries not matching the cwe schema but checks valid ones', async function () { + const result = await recommendedTest_6_2_24({ + document: { + tracking: { current_release_date: '2024-01-21T10:00:00.000Z' }, + }, + vulnerabilities: [ + { + cwes: [ + { id: 'CWE-256', name: 'Plaintext Storage of a Password' }, // version missing + { + id: 'CWE-256', + name: 'Plaintext Storage of a Password', + version: 4.12, + }, // wrong type + 'CWE-256', // not an object + { + id: 'CWE-256', + name: 'Plaintext Storage of a Password', + version: '4.12', + }, + ], + }, + ], + }) + + expect(result.warnings[0].instancePath).to.equal( + '/vulnerabilities/0/cwes/3/version' + ) + }) +})