Files
renovate/lib/workers/repository/init/vulnerability.ts
Sebastian Poxhofer c32e2391a0 refactor(linters): add prefer-coerce-object rule (#45493)
Refs #45216

The object counterpart to `prefer-coerce-array`: `x ?? {}` becomes
`coerceObject(x)`. The rule flags a `??` whose default is a bare `{}`
literal, so a populated default or an asserted `({} as Foo)` still
passes; a `x ?? y ?? {}` chain maps onto `coerceObject(x, y)`.

Every hit across `lib/` converted cleanly, so no disable comments were
needed.
2026-08-28 09:27:59 +00:00

220 lines
7.5 KiB
TypeScript

import is from '@sindresorhus/is';
import type { PackageRule, RenovateConfig } from '../../../config/types.ts';
import { NO_VULNERABILITY_ALERTS } from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { getDefaultVersioning } from '../../../modules/datasource/common.ts';
import { GithubTagsDatasource } from '../../../modules/datasource/github-tags/index.ts';
import { MavenDatasource } from '../../../modules/datasource/maven/index.ts';
import { NugetDatasource } from '../../../modules/datasource/nuget/index.ts';
import type {
Ecosystem,
SecurityAdvisory,
} from '../../../modules/platform/github/schema.ts';
import { platform } from '../../../modules/platform/index.ts';
import * as allVersioning from '../../../modules/versioning/index.ts';
import { coerceArray } from '../../../util/array.ts';
import { sanitizeMarkdown } from '../../../util/markdown.ts';
import { coerceObject } from '../../../util/object.ts';
import { regEx } from '../../../util/regex.ts';
import { titleCase } from '../../../util/string.ts';
import { githubEcosystemToDatasource } from '../../../util/vulnerability/ecosystem.ts';
import {
getFixedVersionConstraint,
getHighestVulnerabilitySeverity,
} from '../../../util/vulnerability/utils.ts';
type DependencyName = string;
type CombinedAlert = Partial<
Record<
Ecosystem,
Record<
DependencyName,
{
advisories: SecurityAdvisory[];
firstPatchedVersion?: string;
severity?: string;
}
>
>
>;
export function getFixedVersionByDatasource(
fixedVersion: string,
datasource: string,
): string {
return getFixedVersionConstraint(fixedVersion, datasource);
}
// TODO can return `null` and `undefined` (#22198)
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 combinedAlerts: CombinedAlert = {};
for (const alert of alerts) {
try {
if (alert.dismissed_reason) {
continue;
}
if (!alert.security_vulnerability?.first_patched_version) {
logger.debug(
{ alert },
'Vulnerability alert has no firstPatchedVersion - skipping',
);
continue;
}
const ecosystem = alert.security_vulnerability.package.ecosystem;
const datasources = githubEcosystemToDatasource[ecosystem];
const depName = alert.security_vulnerability.package.name;
const firstPatchedVersion =
alert.security_vulnerability.first_patched_version.identifier;
const advisory = alert.security_advisory;
// Keying off of ecosystem instead of datasource because now a single ecosystem (such as maven) could have
// multiple datasources.
combinedAlerts[ecosystem] ??= {};
combinedAlerts[ecosystem][depName] ??= {
advisories: [],
};
const alertDetails = combinedAlerts[ecosystem][depName];
alertDetails.advisories.push(advisory);
alertDetails.severity = getHighestVulnerabilitySeverity(
{ vulnerabilitySeverity: alertDetails.severity },
{ vulnerabilitySeverity: alert.security_vulnerability.severity },
);
const versioningApi = allVersioning.get(
getDefaultVersioning(datasources[0]),
);
if (versioningApi.isVersion(firstPatchedVersion)) {
if (
!alertDetails.firstPatchedVersion ||
versioningApi.isGreaterThan(
firstPatchedVersion,
alertDetails.firstPatchedVersion,
)
) {
alertDetails.firstPatchedVersion = firstPatchedVersion;
}
} else {
logger.debug(`Invalid firstPatchedVersion: ${firstPatchedVersion}`);
}
} catch (err) {
logger.warn({ err }, 'Error parsing vulnerability alert');
}
}
const alertPackageRules: PackageRule[] = [];
config.remediations = {};
for (const [ecosystem, dependencies] of Object.entries(combinedAlerts)) {
const matchDatasources =
githubEcosystemToDatasource[ecosystem as Ecosystem];
const primaryDatasource = matchDatasources[0];
for (const [depName, val] of Object.entries(dependencies)) {
if (!val.firstPatchedVersion) {
continue;
}
let prBodyNotes: string[] = [];
try {
prBodyNotes = val.advisories.flatMap((advisory) =>
generatePrBodyNotes(advisory),
);
} catch (err) /* v8 ignore next -- defensive: note generation from already-parsed advisories does not throw in specs */ {
logger.warn({ err }, 'Error generating vulnerability PR notes');
}
let matchRule: PackageRule = {
matchDatasources,
matchPackageNames: [depName],
};
let matchCurrentVersion = `< ${val.firstPatchedVersion}`;
if (
primaryDatasource === MavenDatasource.id ||
primaryDatasource === NugetDatasource.id
) {
matchCurrentVersion = `(,${val.firstPatchedVersion})`;
} else if (primaryDatasource === GithubTagsDatasource.id) {
matchCurrentVersion = `!/^${RegExp.escape(val.firstPatchedVersion)}$/`;
}
matchRule = {
...matchRule,
matchCurrentVersion,
vulnerabilityFixVersion: val.firstPatchedVersion,
vulnerabilitySeverity: val.severity,
prBodyNotes,
isVulnerabilityAlert: true,
force: {
...config.vulnerabilityAlerts,
},
};
alertPackageRules.push(matchRule);
}
}
logger.debug({ alertPackageRules }, 'alert package rules');
config.packageRules = coerceArray(config.packageRules).concat(
alertPackageRules,
);
return config;
}
function generatePrBodyNotes(advisory: SecurityAdvisory): string[] {
const aliases = advisory.identifiers
.map((id) => id.value)
.sort()
.map((id) => {
if (id.startsWith('CVE-')) {
return `[${id}](https://nvd.nist.gov/vuln/detail/${id})`;
}
if (id.startsWith('GHSA-')) {
return `[${id}](https://github.com/advisories/${id})`;
}
return id;
});
let content = '\n\n---\n\n### ';
content += `${advisory.summary}\n`;
content += `${aliases.join(' / ')}\n`;
content += `\n<details>\n<summary>More information</summary>\n`;
const details = advisory.description.replace(regEx(/^#{1,4} /gm), '##### ');
content += `#### Details\n${details}\n`;
content += '#### Severity\n';
const { cvss_v4, cvss_v3 } = coerceObject(advisory.cvss_severities);
const cvss = cvss_v4?.vector_string ? cvss_v4 : cvss_v3;
if (is.number(cvss?.score) && cvss?.vector_string) {
content += `- CVSS Score: ${cvss.score.toFixed(1)} / 10 (${titleCase(advisory.severity)})\n`;
content += `- Vector String: \`${cvss.vector_string}\`\n`;
} else {
content += `${titleCase(advisory.severity)}\n`;
}
content += `\n#### References\n${
advisory.references
?.map((ref) => `- [${ref.url}](${ref.url})`)
.join('\n') ?? 'No references.'
}`;
content += `\n\nThis data is provided by the [GitHub Advisory Database](https://github.com/advisories/${advisory.ghsa_id}) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).\n`;
content += `</details>`;
return [sanitizeMarkdown(content)];
}