renovate/lib/workers/repository/init/vulnerability.ts

265 lines
9.6 KiB
TypeScript

import type { PackageRule, RenovateConfig } from '../../../config/types';
import { NO_VULNERABILITY_ALERTS } from '../../../constants/error-messages';
import { logger } from '../../../logger';
import { CrateDatasource } from '../../../modules/datasource/crate';
import { GithubTagsDatasource } from '../../../modules/datasource/github-tags';
import { GoDatasource } from '../../../modules/datasource/go';
import { MavenDatasource } from '../../../modules/datasource/maven';
import { NpmDatasource } from '../../../modules/datasource/npm';
import { NugetDatasource } from '../../../modules/datasource/nuget';
import { PackagistDatasource } from '../../../modules/datasource/packagist';
import { PypiDatasource } from '../../../modules/datasource/pypi';
import { RubyGemsDatasource } from '../../../modules/datasource/rubygems';
import { platform } from '../../../modules/platform';
import * as allVersioning from '../../../modules/versioning';
import * as composerVersioning from '../../../modules/versioning/composer';
import * as mavenVersioning from '../../../modules/versioning/maven';
import * as npmVersioning from '../../../modules/versioning/npm';
import * as pep440Versioning from '../../../modules/versioning/pep440';
import * as rubyVersioning from '../../../modules/versioning/ruby';
import * as semverVersioning from '../../../modules/versioning/semver';
import type { SecurityAdvisory } from '../../../types';
import { sanitizeMarkdown } from '../../../util/markdown';
import { regEx } from '../../../util/regex';
type Datasource = string;
type DependencyName = string;
type FileName = string;
type VulnerableRequirements = string;
type CombinedAlert = Record<
FileName,
Record<
Datasource,
Record<
DependencyName,
Record<
VulnerableRequirements,
{
advisories: SecurityAdvisory[];
fileType?: string;
firstPatchedVersion?: string;
}
>
>
>
>;
// TODO can return `null` and `undefined` (#7154)
export async function detectVulnerabilityAlerts(
input: RenovateConfig
): Promise<RenovateConfig> {
if (!input?.vulnerabilityAlerts) {
return input;
}
if (input.vulnerabilityAlerts.enabled === false) {
logger.debug('Vulnerability alerts are disabled');
return input;
}
const alerts = await platform.getVulnerabilityAlerts();
if (!alerts.length) {
logger.debug('No vulnerability alerts found');
if (input.vulnerabilityAlertsOnly) {
throw new Error(NO_VULNERABILITY_ALERTS);
}
return input;
}
const config = { ...input };
const versionings: Record<string, string> = {
'github-tags': semverVersioning.id,
go: semverVersioning.id,
packagist: composerVersioning.id,
maven: mavenVersioning.id,
npm: npmVersioning.id,
nuget: semverVersioning.id,
pypi: pep440Versioning.id,
rubygems: rubyVersioning.id,
};
const combinedAlerts: CombinedAlert = {};
for (const alert of alerts) {
if (
alert.securityVulnerability?.package?.name === 'yargs-parser' &&
(alert.vulnerableRequirements === '= 5.0.0-security.0' ||
alert.vulnerableRequirements === '= 5.0.1')
) {
continue;
}
try {
if (alert.dismissReason) {
continue;
}
if (!alert.securityVulnerability.firstPatchedVersion) {
logger.debug(
{ alert },
'Vulnerability alert has no firstPatchedVersion - skipping'
);
continue;
}
const datasourceMapping: Record<string, string> = {
ACTIONS: GithubTagsDatasource.id,
COMPOSER: PackagistDatasource.id,
GO: GoDatasource.id,
MAVEN: MavenDatasource.id,
NPM: NpmDatasource.id,
NUGET: NugetDatasource.id,
PIP: PypiDatasource.id,
RUBYGEMS: RubyGemsDatasource.id,
RUST: CrateDatasource.id,
};
const datasource =
datasourceMapping[alert.securityVulnerability.package.ecosystem];
const depName = alert.securityVulnerability.package.name;
const fileName = alert.vulnerableManifestPath;
const fileType = alert.vulnerableManifestFilename;
const firstPatchedVersion =
alert.securityVulnerability.firstPatchedVersion.identifier;
const advisory = alert.securityAdvisory;
// TODO #7154
let vulnerableRequirements = alert.vulnerableRequirements!;
// istanbul ignore if
if (!vulnerableRequirements.length) {
if (datasource === MavenDatasource.id) {
vulnerableRequirements = `(,${firstPatchedVersion})`;
} else {
vulnerableRequirements = `< ${firstPatchedVersion}`;
}
}
if (datasource === PypiDatasource.id) {
vulnerableRequirements = vulnerableRequirements.replace(
regEx(/^= /),
'== '
);
}
if (datasource === GithubTagsDatasource.id) {
// GitHub Actions uses docker versioning, which doesn't support `= 1.2.3` matching, so we strip the equals
vulnerableRequirements = vulnerableRequirements.replace(/^=\s*/, '');
}
combinedAlerts[fileName] ||= {};
combinedAlerts[fileName][datasource] ||= {};
combinedAlerts[fileName][datasource][depName] ||= {};
combinedAlerts[fileName][datasource][depName][vulnerableRequirements] ||=
{
advisories: [],
};
const alertDetails =
combinedAlerts[fileName][datasource][depName][vulnerableRequirements];
alertDetails.advisories.push(advisory);
const version = allVersioning.get(versionings[datasource]);
if (version.isVersion(firstPatchedVersion)) {
if (
!alertDetails.firstPatchedVersion ||
version.isGreaterThan(
firstPatchedVersion,
alertDetails.firstPatchedVersion
)
) {
alertDetails.firstPatchedVersion = firstPatchedVersion;
}
} else {
logger.debug('Invalid firstPatchedVersion: ' + firstPatchedVersion);
}
alertDetails.fileType = fileType;
} catch (err) {
logger.warn({ err }, 'Error parsing vulnerability alert');
}
}
const alertPackageRules: PackageRule[] = [];
config.remediations = {} as never;
for (const [fileName, files] of Object.entries(combinedAlerts)) {
for (const [datasource, dependencies] of Object.entries(files)) {
for (const [depName, currentValues] of Object.entries(dependencies)) {
for (const [matchCurrentVersion, val] of Object.entries(
currentValues
)) {
let prBodyNotes: string[] = [];
try {
prBodyNotes = ['### GitHub Vulnerability Alerts'].concat(
val.advisories.map((advisory) => {
const identifiers = advisory.identifiers!;
const description = advisory.description!;
let content = '#### ';
let heading: string;
if (identifiers.some((id) => id.type === 'CVE')) {
heading = identifiers
.filter((id) => id.type === 'CVE')
.map((id) => id.value)
.join(' / ');
} else {
heading = identifiers.map((id) => id.value).join(' / ');
}
if (advisory.references.length) {
heading = `[${heading}](${advisory.references[0].url})`;
}
content += heading;
content += '\n\n';
content += sanitizeMarkdown(description);
return content;
})
);
} catch (err) /* istanbul ignore next */ {
logger.warn({ err }, 'Error generating vulnerability PR notes');
}
// TODO: types (#7154)
const allowedVersions =
datasource === PypiDatasource.id
? `==${val.firstPatchedVersion!}`
: val.firstPatchedVersion;
const matchFiles =
datasource === GoDatasource.id
? [fileName.replace('go.sum', 'go.mod')]
: [fileName];
let matchRule: PackageRule = {
matchDatasources: [datasource],
matchPackageNames: [depName],
matchCurrentVersion,
matchFiles,
};
const supportedRemediationFileTypes = ['package-lock.json'];
if (
config.transitiveRemediation &&
supportedRemediationFileTypes.includes(val.fileType!)
) {
const remediations = config.remediations as Record<
string,
unknown[]
>;
remediations[fileName] ??= [];
const currentVersion = matchCurrentVersion.replace('=', '').trim();
const newVersion = allowedVersions;
const remediation = {
datasource,
depName,
currentVersion,
newVersion,
prBodyNotes,
};
remediations[fileName].push(remediation);
} else {
// Remediate only direct dependencies
matchRule = {
...matchRule,
allowedVersions,
prBodyNotes,
isVulnerabilityAlert: true,
force: {
...config.vulnerabilityAlerts,
},
};
// istanbul ignore if
if (
config.transitiveRemediation &&
matchRule.matchFiles?.[0] === 'package.json'
) {
matchRule.force!.rangeStrategy = 'replace';
}
}
alertPackageRules.push(matchRule);
}
}
}
}
logger.debug({ alertPackageRules }, 'alert package rules');
config.packageRules = (config.packageRules ?? []).concat(alertPackageRules);
return config;
}