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 @@ -336,7 +336,6 @@ The following tests are not yet implemented and therefore missing:
- Recommended Test 6.2.32
- Recommended Test 6.2.33
- Recommended Test 6.2.34
- Recommended Test 6.2.35
- Recommended Test 6.2.36
- Recommended Test 6.2.37
- Recommended Test 6.2.38
Expand Down Expand Up @@ -495,6 +494,7 @@ export const recommendedTest_6_2_27: DocumentTest
export const recommendedTest_6_2_28: DocumentTest
export const recommendedTest_6_2_29: DocumentTest
export const recommendedTest_6_2_30: DocumentTest
export const recommendedTest_6_2_35: DocumentTest
export const recommendedTest_6_2_39_2: DocumentTest
export const recommendedTest_6_2_39_3: DocumentTest
export const recommendedTest_6_2_39_4: DocumentTest
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 @@ -33,6 +33,7 @@ export { recommendedTest_6_2_27 } from './recommendedTests/recommendedTest_6_2_2
export { recommendedTest_6_2_28 } from './recommendedTests/recommendedTest_6_2_28.js'
export { recommendedTest_6_2_29 } from './recommendedTests/recommendedTest_6_2_29.js'
export { recommendedTest_6_2_30 } from './recommendedTests/recommendedTest_6_2_30.js'
export { recommendedTest_6_2_35 } from './recommendedTests/recommendedTest_6_2_35.js'
export { recommendedTest_6_2_38 } from './recommendedTests/recommendedTest_6_2_38.js'
export { recommendedTest_6_2_39_2 } from './recommendedTests/recommendedTest_6_2_39_2.js'
export { recommendedTest_6_2_39_3 } from './recommendedTests/recommendedTest_6_2_39_3.js'
Expand Down
106 changes: 106 additions & 0 deletions csaf_2_1/recommendedTests/recommendedTest_6_2_35.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Ajv } from 'ajv/dist/jtd.js'
import {
isSpecialPurposeSsvcNamespace,
isUnregisteredNamespace,
} from '../shared/ssvcNamespaces.js'

const ajv = new Ajv()

const inputSchema = /** @type {const} */ ({
additionalProperties: true,
properties: {
document: {
additionalProperties: true,
properties: {
distribution: {
additionalProperties: true,
properties: {
tlp: {
additionalProperties: true,
properties: {
label: { type: 'string' },
},
},
},
},
},
},
vulnerabilities: {
elements: {
additionalProperties: true,
optionalProperties: {
metrics: {
elements: {
additionalProperties: true,
optionalProperties: {
content: {
additionalProperties: true,
optionalProperties: {
ssvc_v2: {
additionalProperties: true,
optionalProperties: {
selections: {
elements: {
additionalProperties: true,
optionalProperties: {
namespace: {
type: 'string',
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
})

const validate = ajv.compile(inputSchema)

/**
* This implements the recommended test 6.2.35 of the CSAF 2.1 standard.
*
* @param {any} doc
*/
export function recommendedTest_6_2_35(doc) {
/** @type {Array<{ message: string; instancePath: string }>} */
const warnings = []
const context = { warnings }

if (!validate(doc)) {
return context
}

if (doc.document.distribution.tlp.label !== 'CLEAR') {
return context
}
doc.vulnerabilities?.forEach((vulnerability, vulnerabilityIndex) => {
vulnerability.metrics?.forEach((metric, metricIndex) => {
const selections = metric.content?.ssvc_v2?.selections || []
selections.forEach((selection, selectionIndex) => {
if (!selection?.namespace) return
const instancePath = `/vulnerabilities/${vulnerabilityIndex}/metrics/${metricIndex}/content/ssvc_v2/selections/${selectionIndex}/namespace`
if (isSpecialPurposeSsvcNamespace(selection.namespace)) {
context.warnings.push({
message: `The namespace "${selection.namespace}" is reserved for special purpose (documentation/testing) and must not be used in production`,
instancePath,
})
} else if (isUnregisteredNamespace(selection.namespace)) {
context.warnings.push({
message: `The namespace "${selection.namespace}" is an unregistered namespace`,
instancePath,
})
}
})
})
})

return context
}
59 changes: 59 additions & 0 deletions csaf_2_1/shared/ssvcNamespaces.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* SSVC Namespace Specification: https://certcc.github.io/SSVC/reference/code/namespaces/
*
* Namespaces reserved for special purpose and MUST be treated as per their
* definition (spec section "Reserved Namespace Strings").
*
* @type {string[]}
*/
export const specialPurposeSsvcNamespaces = ['x_example', 'x_test']

/**
* Extracts the base namespace from a full SSVC namespace string.
* Strips everything after the first '#' or '/'.
*
* @param {string} namespace
* @returns {string}
*/
export function getSsvcBaseNamespace(namespace) {
const hashIdx = namespace.indexOf('#')
const slashIdx = namespace.indexOf('/')

let endIdx = namespace.length
if (hashIdx !== -1) endIdx = Math.min(endIdx, hashIdx)
if (slashIdx !== -1) endIdx = Math.min(endIdx, slashIdx)

return namespace.substring(0, endIdx)
}

/**
* Returns true if the namespace is a valid unregistered SSVC namespace.
*
* Unregistered namespaces must:
* - start with the `x_` prefix
* - followed by a reverse domain name (alphanumeric, dots, dashes)
* - followed by `#` and a non-empty fragment
* - contain only alphanumeric characters, dots (.), and dashes (-) besides `x_` and `#`
*
* @param {string} namespace - full namespace string
* @returns {boolean}
*/
export function isUnregisteredNamespace(namespace) {
// x_ + one or more labels (dot-separated) + optional #fragment
return /^x_[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(#[a-zA-Z0-9.\-]+)?$/.test(
namespace
)
}

/**
* Returns true if the namespace uses a reserved special-purpose prefix
* (x_example or x_test). These namespaces are valid structurally but must
* not be used in production.
*
* @param {string} namespace - full namespace string
* @returns {boolean}
*/
export function isSpecialPurposeSsvcNamespace(namespace) {
const base = getSsvcBaseNamespace(namespace)
return specialPurposeSsvcNamespaces.some((prefix) => base === prefix)
}
1 change: 0 additions & 1 deletion tests/csaf_2_1/oasis.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ const excluded = [
'6.2.32',
'6.2.33',
'6.2.34',
'6.2.35',
'6.2.36',
'6.2.37',
'6.2.39.1',
Expand Down
185 changes: 185 additions & 0 deletions tests/csaf_2_1/recommendedTest_6_2_35.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import assert from 'node:assert'
import { recommendedTest_6_2_35 } from '../../csaf_2_1/recommendedTests.js'

describe('recommendedTest_6_2_35', function () {
it('only runs on relevant documents', function () {
assert.equal(
recommendedTest_6_2_35({ vulnerabilities: 'mydoc' }).warnings.length,
0
)
})
it('skips empty objects', function () {
assert.equal(
recommendedTest_6_2_35({
document: {
distribution: {
tlp: {
label: 'CLEAR',
},
},
},
vulnerabilities: [
{
metrics: [
{
content: {
ssvc_v2: {}, // should be ignored
},
},
],
},
{
metrics: [
{
content: {
ssvc_v2: {
selections: [
{
namespace: 'x_com.example#custom',
},
],
},
},
},
],
},
],
}).warnings.length,
1
)
})

it('skips selections without a namespace', function () {
assert.equal(
recommendedTest_6_2_35({
document: {
distribution: { tlp: { label: 'CLEAR' } },
},
vulnerabilities: [
{
metrics: [
{
content: {
ssvc_v2: {
selections: [{}, { namespace: '' }],
},
},
},
],
},
],
}).warnings.length,
0
)
})

it('warns for special purpose namespaces', function () {
assert.equal(
recommendedTest_6_2_35({
document: {
distribution: { tlp: { label: 'CLEAR' } },
},
vulnerabilities: [
{
metrics: [
{
content: {
ssvc_v2: {
selections: [
{ namespace: 'x_example#test' },
{ namespace: 'x_test#ref' },
],
},
},
},
],
},
],
}).warnings.length,
2
)
})

it('warns for unregistered namespaces that look like special purpose (x_example.something)', function () {
const result = recommendedTest_6_2_35({
document: {
distribution: { tlp: { label: 'CLEAR' } },
},
vulnerabilities: [
{
metrics: [
{
content: {
ssvc_v2: {
selections: [
{ namespace: 'x_example.unregistered#namespace' },
{ namespace: 'x_test.org#ref' },
],
},
},
},
],
},
],
})
assert.equal(result.warnings.length, 2)
assert.ok(
result.warnings.every((w) =>
w.message.includes('unregistered namespace')
),
'expected "unregistered namespace" message for x_example.something and x_test.org forms'
)
})

it('warns for single-label unregistered namespace without dot (x_somedomain#fragment)', function () {
const result = recommendedTest_6_2_35({
document: {
distribution: { tlp: { label: 'CLEAR' } },
},
vulnerabilities: [
{
metrics: [
{
content: {
ssvc_v2: {
selections: [{ namespace: 'x_somedomain#fragment' }],
},
},
},
],
},
],
})
assert.equal(result.warnings.length, 1)
assert.ok(
result.warnings[0].message.includes('unregistered namespace'),
'expected "unregistered namespace" message for x_somedomain#fragment'
)
})

it('warns for fragment-free unregistered namespace (x_com.example)', function () {
const result = recommendedTest_6_2_35({
document: {
distribution: { tlp: { label: 'CLEAR' } },
},
vulnerabilities: [
{
metrics: [
{
content: {
ssvc_v2: {
selections: [{ namespace: 'x_com.example' }],
},
},
},
],
},
],
})
assert.equal(result.warnings.length, 1)
assert.ok(
result.warnings[0].message.includes('unregistered namespace'),
'expected "unregistered namespace" message for x_com.example (no fragment)'
)
})
})
Loading