diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index 8bc9a647..3f562005 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -1,8 +1,546 @@ -import { optionalTest_6_2_1 } from '../../optionalTests.js' +import { Ajv } from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + full_product_names: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + product_paths: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, + }, + }, + optionalProperties: { + document: { + additionalProperties: true, + optionalProperties: { + category: { type: 'string' }, + }, + }, + }, +}) + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + product_id: { type: 'string' }, + }, +}) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product: fullProductNameSchema, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, +}) + +const productPathSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + full_product_name: fullProductNameSchema, + }, +}) + +const validate = ajv.compile(inputSchema) +const validateFullProductName = ajv.compile(fullProductNameSchema) +const validateBranch = ajv.compile(branchSchema) +const validateProductPath = ajv.compile(productPathSchema) /** - * @param {unknown} doc + * @typedef {import('ajv/dist/core.js').JTDDataType} Branch + * @typedef {import('ajv/dist/core.js').JTDDataType} Note + * @typedef {import('ajv/dist/core.js').JTDDataType['product_tree']['product_groups'][number]} ProductGroup + * @typedef {import('ajv/dist/core.js').JTDDataType['product_tree']['product_paths'][number]} ProductPathRef + * @typedef {NonNullable['vulnerabilities'][number]['remediations']>[number]} Remediation + * @typedef {NonNullable['vulnerabilities'][number]['metrics']>[number]} Metric + * @typedef {NonNullable['vulnerabilities'][number]['flags']>[number]} Flag + * @typedef {NonNullable['vulnerabilities'][number]['first_known_exploitation_dates']>[number]} ExploitationDate + * @typedef {NonNullable['vulnerabilities'][number]['threats']>[number]} Threat + * @typedef {NonNullable['vulnerabilities'][number]['involvements']>[number]} Involvement + */ + +/** + * This implements the recommended test 6.2.1 of the CSAF 2.1 standard. + * @param {any} doc */ export function recommendedTest_6_2_1(doc) { - return optionalTest_6_2_1(doc) + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if ( + !validate(doc) || + doc.document?.category === 'csaf_informational_advisory' + ) { + return ctx + } + + const referencedProductIds = collectReferencedProductIds(doc) + + /** + * @param {object} params + * @param {string} params.path + * @param {Branch[]} params.branches + */ + function checkBranches({ path, branches }) { + branches.forEach((branch, branchIndex) => { + if (validateBranch(branch)) { + if ( + typeof branch.product?.product_id === 'string' && + !referencedProductIds.has(branch.product.product_id) + ) { + ctx.warnings.push({ + instancePath: `${path}/${branchIndex}/product/product_id`, + message: 'is not referenced', + }) + } + + if (Array.isArray(branch.branches)) { + checkBranches({ + path: `${path}/${branchIndex}/branches`, + branches: branch.branches, + }) + } + } + }) + } + + checkBranches({ + path: '/product_tree/branches', + branches: doc.product_tree?.branches ?? [], + }) + + doc.product_tree.full_product_names?.forEach( + (fullProductName, fullProductNameIndex) => { + if (validateFullProductName(fullProductName)) { + if (!referencedProductIds.has(fullProductName.product_id)) { + ctx.warnings.push({ + instancePath: `/product_tree/full_product_names/${fullProductNameIndex}/product_id`, + message: 'is not referenced', + }) + } + } + } + ) + + doc.product_tree.product_paths?.forEach((productPath, productPathIndex) => { + if (validateProductPath(productPath)) { + if (!referencedProductIds.has(productPath.full_product_name.product_id)) { + ctx.warnings.push({ + instancePath: `/product_tree/product_paths/${productPathIndex}/full_product_name/product_id`, + message: 'is not referenced', + }) + } + } + }) + + return ctx +} + +const productGroupsSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + product_tree: { + additionalProperties: true, + properties: { + product_groups: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, +}) + +const productPathRefsSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + product_tree: { + additionalProperties: true, + properties: { + product_paths: { + elements: { + additionalProperties: true, + optionalProperties: { + beginning_product_reference: { type: 'string' }, + subpaths: { + elements: { + additionalProperties: true, + optionalProperties: { + next_product_reference: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const vulnStatusSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + product_status: { + additionalProperties: true, + optionalProperties: { + first_affected: { elements: { type: 'string' } }, + first_fixed: { elements: { type: 'string' } }, + fixed: { elements: { type: 'string' } }, + known_affected: { elements: { type: 'string' } }, + known_not_affected: { elements: { type: 'string' } }, + last_affected: { elements: { type: 'string' } }, + recommended: { elements: { type: 'string' } }, + under_investigation: { elements: { type: 'string' } }, + unknown: { elements: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +}) + +const vulnOptionalRefsSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + remediations: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + metrics: { + elements: { + additionalProperties: true, + optionalProperties: { + products: { + elements: { type: 'string' }, + }, + }, + }, + }, + flags: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + first_known_exploitation_dates: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + threats: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const noteSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, +}) + +const docNotesSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + notes: { elements: noteSchema }, + }, + }, + }, +}) + +const vulnNotesSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + notes: { elements: noteSchema }, + }, + }, + }, + }, +}) + +const vulnInvolvementsSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + involvements: { + elements: { + additionalProperties: true, + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, +}) + +const hasDocNotes = ajv.compile(docNotesSchema) +const hasProductGroups = ajv.compile(productGroupsSchema) +const hasProductPathRefs = ajv.compile(productPathRefsSchema) +const hasVulnStatus = ajv.compile(vulnStatusSchema) +const hasVulnOptionalRefs = ajv.compile(vulnOptionalRefsSchema) +const hasVulnNotes = ajv.compile(vulnNotesSchema) +const hasVulnInvolvements = ajv.compile(vulnInvolvementsSchema) + +/** + * Collects all product IDs in a set that are referenced anywhere in the document + * + * @param {unknown} doc + * @returns {Set} + */ +function collectReferencedProductIds(doc) { + /** @type {Set} */ + const ids = new Set() + collectDocumentNotes(doc, ids) + collectProductGroups(doc, ids) + collectProductPathRefs(doc, ids) + collectVulnerabilityStatus(doc, ids) + collectVulnerabilityOptionalRefs(doc, ids) + collectVulnerabilityNotes(doc, ids) + collectVulnerabilityInvolvements(doc, ids) + return ids +} + +/** + * Collects all product IDs that are referenced in document notes + * @param {unknown} doc + * @param {Set} ids + */ +function collectDocumentNotes(doc, ids) { + if (hasDocNotes(doc)) { + for (const /** @type {Note} */ note of doc.document.notes) { + for (const id of note.product_ids ?? []) { + ids.add(id) + } + } + } +} + +/** + * Collects all product IDs that are referenced in product groups + * @param {unknown} doc + * @param {Set} ids + */ +function collectProductGroups(doc, ids) { + if (hasProductGroups(doc)) { + for (const /** @type {ProductGroup} */ group of doc.product_tree + .product_groups) { + if (group.product_ids) { + for (const id of group.product_ids) ids.add(id) + } + } + } +} + +/** + * Collects all product IDs that are referenced in product paths + * @param {unknown} doc + * @param {Set} ids + */ +function collectProductPathRefs(doc, ids) { + if (hasProductPathRefs(doc)) { + for (const /** @type {ProductPathRef} */ productPath of doc.product_tree + .product_paths) { + if (typeof productPath.beginning_product_reference === 'string') { + ids.add(productPath.beginning_product_reference) + } + if (productPath.subpaths) { + for (const subpath of productPath.subpaths) { + if (typeof subpath.next_product_reference === 'string') { + ids.add(subpath.next_product_reference) + } + } + } + } + } +} + +/** + * Collects all product IDs that are referenced in the product status of vulnerabilities + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityStatus(doc, ids) { + if (hasVulnStatus(doc)) { + const keys = /** @type {const} */ ([ + 'first_affected', + 'first_fixed', + 'fixed', + 'known_affected', + 'known_not_affected', + 'last_affected', + 'recommended', + 'under_investigation', + 'unknown', + ]) + for (const vulnerability of doc.vulnerabilities) { + for (const key of keys) { + const list = vulnerability.product_status?.[key] + if (list) { + for (const id of list) { + ids.add(id) + } + } + } + } + } +} + +/** + * Collects all product IDs that are referenced in optional references of vulnerabilities + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityOptionalRefs(doc, ids) { + if (hasVulnOptionalRefs(doc)) { + for (const vulnerability of doc.vulnerabilities) { + for (const /** @type {Remediation} */ remediation of vulnerability.remediations ?? + []) { + for (const id of remediation.product_ids ?? []) { + ids.add(id) + } + } + for (const /** @type {Metric} */ metric of vulnerability.metrics ?? []) { + for (const id of metric.products ?? []) { + ids.add(id) + } + } + for (const /** @type {Flag} */ flag of vulnerability.flags ?? []) { + for (const id of flag.product_ids ?? []) { + ids.add(id) + } + } + for (const /** @type {ExploitationDate} */ entry of vulnerability.first_known_exploitation_dates ?? + []) { + for (const id of entry.product_ids ?? []) { + ids.add(id) + } + } + for (const /** @type {Threat} */ threat of vulnerability.threats ?? []) { + for (const id of threat.product_ids ?? []) { + ids.add(id) + } + } + } + } +} + +/** + * Collects all product IDs that are referenced in vulnerability notes + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityNotes(doc, ids) { + if (hasVulnNotes(doc)) { + for (const vulnerability of doc.vulnerabilities) { + for (const /** @type {Note} */ note of vulnerability.notes ?? []) { + for (const id of note.product_ids ?? []) { + ids.add(id) + } + } + } + } +} + +/** + * Collects all product IDs that are referenced in vulnerability involvements + * @param {unknown} doc + * @param {Set} ids + */ +function collectVulnerabilityInvolvements(doc, ids) { + if (hasVulnInvolvements(doc)) { + for (const vulnerability of doc.vulnerabilities) { + for (const /** @type {Involvement} */ involvement of vulnerability.involvements ?? + []) { + for (const id of involvement.product_ids ?? []) { + ids.add(id) + } + } + } + } } diff --git a/tests/csaf_2_1/recommendedTest_6_2_1.js b/tests/csaf_2_1/recommendedTest_6_2_1.js new file mode 100644 index 00000000..630b08aa --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_1.js @@ -0,0 +1,368 @@ +import assert from 'node:assert' +import { recommendedTest_6_2_1 } from '../../csaf_2_1/recommendedTests.js' + +const baseDoc = { + product_tree: { + full_product_names: [{ product_id: 'CSAFPID-0001', name: 'Product A' }], + }, +} + +describe('recommendedTest_6_2_1', function () { + it('only runs on relevant documents', function () { + assert.equal( + recommendedTest_6_2_1({ vulnerabilities: 'mydoc' }).warnings.length, + 0 + ) + }) + + it('skips documents with category csaf_informational_advisory', function () { + assert.equal( + recommendedTest_6_2_1({ + document: { category: 'csaf_informational_advisory' }, + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + ], + }, + }).warnings.length, + 0 + ) + }) + + // collectDocumentNotes + it('no warning if product_id is referenced in document.notes[].product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + document: { + notes: [{ product_ids: ['CSAFPID-0001'] }], + }, + }).warnings.length, + 0 + ) + }) + + it('warning if note has no product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + document: { + notes: [{ category: 'general', text: 'note without product_ids' }], + }, + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in product_tree.product_groups.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + ], + product_groups: [ + { + group_id: 'CSAFGID-0001', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in product_paths.beginning_product_reference', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + product_paths: [{ beginning_product_reference: 'CSAFPID-0001' }], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in product_paths.subpath.next_product_reference', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + product_paths: [ + { subpaths: [{ next_product_reference: 'CSAFPID-0002' }] }, + ], + }, + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities[].product_status.unknown', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + product_status: { + unknown: ['CSAFPID-0001'], + }, + }, + ], + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.remediations.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + remediations: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warns if product_id is unreferenced and remediation has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + remediations: [ + { + category: 'vendor_fix', + details: 'Update.', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.metrics.products', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + metrics: [ + { + products: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warning if product_id is unreferenced and metric has no products field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + metrics: [ + { + content: {}, + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.flags.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + flags: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warning if product_id is unreferenced and flag has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + flags: [ + { + label: 'component_not_present', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.first_known_exploitation_dates.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + first_known_exploitation_dates: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warning if product_id is unreferenced and first_known_exploitation_date has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + first_known_exploitation_dates: [ + { + date: '2024-01-01T00:00:00Z', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.threats.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + threats: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warning if product_id is unreferenced and threat has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + threats: [ + { + category: 'exploit_status', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities[].notes[].product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + notes: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warning if product_id is unreferenced and vulnerability note has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + notes: [ + { + category: 'general', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities[].involvements[].product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + involvements: [ + { + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warning if product_id is unreferenced and involvement has no product_ids field', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + involvements: [ + { + party: 'vendor', + }, + ], + }, + ], + }).warnings.length, + 1 + ) + }) +})