diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js index 8bc9a647..dfed08c5 100644 --- a/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_1.js @@ -1,8 +1,395 @@ -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 validate = ajv.compile(inputSchema) + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + product_id: { type: 'string' }, + }, +}) +const validateFullProductName = ajv.compile(fullProductNameSchema) + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product: fullProductNameSchema, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + }, +}) +const validateBranch = ajv.compile(branchSchema) + +const productPathSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + full_product_name: fullProductNameSchema, + }, +}) +const validateProductPath = ajv.compile(productPathSchema) /** - * @param {unknown} doc + * @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 + } + + /** + * @param {object} params + * @param {string} params.path + * @param {unknown[]} params.branches + */ + function checkBranches({ path, branches }) { + branches.forEach((branch, branchIndex) => { + if (validateBranch(branch)) { + if ( + typeof branch.product?.product_id === 'string' && + !isReferenced(doc, 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)) return + if (!isReferenced(doc, 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)) return + if (!isReferenced(doc, 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 containsProductGroupsSchema = /** @type {const} */ ({ + additionalProperties: true, + + properties: { + product_tree: { + additionalProperties: true, + + properties: { + product_groups: { + elements: { + additionalProperties: true, + + optionalProperties: { + product_ids: { + elements: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, +}) + +const containsProductPathsWithReferencesSchema = /** @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 containsVulnerabilitiesWithReferencesSchema = /** @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 containsVulnerabilitiesWithOptionalReferencesSchema = + /** @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 validateContainsProductGroups = ajv.compile(containsProductGroupsSchema) +const validateContainsProductPathsWithReferences = ajv.compile( + containsProductPathsWithReferencesSchema +) +const validateContainsVulnerabilitiesWithReferences = ajv.compile( + containsVulnerabilitiesWithReferencesSchema +) +const validateContainsVulnerabilitiesWithOptionalReferences = ajv.compile( + containsVulnerabilitiesWithOptionalReferencesSchema +) + +/** + * @param {unknown} doc + * @param {string} productId + */ +function isReferenced(doc, productId) { + let referenced = false + + if (!referenced && validateContainsProductGroups(doc)) { + referenced = doc.product_tree.product_groups.some((group) => { + return group.product_ids?.includes(productId) ?? false + }) + } + + if (!referenced && validateContainsProductPathsWithReferences(doc)) { + referenced = doc.product_tree.product_paths.some((productPath) => { + return ( + productPath.beginning_product_reference === productId || + productPath.subpaths?.some( + (subpath) => subpath.next_product_reference === productId + ) + ) + }) + } + + if (!referenced && validateContainsVulnerabilitiesWithReferences(doc)) { + referenced = doc.vulnerabilities.some((vulnerability) => { + const keys = /** @type {const} */ ([ + 'first_affected', + 'first_fixed', + 'fixed', + 'known_affected', + 'known_not_affected', + 'last_affected', + 'recommended', + 'under_investigation', + 'unknown', + ]) + return keys.some( + (key) => + vulnerability.product_status?.[key]?.includes(productId) ?? false + ) + }) + } + + if ( + !referenced && + validateContainsVulnerabilitiesWithOptionalReferences(doc) + ) { + referenced = doc.vulnerabilities.some((vulnerability) => { + return ( + vulnerability.remediations?.some((remediation) => + remediation.product_ids?.includes(productId) + ) || + vulnerability.metrics?.some((metric) => + metric.products?.includes(productId) + ) || + vulnerability.flags?.some((flag) => + flag.product_ids?.includes(productId) + ) || + vulnerability.first_known_exploitation_dates?.some((entry) => + entry.product_ids?.includes(productId) + ) || + vulnerability.threats?.some((threat) => + threat.product_ids?.includes(productId) + ) || + false + ) + }) + } + + return referenced } 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..ec28e81b --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_1.js @@ -0,0 +1,237 @@ +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 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.remediations.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + remediations: [ + { + category: 'vendor_fix', + details: 'Update.', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warns if product_id appears only in unrelated vulnerability fields', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + remediations: [ + { + category: 'vendor_fix', + details: 'Update.', + product_ids: ['CSAFPID-9999'], + }, + ], + }, + ], + }).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('no warning if product_id is referenced in vulnerabilities.flags.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + flags: [ + { + label: 'component_not_present', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + 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: [ + { + date: '2024-01-01T00:00:00Z', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('no warning if product_id is referenced in vulnerabilities.threats.product_ids', function () { + assert.equal( + recommendedTest_6_2_1({ + ...baseDoc, + vulnerabilities: [ + { + threats: [ + { + category: 'exploit_status', + details: 'Exploits available.', + product_ids: ['CSAFPID-0001'], + }, + ], + }, + ], + }).warnings.length, + 0 + ) + }) + + it('skips full_product_name entries that have no product_id field', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [{ name: 'Product A without ID' }], + }, + }).warnings.length, + 0 + ) + }) + + it('skips product_path entries that have no full_product_name field', 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_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.subpaths.next_product_reference', function () { + assert.equal( + recommendedTest_6_2_1({ + product_tree: { + full_product_names: [ + { product_id: 'CSAFPID-0001', name: 'Product A' }, + { product_id: 'CSAFPID-0002', name: 'Product B' }, + ], + product_paths: [ + { + full_product_name: { + product_id: 'CSAFPID-0003', + name: 'Product A on Product B', + }, + beginning_product_reference: 'CSAFPID-0001', + subpaths: [{ next_product_reference: 'CSAFPID-0002' }], + }, + ], + }, + vulnerabilities: [ + { + product_status: { + known_affected: ['CSAFPID-0001', 'CSAFPID-0002', 'CSAFPID-0003'], + }, + }, + ], + }).warnings.length, + 0 + ) + }) + + it('warns if product_id is not referenced and product_group has no 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', + }, + ], + }, + }).warnings.length, + 1 + ) + }) +})