refactor(vulnerabilities): return interface for Vulnerabilities (#21310)

This commit is contained in:
Sebastian Poxhofer 2023-04-18 11:07:36 +02:00 committed by GitHub
parent 2a72f85cc2
commit 75a1ab04eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 201 additions and 61 deletions

View file

@ -0,0 +1,19 @@
import type { Osv } from '@renovatebot/osv-offline';
import type { RenovateConfig } from '../../../config/types';
import type { PackageFile } from '../../../modules/manager/types';
import type { VersioningApi } from '../../../modules/versioning';
export interface Vulnerability {
packageFileConfig: RenovateConfig & PackageFile;
packageName: string;
depVersion: string;
fixedVersion: string | null;
datasource: string;
vulnerability: Osv.Vulnerability;
affected: Osv.Affected;
}
export interface DependencyVulnerabilities {
versioningApi: VersioningApi;
vulnerabilities: Vulnerability[];
}

View file

@ -36,6 +36,89 @@ describe('workers/repository/process/vulnerabilities', () => {
describe('fetchVulnerabilities()', () => { describe('fetchVulnerabilities()', () => {
let config: RenovateConfig; let config: RenovateConfig;
let vulnerabilities: Vulnerabilities; let vulnerabilities: Vulnerabilities;
beforeAll(async () => {
createMock.mockResolvedValue({
getVulnerabilities: getVulnerabilitiesMock,
});
vulnerabilities = await Vulnerabilities.create();
});
beforeEach(() => {
config = getConfig();
config.packageRules = [];
});
it('return list of Vulnerabilities', async () => {
const packageFiles: Record<string, PackageFile[]> = {
poetry: [
{
deps: [
{ depName: 'django', currentValue: '3.2', datasource: 'pypi' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-qrw5-5h28-modded',
modified: '',
affected: [
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.0' }, { fixed: '3.3.8' }],
},
],
},
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.2' }, { fixed: '3.2.16' }],
},
],
},
],
},
]);
const vulnerabilityList = await vulnerabilities.fetchVulnerabilities(
config,
packageFiles
);
expect(vulnerabilityList).toMatchObject([
{
packageName: 'django',
depVersion: '3.2',
fixedVersion: '==3.3.8',
datasource: 'pypi',
},
{
packageName: 'django',
depVersion: '3.2',
fixedVersion: '==3.2.16',
datasource: 'pypi',
},
]);
});
});
describe('appendVulnerabilityPackageRules()', () => {
let config: RenovateConfig;
let vulnerabilities: Vulnerabilities;
const lodashVulnerability: Osv.Vulnerability = { const lodashVulnerability: Osv.Vulnerability = {
id: 'GHSA-x5rq-j2xg-h7qm', id: 'GHSA-x5rq-j2xg-h7qm',
modified: '', modified: '',

View file

@ -18,6 +18,7 @@ import {
import { sanitizeMarkdown } from '../../../util/markdown'; import { sanitizeMarkdown } from '../../../util/markdown';
import * as p from '../../../util/promises'; import * as p from '../../../util/promises';
import { regEx } from '../../../util/regex'; import { regEx } from '../../../util/regex';
import type { DependencyVulnerabilities, Vulnerability } from './types';
export class Vulnerabilities { export class Vulnerabilities {
private osvOffline: OsvOffline | undefined; private osvOffline: OsvOffline | undefined;
@ -54,67 +55,104 @@ export class Vulnerabilities {
config: RenovateConfig, config: RenovateConfig,
packageFiles: Record<string, PackageFile[]> packageFiles: Record<string, PackageFile[]>
): Promise<void> { ): Promise<void> {
const dependencyVulnerabilities = await this.fetchDependencyVulnerabilities(
config,
packageFiles
);
config.packageRules ??= [];
for (const {
vulnerabilities,
versioningApi,
} of dependencyVulnerabilities) {
const groupPackageRules: PackageRule[] = [];
for (const vulnerability of vulnerabilities) {
const rule = this.vulnerabilityToPackageRules(vulnerability);
if (is.nullOrUndefined(rule)) {
continue;
}
groupPackageRules.push(rule);
}
this.sortByFixedVersion(groupPackageRules, versioningApi);
config.packageRules.push(...groupPackageRules);
}
}
async fetchVulnerabilities(
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>
): Promise<Vulnerability[]> {
const groups = await this.fetchDependencyVulnerabilities(
config,
packageFiles
);
return groups.flatMap((group) => group.vulnerabilities);
}
private async fetchDependencyVulnerabilities(
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>
): Promise<DependencyVulnerabilities[]> {
const managers = Object.keys(packageFiles); const managers = Object.keys(packageFiles);
const allManagerJobs = managers.map((manager) => const allManagerJobs = managers.map((manager) =>
this.fetchManagerVulnerabilities(config, packageFiles, manager) this.fetchManagerVulnerabilities(config, packageFiles, manager)
); );
await Promise.all(allManagerJobs); return (await Promise.all(allManagerJobs)).flat();
} }
private async fetchManagerVulnerabilities( private async fetchManagerVulnerabilities(
config: RenovateConfig, config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>, packageFiles: Record<string, PackageFile[]>,
manager: string manager: string
): Promise<void> { ): Promise<DependencyVulnerabilities[]> {
const managerConfig = getManagerConfig(config, manager); const managerConfig = getManagerConfig(config, manager);
const queue = packageFiles[manager].map( const queue = packageFiles[manager].map(
(pFile) => (): Promise<void> => (pFile) => (): Promise<DependencyVulnerabilities[]> =>
this.fetchManagerPackageFileVulnerabilities( this.fetchManagerPackageFileVulnerabilities(managerConfig, pFile)
config,
managerConfig,
pFile
)
); );
logger.trace( logger.trace(
{ manager, queueLength: queue.length }, { manager, queueLength: queue.length },
'fetchManagerUpdates starting' 'fetchManagerVulnerabilities starting'
); );
await p.all(queue); const result = (await p.all(queue)).flat();
logger.trace({ manager }, 'fetchManagerUpdates finished'); logger.trace({ manager }, 'fetchManagerVulnerabilities finished');
return result;
} }
private async fetchManagerPackageFileVulnerabilities( private async fetchManagerPackageFileVulnerabilities(
config: RenovateConfig,
managerConfig: RenovateConfig, managerConfig: RenovateConfig,
pFile: PackageFile pFile: PackageFile
): Promise<void> { ): Promise<DependencyVulnerabilities[]> {
const { packageFile } = pFile; const { packageFile } = pFile;
const packageFileConfig = mergeChildConfig(managerConfig, pFile); const packageFileConfig = mergeChildConfig(managerConfig, pFile);
const { manager } = packageFileConfig; const { manager } = packageFileConfig;
const queue = pFile.deps.map( const queue = pFile.deps.map(
(dep) => (): Promise<PackageRule[]> => (dep) => (): Promise<DependencyVulnerabilities | null> =>
this.fetchDependencyVulnerabilities(packageFileConfig, dep) this.fetchDependencyVulnerability(packageFileConfig, dep)
); );
logger.trace( logger.trace(
{ manager, packageFile, queueLength: queue.length }, { manager, packageFile, queueLength: queue.length },
'fetchManagerPackageFileVulnerabilities starting with concurrency' 'fetchManagerPackageFileVulnerabilities starting with concurrency'
); );
config.packageRules?.push(...(await p.all(queue)).flat()); const result = await p.all(queue);
logger.trace( logger.trace(
{ packageFile }, { packageFile },
'fetchManagerPackageFileVulnerabilities finished' 'fetchManagerPackageFileVulnerabilities finished'
); );
return result.filter(is.truthy);
} }
private async fetchDependencyVulnerabilities( private async fetchDependencyVulnerability(
packageFileConfig: RenovateConfig & PackageFile, packageFileConfig: RenovateConfig & PackageFile,
dep: PackageDependency dep: PackageDependency
): Promise<PackageRule[]> { ): Promise<DependencyVulnerabilities | null> {
const ecosystem = Vulnerabilities.datasourceEcosystemMap[dep.datasource!]; const ecosystem = Vulnerabilities.datasourceEcosystemMap[dep.datasource!];
if (!ecosystem) { if (!ecosystem) {
logger.trace(`Cannot map datasource ${dep.datasource!} to OSV ecosystem`); logger.trace(`Cannot map datasource ${dep.datasource!} to OSV ecosystem`);
return []; return null;
} }
let packageName = dep.packageName ?? dep.depName!; let packageName = dep.packageName ?? dep.depName!;
@ -123,20 +161,19 @@ export class Vulnerabilities {
packageName = packageName.toLowerCase().replace(regEx(/[_.-]+/g), '-'); packageName = packageName.toLowerCase().replace(regEx(/[_.-]+/g), '-');
} }
const packageRules: PackageRule[] = [];
try { try {
const vulnerabilities = await this.osvOffline?.getVulnerabilities( const osvVulnerabilities = await this.osvOffline?.getVulnerabilities(
ecosystem, ecosystem,
packageName packageName
); );
if ( if (
is.nullOrUndefined(vulnerabilities) || is.nullOrUndefined(osvVulnerabilities) ||
is.emptyArray(vulnerabilities) is.emptyArray(osvVulnerabilities)
) { ) {
logger.trace( logger.trace(
`No vulnerabilities found in OSV database for ${packageName}` `No vulnerabilities found in OSV database for ${packageName}`
); );
return []; return null;
} }
const depVersion = const depVersion =
@ -149,16 +186,19 @@ export class Vulnerabilities {
logger.debug( logger.debug(
`Skipping vulnerability lookup for package ${packageName} due to unsupported version ${depVersion}` `Skipping vulnerability lookup for package ${packageName} due to unsupported version ${depVersion}`
); );
return []; return null;
} }
for (const vulnerability of vulnerabilities) { const vulnerabilities: Vulnerability[] = [];
if (vulnerability.withdrawn) { for (const osvVulnerability of osvVulnerabilities) {
logger.trace(`Skipping withdrawn vulnerability ${vulnerability.id}`); if (osvVulnerability.withdrawn) {
logger.trace(
`Skipping withdrawn vulnerability ${osvVulnerability.id}`
);
continue; continue;
} }
for (const affected of vulnerability.affected ?? []) { for (const affected of osvVulnerability.affected ?? []) {
const isVulnerable = this.isPackageVulnerable( const isVulnerable = this.isPackageVulnerable(
ecosystem, ecosystem,
packageName, packageName,
@ -171,7 +211,7 @@ export class Vulnerabilities {
} }
logger.debug( logger.debug(
`Vulnerability ${vulnerability.id} affects ${packageName} ${depVersion}` `Vulnerability ${osvVulnerability.id} affects ${packageName} ${depVersion}`
); );
const fixedVersion = this.getFixedVersion( const fixedVersion = this.getFixedVersion(
ecosystem, ecosystem,
@ -179,39 +219,27 @@ export class Vulnerabilities {
affected, affected,
versioningApi versioningApi
); );
if (is.nullOrUndefined(fixedVersion)) {
logger.info(
`No fixed version available for vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`
);
continue;
}
logger.debug( vulnerabilities.push({
`Setting allowed version ${fixedVersion} to fix vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`
);
const rule = this.convertToPackageRule(
packageFileConfig,
dep,
packageName, packageName,
vulnerability: osvVulnerability,
affected,
depVersion, depVersion,
fixedVersion, fixedVersion,
vulnerability, datasource: dep.datasource!,
affected packageFileConfig,
); });
packageRules.push(rule);
} }
} }
this.sortByFixedVersion(packageRules, versioningApi); return { vulnerabilities, versioningApi };
} catch (err) { } catch (err) {
logger.warn( logger.warn(
{ err }, { err },
`Error fetching vulnerability information for ${packageName}` `Error fetching vulnerability information for ${packageName}`
); );
return []; return null;
} }
return packageRules;
} }
private sortByFixedVersion( private sortByFixedVersion(
@ -223,7 +251,6 @@ export class Vulnerabilities {
const version = rule.allowedVersions as string; const version = rule.allowedVersions as string;
versionsCleaned[version] = version.replace(regEx(/[=> ]+/g), ''); versionsCleaned[version] = version.replace(regEx(/[=> ]+/g), '');
} }
packageRules.sort((a, b) => packageRules.sort((a, b) =>
versioningApi.sortVersions( versioningApi.sortVersions(
versionsCleaned[a.allowedVersions as string], versionsCleaned[a.allowedVersions as string],
@ -407,17 +434,28 @@ export class Vulnerabilities {
); );
} }
private convertToPackageRule( private vulnerabilityToPackageRules(vul: Vulnerability): PackageRule | null {
packageFileConfig: RenovateConfig & PackageFile, const {
dep: PackageDependency, vulnerability,
packageName: string, affected,
depVersion: string, packageName,
fixedVersion: string, depVersion,
vulnerability: Osv.Vulnerability, fixedVersion,
affected: Osv.Affected datasource,
): PackageRule { packageFileConfig,
} = vul;
if (is.nullOrUndefined(fixedVersion)) {
logger.info(
`No fixed version available for vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`
);
return null;
}
logger.debug(
`Setting allowed version ${fixedVersion} to fix vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`
);
return { return {
matchDatasources: [dep.datasource!], matchDatasources: [datasource],
matchPackageNames: [packageName], matchPackageNames: [packageName],
matchCurrentVersion: depVersion, matchCurrentVersion: depVersion,
allowedVersions: fixedVersion, allowedVersions: fixedVersion,