diff --git a/README.md b/README.md index 6f0971ec..e4a628ec 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.39.1 - Recommended Test 6.2.45 - Recommended Test 6.2.46 -- Recommended Test 6.2.50.1 - Recommended Test 6.2.50.2 - Recommended Test 6.2.50.3 - Recommended Test 6.2.51 @@ -535,6 +534,7 @@ export const recommendedTest_6_2_44: DocumentTest export const recommendedTest_6_2_47: DocumentTest export const recommendedTest_6_2_48: DocumentTest export const recommendedTest_6_2_49: DocumentTest +export const recommendedTest_6_2_50_1: DocumentTest export const recommendedTest_6_2_52: DocumentTest export const recommendedTest_6_2_53: DocumentTest export const recommendedTest_6_2_54_3: DocumentTest diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 829ed23c..f369afea 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -61,6 +61,7 @@ export { recommendedTest_6_2_44 } from './recommendedTests/recommendedTest_6_2_4 export { recommendedTest_6_2_47 } from './recommendedTests/recommendedTest_6_2_47.js' export { recommendedTest_6_2_48 } from './recommendedTests/recommendedTest_6_2_48.js' export { recommendedTest_6_2_49 } from './recommendedTests/recommendedTest_6_2_49.js' +export { recommendedTest_6_2_50_1 } from './recommendedTests/recommendedTest_6_2_50_1.js' export { recommendedTest_6_2_52 } from './recommendedTests/recommendedTest_6_2_52.js' export { recommendedTest_6_2_53 } from './recommendedTests/recommendedTest_6_2_53.js' export { recommendedTest_6_2_54_3 } from './recommendedTests/recommendedTest_6_2_54_3.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_50_1.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_50_1.js new file mode 100644 index 00000000..fcd54d33 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_50_1.js @@ -0,0 +1,146 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import { + branchSchema, + collectCtpvrsByProductId, + collectProductStatusGroupsByProductId, + collectPvrss, + filterLeaf, + filterVers, + isVersFormat, +} from '../shared/productVersionRangeHelper/productVersionRange.js' +import { + initVers, + isValidVers, + versRangesOverlap, +} from '../shared/productVersionRangeHelper/versRangesOverlap.js' + +const ajv = new Ajv() + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match, it normally means that the input + document does not validate against the csaf JSON schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + branches: { + elements: branchSchema, + }, + }, + }, + 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 validateInput = ajv.compile(inputSchema) + +/** + * This implements the recommended test 6.2.50.1 of the CSAF 2.1 standard. + * + * For each item in `/vulnerabilities` all `EPVRPID` (Product IDs of elements with + * a product version range, "EPVR") in the product status groups are identified. + * For each `EPVR` it is checked whether sibling elements of `PVRSS+l-vers` (product + * version range siblings with a product leaf using the VERS syntax) that + * overlap with it belong to a contradicting product status group. + * + * Every overlapping pair is reported once, as the overlap is commutative. + * + * @param {unknown} doc + */ +export async function recommendedTest_6_2_50_1(doc) { + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if (!validateInput(doc)) { + return ctx + } + + await initVers() + + const ctpvrsByProductId = collectCtpvrsByProductId(doc.product_tree?.branches) + + doc.vulnerabilities?.forEach((vulnerability) => { + const productStatus = vulnerability.product_status + if (!productStatus) return + + // A product can be a member of several contradicting groups. + const productStatusGroupsByProductId = + collectProductStatusGroupsByProductId(productStatus) + + /** @type {Set} */ + const reportedPairs = new Set() + + for (const [productId, groups] of productStatusGroupsByProductId) { + for (const productCtpvr of ctpvrsByProductId.get(productId) ?? []) { + const rangeName = productCtpvr.ctpvr.name + if ( + rangeName === undefined || + !isVersFormat(rangeName) || + !isValidVers(rangeName) + ) { + continue + } + + // PVRSS+l-vers + const versSiblings = filterVers(filterLeaf(collectPvrss(productCtpvr))) + + for (const sibling of versSiblings) { + const siblingGroups = productStatusGroupsByProductId.get( + sibling.productId + ) + // Siblings that are not referenced in the product status groups are + // irrelevant. Groups only contradict if they differ. + if (siblingGroups === undefined || sibling.productId === productId) { + continue + } + const contradicting = [...groups].some((group) => + [...siblingGroups].some((other) => other !== group) + ) + if (!contradicting || !versRangesOverlap(rangeName, sibling.name)) { + continue + } + + const pair = JSON.stringify([productId, sibling.productId].sort()) + if (reportedPairs.has(pair)) continue + reportedPairs.add(pair) + + ctx.warnings.push({ + instancePath: productCtpvr.instancePath, + message: + `Product version range "${rangeName}" of ${productId} overlaps ` + + `with "${sibling.name}" of ${sibling.productId}, which belongs ` + + 'to a contradicting product status group.', + }) + } + } + } + }) + + return ctx +} diff --git a/csaf_2_1/shared/productVersionRangeHelper/productVersionRange.js b/csaf_2_1/shared/productVersionRangeHelper/productVersionRange.js new file mode 100644 index 00000000..095076b6 --- /dev/null +++ b/csaf_2_1/shared/productVersionRangeHelper/productVersionRange.js @@ -0,0 +1,279 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import { isValidVers } from './versRangesOverlap.js' + +const ajv = new Ajv() + +// Regex to match a Version Range Specifier (VERS) +export const VERS_PREFIX = new RegExp( + '^vers:[a-z\\.\\-\\+][a-z0-9\\.\\-\\+]*/.+' +) + +/** + * Checks whether the `name` matches the `vers` format. + * + * @param {string} name + * @returns {boolean} + */ +export function isVersFormat(name) { + return VERS_PREFIX.test(name.trim()) +} + +/** + * Product status groups that contradict each other (see 3.2.4.11 of the + * CSAF 2.1 standard). + */ +export const CONTRADICTING_PRODUCT_STATUS_GROUPS = /** @type {const} */ ({ + affected: ['first_affected', 'known_affected', 'last_affected'], + not_affected: ['known_not_affected'], + fixed: ['first_fixed', 'fixed'], + under_investigation: ['under_investigation'], + unknown: ['unknown'], +}) + +export const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_identification_helper: { + additionalProperties: true, + optionalProperties: { + cpe: { type: 'string' }, + purls: { elements: { type: 'string' } }, + }, + }, + }, +}) + +export const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + category: { type: 'string' }, + name: { type: 'string' }, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + product: fullProductNameSchema, + }, +}) + +const validateBranch = ajv.compile(branchSchema) + +/** + * @typedef {import('ajv/dist/core.js').JTDDataType} Branch + * @typedef {import('ajv/dist/core.js').JTDDataType} FullProductName + * + * @typedef {{ + * instancePath: string + * ctpvr: Sibling + * siblings: Sibling[] + * }} ProductCtpvr + * A product (`EPVRPID`, at `instancePath`) together with one `CTPVR` + * above it. `siblings` are all branches on the same level as the + * `CTPVR`, including the `CTPVR` itself (see `collectPvrss`). The + * array is shared by all products below that level. + * + * @typedef {{ + * category: string | undefined + * name: string | undefined + * hasBranches: boolean + * productId: string | undefined + * }} Sibling + * A branch and its own `product_id` (not the ones of its descendants). + */ + +/** + * Whether the sibling is a `PVRSS` (category `product_version_range`). + * + * @param {Sibling} sibling + */ +export function isPvrss(sibling) { + return sibling.category === 'product_version_range' +} + +/** + * Whether the sibling is a `PVSS` (category `product_version`). + * + * @param {Sibling} sibling + */ +export function isPvss(sibling) { + return sibling.category === 'product_version' +} + +/** + * Whether the sibling is a leaf: has a product, no `branches` (`+l` sets). + * + * @param {Sibling} sibling + */ +export function isLeaf(sibling) { + return !sibling.hasBranches && sibling.productId !== undefined +} + +/** + * Whether the sibling only has `branches` (`+b` sets). + * + * @param {Sibling} sibling + */ +export function hasOnlyBranches(sibling) { + return sibling.hasBranches +} + +/** + * Gets the `PVRSS` siblings of a product's `CTPVR` (without the `CTPVR` itself). + * + * @param {ProductCtpvr} productCtpvr + * @returns {Sibling[]} + */ +export function collectPvrss(productCtpvr) { + return productCtpvr.siblings.filter( + (sibling) => sibling !== productCtpvr.ctpvr && isPvrss(sibling) + ) +} + +/** + * Keeps only the leaf siblings. + * + * @param {Sibling[]} siblings + * @returns {Sibling[]} + */ +export function filterLeaf(siblings) { + return siblings.filter(isLeaf) +} + +/** + * Keeps only siblings whose name is a valid `vers` range. + * + * @param {Sibling[]} siblings leaf siblings + * @returns {Array<{ productId: string; name: string }>} + */ +export function filterVers(siblings) { + return siblings.flatMap((sibling) => + sibling.name !== undefined && + isVersFormat(sibling.name) && + isValidVers(sibling.name) + ? [ + { + productId: /** @type {string} */ (sibling.productId), + name: sibling.name, + }, + ] + : [] + ) +} + +/** + * Walks `branches` recursively and adds to `ctpvrsByProductId`. + * + * @param {Branch[] | undefined} branches + * @param {string} basePath + * @param {Array<{ ctpvr: Sibling; siblings: Sibling[] }>} ctpvrs + * the `CTPVR`s on the way down to `branches` + * @param {Map} ctpvrsByProductId + */ +function collectCtpvrsByProductIdRecursive( + branches, + basePath, + ctpvrs, + ctpvrsByProductId +) { + if (!Array.isArray(branches)) return + + /** @type {Array} */ + const siblingsByIndex = branches.map((branch) => + validateBranch(branch) + ? { + category: branch.category, + name: branch.name, + hasBranches: branch.branches !== undefined, + productId: + typeof branch.product?.product_id === 'string' + ? branch.product.product_id + : undefined, + } + : undefined + ) + const siblings = /** @type {Sibling[]} */ ( + siblingsByIndex.filter((sibling) => sibling !== undefined) + ) + + branches.forEach((branch, index) => { + const sibling = siblingsByIndex[index] + if (!sibling || !validateBranch(branch)) return + const branchPath = `${basePath}/${index}` + + let currentCtpvrs = ctpvrs + if (branch.category === 'product_version_range') { + currentCtpvrs = [...ctpvrs, { ctpvr: sibling, siblings }] + } + + const productId = branch.product?.product_id + if (typeof productId === 'string' && currentCtpvrs.length > 0) { + const productCtpvrs = ctpvrsByProductId.get(productId) ?? [] + for (const ctpvr of currentCtpvrs) { + productCtpvrs.push({ instancePath: `${branchPath}/product`, ...ctpvr }) + } + ctpvrsByProductId.set(productId, productCtpvrs) + } + + if (Array.isArray(branch.branches)) { + collectCtpvrsByProductIdRecursive( + branch.branches, + `${branchPath}/branches`, + currentCtpvrs, + ctpvrsByProductId + ) + } + }) +} + +/** + * Finds all products (`EPVRPID`) below `product_version_range` branches + * (`CTPVR`) and maps each product ID to one `ProductCtpvr` per `CTPVR` + * on its path. + * + * This doesn't decide which sets (`PVRSS`, `PVSS`, `OBCSS`, `+l`, `+b`) or + * which range syntax (`vers:`, "vls") matters. + * + * @param {Branch[] | undefined} branches the `/product_tree/branches` of a document + * @returns {Map} + */ +export function collectCtpvrsByProductId(branches) { + /** @type {Map} */ + const ctpvrsByProductId = new Map() + collectCtpvrsByProductIdRecursive( + branches, + '/product_tree/branches', + [], + ctpvrsByProductId + ) + return ctpvrsByProductId +} + +/** + * Maps each product ID of a `product_status` to the contradicting groups + * (see `CONTRADICTING_PRODUCT_STATUS_GROUPS`) it belongs to. + * + * @param {Partial>} productStatus + * @returns {Map>} + */ +export function collectProductStatusGroupsByProductId(productStatus) { + /** @type {Map>} */ + const groupsByProductId = new Map() + for (const [group, properties] of Object.entries( + CONTRADICTING_PRODUCT_STATUS_GROUPS + )) { + for (const productId of properties.flatMap( + (property) => productStatus[property] ?? [] + )) { + if (!groupsByProductId.has(productId)) { + groupsByProductId.set(productId, new Set()) + } + groupsByProductId.get(productId)?.add(group) + } + } + return groupsByProductId +} diff --git a/csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js b/csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js new file mode 100644 index 00000000..4930f1f8 --- /dev/null +++ b/csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js @@ -0,0 +1,127 @@ +/** @type {Promise | undefined} */ +let loading + +/** @type {typeof import('@csaf-rs/vers-rs') | undefined} */ +let versRs + +/** + * Loads the WebAssembly module of vers-rs. Must be awaited once before + * `isValidVers` or `versRangesOverlap` are used. + * + * @returns {Promise} + */ +export async function initVers() { + loading ??= import('@csaf-rs/vers-rs') + versRs = await loading +} + +/** + * @typedef {{ versioning_scheme: string, constraints: { comparator: string, version: string }[] }} ParsedVers + */ + +/** + * Parses a product version range in the VERS syntax (e.g. vers:semver/>2.1.0|<4.2.0) + * using vers-rs. + * + * @param {string} range + * @returns {ParsedVers | undefined} `undefined` if the range is not valid VERS + */ +function parseVers(range) { + try { + return /** @type {ParsedVers} */ ( + /** @type {unknown} */ (versRs?.parse(range.trim())) + ) + } catch { + return undefined + } +} + +/** + * Checks whether a product version range is well-formed VERS and uses a + * versioning scheme supported by vers-rs. + * + * @param {string} range + * @returns {boolean} + */ +export function isValidVers(range) { + return parseVers(range) !== undefined +} + +/** + * Returns a version that is greater than Version but smaller than any + * other version that is greater than Version and appears as a boundary + * in a range, so that it represents the gap behind Version. + * + * @param {string} scheme + * @param {string} version + * @returns {string} + */ +function successor(scheme, version) { + if (scheme === 'deb') return `${version}+` + const core = version.split('+')[0] + if (core.includes('-')) return `${core}.0` + const [major, minor, patch] = core.split('.').map(Number) + return `${major}.${minor}.${patch + 1}-0` +} + +/** + * Returns a version that is smaller than every other version. + * + * @param {string} scheme + */ +function lowest(scheme) { + return scheme === 'deb' ? '0~' : '0.0.0-0' +} + +/** + * Checks whether `version` is within `range`. The range is parsed again for + * each call because `contains` consumes the parsed range. + * + * @param {string} range + * @param {string} version + */ +function inRange(range, version) { + return /** @type {typeof import('@csaf-rs/vers-rs')} */ (versRs).contains( + /** @type {any} */ (versRs?.parse(range.trim())), + version + ) +} + +/** + * Determines whether two product version ranges in the VERS syntax + * (e.g. vers:semver/>2.1.0|<4.2.0) overlap, i.e. whether at least one + * version exists that is in both ranges. + * + * The check is based on vers-rs: every boundary version of both ranges, the + * gap behind it and the gap before the first boundary is probed. + * + * Ranges of different versioning schemes never overlap. If a range is not + * valid VERS, `false` is returned so that no false positives are reported. + * + * @param {string} a + * @param {string} b + * @returns {boolean} + */ +export function versRangesOverlap(a, b) { + const rangeA = parseVers(a) + const rangeB = parseVers(b) + if (!rangeA || !rangeB) return false + const scheme = rangeA.versioning_scheme + if (scheme !== rangeB.versioning_scheme) return false + + const boundaries = [...rangeA.constraints, ...rangeB.constraints].map( + (c) => c.version + ) + const candidates = [ + lowest(scheme), + ...boundaries.flatMap((version) => [version, successor(scheme, version)]), + ] + + return candidates.some((version) => { + try { + return inRange(a, version) && inRange(b, version) + } catch { + return false + } + }) +} diff --git a/package-lock.json b/package-lock.json index dba4284d..5a3ee7c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.0", "license": "MIT", "dependencies": { + "@csaf-rs/vers-rs": "^0.1.2", "@secvisogram/is-leap-second": "^1.0.0", "@secvisogram/license-deprecation-list": "^1.0.1", "ae-cvss-calculator": "^1.0.13", @@ -108,6 +109,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@csaf-rs/vers-rs": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@csaf-rs/vers-rs/-/vers-rs-0.1.3.tgz", + "integrity": "sha512-/8ry2LM6zSvz5ZfiE6kmkVMYx5CA5v6x7LmQs9+IlRoqzc5jtFkBjfnuRI+RUadyYJslwTGi+kw8Ee3mkCm6tg==", + "license": "Apache-2.0" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", diff --git a/package.json b/package.json index 42b55796..60e36086 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "access": "public" }, "dependencies": { + "@csaf-rs/vers-rs": "^0.1.2", "@secvisogram/is-leap-second": "^1.0.0", "@secvisogram/license-deprecation-list": "^1.0.1", "ae-cvss-calculator": "^1.0.13", diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index f40f8703..6a157788 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -22,7 +22,6 @@ const excluded = [ '6.2.39.1', '6.2.45', '6.2.46', - '6.2.50.1', '6.2.50.2', '6.2.50.3', '6.2.51', diff --git a/tests/csaf_2_1/recommendedTest_6_2_50_1.js b/tests/csaf_2_1/recommendedTest_6_2_50_1.js new file mode 100644 index 00000000..93ad610b --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_50_1.js @@ -0,0 +1,198 @@ +import isBrowserRuntime from '../shared/isBrowserRuntime.js' +import { recommendedTest_6_2_50_1 } from '../../csaf_2_1/recommendedTests.js' + +describe.skipIf(isBrowserRuntime)('recommendedTest_6_2_50_1', function () { + it('skips documents not matching the input schema', async function () { + const result = await recommendedTest_6_2_50_1({ + product_tree: { branches: 'invalid' }, + }) + expect(result.warnings.length).to.equal(0) + }) + + it('skips documents not matching the input schema', async function () { + const result = await recommendedTest_6_2_50_1({ + product_tree: { + branches: [ + { + category: 'vendor', + name: 'Example Company', + branches: [ + { + branches: [], + name: 5, + }, + ], + }, + ], + }, + }) + expect(result.warnings.length).to.equal(0) + }) + + it('handles missing product trees, product statuses and unknown ids', async function () { + expect( + (await recommendedTest_6_2_50_1({ vulnerabilities: [{}] })).warnings + ).to.have.length(0) + expect( + ( + await recommendedTest_6_2_50_1({ + product_tree: { branches: [{ category: 'vendor', name: 'X' }] }, + vulnerabilities: [{ product_status: { known_affected: ['NOPE'] } }], + }) + ).warnings + ).to.have.length(0) + }) + + it('ignores invalid vers siblings in recommendedTest_6_2_50_1', async function () { + const branch = ( + /** @type {string} */ name, + /** @type {string} */ productId + ) => ({ + category: 'product_version_range', + name, + product: { name, product_id: productId }, + }) + const result = await recommendedTest_6_2_50_1({ + product_tree: { + branches: [ + { + category: 'vendor', + name: 'Example Company', + branches: [ + branch('vers:semver/>1.0.0|<5.0.0', 'A'), + branch('vers:semver/>1.0.0||', 'B'), + ], + }, + ], + }, + vulnerabilities: [ + { + product_status: { known_affected: ['A'], known_not_affected: ['B'] }, + }, + ], + }) + expect(result.warnings.length).to.equal(0) + }) + + it('ignores same group, unreferenced and non-vers products, and recurses into nested branches', async function () { + const branch = ( + /** @type {string} */ name, + /** @type {string} */ productId + ) => ({ + category: 'product_version_range', + name, + product: { name, product_id: productId }, + }) + const result = await recommendedTest_6_2_50_1({ + product_tree: { + branches: [ + { + category: 'vendor', + name: 'Example Company', + branches: [ + { + category: 'product_name', + name: 'Nested', + branches: [ + branch('vers:semver/>1.0.0|<5.0.0', 'A'), + branch('vers:semver/>2.0.0|<6.0.0', 'B'), + branch('vers:semver/>3.0.0|<7.0.0', 'C'), + branch('>1|<5', 'D'), + { + category: 'product_version_range', + name: 'vers:semver/>1.0.0|<5.0.0', + }, + { branches: [], name: 5 }, + ], + }, + ], + }, + ], + }, + vulnerabilities: [ + { + product_status: { + known_affected: ['A'], + last_affected: ['B'], + known_not_affected: ['D'], + }, + }, + ], + }) + expect(result.warnings.length).to.equal(0) + }) + + const leaf = ( + /** @type {string} */ name, + /** @type {string} */ productId + ) => ({ + category: 'product_version_range', + name, + product: { name, product_id: productId }, + }) + + const vendor = (/** @type {unknown[]} */ branches) => ({ + product_tree: { + branches: [{ category: 'vendor', name: 'Example Company', branches }], + }, + }) + + it('reports each overlapping pair only once', async function () { + const result = await recommendedTest_6_2_50_1({ + ...vendor([ + leaf('vers:semver/>1.0.0|<5.0.0', 'A'), + leaf('vers:semver/>3.0.0|<7.0.0', 'B'), + ]), + vulnerabilities: [ + { + product_status: { known_affected: ['A'], known_not_affected: ['B'] }, + }, + ], + }) + expect(result.warnings).to.have.length(1) + expect(result.warnings[0].instancePath).to.equal( + '/product_tree/branches/0/branches/0/product' + ) + }) + + it('considers all groups of a product', async function () { + const result = await recommendedTest_6_2_50_1({ + ...vendor([ + leaf('vers:semver/>1.0.0|<5.0.0', 'A'), + leaf('vers:semver/>3.0.0|<7.0.0', 'B'), + ]), + vulnerabilities: [ + { product_status: { known_affected: ['A', 'B'], fixed: ['A'] } }, + ], + }) + expect(result.warnings).to.have.length(1) + }) + + it('treats products nested below a product version range as EPVR', async function () { + const result = await recommendedTest_6_2_50_1({ + ...vendor([ + { + category: 'product_version_range', + name: 'vers:semver/>1.0.0|<5.0.0', + branches: [ + { + category: 'architecture', + name: 'x86', + product: { name: 'A', product_id: 'A' }, + }, + ], + }, + leaf('vers:semver/>3.0.0|<7.0.0', 'B'), + ]), + vulnerabilities: [ + { + product_status: { known_affected: ['A'], known_not_affected: ['B'] }, + }, + ], + }) + expect(result.warnings).to.have.length(1) + expect(result.warnings[0].instancePath).to.equal( + '/product_tree/branches/0/branches/0/branches/0/product' + ) + }) +}) diff --git a/tests/csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js b/tests/csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js new file mode 100644 index 00000000..02acdee6 --- /dev/null +++ b/tests/csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js @@ -0,0 +1,77 @@ +import isBrowserRuntime from '../../../shared/isBrowserRuntime.js' +import { + initVers, + isValidVers, + versRangesOverlap, +} from '../../../../csaf_2_1/shared/productVersionRangeHelper/versRangesOverlap.js' + +describe.skipIf(isBrowserRuntime)('versRangesOverlap', function () { + beforeAll(initVers) + it('validates VERS ranges', function () { + expect(isValidVers('vers:semver/>1.0.0|<2.0.0')).to.equal(true) + expect(isValidVers('vers:semver/*')).to.equal(true) + expect(isValidVers('>1.0.0|<2.0.0')).to.equal(false) + expect(isValidVers('vers:semver/>1.0.0||')).to.equal(false) + expect(isValidVers('vers:semver/>%')).to.equal(false) + expect(isValidVers('vers:semver/>')).to.equal(false) + expect(isValidVers('vers:intdot/>1.0|<2.0')).to.equal(false) + }) + + it('detects overlapping ranges', function () { + expect( + versRangesOverlap( + 'vers:semver/>2.1.0|<4.2.0', + 'vers:semver/>1.2.0|<2.4.0' + ) + ).to.equal(true) + expect(versRangesOverlap('vers:semver/*', 'vers:semver/1.0.0')).to.equal( + true + ) + expect( + versRangesOverlap('vers:semver/>=1.0.0|<2.0.0', 'vers:semver/1.5.0') + ).to.equal(true) + expect( + versRangesOverlap( + 'vers:semver/>1.0.0|<2.0.0', + 'vers:semver/>1.0.0|<2.0.0' + ) + ).to.equal(true) + expect( + versRangesOverlap('vers:deb/>1.0|<2.0', 'vers:deb/>1.5|<3.0') + ).to.equal(true) + }) + + it('detects non-overlapping ranges', function () { + expect( + versRangesOverlap( + 'vers:semver/>2.4.0|<4.2.0', + 'vers:semver/>1.2.0|<2.1.0' + ) + ).to.equal(false) + expect( + versRangesOverlap('vers:semver/<2.0.0', 'vers:semver/>=2.0.0') + ).to.equal(false) + expect( + versRangesOverlap('vers:semver/!=1.0.0', 'vers:semver/1.0.0') + ).to.equal(false) + }) + + it('does not report overlaps for different schemes or invalid input', function () { + expect(versRangesOverlap('vers:semver/>1.0.0', 'vers:deb/>1.0')).to.equal( + false + ) + expect(versRangesOverlap('invalid', 'vers:semver/>1.0.0')).to.equal(false) + expect(versRangesOverlap('vers:semver/>1.0.0', 'vers:semver/>a')).to.equal( + false + ) + }) + + it('returns false if a version cannot be probed', function () { + expect( + versRangesOverlap( + 'vers:semver/>1.0.0', + 'vers:semver/>1.0.18446744073709551615' + ) + ).to.equal(false) + }) +}) diff --git a/vitest.config.js b/vitest.config.js index 9decfbdf..3048998d 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -21,7 +21,11 @@ export default defineConfig({ name: 'node', environment: 'node', globals: true, - include: ['tests/*.js', 'tests/csaf_2_1/*.js'], + include: [ + 'tests/*.js', + 'tests/csaf_2_1/*.js', + 'tests/csaf_2_1/shared/productVersionRangeHelper/*.js', + ], env: hunspellEnv, }, }, @@ -29,7 +33,11 @@ export default defineConfig({ test: { name: 'browser', globals: true, - include: ['tests/*.js', 'tests/csaf_2_1/*.js'], + include: [ + 'tests/*.js', + 'tests/csaf_2_1/*.js', + 'tests/csaf_2_1/shared/productVersionRangeHelper/*.js', + ], // Only genuinely Node-only test content: `undici`'s `MockAgent`-based // network mocking. The other Node-only content (real `hunspell` CLI // in the OASIS 6.3.8 branches) is skipped at runtime instead, via