From f2d6e3f8c2eec418105a43b596665b2e02fd4fa9 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 11 Sep 2026 13:02:04 +0100 Subject: [PATCH 1/2] refactor(remediation): give each CVE its own severity and advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractRemediations flattened every CVE on a dependency into one severity, one CVE list, and one merged advisory list. That is lossy: a dependency with a MEDIUM and a CRITICAL CVE reported both as CRITICAL, and advisories could no longer be traced back to the CVE they belong to. The report generator consumed exactly that flattened data, so its per-CVE table showed every row at the dependency's max severity with a shared advisory list — the extractor had the correct per-CVE answer at parse time and was discarding it before the report could use it. Model vulnerabilities as a per-CVE array instead, each entry keeping the severity and advisories it was reported with. The report now renders one row per CVE from its own data. A dependency-level severity is derived on demand via maxSeverity (used by the bundled and dry-run views) rather than stored, so it cannot drift out of sync with the underlying CVEs. Co-Authored-By: Claude Opus 4.8 --- src/index.js | 1 - src/remediate.js | 25 ++--- src/remediation.js | 85 +++++++++++++---- src/remediation_report.js | 54 +++++++---- test/remediate.test.js | 7 +- test/remediation.test.js | 162 ++++++++++++++++++++++++++++---- test/remediation_report.test.js | 111 +++++++++++++++------- 7 files changed, 342 insertions(+), 103 deletions(-) diff --git a/src/index.js b/src/index.js index ffe41b97..d1fd255c 100644 --- a/src/index.js +++ b/src/index.js @@ -80,7 +80,6 @@ export { * TRUSTIFY_DA_SOURCE?: string | undefined, * TRUSTIFY_DA_TOKEN?: string | undefined, * TRUSTIFY_DA_TELEMETRY_ID?: string | undefined, - * TRUSTIFY_DA_WORKSPACE_DIR?: string | undefined, * batchConcurrency?: number | undefined, * TRUSTIFY_DA_BATCH_CONCURRENCY?: string | undefined, * workspaceDiscoveryIgnore?: string[] | undefined, diff --git a/src/remediate.js b/src/remediate.js index af98f677..40c03735 100644 --- a/src/remediate.js +++ b/src/remediate.js @@ -56,23 +56,14 @@ const SKIP_DIRS = new Set(['node_modules', '.git']) */ /** - * A single applicable remediation, as produced by `extractRemediations` and enriched by - * `runRemediation` with the originating manifest path(s) and (optionally) per-dependency changes. - * @typedef {{ - * 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[], + * A remediation grounded in the scanned workspace: the canonical + * {@link import('./remediation.js').Remediation} base as produced by `extractRemediations`, enriched + * by `runRemediation` with the originating manifest path(s) in `files` (always present) and, + * optionally, the isolated per-dependency edits in `changes`. + * @typedef {import('./remediation.js').Remediation & { * files: string[], * changes?: DependencyFix[] - * }} Remediation + * }} AppliedRemediation */ /** @type {ManifestType[]} */ @@ -161,7 +152,7 @@ export function findManifests(targetPath) { * @param {boolean} [options.perDependencyChanges=false] - when true, each remediation is populated with * a `changes` array describing the isolated, single-dependency edit (see {@link DependencyFix}). This lets * callers create one commit/PR per dependency without attributing diff hunks themselves. - * @returns {Promise<{exitCode: number, output: string, remediations: Remediation[], manifests: string[], appliedFiles: string[]}>} + * @returns {Promise<{exitCode: number, remediations: AppliedRemediation[], manifests: string[], appliedFiles: string[]}>} * exitCode is 2 for a dry-run that found remediations (nothing written), 0 otherwise. `remediations` * is the structured, per-manifest list of applicable updates — each entry carries the originating * manifest path(s) in `files` so callers can group and create per-dependency changes. `appliedFiles` @@ -216,7 +207,7 @@ export async function runRemediation(targetPath, options = {}) { // Tag each remediation with the manifest it came from so callers can group // changes per dependency across a multi-manifest workspace. - for (const remediation of remediations) { + for (const remediation of /** @type {AppliedRemediation[]} */ (remediations)) { remediation.files = [manifestPath] } diff --git a/src/remediation.js b/src/remediation.js index b25bb13f..29ab5fc7 100644 --- a/src/remediation.js +++ b/src/remediation.js @@ -1,5 +1,27 @@ import { PackageURL } from 'packageurl-js' +/** + * A single per-CVE vulnerability carried by a remediation: one CVE with its own severity and the + * advisories attributed to it. + * @typedef {{id: string, severity: string, advisories: Array<{id: string, url: string}>}} Vulnerability + */ + +/** + * A single applicable remediation as produced by {@link extractRemediations}: a dependency, the + * version that fixes it, and the per-CVE `vulnerabilities` it resolves. + * @typedef {{ + * purl: string, + * groupId: string, + * artifactId: string, + * currentVersion: string, + * fixedInVersion: string, + * fixedInPurl: string, + * provider: string, + * source: string, + * vulnerabilities: Vulnerability[] + * }} Remediation + */ + /** * Extracts the major version segment from a version string. * @param {string} version @@ -90,7 +112,9 @@ export const highestStrategy = { * When omitted or empty, all providers are treated equally and the highest fix version wins. * @param {VersionStrategy} [options.versionStrategy] - version selection strategy with selectVersion * and resolveConflict methods. Defaults to closestCoverageStrategy. - * @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[]}>} + * @returns {Remediation[]} `vulnerabilities` is the sole source of vulnerability data — each entry + * holds one CVE with its own severity and advisories. Use {@link maxSeverity} to derive a + * dependency-level severity. */ export function extractRemediations(analysisReport, options = {}) { if (!analysisReport || !analysisReport.providers) { @@ -216,7 +240,7 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR const existing = remediationsByDep.get(depPurl) if (!existing) { - remediationsByDep.set(depPurl, { + const entry = { purl: depPurl, groupId: parsedDep.namespace || '', artifactId: parsedDep.name, @@ -225,20 +249,16 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR fixedInPurl, provider: providerName, source: sourceName, - advisories, - severity: severity.toUpperCase(), - cves: cveId ? [cveId] : [], + vulnerabilities: [], _fromTrustedContent: isTrustedContent, - }) + } + addVulnerability(entry, cveId, severity, advisories) + remediationsByDep.set(depPurl, entry) rankByDep.set(depPurl, providerRank) return } - if (cveId && !existing.cves.includes(cveId)) { - existing.cves.push(cveId) - } - - mergeAdvisories(existing.advisories, advisories) + addVulnerability(existing, cveId, severity, advisories) const existingRank = rankByDep.get(depPurl) @@ -247,7 +267,6 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR existing.fixedInPurl = fixedInPurl existing.provider = providerName existing.source = sourceName - existing.severity = higherSeverity(existing.severity, severity) existing._fromTrustedContent = isTrustedContent rankByDep.set(depPurl, providerRank) } else if (providerRank === existingRank) { @@ -262,7 +281,6 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR existing.source = sourceName existing._fromTrustedContent = isTrustedContent } - existing.severity = higherSeverity(existing.severity, severity) } } @@ -317,9 +335,7 @@ function extractFromRecommendations(providerReport, providerName, providerRank, fixedInPurl: recommendedPurl, provider: providerName, source: 'recommendation', - advisories: [], - severity: 'UNKNOWN', - cves: [], + vulnerabilities: [], }) rankByDep.set(depPurl, providerRank) continue @@ -382,6 +398,33 @@ function getFixedInPurl(issue, depPurl, strategy, currentVersion) { return undefined } +/** + * Adds a per-CVE vulnerability entry to a remediation, deduplicating by CVE id. When the + * CVE is already present, the higher severity is kept and its advisories are merged. + * Issues without a CVE id contribute no vulnerability entry. + * @param {object} entry - remediation accumulator entry with a `vulnerabilities` array + * @param {string|undefined} cveId - the CVE identifier for this issue + * @param {string} severity - the issue's severity + * @param {Array<{id: string, url: string}>} advisories - advisories attributed to this issue + */ +function addVulnerability(entry, cveId, severity, advisories) { + if (!cveId) { + return + } + const normalizedSeverity = (severity || 'UNKNOWN').toUpperCase() + const existingVuln = entry.vulnerabilities.find(v => v.id === cveId) + if (existingVuln) { + existingVuln.severity = higherSeverity(existingVuln.severity, normalizedSeverity) + mergeAdvisories(existingVuln.advisories, advisories) + return + } + entry.vulnerabilities.push({ + id: cveId, + severity: normalizedSeverity, + advisories: [...advisories], + }) +} + /** * Extracts advisory objects from an issue. * @param {import('@trustify-da/trustify-da-api-model/model/v5/Issue.js').Issue} issue @@ -458,3 +501,13 @@ function higherSeverity(a, b) { const indexB = SEVERITY_ORDER.indexOf(upperB) return indexA >= indexB ? upperA : upperB } + +/** + * Derives a dependency-level severity as the max across a list of vulnerabilities. + * Returns 'UNKNOWN' for an empty or missing list. + * @param {Array<{severity: string}>} [vulnerabilities] + * @returns {string} + */ +export function maxSeverity(vulnerabilities) { + return (vulnerabilities || []).reduce((acc, v) => higherSeverity(acc, v.severity), 'UNKNOWN') +} diff --git a/src/remediation_report.js b/src/remediation_report.js index 47827ab6..cf9fc7cd 100644 --- a/src/remediation_report.js +++ b/src/remediation_report.js @@ -3,14 +3,12 @@ * markdown for PR bodies, CLI dry-run output, and JSON. */ -import { SEVERITY_ORDER } from './remediation.js' +import { SEVERITY_ORDER, maxSeverity } from './remediation.js' /** * Generates a formatted report from an array of remediation entries. * - * @param {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[]}>} remediations + * @param {import('./remediation.js').Remediation[]} remediations * @param {object} [options] * @param {'dependency'|'bundle'} [options.groupBy='dependency'] - grouping strategy * @param {'markdown'|'json'} [options.format='markdown'] - output format @@ -41,7 +39,11 @@ export function generateReport(remediations, options = {}) { /** * Generates a per-dependency markdown report with one section per remediation entry. - * @param {Array} remediations + * + * Each vulnerability row is rendered from its own per-CVE severity and advisories + * (from `rem.vulnerabilities`), so a Moderate CVE is no longer inflated to the + * dependency's max severity. + * @param {import('./remediation.js').Remediation[]} remediations * @returns {string} */ function generatePerDependencyReport(remediations) { @@ -57,14 +59,14 @@ function generatePerDependencyReport(remediations) { '', ] - if (rem.cves && rem.cves.length > 0) { + const vulnerabilities = rem.vulnerabilities || [] + if (vulnerabilities.length > 0) { lines.push('### Vulnerabilities resolved') lines.push('') lines.push('| CVE | Severity | Advisory |') lines.push('| --- | --- | --- |') - const advisoryLinks = formatAdvisoryLinks(rem.advisories) - for (const cve of rem.cves) { - lines.push(`| ${cve} | ${rem.severity} | ${advisoryLinks} |`) + for (const v of vulnerabilities) { + lines.push(`| ${v.id} | ${v.severity} | ${formatAdvisoryLinks(v.advisories)} |`) } } @@ -76,7 +78,7 @@ function generatePerDependencyReport(remediations) { /** * Generates a bundled markdown report grouping all remediations by severity. - * @param {Array} remediations + * @param {import('./remediation.js').Remediation[]} remediations * @returns {string} */ function generateBundledReport(remediations) { @@ -99,8 +101,9 @@ function generateBundledReport(remediations) { const depName = rem.groupId ? `${rem.groupId}:${rem.artifactId}` : rem.artifactId - const cves = (rem.cves || []).join(', ') - const advisoryLinks = formatAdvisoryLinks(rem.advisories) + const vulnerabilities = rem.vulnerabilities || [] + const cves = vulnerabilities.map(v => v.id).join(', ') + const advisoryLinks = formatAdvisoryLinks(collectAdvisories(vulnerabilities)) lines.push( `| ${depName} | ${rem.currentVersion} | ${rem.fixedInVersion}` + ` | ${rem.provider} | ${cves} | ${advisoryLinks} |` @@ -115,7 +118,7 @@ function generateBundledReport(remediations) { /** * Generates a tabular dry-run summary of proposed changes. - * @param {Array} remediations + * @param {import('./remediation.js').Remediation[]} remediations * @returns {string} */ function generateDryRunReport(remediations) { @@ -132,7 +135,7 @@ function generateDryRunReport(remediations) { : rem.artifactId lines.push( `| ${depName} | ${rem.currentVersion} | ${rem.fixedInVersion}` - + ` | ${rem.severity} | ${rem.provider} |` + + ` | ${maxSeverity(rem.vulnerabilities)} | ${rem.provider} |` ) } @@ -141,7 +144,7 @@ function generateDryRunReport(remediations) { /** * Groups remediations by their severity. - * @param {Array} remediations + * @param {import('./remediation.js').Remediation[]} remediations * @returns {Map>} */ function groupBySeverity(remediations) { @@ -150,7 +153,7 @@ function groupBySeverity(remediations) { map.set(severity, []) } for (const rem of remediations) { - const sev = rem.severity || 'UNKNOWN' + const sev = maxSeverity(rem.vulnerabilities) if (!map.has(sev)) { map.set(sev, []) } @@ -159,6 +162,25 @@ function groupBySeverity(remediations) { return map } +/** + * Collects the de-duplicated union of advisories across a list of vulnerabilities. + * @param {Array<{advisories: Array<{id: string, url: string}>}>} vulnerabilities + * @returns {Array<{id: string, url: string}>} + */ +function collectAdvisories(vulnerabilities) { + const merged = [] + const seen = new Set() + for (const v of vulnerabilities) { + for (const adv of v.advisories || []) { + if (!seen.has(adv.id)) { + seen.add(adv.id) + merged.push(adv) + } + } + } + return merged +} + /** * Formats advisory entries into markdown links or plain text. * @param {Array<{id: string, url: string}>} advisories diff --git a/test/remediate.test.js b/test/remediate.test.js index 78c58d9d..83927830 100644 --- a/test/remediate.test.js +++ b/test/remediate.test.js @@ -366,7 +366,7 @@ suite('remediate — runRemediation', () => { suite('structured output', () => { /** Verifies that runRemediation returns the full structured remediation shape. */ - test('returns structured remediations with cves, severity, provider and files', async () => { + test('returns structured remediations with vulnerabilities, provider and files', async () => { const { dir, cleanup } = createTempDir({ 'pom.xml': SAMPLE_POM }) try { const pomPath = path.join(dir, 'pom.xml') @@ -382,8 +382,9 @@ suite('remediate — runRemediation', () => { expect(rem.artifactId).to.equal('commons-text') expect(rem.currentVersion).to.equal('1.9') expect(rem.fixedInVersion).to.equal('1.10.0') - expect(rem.severity).to.equal('CRITICAL') - expect(rem.cves).to.deep.equal(['CVE-2022-42889']) + expect(rem.vulnerabilities).to.have.lengthOf(1) + expect(rem.vulnerabilities[0].id).to.equal('CVE-2022-42889') + expect(rem.vulnerabilities[0].severity).to.equal('CRITICAL') expect(rem.provider).to.equal('redhat') expect(rem.files).to.deep.equal([pomPath]) } finally { diff --git a/test/remediation.test.js b/test/remediation.test.js index e6ba019e..d469366c 100644 --- a/test/remediation.test.js +++ b/test/remediation.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { extractRemediations, closestCoverageStrategy, highestStrategy } from '../src/remediation.js' +import { extractRemediations, closestCoverageStrategy, highestStrategy, maxSeverity } from '../src/remediation.js' /** * Builds a minimal AnalysisReport with a single provider, source, dependency, and issue. @@ -85,9 +85,12 @@ suite('remediation extractor', () => { 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' }, + expect(result[0].vulnerabilities).to.deep.equal([ + { + id: 'CVE-2022-42889', + severity: 'CRITICAL', + advisories: [{ id: 'ADV-2022-001', url: 'https://example.com/ADV-2022-001' }], + }, ]) }) @@ -223,9 +226,10 @@ suite('remediation extractor', () => { // 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) + const ids = result[0].vulnerabilities.map(v => v.id) + expect(ids).to.include('CVE-2022-42889') + expect(ids).to.include('CVE-2023-99999') + expect(ids).to.have.lengthOf(2) }) /** Verifies that the highest severity is preserved across merged CVEs. */ @@ -246,7 +250,7 @@ suite('remediation extractor', () => { const result = extractRemediations(report) expect(result).to.have.lengthOf(1) - expect(result[0].severity).to.equal('CRITICAL') + expect(maxSeverity(result[0].vulnerabilities)).to.equal('CRITICAL') }) /** Verifies that severity values are normalized to uppercase in output. */ @@ -258,7 +262,7 @@ suite('remediation extractor', () => { const result = extractRemediations(report) expect(result).to.have.lengthOf(1) - expect(result[0].severity).to.equal('CRITICAL') + expect(result[0].vulnerabilities[0].severity).to.equal('CRITICAL') }) }) @@ -416,13 +420,128 @@ suite('remediation extractor', () => { // 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' }, + expect(result[0].vulnerabilities).to.deep.equal([ + { + id: 'CVE-2024-11111', + severity: 'HIGH', + advisories: [{ id: 'ADV-001', url: 'https://example.com/ADV-001' }], + }, ]) }) }) + suite('per-CVE vulnerabilities', () => { + /** Verifies each issue contributes its own vulnerability entry with its own severity and advisories. */ + test('carries per-CVE severity and advisories in vulnerabilities', () => { + // Given a dependency with two CVEs of differing severity, each with its own advisory + const report = buildReport({ + issueId: 'CVE-2025-41242', + severity: 'MEDIUM', + fixedIn: ['pkg:maven/org.springframework/spring-webmvc@5.3.18'], + advisory: { id: 'GHSA-mod', url: 'https://example.com/mod' }, + extraIssues: [{ + id: 'CVE-2024-99999', + severity: 'CRITICAL', + remediation: { + fixedIn: ['pkg:maven/org.springframework/spring-webmvc@5.3.18'], + advisories: [{ advisory: { id: 'GHSA-crit', url: 'https://example.com/crit' } }], + }, + }], + }) + report.providers['provider-a'].sources['source-a'].dependencies[0].ref = + 'pkg:maven/org.springframework/spring-webmvc@5.3.0' + + // When extracting remediations + const result = extractRemediations(report) + + // Then each CVE keeps its own severity and advisories + expect(result).to.have.lengthOf(1) + const vulns = result[0].vulnerabilities + expect(vulns).to.have.lengthOf(2) + const mod = vulns.find(v => v.id === 'CVE-2025-41242') + const crit = vulns.find(v => v.id === 'CVE-2024-99999') + expect(mod.severity).to.equal('MEDIUM') + expect(mod.advisories).to.deep.equal([{ id: 'GHSA-mod', url: 'https://example.com/mod' }]) + expect(crit.severity).to.equal('CRITICAL') + expect(crit.advisories).to.deep.equal([{ id: 'GHSA-crit', url: 'https://example.com/crit' }]) + }) + + /** Verifies the aggregate top-level severity is the max across vulnerabilities. */ + test('aggregate severity is the max across vulnerabilities', () => { + const report = buildReport({ + issueId: 'CVE-2025-41242', + severity: 'MEDIUM', + fixedIn: ['pkg:maven/org.apache.commons/commons-text@1.10.0'], + advisory: undefined, + extraIssues: [{ + id: 'CVE-2024-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(maxSeverity(result[0].vulnerabilities)).to.equal('CRITICAL') + expect(result[0].vulnerabilities.map(v => v.id)).to.have.members([ + 'CVE-2025-41242', 'CVE-2024-99999', + ]) + }) + + /** Verifies a duplicate CVE id across sources merges advisories and keeps higher severity. */ + test('deduplicates vulnerabilities by CVE id', () => { + const report = buildReport({ + issueId: 'CVE-2022-42889', + severity: 'MEDIUM', + fixedIn: ['pkg:maven/org.apache.commons/commons-text@1.10.0'], + advisory: { id: 'ADV-1', url: 'https://example.com/1' }, + extraIssues: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + remediation: { + fixedIn: ['pkg:maven/org.apache.commons/commons-text@1.10.0'], + advisories: [{ advisory: { id: 'ADV-2', url: 'https://example.com/2' } }], + }, + }], + }) + + const result = extractRemediations(report) + + expect(result).to.have.lengthOf(1) + expect(result[0].vulnerabilities).to.have.lengthOf(1) + const vuln = result[0].vulnerabilities[0] + expect(vuln.id).to.equal('CVE-2022-42889') + expect(vuln.severity).to.equal('CRITICAL') + expect(vuln.advisories).to.have.deep.members([ + { id: 'ADV-1', url: 'https://example.com/1' }, + { id: 'ADV-2', url: 'https://example.com/2' }, + ]) + }) + + /** Verifies a recommendation-only entry has an empty vulnerabilities list and derived UNKNOWN severity. */ + test('recommendation-only entry has empty vulnerabilities and UNKNOWN severity', () => { + 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' }, + }], + }, + }) + + const result = extractRemediations(report) + + const rec = result.find(r => r.purl === 'pkg:maven/com.example/old-lib@1.0.0') + expect(rec.vulnerabilities).to.deep.equal([]) + expect(maxSeverity(rec.vulnerabilities)).to.equal('UNKNOWN') + }) + }) + suite('output structure', () => { /** Verifies that the output includes all required fields with correct types. */ test('output includes all required fields', () => { @@ -444,10 +563,16 @@ suite('remediation extractor', () => { 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.have.property('vulnerabilities').that.is.an('array') + expect(entry).to.not.have.property('severity') + expect(entry).to.not.have.property('cves') + expect(entry).to.not.have.property('advisories') expect(entry).to.not.have.property('_priority') + expect(entry).to.not.have.property('_fromTrustedContent') + // vulnerabilities carry per-CVE shape + expect(entry.vulnerabilities[0]).to.have.property('id').that.is.a('string') + expect(entry.vulnerabilities[0]).to.have.property('severity').that.is.a('string') + expect(entry.vulnerabilities[0]).to.have.property('advisories').that.is.an('array') }) }) }) @@ -607,9 +732,10 @@ suite('version selection strategies', () => { // Then same-major version should be preferred expect(result).to.have.lengthOf(1) expect(result[0].fixedInVersion).to.equal('2.13.9.Final-redhat-00003') - expect(result[0].cves).to.include('CVE-2025-1634') - expect(result[0].cves).to.include('CVE-2023-6267') - expect(result[0].cves).to.include('CVE-2023-5675') + const ids = result[0].vulnerabilities.map(v => v.id) + expect(ids).to.include('CVE-2025-1634') + expect(ids).to.include('CVE-2023-6267') + expect(ids).to.include('CVE-2023-5675') }) /** Verifies highestStrategy picks highest version across all CVEs. */ diff --git a/test/remediation_report.test.js b/test/remediation_report.test.js index 2f54dfac..01087b9f 100644 --- a/test/remediation_report.test.js +++ b/test/remediation_report.test.js @@ -17,9 +17,11 @@ function buildRemediation(overrides = {}) { fixedInPurl: 'pkg:maven/org.apache.commons/commons-text@1.10.0', provider: 'trusted-content', source: 'redhat', - advisories: [{ id: 'RHSA-2022:001', url: 'https://access.redhat.com/errata/RHSA-2022:001' }], - severity: 'CRITICAL', - cves: ['CVE-2022-42889'], + vulnerabilities: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + advisories: [{ id: 'RHSA-2022:001', url: 'https://access.redhat.com/errata/RHSA-2022:001' }], + }], ...overrides, } } @@ -50,7 +52,10 @@ suite('remediation report generator', () => { test('renders one row per CVE in the vulnerability table', () => { // Given a remediation entry with two CVEs const remediations = [buildRemediation({ - cves: ['CVE-2022-42889', 'CVE-2023-99999'], + vulnerabilities: [ + { id: 'CVE-2022-42889', severity: 'CRITICAL', advisories: [] }, + { id: 'CVE-2023-99999', severity: 'HIGH', advisories: [] }, + ], })] // When generating the report @@ -72,11 +77,9 @@ suite('remediation report generator', () => { artifactId: 'lib', currentVersion: '1.0.0', fixedInVersion: '2.0.0', - severity: 'HIGH', - cves: ['CVE-2024-00001'], provider: 'snyk', source: 'snyk-db', - advisories: [], + vulnerabilities: [{ id: 'CVE-2024-00001', severity: 'HIGH', advisories: [] }], }), ] @@ -104,8 +107,7 @@ suite('remediation report generator', () => { fixedInVersion: '4.17.21', provider: 'snyk', source: 'snyk-db', - cves: ['CVE-2021-23337'], - advisories: [], + vulnerabilities: [{ id: 'CVE-2021-23337', severity: 'HIGH', advisories: [] }], }), ] @@ -130,7 +132,11 @@ suite('remediation report generator', () => { /** Verifies that advisories without URLs are rendered as plain text. */ test('renders advisories without URLs as plain text', () => { const remediations = [buildRemediation({ - advisories: [{ id: 'ADV-001', url: '' }], + vulnerabilities: [{ + id: 'CVE-2022-42889', + severity: 'CRITICAL', + advisories: [{ id: 'ADV-001', url: '' }], + }], })] const report = generateReport(remediations) @@ -141,12 +147,55 @@ suite('remediation report generator', () => { /** Verifies that entries with no advisories show a dash placeholder. */ test('shows dash for entries with no advisories', () => { - const remediations = [buildRemediation({ advisories: [] })] + const remediations = [buildRemediation({ + vulnerabilities: [{ id: 'CVE-2022-42889', severity: 'CRITICAL', advisories: [] }], + })] const report = generateReport(remediations) expect(report).to.include('| - |') }) + + /** Verifies that each CVE row renders its OWN severity and advisories from vulnerabilities. */ + test('renders per-CVE severity and advisories from vulnerabilities', () => { + // Given a remediation whose CVEs have distinct severities and advisories + const remediations = [buildRemediation({ + vulnerabilities: [ + { + id: 'CVE-2025-41242', + severity: 'MEDIUM', + advisories: [{ id: 'GHSA-mod', url: 'https://example.com/mod' }], + }, + { + id: 'CVE-2024-99999', + severity: 'CRITICAL', + advisories: [{ id: 'GHSA-crit', url: 'https://example.com/crit' }], + }, + ], + })] + + // When generating the per-dependency report + const report = generateReport(remediations) + + // Then each row shows its own severity + advisory, not the dep-level max + expect(report).to.include( + '| CVE-2025-41242 | MEDIUM | [GHSA-mod](https://example.com/mod) |' + ) + expect(report).to.include( + '| CVE-2024-99999 | CRITICAL | [GHSA-crit](https://example.com/crit) |' + ) + // The moderate CVE must NOT be inflated to CRITICAL + expect(report).to.not.include('| CVE-2025-41242 | CRITICAL |') + }) + + /** Verifies the update heading renders the fix version. */ + test('renders the fix version in the update heading', () => { + const remediations = [buildRemediation({ fixedInVersion: '1.10.0' })] + + const report = generateReport(remediations) + + expect(report).to.include('1.9 → 1.10.0') + }) }) suite('bundled report', () => { @@ -154,18 +203,16 @@ suite('remediation report generator', () => { test('groups all remediations by severity in a single document', () => { // Given remediations with different severities const remediations = [ - buildRemediation({ severity: 'CRITICAL' }), + buildRemediation(), buildRemediation({ purl: 'pkg:maven/com.example/lib@1.0.0', groupId: 'com.example', artifactId: 'lib', currentVersion: '1.0.0', fixedInVersion: '2.0.0', - severity: 'HIGH', - cves: ['CVE-2024-00001'], provider: 'snyk', source: 'snyk-db', - advisories: [], + vulnerabilities: [{ id: 'CVE-2024-00001', severity: 'HIGH', advisories: [] }], }), ] @@ -200,7 +247,9 @@ suite('remediation report generator', () => { /** Verifies that empty severity groups are omitted. */ test('omits severity groups with no entries', () => { - const remediations = [buildRemediation({ severity: 'HIGH' })] + const remediations = [buildRemediation({ + vulnerabilities: [{ id: 'CVE-2022-42889', severity: 'HIGH', advisories: [] }], + })] const report = generateReport(remediations, { groupBy: 'bundle' }) @@ -224,10 +273,8 @@ suite('remediation report generator', () => { artifactId: 'lib', currentVersion: '1.0.0', fixedInVersion: '2.0.0', - severity: 'HIGH', provider: 'snyk', - cves: ['CVE-2024-00001'], - advisories: [], + vulnerabilities: [{ id: 'CVE-2024-00001', severity: 'HIGH', advisories: [] }], }), ] @@ -293,33 +340,33 @@ suite('remediation report generator', () => { }) }) - suite('null/undefined cves handling', () => { - /** Verifies that per-dependency report handles null cves without throwing. */ - test('per-dependency report handles null cves', () => { - const remediations = [buildRemediation({ cves: null })] + suite('null/undefined vulnerabilities handling', () => { + /** Verifies that per-dependency report handles null vulnerabilities without throwing. */ + test('per-dependency report handles null vulnerabilities', () => { + const remediations = [buildRemediation({ vulnerabilities: null })] const report = generateReport(remediations) expect(report).to.include('Security Update:') expect(report).to.not.include('Vulnerabilities resolved') }) - /** Verifies that per-dependency report handles undefined cves without throwing. */ - test('per-dependency report handles undefined cves', () => { - const remediations = [buildRemediation({ cves: undefined })] + /** Verifies that per-dependency report handles undefined vulnerabilities without throwing. */ + test('per-dependency report handles undefined vulnerabilities', () => { + const remediations = [buildRemediation({ vulnerabilities: undefined })] const report = generateReport(remediations) expect(report).to.include('Security Update:') expect(report).to.not.include('Vulnerabilities resolved') }) - /** Verifies that bundled report handles null cves without throwing. */ - test('bundled report handles null cves', () => { - const remediations = [buildRemediation({ cves: null })] + /** Verifies that bundled report handles null vulnerabilities without throwing. */ + test('bundled report handles null vulnerabilities', () => { + const remediations = [buildRemediation({ vulnerabilities: null })] const report = generateReport(remediations, { groupBy: 'bundle' }) expect(report).to.include('# Security Update Summary') }) - /** Verifies that bundled report handles undefined cves without throwing. */ - test('bundled report handles undefined cves', () => { - const remediations = [buildRemediation({ cves: undefined })] + /** Verifies that bundled report handles undefined vulnerabilities without throwing. */ + test('bundled report handles undefined vulnerabilities', () => { + const remediations = [buildRemediation({ vulnerabilities: undefined })] const report = generateReport(remediations, { groupBy: 'bundle' }) expect(report).to.include('# Security Update Summary') }) From 2179c7945158a5e49e8cd1abf4128b1cd05bb9b2 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 15 Sep 2026 14:16:50 +0100 Subject: [PATCH 2/2] chore: export maxSeverity --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index d1fd255c..aa64a037 100644 --- a/src/index.js +++ b/src/index.js @@ -24,7 +24,7 @@ import { export { parseImageRef } from "./oci_image/utils.js"; export { ImageRef } from "./oci_image/images.js"; export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js"; -export { extractRemediations } from "./remediation.js"; +export { extractRemediations, maxSeverity } from "./remediation.js"; export { generateReport, generateDeduplicationKey } from './remediation_report.js' export { loadConfig, mergeConfig, resolveConfig, CONFIG_FILENAMES } from './config.js' export { runRemediation, findManifests } from './remediate.js'