Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,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**
Expand Down Expand Up @@ -450,6 +449,7 @@ export const mandatoryTest_6_1_53: DocumentTest
export const mandatoryTest_6_1_56: 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
```

Expand Down
64 changes: 62 additions & 2 deletions csaf_2_1/csafAjv.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,67 @@ 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<string, Promise<import('ajv').AnySchemaObject>>}
*/
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<import('ajv').AnySchemaObject>}
*/
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 fetch(uri, {
method: 'GET',
headers: { Accept: 'application/json' },
})
if (!res.ok) {
throw new Error(
`Cannot load schema "${uri}": received HTTP status ${res.status}`
)
}
return /** @type {Promise<import('ajv').AnySchemaObject>} */ (res.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,
Expand All @@ -26,7 +86,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,
Expand Down
2 changes: 1 addition & 1 deletion csaf_2_1/csafAjv/content_schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export default {
uniqueItems: true,
items: {
type: 'string',
enum: ['critical', 'high_value', 'informational'],
enum: ['essential', 'significant', 'supplementary'],
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion csaf_2_1/csafAjv/extension-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions csaf_2_1/mandatoryTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ export { mandatoryTest_6_1_53 } from './mandatoryTests/mandatoryTest_6_1_53.js'
export { mandatoryTest_6_1_56 } from './mandatoryTests/mandatoryTest_6_1_56.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'
67 changes: 67 additions & 0 deletions csaf_2_1/mandatoryTests/mandatoryTest_6_1_60_2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast is not necessary.

Suggested change
? /** @type {{ $schema: unknown }} */ (value).$schema
? 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 according to declared CSAF Extension Schema',
})
})
}
})
}

return ctx
}
34 changes: 34 additions & 0 deletions tests/csaf_2_1/mandatoryTest_6_1_60_2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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,
},
],
})

expect(result.isValid).to.equal(true)
expect(result.errors.length).to.equal(0)
expect(result.warnings.length).to.equal(1)
expect(result.warnings[0].instancePath).to.equal('/x_extensions/0')
expect(result.warnings[0].message).to.match(
/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: {} }],
})

expect(result.isValid).to.equal(true)
expect(result.errors.length).to.equal(0)
expect(result.warnings.length).to.equal(0)
})
})
35 changes: 30 additions & 5 deletions tests/csaf_2_1/oasis.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ const excluded = [
'6.1.55',
'6.1.59',
'6.1.60.1',
'6.1.60.2',
'6.1.60.3',
'6.2.19',
'6.2.20',
Expand Down Expand Up @@ -141,16 +140,42 @@ for (const [group, t] of testMap) {
// CLI - not available in the Vitest browser project.
// informativeTest_6_3_6/6_3_7 perform real HTTP HEAD requests (see
// lib/informativeTests/shared/testURL.js); a real browser sandbox can't
// make arbitrary cross-origin requests without CORS. Therefore we skip
// the tests here.
// make arbitrary cross-origin requests without CORS.
// mandatoryTest_6_1_60_2 needs undici's MockAgent (Node-only) for
// the GitHub schema fetches. Skip all of these in the browser project.
const isSkipped =
isBrowserRuntime &&
group === 'informative' &&
['6.3.6', '6.3.7', '6.3.8'].includes(testId)
((group === 'informative' &&
['6.3.6', '6.3.7', '6.3.8'].includes(testId)) ||
(group === 'mandatory' && testId === '6.1.60.2'))

if (isSkipped) continue

describe(testId, function () {
const mockedTestIds = new Set(['6.1.60.2'])
if (mockedTestIds.has(testId)) {
// Dynamic (not static) import: `undici` has no browser build, and a
// static top-level import breaks Vite's browser-project bundling
// even though this branch is unreachable there (see `isSkipped`
// above, which excludes 6.1.60.2 from the browser project).
/** @type {import('undici').Dispatcher} */
let globalDispatcher
beforeAll(async function () {
const { getGlobalDispatcher, setGlobalDispatcher } = await import(
'undici'
)
const { extensionSchemaMockAgent } = await import(
'../shared/extensionSchemaMockAgent.js'
)
globalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(await extensionSchemaMockAgent())
})
afterAll(async function () {
const { setGlobalDispatcher } = await import('undici')
setGlobalDispatcher(globalDispatcher)
})
}

for (const [type, testSpecs] of u) {
const filteredTestSpecs = testSpecs.filter(
(testSpec) => !skippedTests.has(testSpec.name)
Expand Down
41 changes: 41 additions & 0 deletions tests/shared/extensionSchemaMockAgent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises'
import { MockAgent } from 'undici'

// Pulled out of oasis.js because undici has no browser build - keeping it
// separate lets oasis.js load this only via dynamic import when running under
// Node.

const extensionDataBaseUrl = new URL(
'../../csaf/csaf_2.1/test/extension/data/valid/',
import.meta.url
)

/**
* Mocks GitHub raw-content requests for extension schemas via csafAjv's
* dynamic loadSchema mechanism, so the test doesn't need real network access.
*
* @returns {Promise<MockAgent>}
*/
export async 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 = await readFile(
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
}