diff --git a/csaf_2_1/informativeTests.js b/csaf_2_1/informativeTests.js index 0ecb389f..5c5ea56e 100644 --- a/csaf_2_1/informativeTests.js +++ b/csaf_2_1/informativeTests.js @@ -1,13 +1,13 @@ export { informativeTest_6_3_3, informativeTest_6_3_5, - informativeTest_6_3_6, informativeTest_6_3_7, informativeTest_6_3_8, informativeTest_6_3_9, informativeTest_6_3_10, informativeTest_6_3_11, } from '../informativeTests.js' +export { informativeTest_6_3_6 } from './informativeTests/informativeTest_6_3_6.js' export { informativeTest_6_3_1 } from './informativeTests/informativeTest_6_3_1.js' export { informativeTest_6_3_2 } from './informativeTests/informativeTest_6_3_2.js' export { informativeTest_6_3_4 } from './informativeTests/informativeTest_6_3_4.js' diff --git a/csaf_2_1/informativeTests/informativeTest_6_3_6.js b/csaf_2_1/informativeTests/informativeTest_6_3_6.js new file mode 100644 index 00000000..70c06fd4 --- /dev/null +++ b/csaf_2_1/informativeTests/informativeTest_6_3_6.js @@ -0,0 +1,219 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import testURL from '#lib/informativeTests/shared/testURL.js' +import { walkPath } from '#lib/walkPaths.js' + +const ajv = new Ajv() + +const referenceSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + url: { type: 'string' }, + category: { type: 'string' }, + }, +}) + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + document: { + additionalProperties: true, + optionalProperties: { + acknowledgments: { + elements: { + additionalProperties: true, + optionalProperties: { + urls: { + elements: { type: 'string' }, + }, + }, + }, + }, + references: { + elements: referenceSchema, + }, + aggregate_severity: { + additionalProperties: true, + optionalProperties: { + namespace: { type: 'string' }, + }, + }, + distribution: { + additionalProperties: true, + optionalProperties: { + tlp: { + additionalProperties: true, + optionalProperties: { + url: { type: 'string' }, + }, + }, + }, + }, + publisher: { + additionalProperties: true, + optionalProperties: { + namespace: { type: 'string' }, + }, + }, + }, + }, + product_tree: { + additionalProperties: true, + optionalProperties: { + full_product_names: { + elements: { + additionalProperties: true, + optionalProperties: { + product_identification_helper: { + additionalProperties: true, + optionalProperties: { + sbom_urls: { elements: { type: 'string' } }, + x_generic_uris: { + elements: { + additionalProperties: true, + optionalProperties: { + namespace: { type: 'string' }, + uri: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + branches: { + elements: { + additionalProperties: true, + properties: {}, + }, + }, + product_paths: { + elements: { + additionalProperties: true, + optionalProperties: { + full_product_name: { + additionalProperties: true, + optionalProperties: { + product_identification_helper: { + additionalProperties: true, + optionalProperties: { + sbom_urls: { elements: { type: 'string' } }, + x_generic_uris: { + elements: { + additionalProperties: true, + optionalProperties: { + namespace: { type: 'string' }, + uri: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + vulnerabilities: { + elements: { + additionalProperties: true, + optionalProperties: { + remediations: { + elements: { + additionalProperties: true, + optionalProperties: { + url: { type: 'string' }, + }, + }, + }, + acknowledgments: { + elements: { + additionalProperties: true, + optionalProperties: { + urls: { + elements: { type: 'string' }, + }, + }, + }, + }, + references: { + elements: referenceSchema, + }, + }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) +const validateReference = ajv.compile(referenceSchema) + +/** + * CSAF 2.1 Informative Test 6.3.6: + * verifies that all non-self references using URL fields resolve to HTTP 2xx or 3xx. + * + * @param {unknown} doc + */ +export async function informativeTest_6_3_6(doc) { + const ctx = { + infos: /** @type {Array<{ message: string; instancePath: string }>} */ ([]), + } + + if (!validateInput(doc)) { + return ctx + } + + for (const path of [ + '/document/acknowledgments[]/urls[]', + '/document/aggregate_severity/namespace', + '/document/distribution/tlp/url', + '/document/publisher/namespace', + '/product_tree/branches[*]/product/product_identification_helper/sbom_urls[]', + '/product_tree/branches[*]/product/product_identification_helper/x_generic_uris[]/namespace', + '/product_tree/branches[*]/product/product_identification_helper/x_generic_uris[]/uri', + '/product_tree/full_product_names[]/product_identification_helper/sbom_urls[]', + '/product_tree/full_product_names[]/product_identification_helper/x_generic_uris[]/namespace', + '/product_tree/full_product_names[]/product_identification_helper/x_generic_uris[]/uri', + '/product_tree/product_paths[]/full_product_name/product_identification_helper/sbom_urls[]', + '/product_tree/product_paths[]/full_product_name/product_identification_helper/x_generic_uris[]/namespace', + '/product_tree/product_paths[]/full_product_name/product_identification_helper/x_generic_uris[]/uri', + '/vulnerabilities[]/acknowledgments[]/urls[]', + '/vulnerabilities[]/remediations[]/url', + ]) { + await walkPath(doc, path, async (instancePath, value) => { + if (typeof value !== 'string') return + await testURL(value, () => { + ctx.infos.push({ + instancePath, + message: 'use of non-self referencing urls failing to resolve', + }) + }) + }) + } + + for (const path of [ + '/document/references[]', + '/vulnerabilities[]/references[]', + ]) { + await walkPath(doc, path, async (instancePath, value) => { + if ( + !validateReference(value) || + value.category === 'self' || + typeof value.url !== 'string' + ) { + return + } + + await testURL(value.url, () => { + ctx.infos.push({ + instancePath: instancePath + '/url', + message: 'use of non-self referencing urls failing to resolve', + }) + }) + }) + } + + return ctx +} diff --git a/lib/walkPaths.js b/lib/walkPaths.js new file mode 100644 index 00000000..cd674ec9 --- /dev/null +++ b/lib/walkPaths.js @@ -0,0 +1,83 @@ +/** + * Traverses a simplified JSON-path-like expression and executes a callback on + * every matching node. + * + * Path syntax supported here: + * - `/foo/bar` for object traversal + * - `/items[]/url` for array traversal + * - '/items[*]/url` for recursive array traversal + * - combinations of all of the above + * + * The callback receives a JSON Pointer-like `instancePath` for each match. + * + * @param {unknown} root - Root object to traverse. + * @param {string} path - Traversal path, for example + * `/document/acknowledgments[]/urls[]`. + * @param {(instancePath: string, value: unknown) => Promise} onCheck + * Callback invoked for each matched value. + */ +export async function walkPath(root, path, onCheck) { + return walk([], path.split('/').slice(1), root) + + /** + * The actual recursive function. + * + * @param {string[]} resolvedSegments + * @param {string[]} remainingSegments + * @param {unknown} node + */ + async function walk(resolvedSegments, remainingSegments, node) { + if (node == null) return + const keyEntry = remainingSegments[0] + + if (!keyEntry) { + // Reached the end of the path: call the callback now ... + + await onCheck('/' + resolvedSegments.join('/'), node) + } else if (keyEntry.endsWith('[*]')) { + // ... Recursive-descent array: visit every element and re-apply this same + // segment on each element to handle arbitrary nesting depth. + + const arrayName = keyEntry.slice(0, -3) + /** @type {unknown} */ + const array = Reflect.get(node, arrayName) + + if (Array.isArray(array)) { + for (const [elementIndex, element] of array.entries()) { + const nextResolved = [ + ...resolvedSegments, + arrayName, + String(elementIndex), + ] + // ... Continue with the rest of the path at this depth + await walk(nextResolved, remainingSegments.slice(1), element) + // ... Recurse deeper into same-named sub-arrays + await walk(nextResolved, remainingSegments, element) + } + } + } else if (keyEntry.endsWith('[]')) { + // ... Array entry: iterate through all the elements. + + const arrayName = keyEntry.split('[')[0] + /** @type {unknown} */ + const array = Reflect.get(node, arrayName) + + if (Array.isArray(array)) { + for (const [elementIndex, element] of array?.entries() ?? []) { + await walk( + [...resolvedSegments, arrayName, String(elementIndex)], + [...remainingSegments.slice(1)], + element + ) + } + } + } else { + // ... Otherwise we recurse deeper + await walk( + [...resolvedSegments, keyEntry], + remainingSegments.slice(1), + Reflect.get(node, keyEntry) + ) + } + } +} diff --git a/package.json b/package.json index 225e9562..8ffdd2d7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "repository": { "url": "https://github.com/secvisogram/csaf-validator-lib" }, + "imports": { + "#*.js": "./*.js" + }, "files": [ "lib", "schemas", diff --git a/tests/csaf_2_1/informativeTest_6_3_6.js b/tests/csaf_2_1/informativeTest_6_3_6.js new file mode 100644 index 00000000..89522271 --- /dev/null +++ b/tests/csaf_2_1/informativeTest_6_3_6.js @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict' +import { informativeTest_6_3_6 } from '../../csaf_2_1/informativeTests.js' + +describe('informativeTest_6_3_6', function () { + it('returns no infos for invalid input', async function () { + const result = await informativeTest_6_3_6('not-an-object') + assert.equal(result.infos.length, 0) + }) +}) diff --git a/tests/walkPaths.js b/tests/walkPaths.js new file mode 100644 index 00000000..05fc4165 --- /dev/null +++ b/tests/walkPaths.js @@ -0,0 +1,144 @@ +import { expect } from 'chai' +import { walkPath } from '../lib/walkPaths.js' + +/** + * @param {unknown} root + * @param {string} path + */ +async function collect(root, path) { + /** @type {Array<{ instancePath: string; value: unknown }>} */ + const results = [] + await walkPath(root, path, async function (instancePath, value) { + results.push({ instancePath, value }) + }) + return results +} + +describe('walkPath', function () { + describe('object traversal', function () { + it('resolves /foo on { foo: "bar" }', async function () { + const results = await collect({ foo: 'bar' }, '/foo') + expect(results).to.deep.equal([{ instancePath: '/foo', value: 'bar' }]) + }) + + it('resolves /foo/bar on { foo: { bar: 42 } }', async function () { + const results = await collect({ foo: { bar: 42 } }, '/foo/bar') + expect(results).to.deep.equal([{ instancePath: '/foo/bar', value: 42 }]) + }) + + it('resolves /a/b/c on { a: { b: { c: true } } }', async function () { + const results = await collect({ a: { b: { c: true } } }, '/a/b/c') + expect(results).to.deep.equal([{ instancePath: '/a/b/c', value: true }]) + }) + }) + + describe('array [] traversal', function () { + it('resolves /items[] on { items: ["x", "y"] }', async function () { + const results = await collect({ items: ['x', 'y'] }, '/items[]') + expect(results).to.deep.equal([ + { instancePath: '/items/0', value: 'x' }, + { instancePath: '/items/1', value: 'y' }, + ]) + }) + + it('resolves /items[]/url on array of objects', async function () { + const results = await collect( + { items: [{ url: 'u1' }, { url: 'u2' }] }, + '/items[]/url' + ) + expect(results).to.deep.equal([ + { instancePath: '/items/0/url', value: 'u1' }, + { instancePath: '/items/1/url', value: 'u2' }, + ]) + }) + + it('resolves /a[]/b[] on nested arrays', async function () { + const results = await collect( + { a: [{ b: [1, 2] }, { b: [3] }] }, + '/a[]/b[]' + ) + expect(results).to.deep.equal([ + { instancePath: '/a/0/b/0', value: 1 }, + { instancePath: '/a/0/b/1', value: 2 }, + { instancePath: '/a/1/b/0', value: 3 }, + ]) + }) + }) + + describe('recursive descent [*]', function () { + it('resolves /branches[*]/name flat (1 level)', async function () { + const results = await collect( + { branches: [{ name: 'root' }] }, + '/branches[*]/name' + ) + expect(results).to.deep.equal([ + { instancePath: '/branches/0/name', value: 'root' }, + ]) + }) + + it('resolves /branches[*]/name across 2 levels', async function () { + const results = await collect( + { branches: [{ name: 'root', branches: [{ name: 'child' }] }] }, + '/branches[*]/name' + ) + expect(results).to.deep.equal([ + { instancePath: '/branches/0/name', value: 'root' }, + { instancePath: '/branches/0/branches/0/name', value: 'child' }, + ]) + }) + + it('resolves /branches[*]/name across 3 levels', async function () { + const results = await collect( + { + branches: [ + { + name: 'L1', + branches: [{ name: 'L2', branches: [{ name: 'L3' }] }], + }, + ], + }, + '/branches[*]/name' + ) + expect(results).to.deep.equal([ + { instancePath: '/branches/0/name', value: 'L1' }, + { instancePath: '/branches/0/branches/0/name', value: 'L2' }, + { + instancePath: '/branches/0/branches/0/branches/0/name', + value: 'L3', + }, + ]) + }) + }) + + describe('edge cases', function () { + it('returns empty array for null root', async function () { + const results = await collect(null, '/foo') + expect(results).to.deep.equal([]) + }) + + it('returns empty array for undefined root', async function () { + const results = await collect(undefined, '/foo') + expect(results).to.deep.equal([]) + }) + + it('returns empty array for missing property', async function () { + const results = await collect({}, '/foo/bar') + expect(results).to.deep.equal([]) + }) + + it('returns empty array when mid-path value is null', async function () { + const results = await collect({ foo: null }, '/foo/bar') + expect(results).to.deep.equal([]) + }) + + it('returns empty array for empty array target', async function () { + const results = await collect({ items: [] }, '/items[]') + expect(results).to.deep.equal([]) + }) + + it('returns empty array when [] target is not an array', async function () { + const results = await collect({ items: 'not-an-array' }, '/items[]') + expect(results).to.deep.equal([]) + }) + }) +})