diff --git a/README.md b/README.md index 2910de45..3cab57bf 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,6 @@ The following tests are not yet implemented and therefore missing: - Mandatory Test 6.1.55 - Mandatory Test 6.1.59 - Mandatory Test 6.1.60.1 -- Mandatory Test 6.1.60.2 - Mandatory Test 6.1.60.3 **Recommended Tests** @@ -462,6 +461,7 @@ export const mandatoryTest_6_1_52: DocumentTest export const mandatoryTest_6_1_53: DocumentTest export const mandatoryTest_6_1_57: DocumentTest export const mandatoryTest_6_1_58: DocumentTest +export const mandatoryTest_6_1_60_2: DocumentTest export const mandatoryTest_6_1_61: DocumentTest ``` diff --git a/csaf_2_1/csafAjv.js b/csaf_2_1/csafAjv.js index 9c05c814..a4de87e6 100644 --- a/csaf_2_1/csafAjv.js +++ b/csaf_2_1/csafAjv.js @@ -1,5 +1,6 @@ import addFormats from 'ajv-formats' import { Ajv2020 } from 'ajv/dist/2020.js' +import { request } from 'undici' import cvss_v2_0 from '../schemas/cvss-v2.0.js' import cvss_v3_0 from '../schemas/cvss-v3.0.js' import cvss_v3_1 from '../schemas/cvss-v3.1.js' @@ -14,7 +15,69 @@ import selectionList_2_0_0Schema from './csafAjv/SelectionList_2_0_0.schema.js' import { validateTimestamp } from './dateHelper.js' -const csafAjv = new Ajv2020({ strict: false, allErrors: true }) +/** + * Cache of in-flight/loaded remote schemas, keyed by URI, so that a schema + * referenced multiple times (e.g. by several `x_extensions` in the same + * document) is only fetched once per process. + * + * @type {Map>} + */ +const remoteSchemaCache = new Map() + +/** + * Loader used by ajv to resolve `$ref`s that point to schemas which are not + * already registered via `addSchema` above (e.g. CSAF extension schemas + * declared via a document's own `$schema` property). + * + * SECURITY NOTE: `uri` can originate directly from the document being + * validated (attacker-controlled). Restricting the protocol to `https:` + * blocks the obvious SSRF vectors (`file:`, plaintext `http:`), but not + * requests to internal hosts reachable via `https:`. Further hardening + * (request timeout, response size limit, redirect handling) is not + * implemented yet and should be added for security-sensitive/production + * environments. + * + * @param {string} uri + * @returns {Promise} + */ +async function loadSchema(uri) { + const cached = remoteSchemaCache.get(uri) + if (cached) return cached + + const promise = (async () => { + let parsed + try { + parsed = new URL(uri) + } catch { + throw new Error(`Cannot load schema "${uri}": not a valid URL`) + } + if (parsed.protocol !== 'https:') { + throw new Error( + `Cannot load schema "${uri}": only "https:" URLs may be loaded, got "${parsed.protocol}"` + ) + } + + const res = await request(uri, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + if (res.statusCode < 200 || 400 <= res.statusCode) { + throw new Error( + `Cannot load schema "${uri}": received HTTP status ${res.statusCode}` + ) + } + return /** @type {Promise} */ ( + res.body.json() + ) + })() + + remoteSchemaCache.set(uri, promise) + // Don't keep failed lookups cached - allow a retry on the next call. + promise.catch(() => remoteSchemaCache.delete(uri)) + return promise +} + +const csafAjv = new Ajv2020({ strict: false, allErrors: true, loadSchema }) addFormats.default(csafAjv) csafAjv.addMetaSchema( draft_07_schema, @@ -26,7 +89,7 @@ csafAjv.addSchema(cvss_v3_1, 'https://www.first.org/cvss/cvss-v3.1.json') csafAjv.addSchema(cvss_meta, 'https://www.first.org/cvss/meta.json') csafAjv.addSchema( content_schema, - 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/extension-metaschema.json#/$defs/content_schema_t' + 'https://docs.oasis-open.org/csaf/csaf/v2.1/schema/extension-metaschema.json' ) csafAjv.addSchema( meta_format_assertion, diff --git a/csaf_2_1/csafAjv/content_schema.js b/csaf_2_1/csafAjv/content_schema.js index a985c9bb..cdee0f62 100644 --- a/csaf_2_1/csafAjv/content_schema.js +++ b/csaf_2_1/csafAjv/content_schema.js @@ -194,7 +194,7 @@ export default { uniqueItems: true, items: { type: 'string', - enum: ['critical', 'high_value', 'informational'], + enum: ['essential', 'significant', 'supplementary'], }, }, }, diff --git a/csaf_2_1/csafAjv/extension-content.js b/csaf_2_1/csafAjv/extension-content.js index 9a79f18c..031fc052 100644 --- a/csaf_2_1/csafAjv/extension-content.js +++ b/csaf_2_1/csafAjv/extension-content.js @@ -18,7 +18,7 @@ export default { title: 'Extension Category', description: 'Holds the category of the extension content.', type: 'string', - enum: ['critical', 'high_value', 'informational'], + enum: ['essential', 'significant', 'supplementary'], }, content: { title: 'Content', diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index bf9715a3..5ef032fd 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -68,4 +68,5 @@ export { mandatoryTest_6_1_52 } from './mandatoryTests/mandatoryTest_6_1_52.js' export { mandatoryTest_6_1_53 } from './mandatoryTests/mandatoryTest_6_1_53.js' export { mandatoryTest_6_1_57 } from './mandatoryTests/mandatoryTest_6_1_57.js' export { mandatoryTest_6_1_58 } from './mandatoryTests/mandatoryTest_6_1_58.js' +export { mandatoryTest_6_1_60_2 } from './mandatoryTests/mandatoryTest_6_1_60_2.js' export { mandatoryTest_6_1_61 } from './mandatoryTests/mandatoryTest_6_1_61.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_60_2.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_60_2.js new file mode 100644 index 00000000..6e78acad --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_60_2.js @@ -0,0 +1,65 @@ +import { walkPath } from '../../lib/walkPaths.js' +import csafAjv from '../csafAjv.js' + +const X_EXTENSIONS_PATHS /** @type {string[]} */ = [ + '/document/x_extensions[]', + '/product_tree/branches[*]/product/x_extensions[]', + '/product_tree/full_product_names[]/x_extensions[]', + '/product_tree/product_paths[]/full_product_name/x_extensions[]', + '/vulnerabilities[]/metrics[]/content/x_extensions[]', + '/vulnerabilities[]/x_extensions[]', + '/x_extensions[]', +] + +/** + * This implements the mandatory test 6.1.60.2 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export async function mandatoryTest_6_1_60_2(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + for (const path of X_EXTENSIONS_PATHS) { + await walkPath(doc, path, async (instancePath, value) => { + const schemaUrl = + value && typeof value === 'object' && '$schema' in value + ? /** @type {{ $schema: unknown }} */ (value).$schema + : undefined + + if (typeof schemaUrl !== 'string') return + + let validateDeclaredSchema = csafAjv.getSchema(schemaUrl) + if (typeof validateDeclaredSchema !== 'function') { + try { + validateDeclaredSchema = await csafAjv.compileAsync({ + $ref: schemaUrl, + }) + } catch { + ctx.warnings.push({ + instancePath, + message: `declared CSAF Extension Schema "${schemaUrl}" is not supported and could not be validated`, + }) + return + } + } + + if (!validateDeclaredSchema(value)) { + ctx.isValid = false + validateDeclaredSchema.errors?.forEach((err) => { + ctx.errors.push({ + instancePath: `${instancePath}${err.instancePath}`, + message: err.message ?? 'invalid extension content', + }) + }) + } + }) + } + + return ctx +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_60_2.js b/tests/csaf_2_1/mandatoryTest_6_1_60_2.js new file mode 100644 index 00000000..c364a45d --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_60_2.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { mandatoryTest_6_1_60_2 } from '../../csaf_2_1/mandatoryTests.js' + +describe('mandatoryTest_6_1_60_2', function () { + it('reports a warning when the declared schema is unknown', async function () { + const result = await mandatoryTest_6_1_60_2({ + x_extensions: [ + { + $schema: 'https://example.com/csaf/extension/unknown_1.0.0.json', + category: 'supplementary', + content: { note: 'unknown schema' }, + critical: false, + }, + ], + }) + + assert.equal(result.isValid, true) + assert.equal(result.errors.length, 0) + assert.equal(result.warnings.length, 1) + assert.equal(result.warnings[0].instancePath, '/x_extensions/0') + assert.match( + result.warnings[0].message, + /https:\/\/example\.com\/csaf\/extension\/unknown_1\.0\.0\.json/ + ) + }) + + it('skips extensions that have no $schema property', async function () { + const result = await mandatoryTest_6_1_60_2({ + x_extensions: [{ category: 'supplementary', content: {} }], + }) + + assert.equal(result.isValid, true) + assert.equal(result.errors.length, 0) + assert.equal(result.warnings.length, 0) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index f6b8d926..4b645bfd 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { readFileSync } from 'node:fs' import assert from 'node:assert/strict' +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici' import * as informative from '../../csaf_2_1/informativeTests.js' import * as recommended from '../../csaf_2_1/recommendedTests.js' import * as mandatory from '../../csaf_2_1/mandatoryTests.js' @@ -23,7 +24,6 @@ const excluded = [ '6.1.56', '6.1.59', '6.1.60.1', - '6.1.60.2', '6.1.60.3', '6.2.11', '6.2.19', @@ -132,6 +132,42 @@ const testDataBaseUrl = new URL( import.meta.url ) +const extensionDataBaseUrl = new URL( + '../../csaf/csaf_2.1/test/extension/data/valid/', + import.meta.url +) + +/** + * Mocks GitHub raw-content requests for extension schemas so that tests + * exercising csafAjv's dynamic `loadSchema` mechanism stay hermetic (no real + * network access). + * + * @returns {MockAgent} + */ +function extensionSchemaMockAgent() { + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + + const pool = mockAgent.get('https://raw.githubusercontent.com') + for (const name of ['documentation-11', 'documentation-12']) { + const content = readFileSync( + new URL(`${name}/${name}-content_1.0.0.json`, extensionDataBaseUrl), + 'utf-8' + ) + pool + .intercept({ + method: 'GET', + path: `/oasis-tcs/csaf/refs/heads/master/csaf_2.1/extension/data/valid/${name}/${name}-content_1.0.0.json`, + }) + .reply(200, content, { + headers: { 'content-type': 'application/json' }, + }) + .persist() + } + + return mockAgent +} + const testCases = /** @type {TestCases} */ ( JSON.parse( await readFile(new URL('testcases.json', testDataBaseUrl), 'utf-8') @@ -144,6 +180,17 @@ for (const [group, t] of testMap) { describe(group, function () { for (const [testId, u] of t) { describe(testId, function () { + const mockedTestIds = new Set(['6.1.60.2']) + if (mockedTestIds.has(testId)) { + const globalDispatcher = getGlobalDispatcher() + before(function () { + setGlobalDispatcher(extensionSchemaMockAgent()) + }) + after(function () { + setGlobalDispatcher(globalDispatcher) + }) + } + for (const [type, testSpecs] of u) { describe(type, function () { for (const testSpec of testSpecs) {