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 @@ -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**
Expand Down Expand Up @@ -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
```

Expand Down
67 changes: 65 additions & 2 deletions csaf_2_1/csafAjv.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<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 request(uri, {

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.

Since undici is a node.js module we should rely on fetch here

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<import('ajv').AnySchemaObject>} */ (
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,
Expand All @@ -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,
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 @@ -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'
65 changes: 65 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,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',

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.

The default error seems a bit off. Better is probably something like: invalid according to declared CSAF Extension Schema

})
})
}
})
}

return ctx
}
36 changes: 36 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,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)
})
})
49 changes: 48 additions & 1 deletion tests/csaf_2_1/oasis.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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',
Expand Down Expand Up @@ -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')
Expand All @@ -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) {
Expand Down