diff --git a/src/remediation.js b/src/remediation.js new file mode 100644 index 00000000..40b579de --- /dev/null +++ b/src/remediation.js @@ -0,0 +1,342 @@ +import { PackageURL } from 'packageurl-js' + +/** + * 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 + * @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, options = {}) { + if (!analysisReport || !analysisReport.providers) { + return [] + } + + 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, rankByDep) + extractFromRecommendations(providerReport, providerName, providerRank, remediationsByDep, rankByDep) + } + + return Array.from(remediationsByDep.values()) + .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 + * @param {Map} rankByDep - tracks current winning rank per dependency + */ +function extractFromSources(providerReport, providerName, providerRank, remediationsByDep, rankByDep) { + 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, providerRank, remediationsByDep, rankByDep + ) + } + } + } +} + +/** + * 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 {number} providerRank + * @param {Map} remediationsByDep + * @param {Map} rankByDep + */ +function processIssueRemediation(issue, dep, providerName, sourceName, providerRank, remediationsByDep, rankByDep) { + 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 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: severity.toUpperCase(), + cves: cveId ? [cveId] : [], + }) + rankByDep.set(depPurl, providerRank) + return + } + + if (cveId && !existing.cves.includes(cveId)) { + existing.cves.push(cveId) + } + + mergeAdvisories(existing.advisories, advisories) + + 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) + rankByDep.set(depPurl, providerRank) + } else if (providerRank === existingRank) { + 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. + * 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, rankByDep) { + 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 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: [], + }) + 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' + rankByDep.set(depPurl, providerRank) + } else if (providerRank === existingRank) { + if (compareVersions(fixedInVersion, existing.fixedInVersion) > 0) { + existing.fixedInVersion = fixedInVersion + existing.fixedInPurl = recommendedPurl + existing.provider = providerName + existing.source = 'recommendation' + } + } + } +} + +/** + * 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 +} + +/** + * 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. + * Falls back to lexicographic comparison for non-numeric segments. + * @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 a.localeCompare(b) +} + +const SEVERITY_ORDER = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] + +/** + * Returns the higher of two severity strings, normalized to uppercase. + * @param {string} a + * @param {string} b + * @returns {string} + */ +function higherSeverity(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 new file mode 100644 index 00000000..ffc9e847 --- /dev/null +++ b/test/remediation.test.js @@ -0,0 +1,453 @@ +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 = 'provider-a', + sourceName = 'source-a', + 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 remediations from a single provider are correctly extracted. */ + test('extracts remediations from a single provider', () => { + // Given a report with one provider + const report = buildReport({ + trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', + fixedIn: null, + 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 remediation entry + expect(result).to.have.lengthOf(1) + 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: 'ADV-2022-001', url: 'https://example.com/ADV-2022-001' }, + ]) + }) + + /** 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({ + 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 with provider-high having higher priority + const result = extractRemediations(report, { + providerPriority: ['provider-high', 'provider-low'], + }) + + // Then provider-high should win + expect(result).to.have.lengthOf(1) + expect(result[0].provider).to.equal('provider-high') + expect(result[0].fixedInVersion).to.equal('1.10.1') + }) + + /** 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: 'unlisted-provider', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', + extraProviders: { + 'listed-provider': { + sources: { + 'source-listed': { + 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.9.1?type=jar', + }, + }], + }], + }, + }, + }, + }, + }) + + // When extracting with only listed-provider in the priority list + const result = extractRemediations(report, { + providerPriority: ['listed-provider'], + }) + + // Then the listed provider should win even though its version is lower + expect(result).to.have.lengthOf(1) + expect(result[0].provider).to.equal('listed-provider') + expect(result[0].fixedInVersion).to.equal('1.9.1') + }) + + /** 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: 'provider-a', + fixedIn: 'pkg:maven/org.apache.commons/commons-text@1.10.0', + extraProviders: { + 'provider-b': { + sources: { + '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.11.0', + }, + }], + }], + }, + }, + }, + }, + }) + + // When extracting without providerPriority + const result = extractRemediations(report) + + // Then the highest version should win + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInVersion).to.equal('1.11.0') + }) + }) + + 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') + }) + + /** 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', () => { + /** Verifies that dependencies with no remediation data produce an empty result. */ + test('dependencies with no remediation data return empty result', () => { + const report = { + providers: { + 'provider-a': { + sources: { + 'source-a': { + 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: { + 'provider-a': { + sources: { + 'source-a': { + 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 opts = { providerPriority: ['provider-a'] } + + const result1 = extractRemediations(report, opts) + const result2 = extractRemediations(report, opts) + + 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 when the provider has higher priority. */ + test('source remediation takes precedence over recommendation when higher priority', () => { + // 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', + 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) + + // Source remediation is processed first; recommendation from same provider/rank + // takes the higher version + expect(result).to.have.lengthOf(1) + expect(result[0].fixedInVersion).to.equal('1.2.0') + }) + }) + + 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', () => { + const report = buildReport({ + trustedContentRef: 'pkg:maven/org.apache.commons/commons-text@1.10.0?type=jar', + fixedIn: null, + advisory: { id: 'ADV-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') + }) + }) +})