diff --git a/lib/config/index.spec.ts b/lib/config/index.spec.ts index 41deb82830f776..001ea8c8b53488 100644 --- a/lib/config/index.spec.ts +++ b/lib/config/index.spec.ts @@ -1,4 +1,5 @@ import { getConfig } from './defaults'; +import { filterConfig, getManagerConfig, mergeChildConfig } from './index'; jest.mock('../modules/datasource/npm'); try { @@ -11,7 +12,7 @@ const defaultConfig = getConfig(); describe('config/index', () => { describe('mergeChildConfig(parentConfig, childConfig)', () => { - it('merges', async () => { + it('merges', () => { const parentConfig = { ...defaultConfig }; const childConfig = { foo: 'bar', @@ -20,15 +21,14 @@ describe('config/index', () => { schedule: ['on monday'], }, }; - const configParser = await import('./index'); - const config = configParser.mergeChildConfig(parentConfig, childConfig); + const config = mergeChildConfig(parentConfig, childConfig); expect(config.foo).toBe('bar'); expect(config.rangeStrategy).toBe('replace'); expect(config.lockFileMaintenance.schedule).toEqual(['on monday']); expect(config.lockFileMaintenance).toMatchSnapshot(); }); - it('merges packageRules', async () => { + it('merges packageRules', () => { const parentConfig = { ...defaultConfig }; Object.assign(parentConfig, { packageRules: [{ a: 1 }, { a: 2 }], @@ -36,14 +36,13 @@ describe('config/index', () => { const childConfig = { packageRules: [{ a: 3 }, { a: 4 }], }; - const configParser = await import('./index'); - const config = configParser.mergeChildConfig(parentConfig, childConfig); + const config = mergeChildConfig(parentConfig, childConfig); expect(config.packageRules.map((rule) => rule.a)).toMatchObject([ 1, 2, 3, 4, ]); }); - it('merges constraints', async () => { + it('merges constraints', () => { const parentConfig = { ...defaultConfig }; Object.assign(parentConfig, { constraints: { @@ -56,8 +55,7 @@ describe('config/index', () => { node: '<15', }, }; - const configParser = await import('./index'); - const config = configParser.mergeChildConfig(parentConfig, childConfig); + const config = mergeChildConfig(parentConfig, childConfig); expect(config.constraints).toMatchSnapshot(); expect(config.constraints.node).toBe('<15'); }); @@ -75,39 +73,48 @@ describe('config/index', () => { expect(config.packageRules).toHaveLength(2); }); - it('handles null child packageRules', async () => { + it('handles null child packageRules', () => { const parentConfig = { ...defaultConfig }; parentConfig.packageRules = [{ a: 3 }, { a: 4 }]; - const configParser = await import('./index'); - const config = configParser.mergeChildConfig(parentConfig, {}); + const config = mergeChildConfig(parentConfig, {}); expect(config.packageRules).toHaveLength(2); }); - it('handles undefined childConfig', async () => { + it('handles undefined childConfig', () => { const parentConfig = { ...defaultConfig }; - const configParser = await import('./index'); - const config = configParser.mergeChildConfig(parentConfig, undefined); + const config = mergeChildConfig(parentConfig, undefined); expect(config).toMatchObject(parentConfig); }); - it('getManagerConfig()', async () => { + it('getManagerConfig()', () => { const parentConfig = { ...defaultConfig }; - const configParser = await import('./index'); - const config = configParser.getManagerConfig(parentConfig, 'npm'); + const config = getManagerConfig(parentConfig, 'npm'); expect(config).toContainEntries([ ['fileMatch', ['(^|/)package\\.json$']], ['rollbackPrs', true], ]); - expect( - configParser.getManagerConfig(parentConfig, 'html') - ).toContainEntries([['fileMatch', ['\\.html?$']]]); + expect(getManagerConfig(parentConfig, 'html')).toContainEntries([ + ['fileMatch', ['\\.html?$']], + ]); }); - it('filterConfig()', async () => { + it('filterConfig()', () => { const parentConfig = { ...defaultConfig }; - const configParser = await import('./index'); - const config = configParser.filterConfig(parentConfig, 'pr'); + const config = filterConfig(parentConfig, 'pr'); expect(config).toBeObject(); }); + + it('highest vulnerabilitySeverity maintained when config is vulnerability alert', () => { + const parentConfig = { ...defaultConfig }; + Object.assign(parentConfig, { + isVulnerabilityAlert: true, + vulnerabilitySeverity: 'HIGH', + }); + const childConfig = { + vulnerabilitySeverity: 'CRITICAL', + }; + const config = mergeChildConfig(parentConfig, childConfig); + expect(config.vulnerabilitySeverity).toBe('CRITICAL'); + }); }); }); diff --git a/lib/config/types.ts b/lib/config/types.ts index 0df00b27a9a688..4286c5a512e242 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -251,6 +251,7 @@ export interface RenovateConfig warnings?: ValidationMessage[]; vulnerabilityAlerts?: RenovateSharedConfig; osvVulnerabilityAlerts?: boolean; + vulnerabilitySeverity?: string; regexManagers?: RegExManager[]; fetchReleaseNotes?: boolean; @@ -309,6 +310,7 @@ export interface PackageRule UpdateConfig, Record { description?: string | string[]; + isVulnerabilityAlert?: boolean; matchFiles?: string[]; matchPaths?: string[]; matchLanguages?: string[]; @@ -333,6 +335,7 @@ export interface PackageRule matchUpdateTypes?: UpdateType[]; matchConfidence?: MergeConfidence[]; registryUrls?: string[] | null; + vulnerabilitySeverity?: string; } export interface ValidationMessage { diff --git a/lib/config/utils.ts b/lib/config/utils.ts index 106d9e168f1453..140d5f0df030a6 100644 --- a/lib/config/utils.ts +++ b/lib/config/utils.ts @@ -1,4 +1,5 @@ import { logger } from '../logger'; +import { getHighestVulnerabilitySeverity } from '../util/vulnerability/utils'; import * as options from './options'; import type { RenovateConfig } from './types'; @@ -13,6 +14,15 @@ export function mergeChildConfig< const parentConfig = structuredClone(parent); const childConfig = structuredClone(child); const config: Record = { ...parentConfig, ...childConfig }; + + // Ensure highest severity survives parent / child merge + if (config?.isVulnerabilityAlert) { + config.vulnerabilitySeverity = getHighestVulnerabilitySeverity( + parent, + child + ); + } + for (const option of options.getOptions()) { if ( option.mergeable && @@ -20,6 +30,7 @@ export function mergeChildConfig< parentConfig[option.name] ) { logger.trace(`mergeable option: ${option.name}`); + if (option.name === 'constraints') { config[option.name] = { ...parentConfig[option.name], diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 6e26451238eff8..dfb040005aaee0 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -174,6 +174,7 @@ export interface Upgrade> extends PackageDependency { isLockFileMaintenance?: boolean; isRemediation?: boolean; isVulnerabilityAlert?: boolean; + vulnerabilitySeverity?: string; registryUrls?: string[] | null; currentVersion?: string; replaceString?: string; diff --git a/lib/util/template/index.ts b/lib/util/template/index.ts index f3e4a8809c7e6f..da16eaa90f8cfd 100644 --- a/lib/util/template/index.ts +++ b/lib/util/template/index.ts @@ -100,6 +100,7 @@ export const allowedFields = { isRange: 'true if the new value is a range', isSingleVersion: 'true if the upgrade is to a single version rather than a range', + isVulnerabilityAlert: 'true if the upgrade is a vulnerability alert', logJSON: 'ChangeLogResult object for the upgrade', manager: 'The (package) manager which detected the dependency', newDigest: 'The new digest value', @@ -143,6 +144,8 @@ export const allowedFields = { version: 'The version number of the changelog', versioning: 'The versioning scheme in use', versions: 'An array of ChangeLogRelease objects in the upgrade', + vulnerabilitySeverity: + 'The severity for a vulnerability alert upgrade (eg: LOW, MODERATE, HIGH, CRITICAL)', }; const prBodyFields = [ diff --git a/lib/util/vulnerability/utils.spec.ts b/lib/util/vulnerability/utils.spec.ts new file mode 100644 index 00000000000000..0d9aa2ee34bae1 --- /dev/null +++ b/lib/util/vulnerability/utils.spec.ts @@ -0,0 +1,129 @@ +import { getHighestVulnerabilitySeverity } from './utils'; + +describe('util/vulnerability/utils', () => { + it('parent CRITICAL vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'CRITICAL', + }; + + const childConfig = { + vulnerabilitySeverity: 'MODERATE', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('CRITICAL'); + }); + + it('child CRITICAL vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'MODERATE', + }; + + const childConfig = { + vulnerabilitySeverity: 'CRITICAL', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('CRITICAL'); + }); + + it('parent HIGH vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'HIGH', + }; + + const childConfig = { + vulnerabilitySeverity: 'MODERATE', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('HIGH'); + }); + + it('child HIGH vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'MODERATE', + }; + + const childConfig = { + vulnerabilitySeverity: 'HIGH', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('HIGH'); + }); + + it('parent MODERATE vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'MODERATE', + }; + + const childConfig = { + vulnerabilitySeverity: 'LOW', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('MODERATE'); + }); + + it('child MODERATE vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'LOW', + }; + + const childConfig = { + vulnerabilitySeverity: 'MODERATE', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('MODERATE'); + }); + + it('parent LOW vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: 'LOW', + }; + + const childConfig = { + vulnerabilitySeverity: undefined, + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('LOW'); + }); + + it('child LOW vulnerability severity rating is maintained', () => { + const parentConfig = { + vulnerabilitySeverity: undefined, + }; + + const childConfig = { + vulnerabilitySeverity: 'LOW', + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBe('LOW'); + }); + + it('handled undefined parent and child vulnerability severity', () => { + const parentConfig = { + vulnerabilitySeverity: undefined, + }; + + const childConfig = { + vulnerabilitySeverity: undefined, + }; + + const severity = getHighestVulnerabilitySeverity(parentConfig, childConfig); + + expect(severity).toBeUndefined(); + }); +}); diff --git a/lib/util/vulnerability/utils.ts b/lib/util/vulnerability/utils.ts new file mode 100644 index 00000000000000..5607a6de9237b8 --- /dev/null +++ b/lib/util/vulnerability/utils.ts @@ -0,0 +1,26 @@ +const severityOrder: Record = { + LOW: 1, + MODERATE: 2, + HIGH: 3, + CRITICAL: 4, +}; + +export function getHighestVulnerabilitySeverity< + T extends Record, + TChild extends Record | undefined +>(parent: T, child: TChild): string { + const parentVulSeverity = parent.vulnerabilitySeverity?.toUpperCase(); + const childVulSeverity = child?.vulnerabilitySeverity?.toUpperCase(); + + if (childVulSeverity === undefined) { + return parentVulSeverity; + } + + if (parentVulSeverity === undefined) { + return childVulSeverity; + } + + return severityOrder[parentVulSeverity] >= severityOrder[childVulSeverity] + ? parentVulSeverity + : childVulSeverity; +} diff --git a/lib/workers/repository/process/types.ts b/lib/workers/repository/process/types.ts index fd739b99a7d1c3..e59c569da1d8ab 100644 --- a/lib/workers/repository/process/types.ts +++ b/lib/workers/repository/process/types.ts @@ -17,3 +17,9 @@ export interface DependencyVulnerabilities { versioningApi: VersioningApi; vulnerabilities: Vulnerability[]; } + +export interface SeverityDetails { + cvssVector: string; + score: string; + severityLevel?: string; +} diff --git a/lib/workers/repository/process/vulnerabilities.ts b/lib/workers/repository/process/vulnerabilities.ts index b3745a7692d463..93483f716a0009 100644 --- a/lib/workers/repository/process/vulnerabilities.ts +++ b/lib/workers/repository/process/vulnerabilities.ts @@ -18,7 +18,11 @@ import { import { sanitizeMarkdown } from '../../../util/markdown'; import * as p from '../../../util/promises'; import { regEx } from '../../../util/regex'; -import type { DependencyVulnerabilities, Vulnerability } from './types'; +import type { + DependencyVulnerabilities, + SeverityDetails, + Vulnerability, +} from './types'; export class Vulnerabilities { private osvOffline: OsvOffline | undefined; @@ -454,12 +458,19 @@ export class Vulnerabilities { logger.debug( `Setting allowed version ${fixedVersion} to fix vulnerability ${vulnerability.id} in ${packageName} ${depVersion}` ); + + const severityDetails = this.extractSeverityDetails( + vulnerability, + affected + ); + return { matchDatasources: [datasource], matchPackageNames: [packageName], matchCurrentVersion: depVersion, allowedVersions: fixedVersion, isVulnerabilityAlert: true, + vulnerabilitySeverity: severityDetails.severityLevel?.toUpperCase(), prBodyNotes: this.generatePrBodyNotes(vulnerability, affected), force: { ...packageFileConfig.vulnerabilityAlerts, @@ -513,24 +524,16 @@ export class Vulnerabilities { content += `#### Details\n${details ?? 'No details.'}\n`; content += '#### Severity\n'; - const cvssVector = - vulnerability.severity?.find((e) => e.type === 'CVSS_V3')?.score ?? - vulnerability.severity?.[0]?.score ?? - (affected.database_specific?.cvss as string); // RUSTSEC - if (cvssVector) { - const [baseScore, severity] = this.evaluateCvssVector(cvssVector); - const score = baseScore ? `${baseScore} / 10 (${severity})` : 'Unknown'; - content += `- CVSS Score: ${score}\n`; - content += `- Vector String: \`${cvssVector}\`\n`; - } else if ( - vulnerability.id.startsWith('GHSA-') && - vulnerability.database_specific?.severity - ) { - const severity = vulnerability.database_specific.severity as string; - content += - severity.charAt(0).toUpperCase() + - severity.slice(1).toLowerCase() + - '\n'; + const severityDetails = this.extractSeverityDetails( + vulnerability, + affected + ); + + if (severityDetails.cvssVector) { + content += `- CVSS Score: ${severityDetails.score}\n`; + content += `- Vector String: \`${severityDetails.cvssVector}\`\n`; + } else if (severityDetails.severityLevel) { + content += `${severityDetails.severityLevel}\n`; } else { content += 'Unknown severity.\n'; } @@ -558,4 +561,36 @@ export class Vulnerabilities { return [sanitizeMarkdown(content)]; } + + private extractSeverityDetails( + vulnerability: Osv.Vulnerability, + affected: Osv.Affected + ): SeverityDetails { + let severityLevel: string | undefined; + let score = 'Unknown'; + + const cvssVector = + vulnerability.severity?.find((e) => e.type === 'CVSS_V3')?.score ?? + vulnerability.severity?.[0]?.score ?? + (affected.database_specific?.cvss as string); // RUSTSEC + + if (cvssVector) { + const [baseScore, severity] = this.evaluateCvssVector(cvssVector); + severityLevel = severity; + score = baseScore ? `${baseScore} / 10 (${severityLevel})` : 'Unknown'; + } else if ( + vulnerability.id.startsWith('GHSA-') && + vulnerability.database_specific?.severity + ) { + const severity = vulnerability.database_specific.severity as string; + severityLevel = + severity.charAt(0).toUpperCase() + severity.slice(1).toLowerCase(); + } + + return { + cvssVector, + score, + severityLevel, + }; + } }