Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 8 additions & 17 deletions src/remediate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[]} */
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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]
}

Expand Down
85 changes: 69 additions & 16 deletions src/remediation.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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) {
Expand All @@ -262,7 +281,6 @@ function processIssueRemediation(issue, dep, providerName, sourceName, providerR
existing.source = sourceName
existing._fromTrustedContent = isTrustedContent
}
existing.severity = higherSeverity(existing.severity, severity)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
}
54 changes: 38 additions & 16 deletions src/remediation_report.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,7 +39,11 @@ export function generateReport(remediations, options = {}) {

/**
* Generates a per-dependency markdown report with one section per remediation entry.
* @param {Array<object>} 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) {
Expand All @@ -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)} |`)
}
}

Expand All @@ -76,7 +78,7 @@ function generatePerDependencyReport(remediations) {

/**
* Generates a bundled markdown report grouping all remediations by severity.
* @param {Array<object>} remediations
* @param {import('./remediation.js').Remediation[]} remediations
* @returns {string}
*/
function generateBundledReport(remediations) {
Expand All @@ -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} |`
Expand All @@ -115,7 +118,7 @@ function generateBundledReport(remediations) {

/**
* Generates a tabular dry-run summary of proposed changes.
* @param {Array<object>} remediations
* @param {import('./remediation.js').Remediation[]} remediations
* @returns {string}
*/
function generateDryRunReport(remediations) {
Expand All @@ -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} |`
)
}

Expand All @@ -141,7 +144,7 @@ function generateDryRunReport(remediations) {

/**
* Groups remediations by their severity.
* @param {Array<object>} remediations
* @param {import('./remediation.js').Remediation[]} remediations
* @returns {Map<string, Array<object>>}
*/
function groupBySeverity(remediations) {
Expand All @@ -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, [])
}
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions test/remediate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 {
Expand Down
Loading
Loading