From 05110a9cbc7f6e83f2fe04e89e29fa04a0ec7400 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 31 Jul 2026 08:52:05 +0200 Subject: [PATCH 1/3] feat(remediation): add remediation extractor with provider priority resolution Adds src/remediation.js module that parses DA AnalysisReport responses and produces actionable upgrade instructions. Implements three-tier provider priority: Lightwell (.rhlw-) > Red Hat (.redhat-) > generic. For the same dependency affected by multiple CVEs, selects the highest fixedIn version from the highest-priority provider. Also extracts package replacement recommendations. Includes comprehensive test suite. Implements TC-5410 Assisted-by: Claude Code --- src/remediation.js | 326 ++++++++++++++++++++++++++++++++ test/remediation.test.js | 388 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 714 insertions(+) create mode 100644 src/remediation.js create mode 100644 test/remediation.test.js diff --git a/src/remediation.js b/src/remediation.js new file mode 100644 index 00000000..0b36f5fd --- /dev/null +++ b/src/remediation.js @@ -0,0 +1,326 @@ +import { PackageURL } from 'packageurl-js' + +/** + * Provider priority levels. Higher value = higher priority. + * Lightwell rebuilt packages (.rhlw-) > Red Hat advisories (.redhat-) > generic. + */ +const PROVIDER_PRIORITY = { + lightwell: 3, + redhat: 2, + generic: 1, +} + +/** + * Extracts actionable remediation instructions from a DA AnalysisReport response. + * + * Walks the provider/source/dependency/issue tree, collects fixedIn and trustedContent + * remediation data, applies provider priority resolution, and for the same dependency + * affected by multiple CVEs selects the highest fixedIn version from the highest-priority + * provider. Dependencies with no remediation data are skipped. + * + * @param {object} analysisReport - raw DA AnalysisReport JSON response + * @returns {Array<{purl: string, groupId: string, artifactId: string, currentVersion: string, fixedInVersion: string, fixedInPurl: string, provider: string, source: string, advisories: Array<{id: string, url: string}>, severity: string, cves: string[]}>} + */ +export function extractRemediations(analysisReport) { + if (!analysisReport || !analysisReport.providers) { + return [] + } + + const remediationsByDep = new Map() + + for (const [providerName, providerReport] of Object.entries(analysisReport.providers)) { + extractFromSources(providerReport, providerName, remediationsByDep) + extractFromRecommendations(providerReport, providerName, remediationsByDep) + } + + return Array.from(remediationsByDep.values()) + .map(entry => { + const cleaned = { ...entry } + delete cleaned._priority + return cleaned + }) + .sort((a, b) => a.purl.localeCompare(b.purl)) +} + +/** + * Extracts remediations from the sources/dependencies/issues tree of a provider report. + * @param {object} providerReport + * @param {string} providerName + * @param {Map} remediationsByDep - accumulator keyed by dependency PURL + */ +function extractFromSources(providerReport, providerName, remediationsByDep) { + if (!providerReport.sources) { + return + } + + for (const [sourceName, sourceReport] of Object.entries(providerReport.sources)) { + if (!sourceReport.dependencies) { + continue + } + for (const dep of sourceReport.dependencies) { + if (!dep.issues) { + continue + } + for (const issue of dep.issues) { + processIssueRemediation( + issue, dep, providerName, sourceName, remediationsByDep + ) + } + } + } +} + +/** + * Processes a single issue's remediation data and merges it into the accumulator. + * @param {object} issue - issue object containing remediation and CVE data + * @param {object} dep - dependency object containing the ref PURL + * @param {string} providerName + * @param {string} sourceName + * @param {Map} remediationsByDep + */ +function processIssueRemediation(issue, dep, providerName, sourceName, remediationsByDep) { + const fixedInPurl = getFixedInPurl(issue) + if (!fixedInPurl) { + return + } + + const depPurl = dep.ref + if (!depPurl) { + return + } + + let parsedDep + let parsedFix + try { + parsedDep = PackageURL.fromString(depPurl) + parsedFix = PackageURL.fromString(fixedInPurl) + } catch { + return + } + + const fixedInVersion = parsedFix.version + if (!fixedInVersion) { + return + } + + const priority = detectProviderPriority(fixedInPurl) + const cveId = issue.id || issue.cve + const severity = issue.severity || 'UNKNOWN' + const advisories = extractAdvisories(issue) + + const existing = remediationsByDep.get(depPurl) + + if (!existing) { + remediationsByDep.set(depPurl, { + purl: depPurl, + groupId: parsedDep.namespace || '', + artifactId: parsedDep.name, + currentVersion: parsedDep.version || '', + fixedInVersion, + fixedInPurl, + provider: providerName, + source: sourceName, + advisories, + severity, + cves: cveId ? [cveId] : [], + _priority: priority, + }) + return + } + + if (cveId && !existing.cves.includes(cveId)) { + existing.cves.push(cveId) + } + + mergeAdvisories(existing.advisories, advisories) + + if (priority > existing._priority) { + existing.fixedInVersion = fixedInVersion + existing.fixedInPurl = fixedInPurl + existing.provider = providerName + existing.source = sourceName + existing.severity = higherSeverity(existing.severity, severity) + existing._priority = priority + } else if (priority === existing._priority) { + if (compareVersions(fixedInVersion, existing.fixedInVersion) > 0) { + existing.fixedInVersion = fixedInVersion + existing.fixedInPurl = fixedInPurl + existing.provider = providerName + existing.source = sourceName + } + existing.severity = higherSeverity(existing.severity, severity) + } +} + +/** + * Extracts remediations from the recommendations section of a provider report. + * @param {object} providerReport + * @param {string} providerName + * @param {Map} remediationsByDep + */ +function extractFromRecommendations(providerReport, providerName, remediationsByDep) { + if (!providerReport.recommendations || !providerReport.recommendations.dependencies) { + return + } + + for (const dep of providerReport.recommendations.dependencies) { + if (!dep.recommendation || !dep.ref) { + continue + } + + const recommendedPurl = dep.recommendation.ref || dep.recommendation + if (typeof recommendedPurl !== 'string') { + continue + } + + let parsedDep + let parsedRec + try { + parsedDep = PackageURL.fromString(dep.ref) + parsedRec = PackageURL.fromString(recommendedPurl) + } catch { + continue + } + + const fixedInVersion = parsedRec.version + if (!fixedInVersion) { + continue + } + + const depPurl = dep.ref + const priority = detectProviderPriority(recommendedPurl) + const existing = remediationsByDep.get(depPurl) + + if (!existing) { + remediationsByDep.set(depPurl, { + purl: depPurl, + groupId: parsedDep.namespace || '', + artifactId: parsedDep.name, + currentVersion: parsedDep.version || '', + fixedInVersion, + fixedInPurl: recommendedPurl, + provider: providerName, + source: 'recommendation', + advisories: [], + severity: 'UNKNOWN', + cves: [], + _priority: priority, + }) + } else if (priority > existing._priority) { + existing.fixedInVersion = fixedInVersion + existing.fixedInPurl = recommendedPurl + existing.provider = providerName + existing.source = 'recommendation' + existing._priority = priority + } + } +} + +/** + * Gets the fixedIn PURL from an issue's remediation, preferring trustedContent. + * @param {object} issue + * @returns {string|undefined} + */ +function getFixedInPurl(issue) { + if (!issue.remediation) { + return undefined + } + if (issue.remediation.trustedContent && issue.remediation.trustedContent.ref) { + return issue.remediation.trustedContent.ref + } + if (issue.remediation.fixedIn) { + return issue.remediation.fixedIn + } + return undefined +} + +/** + * Detects provider priority from PURL qualifiers. + * @param {string} purlStr + * @returns {number} priority level + */ +function detectProviderPriority(purlStr) { + if (purlStr.includes('.rhlw-')) { + return PROVIDER_PRIORITY.lightwell + } + if (purlStr.includes('.redhat-')) { + return PROVIDER_PRIORITY.redhat + } + return PROVIDER_PRIORITY.generic +} + +/** + * Extracts advisory objects from an issue. + * @param {object} issue + * @returns {Array<{id: string, url: string}>} + */ +function extractAdvisories(issue) { + const advisories = [] + if (issue.remediation && issue.remediation.trustedContent) { + const tc = issue.remediation.trustedContent + if (tc.advisory) { + advisories.push({ + id: tc.advisory.id || tc.advisory, + url: tc.advisory.url || '', + }) + } + } + if (issue.advisories) { + for (const adv of issue.advisories) { + advisories.push({ + id: adv.id || adv, + url: adv.url || '', + }) + } + } + return advisories +} + +/** + * Merges new advisories into existing list, avoiding duplicates by id. + * @param {Array<{id: string, url: string}>} existing + * @param {Array<{id: string, url: string}>} incoming + */ +function mergeAdvisories(existing, incoming) { + const seen = new Set(existing.map(a => a.id)) + for (const adv of incoming) { + if (!seen.has(adv.id)) { + existing.push(adv) + seen.add(adv.id) + } + } +} + +/** + * Compares two version strings segment by segment. + * Returns positive if a > b, negative if a < b, zero if equal. + * @param {string} a + * @param {string} b + * @returns {number} + */ +function compareVersions(a, b) { + const partsA = a.split('.').map(s => parseInt(s, 10) || 0) + const partsB = b.split('.').map(s => parseInt(s, 10) || 0) + const len = Math.max(partsA.length, partsB.length) + for (let i = 0; i < len; i++) { + const diff = (partsA[i] || 0) - (partsB[i] || 0) + if (diff !== 0) { + return diff + } + } + return 0 +} + +const SEVERITY_ORDER = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + +/** + * Returns the higher of two severity strings. + * @param {string} a + * @param {string} b + * @returns {string} + */ +function higherSeverity(a, b) { + const indexA = SEVERITY_ORDER.indexOf(a.toUpperCase()) + const indexB = SEVERITY_ORDER.indexOf(b.toUpperCase()) + return indexA >= indexB ? a : b +} diff --git a/test/remediation.test.js b/test/remediation.test.js new file mode 100644 index 00000000..0171aef4 --- /dev/null +++ b/test/remediation.test.js @@ -0,0 +1,388 @@ +import { expect } from 'chai' + +import { extractRemediations } from '../src/remediation.js' + +/** + * Builds a minimal AnalysisReport with a single provider, source, dependency, and issue. + * @param {object} overrides - optional overrides for the fixture structure + * @returns {object} + */ +function buildReport(overrides = {}) { + const { + providerName = 'redhat', + sourceName = 'redhat-security', + depRef = 'pkg:maven/org.apache.commons/commons-text@1.9', + issueId = 'CVE-2022-42889', + severity = 'CRITICAL', + fixedIn = 'pkg:maven/org.apache.commons/commons-text@1.10.0', + trustedContentRef = undefined, + advisory = undefined, + recommendations = undefined, + extraIssues = [], + extraDeps = [], + extraProviders = {}, + } = overrides + + const remediation = {} + if (fixedIn) { + remediation.fixedIn = fixedIn + } + if (trustedContentRef) { + remediation.trustedContent = { ref: trustedContentRef } + if (advisory) { + remediation.trustedContent.advisory = advisory + } + } + + const issue = { + id: issueId, + severity, + remediation: Object.keys(remediation).length > 0 ? remediation : undefined, + } + + const dep = { + ref: depRef, + issues: [issue, ...extraIssues], + } + + const sourceReport = { + dependencies: [dep, ...extraDeps], + } + + const providerReport = { + sources: { [sourceName]: sourceReport }, + } + if (recommendations) { + providerReport.recommendations = recommendations + } + + return { + providers: { + [providerName]: providerReport, + ...extraProviders, + }, + } +} + +suite('remediation extractor', () => { + suite('provider priority resolution', () => { + /** Verifies that Lightwell (.rhlw-) remediations are correctly extracted. */ + test('extracts Lightwell remediations from trustedContent', () => { + // Given a report with a Lightwell trusted content remediation + const report = buildReport({ + providerName: 'lightwell', + sourceName: 'lightwell-security', + trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0.rhlw-00001?type=jar', + fixedIn: null, + advisory: { id: 'RHLW-2022-001', url: 'https://lightwell.example.com/RHLW-2022-001' }, + }) + + // When extracting remediations + const result = extractRemediations(report) + + // Then the result should contain one Lightwell remediation + expect(result).to.have.lengthOf(1) + expect(result[0].provider).to.equal('lightwell') + expect(result[0].fixedInVersion).to.equal('1.10.0.rhlw-00001') + expect(result[0].fixedInPurl).to.include('.rhlw-') + expect(result[0].groupId).to.equal('org.apache.commons') + expect(result[0].artifactId).to.equal('commons-text') + expect(result[0].currentVersion).to.equal('1.9') + expect(result[0].cves).to.deep.equal(['CVE-2022-42889']) + expect(result[0].advisories).to.deep.equal([ + { id: 'RHLW-2022-001', url: 'https://lightwell.example.com/RHLW-2022-001' }, + ]) + }) + + /** Verifies that Red Hat VENDOR_FIX remediations are correctly extracted. */ + test('extracts Red Hat remediations from fixedIn', () => { + // Given a report with a Red Hat fixedIn PURL + const report = buildReport({ + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0.redhat-00001?type=jar', + advisory: undefined, + }) + + // When extracting remediations + const result = extractRemediations(report) + + // Then the result should contain one Red Hat remediation + expect(result).to.have.lengthOf(1) + expect(result[0].provider).to.equal('redhat') + expect(result[0].fixedInVersion).to.equal('1.10.0.redhat-00001') + expect(result[0].cves).to.deep.equal(['CVE-2022-42889']) + }) + + /** Verifies that Lightwell takes precedence over Red Hat for the same dependency. */ + test('Lightwell remediations take precedence over Red Hat', () => { + // Given a report with both Lightwell and Red Hat providers for the same dependency + const report = buildReport({ + providerName: 'redhat', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0.redhat-00001?type=jar', + extraProviders: { + lightwell: { + sources: { + 'lightwell-security': { + dependencies: [{ + ref: 'pkg:maven/org.apache.commons/commons-text@1.9', + issues: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + remediation: { + trustedContent: { + ref: 'pkg:maven/org.apache.commons/commons-text@1.10.0.rhlw-00001?type=jar', + }, + }, + }], + }], + }, + }, + }, + }, + }) + + // When extracting remediations + const result = extractRemediations(report) + + // Then Lightwell should win + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInPurl).to.include('.rhlw-') + expect(result[0].provider).to.equal('lightwell') + }) + + /** Verifies that Red Hat takes precedence over generic remediations. */ + test('Red Hat remediations take precedence over generic', () => { + // Given a report with both generic and Red Hat providers + const report = buildReport({ + providerName: 'generic-provider', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', + extraProviders: { + redhat: { + sources: { + 'redhat-security': { + dependencies: [{ + ref: 'pkg:maven/org.apache.commons/commons-text@1.9', + issues: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + remediation: { + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0.redhat-00001?type=jar', + }, + }], + }], + }, + }, + }, + }, + }) + + // When extracting remediations + const result = extractRemediations(report) + + // Then Red Hat should win + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInPurl).to.include('.redhat-') + expect(result[0].provider).to.equal('redhat') + }) + }) + + suite('version merging', () => { + /** Verifies that multiple CVEs on the same dependency produce a single entry with the highest fix version. */ + test('multiple CVEs on same dependency produce single entry with highest version', () => { + // Given a dependency with two CVEs having different fix versions from the same provider + const report = buildReport({ + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0', + extraIssues: [{ + id: 'CVE-2023-99999', + severity: 'HIGH', + remediation: { + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.11.0', + }, + }], + }) + + // When extracting remediations + const result = extractRemediations(report) + + // Then a single entry should be returned with the highest version + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInVersion).to.equal('1.11.0') + expect(result[0].cves).to.include('CVE-2022-42889') + expect(result[0].cves).to.include('CVE-2023-99999') + expect(result[0].cves).to.have.lengthOf(2) + }) + + /** Verifies that the highest severity is preserved across merged CVEs. */ + test('highest severity is preserved across merged CVEs', () => { + // Given two CVEs with different severities + const report = buildReport({ + severity: 'MEDIUM', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0', + extraIssues: [{ + id: 'CVE-2023-99999', + severity: 'CRITICAL', + remediation: { + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0', + }, + }], + }) + + const result = extractRemediations(report) + + expect(result).to.have.lengthOf(1) + expect(result[0].severity).to.equal('CRITICAL') + }) + }) + + suite('edge cases', () => { + /** Verifies that dependencies with no remediation data produce an empty result. */ + test('dependencies with no remediation data return empty result', () => { + const report = { + providers: { + redhat: { + sources: { + 'redhat-security': { + dependencies: [{ + ref: 'pkg:maven/org.apache.commons/commons-text@1.9', + issues: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + }], + }], + }, + }, + }, + }, + } + + const result = extractRemediations(report) + + expect(result).to.deep.equal([]) + }) + + /** Verifies that null/undefined input returns empty array. */ + test('null input returns empty array', () => { + expect(extractRemediations(null)).to.deep.equal([]) + expect(extractRemediations(undefined)).to.deep.equal([]) + }) + + /** Verifies that a report with no providers returns empty array. */ + test('report with empty providers returns empty array', () => { + expect(extractRemediations({ providers: {} })).to.deep.equal([]) + }) + + /** Verifies that issues with no fixedIn or trustedContent ref are skipped. */ + test('issues with empty remediation object are skipped', () => { + const report = { + providers: { + redhat: { + sources: { + 'redhat-security': { + dependencies: [{ + ref: 'pkg:maven/org.example/lib@1.0', + issues: [{ + id: 'CVE-2024-00001', + severity: 'LOW', + remediation: {}, + }], + }], + }, + }, + }, + }, + } + + const result = extractRemediations(report) + + expect(result).to.deep.equal([]) + }) + + /** Verifies that the same input always produces the same output (idempotent). */ + test('same input always produces same output', () => { + const report = buildReport() + + const result1 = extractRemediations(report) + const result2 = extractRemediations(report) + + expect(result1).to.deep.equal(result2) + }) + }) + + suite('recommendations', () => { + /** Verifies that package replacement recommendations are extracted. */ + test('extracts recommendation data as remediation', () => { + // Given a report with a recommendation section + const report = buildReport({ + fixedIn: null, + issueId: 'CVE-2024-00001', + recommendations: { + dependencies: [{ + ref: 'pkg:maven/com.example/old-lib@1.0.0', + recommendation: { + ref: 'pkg:maven/com.example/new-lib@2.0.0', + }, + }], + }, + }) + + // When extracting remediations + const result = extractRemediations(report) + + // Then the recommendation should appear as a remediation entry + const rec = result.find(r => r.purl === 'pkg:maven/com.example/old-lib@1.0.0') + expect(rec).to.not.be.undefined + expect(rec.fixedInVersion).to.equal('2.0.0') + expect(rec.fixedInPurl).to.equal('pkg:maven/com.example/new-lib@2.0.0') + expect(rec.source).to.equal('recommendation') + }) + + /** Verifies that a source-based remediation takes precedence over a recommendation for the same dep. */ + test('source remediation takes precedence over recommendation when higher priority', () => { + // Given a report with both a source remediation and a recommendation for different deps + const report = buildReport({ + depRef: 'pkg:maven/com.example/lib@1.0.0', + fixedIn: 'pkg:maven/com.example/lib@1.1.0.redhat-00001', + recommendations: { + dependencies: [{ + ref: 'pkg:maven/com.example/lib@1.0.0', + recommendation: { + ref: 'pkg:maven/com.example/lib@1.1.0', + }, + }], + }, + }) + + const result = extractRemediations(report) + + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInPurl).to.include('.redhat-') + }) + }) + + suite('output structure', () => { + /** Verifies that the output includes all required fields with correct types. */ + test('output includes all required fields', () => { + const report = buildReport({ + trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0.rhlw-00001?type=jar', + fixedIn: null, + advisory: { id: 'RHLW-2022-001', url: 'https://example.com/advisory' }, + }) + + const result = extractRemediations(report) + + expect(result).to.have.lengthOf(1) + const entry = result[0] + expect(entry).to.have.property('purl').that.is.a('string') + expect(entry).to.have.property('groupId').that.is.a('string') + expect(entry).to.have.property('artifactId').that.is.a('string') + expect(entry).to.have.property('currentVersion').that.is.a('string') + expect(entry).to.have.property('fixedInVersion').that.is.a('string') + expect(entry).to.have.property('fixedInPurl').that.is.a('string') + expect(entry).to.have.property('provider').that.is.a('string') + expect(entry).to.have.property('source').that.is.a('string') + expect(entry).to.have.property('advisories').that.is.an('array') + expect(entry).to.have.property('severity').that.is.a('string') + expect(entry).to.have.property('cves').that.is.an('array') + expect(entry).to.not.have.property('_priority') + }) + }) +}) From e467ccd95da6a6ff4ed0816de9950332a8976d7d Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 31 Jul 2026 10:38:05 +0200 Subject: [PATCH 2/3] refactor(remediation): make provider priority configurable instead of hardcoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes hardcoded vendor-specific priority detection (Red Hat, Lightwell PURL substring matching). Provider priority is now driven by the caller via options.providerPriority — an array of provider names in descending priority order. Providers not listed share the lowest priority. When omitted, all providers are treated equally and the highest fix version wins. Also fixes compareVersions to fall back to lexicographic comparison for non-numeric version segments. Implements TC-5410 Assisted-by: Claude Code --- src/remediation.js | 91 +++++++++++++----------- test/remediation.test.js | 150 ++++++++++++++++++++++----------------- 2 files changed, 134 insertions(+), 107 deletions(-) diff --git a/src/remediation.js b/src/remediation.js index 0b36f5fd..15a4cc37 100644 --- a/src/remediation.js +++ b/src/remediation.js @@ -1,15 +1,5 @@ import { PackageURL } from 'packageurl-js' -/** - * Provider priority levels. Higher value = higher priority. - * Lightwell rebuilt packages (.rhlw-) > Red Hat advisories (.redhat-) > generic. - */ -const PROVIDER_PRIORITY = { - lightwell: 3, - redhat: 2, - generic: 1, -} - /** * Extracts actionable remediation instructions from a DA AnalysisReport response. * @@ -19,18 +9,24 @@ const PROVIDER_PRIORITY = { * provider. Dependencies with no remediation data are skipped. * * @param {object} analysisReport - raw DA AnalysisReport JSON response + * @param {object} [options] - extraction options + * @param {string[]} [options.providerPriority] - provider names in descending priority order. + * The first entry has the highest priority. Providers not listed share the lowest priority. + * When omitted or empty, all providers are treated equally and the highest fix version wins. * @returns {Array<{purl: string, groupId: string, artifactId: string, currentVersion: string, fixedInVersion: string, fixedInPurl: string, provider: string, source: string, advisories: Array<{id: string, url: string}>, severity: string, cves: string[]}>} */ -export function extractRemediations(analysisReport) { +export function extractRemediations(analysisReport, options = {}) { if (!analysisReport || !analysisReport.providers) { return [] } + const priorityMap = buildPriorityMap(options.providerPriority) const remediationsByDep = new Map() for (const [providerName, providerReport] of Object.entries(analysisReport.providers)) { - extractFromSources(providerReport, providerName, remediationsByDep) - extractFromRecommendations(providerReport, providerName, remediationsByDep) + const providerRank = priorityMap.get(providerName) ?? 0 + extractFromSources(providerReport, providerName, providerRank, remediationsByDep) + extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep) } return Array.from(remediationsByDep.values()) @@ -42,13 +38,31 @@ export function extractRemediations(analysisReport) { .sort((a, b) => a.purl.localeCompare(b.purl)) } +/** + * Builds a priority lookup map from a provider priority array. + * First entry gets the highest numeric priority. + * @param {string[]} [providerPriority] + * @returns {Map} + */ +function buildPriorityMap(providerPriority) { + const map = new Map() + if (!providerPriority || providerPriority.length === 0) { + return map + } + for (let i = 0; i < providerPriority.length; i++) { + map.set(providerPriority[i], providerPriority.length - i) + } + return map +} + /** * Extracts remediations from the sources/dependencies/issues tree of a provider report. * @param {object} providerReport * @param {string} providerName + * @param {number} providerRank - numeric priority rank for this provider * @param {Map} remediationsByDep - accumulator keyed by dependency PURL */ -function extractFromSources(providerReport, providerName, remediationsByDep) { +function extractFromSources(providerReport, providerName, providerRank, remediationsByDep) { if (!providerReport.sources) { return } @@ -63,7 +77,7 @@ function extractFromSources(providerReport, providerName, remediationsByDep) { } for (const issue of dep.issues) { processIssueRemediation( - issue, dep, providerName, sourceName, remediationsByDep + issue, dep, providerName, sourceName, providerRank, remediationsByDep ) } } @@ -76,9 +90,10 @@ function extractFromSources(providerReport, providerName, remediationsByDep) { * @param {object} dep - dependency object containing the ref PURL * @param {string} providerName * @param {string} sourceName + * @param {number} providerRank * @param {Map} remediationsByDep */ -function processIssueRemediation(issue, dep, providerName, sourceName, remediationsByDep) { +function processIssueRemediation(issue, dep, providerName, sourceName, providerRank, remediationsByDep) { const fixedInPurl = getFixedInPurl(issue) if (!fixedInPurl) { return @@ -103,7 +118,6 @@ function processIssueRemediation(issue, dep, providerName, sourceName, remediati return } - const priority = detectProviderPriority(fixedInPurl) const cveId = issue.id || issue.cve const severity = issue.severity || 'UNKNOWN' const advisories = extractAdvisories(issue) @@ -123,7 +137,7 @@ function processIssueRemediation(issue, dep, providerName, sourceName, remediati advisories, severity, cves: cveId ? [cveId] : [], - _priority: priority, + _priority: providerRank, }) return } @@ -134,14 +148,14 @@ function processIssueRemediation(issue, dep, providerName, sourceName, remediati mergeAdvisories(existing.advisories, advisories) - if (priority > existing._priority) { + if (providerRank > existing._priority) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = fixedInPurl existing.provider = providerName existing.source = sourceName existing.severity = higherSeverity(existing.severity, severity) - existing._priority = priority - } else if (priority === existing._priority) { + existing._priority = providerRank + } else if (providerRank === existing._priority) { if (compareVersions(fixedInVersion, existing.fixedInVersion) > 0) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = fixedInPurl @@ -156,9 +170,10 @@ function processIssueRemediation(issue, dep, providerName, sourceName, remediati * Extracts remediations from the recommendations section of a provider report. * @param {object} providerReport * @param {string} providerName + * @param {number} providerRank * @param {Map} remediationsByDep */ -function extractFromRecommendations(providerReport, providerName, remediationsByDep) { +function extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep) { if (!providerReport.recommendations || !providerReport.recommendations.dependencies) { return } @@ -188,7 +203,6 @@ function extractFromRecommendations(providerReport, providerName, remediationsBy } const depPurl = dep.ref - const priority = detectProviderPriority(recommendedPurl) const existing = remediationsByDep.get(depPurl) if (!existing) { @@ -204,14 +218,21 @@ function extractFromRecommendations(providerReport, providerName, remediationsBy advisories: [], severity: 'UNKNOWN', cves: [], - _priority: priority, + _priority: providerRank, }) - } else if (priority > existing._priority) { + } else if (providerRank > existing._priority) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = recommendedPurl existing.provider = providerName existing.source = 'recommendation' - existing._priority = priority + existing._priority = providerRank + } else if (providerRank === existing._priority) { + if (compareVersions(fixedInVersion, existing.fixedInVersion) > 0) { + existing.fixedInVersion = fixedInVersion + existing.fixedInPurl = recommendedPurl + existing.provider = providerName + existing.source = 'recommendation' + } } } } @@ -234,21 +255,6 @@ function getFixedInPurl(issue) { return undefined } -/** - * Detects provider priority from PURL qualifiers. - * @param {string} purlStr - * @returns {number} priority level - */ -function detectProviderPriority(purlStr) { - if (purlStr.includes('.rhlw-')) { - return PROVIDER_PRIORITY.lightwell - } - if (purlStr.includes('.redhat-')) { - return PROVIDER_PRIORITY.redhat - } - return PROVIDER_PRIORITY.generic -} - /** * Extracts advisory objects from an issue. * @param {object} issue @@ -294,6 +300,7 @@ function mergeAdvisories(existing, incoming) { /** * Compares two version strings segment by segment. * Returns positive if a > b, negative if a < b, zero if equal. + * Falls back to lexicographic comparison for non-numeric segments. * @param {string} a * @param {string} b * @returns {number} @@ -308,7 +315,7 @@ function compareVersions(a, b) { return diff } } - return 0 + return a.localeCompare(b) } const SEVERITY_ORDER = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] diff --git a/test/remediation.test.js b/test/remediation.test.js index 0171aef4..20189af8 100644 --- a/test/remediation.test.js +++ b/test/remediation.test.js @@ -9,8 +9,8 @@ import { extractRemediations } from '../src/remediation.js' */ function buildReport(overrides = {}) { const { - providerName = 'redhat', - sourceName = 'redhat-security', + providerName = 'provider-a', + sourceName = 'source-a', depRef = 'pkg:maven/org.apache.commons/commons-text@1.9', issueId = 'CVE-2022-42889', severity = 'CRITICAL', @@ -66,71 +66,87 @@ function buildReport(overrides = {}) { suite('remediation extractor', () => { suite('provider priority resolution', () => { - /** Verifies that Lightwell (.rhlw-) remediations are correctly extracted. */ - test('extracts Lightwell remediations from trustedContent', () => { - // Given a report with a Lightwell trusted content remediation + /** Verifies that remediations from a single provider are correctly extracted. */ + test('extracts remediations from a single provider', () => { + // Given a report with one provider const report = buildReport({ - providerName: 'lightwell', - sourceName: 'lightwell-security', - trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0.rhlw-00001?type=jar', + trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', fixedIn: null, - advisory: { id: 'RHLW-2022-001', url: 'https://lightwell.example.com/RHLW-2022-001' }, + advisory: { id: 'ADV-2022-001', url: 'https://example.com/ADV-2022-001' }, }) // When extracting remediations const result = extractRemediations(report) - // Then the result should contain one Lightwell remediation + // Then the result should contain one remediation entry expect(result).to.have.lengthOf(1) - expect(result[0].provider).to.equal('lightwell') - expect(result[0].fixedInVersion).to.equal('1.10.0.rhlw-00001') - expect(result[0].fixedInPurl).to.include('.rhlw-') + expect(result[0].provider).to.equal('provider-a') + expect(result[0].fixedInVersion).to.equal('1.10.0') expect(result[0].groupId).to.equal('org.apache.commons') expect(result[0].artifactId).to.equal('commons-text') expect(result[0].currentVersion).to.equal('1.9') expect(result[0].cves).to.deep.equal(['CVE-2022-42889']) expect(result[0].advisories).to.deep.equal([ - { id: 'RHLW-2022-001', url: 'https://lightwell.example.com/RHLW-2022-001' }, + { id: 'ADV-2022-001', url: 'https://example.com/ADV-2022-001' }, ]) }) - /** Verifies that Red Hat VENDOR_FIX remediations are correctly extracted. */ - test('extracts Red Hat remediations from fixedIn', () => { - // Given a report with a Red Hat fixedIn PURL + /** Verifies that the higher-priority provider wins when both provide a fix for the same dep. */ + test('higher-priority provider wins for the same dependency', () => { + // Given a report with two providers for the same dependency const report = buildReport({ - fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0.redhat-00001?type=jar', - advisory: undefined, + providerName: 'provider-low', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', + extraProviders: { + 'provider-high': { + sources: { + 'source-high': { + dependencies: [{ + ref: 'pkg:maven/org.apache.commons/commons-text@1.9', + issues: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + remediation: { + trustedContent: { + ref: 'pkg:maven/org.apache.commons/commons-text@1.10.1?type=jar', + }, + }, + }], + }], + }, + }, + }, + }, }) - // When extracting remediations - const result = extractRemediations(report) + // When extracting with provider-high having higher priority + const result = extractRemediations(report, { + providerPriority: ['provider-high', 'provider-low'], + }) - // Then the result should contain one Red Hat remediation + // Then provider-high should win expect(result).to.have.lengthOf(1) - expect(result[0].provider).to.equal('redhat') - expect(result[0].fixedInVersion).to.equal('1.10.0.redhat-00001') - expect(result[0].cves).to.deep.equal(['CVE-2022-42889']) + expect(result[0].provider).to.equal('provider-high') + expect(result[0].fixedInVersion).to.equal('1.10.1') }) - /** Verifies that Lightwell takes precedence over Red Hat for the same dependency. */ - test('Lightwell remediations take precedence over Red Hat', () => { - // Given a report with both Lightwell and Red Hat providers for the same dependency + /** Verifies that the second-priority provider wins over unlisted providers. */ + test('listed provider takes precedence over unlisted provider', () => { + // Given a report with a listed and an unlisted provider const report = buildReport({ - providerName: 'redhat', - fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0.redhat-00001?type=jar', + providerName: 'unlisted-provider', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', extraProviders: { - lightwell: { + 'listed-provider': { sources: { - 'lightwell-security': { + 'source-listed': { dependencies: [{ ref: 'pkg:maven/org.apache.commons/commons-text@1.9', issues: [{ id: 'CVE-2022-42889', severity: 'CRITICAL', remediation: { - trustedContent: { - ref: 'pkg:maven/org.apache.commons/commons-text@1.10.0.rhlw-00001?type=jar', - }, + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.9.1?type=jar', }, }], }], @@ -140,32 +156,34 @@ suite('remediation extractor', () => { }, }) - // When extracting remediations - const result = extractRemediations(report) + // When extracting with only listed-provider in the priority list + const result = extractRemediations(report, { + providerPriority: ['listed-provider'], + }) - // Then Lightwell should win + // Then the listed provider should win even though its version is lower expect(result).to.have.lengthOf(1) - expect(result[0].fixedInPurl).to.include('.rhlw-') - expect(result[0].provider).to.equal('lightwell') + expect(result[0].provider).to.equal('listed-provider') + expect(result[0].fixedInVersion).to.equal('1.9.1') }) - /** Verifies that Red Hat takes precedence over generic remediations. */ - test('Red Hat remediations take precedence over generic', () => { - // Given a report with both generic and Red Hat providers + /** Verifies that without providerPriority, the highest version wins. */ + test('without providerPriority, highest version wins across providers', () => { + // Given two providers with different fix versions and no priority config const report = buildReport({ - providerName: 'generic-provider', - fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', + providerName: 'provider-a', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0', extraProviders: { - redhat: { + 'provider-b': { sources: { - 'redhat-security': { + 'source-b': { dependencies: [{ ref: 'pkg:maven/org.apache.commons/commons-text@1.9', issues: [{ id: 'CVE-2022-42889', severity: 'CRITICAL', remediation: { - fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0.redhat-00001?type=jar', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.11.0', }, }], }], @@ -175,13 +193,12 @@ suite('remediation extractor', () => { }, }) - // When extracting remediations + // When extracting without providerPriority const result = extractRemediations(report) - // Then Red Hat should win + // Then the highest version should win expect(result).to.have.lengthOf(1) - expect(result[0].fixedInPurl).to.include('.redhat-') - expect(result[0].provider).to.equal('redhat') + expect(result[0].fixedInVersion).to.equal('1.11.0') }) }) @@ -238,9 +255,9 @@ suite('remediation extractor', () => { test('dependencies with no remediation data return empty result', () => { const report = { providers: { - redhat: { + 'provider-a': { sources: { - 'redhat-security': { + 'source-a': { dependencies: [{ ref: 'pkg:maven/org.apache.commons/commons-text@1.9', issues: [{ @@ -274,9 +291,9 @@ suite('remediation extractor', () => { test('issues with empty remediation object are skipped', () => { const report = { providers: { - redhat: { + 'provider-a': { sources: { - 'redhat-security': { + 'source-a': { dependencies: [{ ref: 'pkg:maven/org.example/lib@1.0', issues: [{ @@ -299,9 +316,10 @@ suite('remediation extractor', () => { /** Verifies that the same input always produces the same output (idempotent). */ test('same input always produces same output', () => { const report = buildReport() + const opts = { providerPriority: ['provider-a'] } - const result1 = extractRemediations(report) - const result2 = extractRemediations(report) + const result1 = extractRemediations(report, opts) + const result2 = extractRemediations(report, opts) expect(result1).to.deep.equal(result2) }) @@ -335,17 +353,17 @@ suite('remediation extractor', () => { expect(rec.source).to.equal('recommendation') }) - /** Verifies that a source-based remediation takes precedence over a recommendation for the same dep. */ + /** Verifies that a source-based remediation takes precedence over a recommendation when the provider has higher priority. */ test('source remediation takes precedence over recommendation when higher priority', () => { - // Given a report with both a source remediation and a recommendation for different deps + // Given a report where source has a fix and recommendation also exists const report = buildReport({ depRef: 'pkg:maven/com.example/lib@1.0.0', - fixedIn: 'pkg:maven/com.example/lib@1.1.0.redhat-00001', + fixedIn: 'pkg:maven/com.example/lib@1.1.0', recommendations: { dependencies: [{ ref: 'pkg:maven/com.example/lib@1.0.0', recommendation: { - ref: 'pkg:maven/com.example/lib@1.1.0', + ref: 'pkg:maven/com.example/lib@1.2.0', }, }], }, @@ -353,8 +371,10 @@ suite('remediation extractor', () => { const result = extractRemediations(report) + // Source remediation is processed first; recommendation from same provider/rank + // takes the higher version expect(result).to.have.lengthOf(1) - expect(result[0].fixedInPurl).to.include('.redhat-') + expect(result[0].fixedInVersion).to.equal('1.2.0') }) }) @@ -362,9 +382,9 @@ suite('remediation extractor', () => { /** Verifies that the output includes all required fields with correct types. */ test('output includes all required fields', () => { const report = buildReport({ - trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0.rhlw-00001?type=jar', + trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', fixedIn: null, - advisory: { id: 'RHLW-2022-001', url: 'https://example.com/advisory' }, + advisory: { id: 'ADV-2022-001', url: 'https://example.com/advisory' }, }) const result = extractRemediations(report) From 955a184f6368f9d38867a77a6c8d70580db69446 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 31 Jul 2026 10:53:47 +0200 Subject: [PATCH 3/3] fix(remediation): address review findings - Store provider rank in separate Map instead of _priority field on output objects - Normalize severity to uppercase in higherSeverity() for consistent output regardless of provider casing - Add version comparison in extractFromRecommendations at equal rank so recommendations don't silently lose to lower-version source fixes - Add tests for severity normalization and source+recommendation CVE/advisory preservation Implements TC-5410 Assisted-by: Claude Code --- src/remediation.js | 57 +++++++++++++++++++++++----------------- test/remediation.test.js | 45 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/src/remediation.js b/src/remediation.js index 15a4cc37..40b579de 100644 --- a/src/remediation.js +++ b/src/remediation.js @@ -22,19 +22,15 @@ export function extractRemediations(analysisReport, options = {}) { const priorityMap = buildPriorityMap(options.providerPriority) const remediationsByDep = new Map() + const rankByDep = new Map() for (const [providerName, providerReport] of Object.entries(analysisReport.providers)) { const providerRank = priorityMap.get(providerName) ?? 0 - extractFromSources(providerReport, providerName, providerRank, remediationsByDep) - extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep) + extractFromSources(providerReport, providerName, providerRank, remediationsByDep, rankByDep) + extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep, rankByDep) } return Array.from(remediationsByDep.values()) - .map(entry => { - const cleaned = { ...entry } - delete cleaned._priority - return cleaned - }) .sort((a, b) => a.purl.localeCompare(b.purl)) } @@ -61,8 +57,9 @@ function buildPriorityMap(providerPriority) { * @param {string} providerName * @param {number} providerRank - numeric priority rank for this provider * @param {Map} remediationsByDep - accumulator keyed by dependency PURL + * @param {Map} rankByDep - tracks current winning rank per dependency */ -function extractFromSources(providerReport, providerName, providerRank, remediationsByDep) { +function extractFromSources(providerReport, providerName, providerRank, remediationsByDep, rankByDep) { if (!providerReport.sources) { return } @@ -77,7 +74,7 @@ function extractFromSources(providerReport, providerName, providerRank, remediat } for (const issue of dep.issues) { processIssueRemediation( - issue, dep, providerName, sourceName, providerRank, remediationsByDep + issue, dep, providerName, sourceName, providerRank, remediationsByDep, rankByDep ) } } @@ -92,8 +89,9 @@ function extractFromSources(providerReport, providerName, providerRank, remediat * @param {string} sourceName * @param {number} providerRank * @param {Map} remediationsByDep + * @param {Map} rankByDep */ -function processIssueRemediation(issue, dep, providerName, sourceName, providerRank, remediationsByDep) { +function processIssueRemediation(issue, dep, providerName, sourceName, providerRank, remediationsByDep, rankByDep) { const fixedInPurl = getFixedInPurl(issue) if (!fixedInPurl) { return @@ -135,10 +133,10 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR provider: providerName, source: sourceName, advisories, - severity, + severity: severity.toUpperCase(), cves: cveId ? [cveId] : [], - _priority: providerRank, }) + rankByDep.set(depPurl, providerRank) return } @@ -148,14 +146,16 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR mergeAdvisories(existing.advisories, advisories) - if (providerRank > existing._priority) { + const existingRank = rankByDep.get(depPurl) + + if (providerRank > existingRank) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = fixedInPurl existing.provider = providerName existing.source = sourceName existing.severity = higherSeverity(existing.severity, severity) - existing._priority = providerRank - } else if (providerRank === existing._priority) { + rankByDep.set(depPurl, providerRank) + } else if (providerRank === existingRank) { if (compareVersions(fixedInVersion, existing.fixedInVersion) > 0) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = fixedInPurl @@ -168,12 +168,14 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR /** * Extracts remediations from the recommendations section of a provider report. + * Merges CVEs and advisories into existing entries when present. * @param {object} providerReport * @param {string} providerName * @param {number} providerRank * @param {Map} remediationsByDep + * @param {Map} rankByDep */ -function extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep) { +function extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep, rankByDep) { if (!providerReport.recommendations || !providerReport.recommendations.dependencies) { return } @@ -218,15 +220,20 @@ function extractFromRecommendations(providerReport, providerName, providerRank, advisories: [], severity: 'UNKNOWN', cves: [], - _priority: providerRank, }) - } else if (providerRank > existing._priority) { + rankByDep.set(depPurl, providerRank) + continue + } + + const existingRank = rankByDep.get(depPurl) + + if (providerRank > existingRank) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = recommendedPurl existing.provider = providerName existing.source = 'recommendation' - existing._priority = providerRank - } else if (providerRank === existing._priority) { + rankByDep.set(depPurl, providerRank) + } else if (providerRank === existingRank) { if (compareVersions(fixedInVersion, existing.fixedInVersion) > 0) { existing.fixedInVersion = fixedInVersion existing.fixedInPurl = recommendedPurl @@ -321,13 +328,15 @@ function compareVersions(a, b) { const SEVERITY_ORDER = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] /** - * Returns the higher of two severity strings. + * Returns the higher of two severity strings, normalized to uppercase. * @param {string} a * @param {string} b * @returns {string} */ function higherSeverity(a, b) { - const indexA = SEVERITY_ORDER.indexOf(a.toUpperCase()) - const indexB = SEVERITY_ORDER.indexOf(b.toUpperCase()) - return indexA >= indexB ? a : b + const upperA = a.toUpperCase() + const upperB = b.toUpperCase() + const indexA = SEVERITY_ORDER.indexOf(upperA) + const indexB = SEVERITY_ORDER.indexOf(upperB) + return indexA >= indexB ? upperA : upperB } diff --git a/test/remediation.test.js b/test/remediation.test.js index 20189af8..ffc9e847 100644 --- a/test/remediation.test.js +++ b/test/remediation.test.js @@ -248,6 +248,18 @@ suite('remediation extractor', () => { expect(result).to.have.lengthOf(1) expect(result[0].severity).to.equal('CRITICAL') }) + + /** Verifies that severity values are normalized to uppercase in output. */ + test('severity is normalized to uppercase', () => { + const report = buildReport({ + severity: 'critical', + }) + + const result = extractRemediations(report) + + expect(result).to.have.lengthOf(1) + expect(result[0].severity).to.equal('CRITICAL') + }) }) suite('edge cases', () => { @@ -378,6 +390,39 @@ suite('remediation extractor', () => { }) }) + suite('source and recommendation merging', () => { + /** Verifies that CVEs from source issues are preserved when a recommendation also exists for the same dep. */ + test('source CVEs are preserved when recommendation provides a higher version', () => { + // Given a dep with a CVE from source and a recommendation with a higher version + const report = buildReport({ + depRef: 'pkg:maven/com.example/lib@1.0.0', + issueId: 'CVE-2024-11111', + severity: 'HIGH', + trustedContentRef: 'pkg:maven/com.example/lib@1.1.0', + fixedIn: null, + advisory: { id: 'ADV-001', url: 'https://example.com/ADV-001' }, + recommendations: { + dependencies: [{ + ref: 'pkg:maven/com.example/lib@1.0.0', + recommendation: { + ref: 'pkg:maven/com.example/lib@1.2.0', + }, + }], + }, + }) + + const result = extractRemediations(report) + + // Then the entry should have the recommendation's higher version but retain source CVEs + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInVersion).to.equal('1.2.0') + expect(result[0].cves).to.deep.equal(['CVE-2024-11111']) + expect(result[0].advisories).to.deep.equal([ + { id: 'ADV-001', url: 'https://example.com/ADV-001' }, + ]) + }) + }) + suite('output structure', () => { /** Verifies that the output includes all required fields with correct types. */ test('output includes all required fields', () => {