Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ export const recommendedTest_6_2_30: DocumentTest
export const recommendedTest_6_2_39_2: DocumentTest
export const recommendedTest_6_2_40: DocumentTest
export const recommendedTest_6_2_43: DocumentTest
export const recommendedTest_6_2_47: DocumentTest
```

[(back to top)](#bsi-csaf-validator-lib)
Expand Down
1 change: 1 addition & 0 deletions csaf_2_1/recommendedTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ export { recommendedTest_6_2_38 } from './recommendedTests/recommendedTest_6_2_3
export { recommendedTest_6_2_39_2 } from './recommendedTests/recommendedTest_6_2_39_2.js'
export { recommendedTest_6_2_40 } from './recommendedTests/recommendedTest_6_2_40.js'
export { recommendedTest_6_2_43 } from './recommendedTests/recommendedTest_6_2_43.js'
export { recommendedTest_6_2_47 } from './recommendedTests/recommendedTest_6_2_47.js'
163 changes: 163 additions & 0 deletions csaf_2_1/recommendedTests/recommendedTest_6_2_47.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import Ajv from 'ajv/dist/jtd.js'
import { isCanonicalUrl } from '../../lib/shared/urlHelper.js'

/** @typedef {import('ajv/dist/jtd.js').JTDDataType<typeof inputSchema>} InputSchema */

/** @typedef {InputSchema['vulnerabilities'][number]} Vulnerability */

/** @typedef {NonNullable<Vulnerability['metrics']>[number]} Metric */

/** @typedef {NonNullable<Metric['content']>} MetricContent */

/** @typedef {{url?: string, category?: string}} Reference */

const jtdAjv = new Ajv()

const inputSchema = /** @type {const} */ ({
additionalProperties: true,
properties: {
document: {
additionalProperties: true,
optionalProperties: {
references: {
elements: {
additionalProperties: true,
optionalProperties: {
category: { type: 'string' },
url: { type: 'string' },
},
},
},

tracking: {
additionalProperties: true,
optionalProperties: {
id: { type: 'string' },
},
},
},
},
vulnerabilities: {
elements: {
additionalProperties: true,
optionalProperties: {
metrics: {
elements: {
additionalProperties: true,
optionalProperties: {
source: {
type: 'string',
},
content: {
additionalProperties: true,
optionalProperties: {
qualitative_severity_rating: {
type: 'string',
},
},
},
},
},
},
},
},
},
},
})

const validateInput = jtdAjv.compile(inputSchema)

/**
* Get the canonical url from the document
* @return {string} canonical url or empty when no canonical url exists
* @param {Array<{url?: string, category?: string}>|undefined} references
* @param {string|undefined} trackingId
*/
function getCanonicalUrl(references, trackingId) {
if (references && trackingId) {
// Find the reference that matches our criteria
/** @type {Reference| undefined} */
const canonicalUrlReference = references.find((reference) =>
isCanonicalUrl(reference, trackingId)
)

// When we find a matching reference, we know it has the url property
// because isCanonicalUrl ensures it matches the Reference schema
return canonicalUrlReference?.url ?? ''
} else {
return ''
}
}

/**
* check whether metric has a qualitative_severity_rating
* and no `source` or `source` that is equal to the canonical URL.
* @param {Metric} metric
* @param {string} canonicalURL
* @return {string | null}
*/
function checkSeverityRatingAndNoSource(metric, canonicalURL) {
if (metric?.content?.qualitative_severity_rating) {
if (!metric.source) {
return 'as no "source" is given'
} else if (metric.source === canonicalURL) {
return 'as the "source" property equals to the canonical URL'
} else {
return null
}
} else {
return null
}
}

/**
* For each item in `metrics` provided by the issuing party it MUST be tested
* that it does not use the qualitative severity rating.
* This covers all items in `metrics` that do not have a `source` property and those where the `source` is equal to
* the canonical URL.
*
/**
* @param {any} doc
*/
export function recommendedTest_6_2_47(doc) {
const ctx = {
warnings:
/** @type {Array<{ instancePath: string; message: string }>} */ ([]),
}
if (!validateInput(doc)) {
return ctx
}

/** @type {Array<Vulnerability>} */
const vulnerabilities = doc.vulnerabilities
const canonicalURL = getCanonicalUrl(
doc.document?.references,
doc.document?.tracking?.id
)

vulnerabilities.forEach((vulnerabilityItem, vulnerabilityIndex) => {
/** @type {Array<Metric> | undefined} */
const metrics = vulnerabilityItem.metrics
/** @type {Array<{path: string, message: string}> | undefined} */
const invalidPaths = metrics
?.map((metric, metricIndex) => {
const message = checkSeverityRatingAndNoSource(metric, canonicalURL)
return message != null
? {
path: `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/qualitative_severity_rating`,
message: message,
}
: null
})
.filter((path) => path !== null)

invalidPaths?.forEach((path) => {
ctx.warnings.push({
message: `a qualitative severity rating is used by the issuing party (${path.message})`,
instancePath: path.path,
})
})
})

return ctx
}
21 changes: 3 additions & 18 deletions lib/optionalTests/optionalTest_6_2_11.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Ajv from 'ajv/dist/jtd.js'
import { isCanonicalUrl } from '../shared/urlHelper.js'

const ajv = new Ajv()

Expand Down Expand Up @@ -26,16 +27,7 @@ const inputSchema = /** @type {const} */ ({
},
})

const referenceSchema = /** @type {const} */ ({
additionalProperties: true,
properties: {
category: { type: 'string' },
url: { type: 'string' },
},
})

const validate = ajv.compile(inputSchema)
const validateReference = ajv.compile(referenceSchema)

/**
* @param {any} doc
Expand All @@ -58,15 +50,8 @@ export default function optionalTest_6_2_11(doc) {
return ctx
}

const hasCanonicalURL = doc.document.references.some(
(r) =>
validateReference(r) &&
r.category === 'self' &&
r.url.startsWith('https://') &&
r.url.endsWith(
doc.document.tracking.id.toLowerCase().replace(/[^+\-a-z0-9]+/g, '_') +
'.json'
)
const hasCanonicalURL = doc.document.references.some((reference) =>
isCanonicalUrl(reference, doc.document.tracking.id)
)

if (!hasCanonicalURL) {
Expand Down
54 changes: 54 additions & 0 deletions lib/shared/urlHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Ajv from 'ajv/dist/jtd.js'

const ajv = new Ajv()

const referenceSchema = /** @type {const} */ ({
additionalProperties: true,
properties: {
category: { type: 'string' },
url: { type: 'string' },
},
})
const validateReference = ajv.compile(referenceSchema)

/**
* Convert the tracking id to apply the csaf filename conventions
* - The value trackingId is converted into lower case
* - Any character sequence which is not part of one of the following groups MUST be replaced by a single underscore (_)
* Lower case ASCII letters (0x61 - 0x7A)
* digits (0x30 - 0x39)
* special characters: + (0x2B), - (0x2D)
* @param {string} trackingId
* @return {string}
*/
export function convertTrackingIdToFilename(trackingId) {
return trackingId.toLowerCase().replace(/[^+\-a-z0-9]+/g, '_')
}

/**
* Checks whether a reference contains a canonical URL
* It works for CSAF 2.0 and CSAF 2.1
* A canonical URL fulfills all the following:
* - It has the category self
* - The url starts with https://
* - The url ends with the valid filename for the CSAF document
* A filename must apply the following rules
* - The value trackingId is converted into lower case
* - Any character sequence which is not part of one of the following groups MUST be replaced by a single underscore (_)
* Lower case ASCII letters (0x61 - 0x7A)
* digits (0x30 - 0x39)
* special characters: + (0x2B), - (0x2D)
* - The file extension .json MUST be appended.
* @param {{url?: string, category?: string}} reference
* @param {string} trackingId
* @return {boolean}
*/
export function isCanonicalUrl(reference, trackingId) {
return (
validateReference(reference) &&
reference.category === 'self' &&
reference.url !== undefined &&
reference.url.startsWith('https://') &&
reference.url.endsWith(convertTrackingIdToFilename(trackingId) + '.json')
)
}
1 change: 0 additions & 1 deletion tests/csaf_2_1/oasis.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ const excluded = [
'6.2.44',
'6.2.45',
'6.2.46',
'6.2.47',
'6.2.48',
'6.3.12',
'6.3.13',
Expand Down
99 changes: 99 additions & 0 deletions tests/csaf_2_1/recommendedTest_6_2_47.js
Comment thread
rainer-exxcellent marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import assert from 'node:assert'
import { recommendedTest_6_2_47 } from '../../csaf_2_1/recommendedTests.js'

describe('recommendedTest_6_2_47', function () {
it('only runs on relevant documents', function () {
assert.equal(
recommendedTest_6_2_47({ vulnerabilities: 'mydoc' }).warnings.length,
0
)
})

it('runs on references with empty category in reference', function () {
assert.equal(
recommendedTest_6_2_47({
document: {
references: [
{
category: 'self',
summary: 'The canonical URL for the CSAF document.',
url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-02.json',
},
{ url: 'https://some.other.url' },
],
tracking: {
id: 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-11',
},
},
vulnerabilities: [
{
metrics: [
{
content: {
qualitative_severity_rating: 'low',
},
products: ['CSAFPID-9080700'],
},
],
},
],
}).warnings.length,
1
)
})

it('runs on references with empty qualitative_severity_rating (considered as not existing)', function () {
assert.equal(
recommendedTest_6_2_47({
document: {
references: [
{
category: 'self',
summary: 'The canonical URL for the CSAF document.',
url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-02.json',
},
{ url: 'https://some.other.url' },
],
tracking: {
id: 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-11',
},
},
vulnerabilities: [
{
metrics: [
{
content: {
qualitative_severity_rating: '',
},
products: ['CSAFPID-9080700'],
},
],
},
],
}).warnings.length,
0
)
})

it('runs on empty metric', function () {
assert.equal(
recommendedTest_6_2_47({
document: {
references: [
{
category: 'self',
summary: 'The canonical URL for the CSAF document.',
url: 'https://example.com/.well-known/csaf/clear/2024/oasis_csaf_tc-csaf_2_1-2024-6-2-47-02.json',
},
{ url: 'https://some.other.url' },
],
tracking: {
id: 'OASIS_CSAF_TC-CSAF_2.1-2024-6-2-47-11',
},
},
vulnerabilities: [],
}).warnings.length,
0
)
})
})
Loading