Files
renovate/lib/workers/repository/process/vulnerabilities.ts
Sebastian Poxhofer 97327c3d28 refactor(linters): add prefer-coerce-array rule (#45492)
* refactor(linters): add prefer-coerce-array rule

Refs #45217

`x ?? []` and `coerceArray(x)` say the same thing, but only the second
one is searchable and consistent. The rule flags a `??` whose default is
a bare `[]` literal; a non-empty default or an asserted `([] as Foo[])`
still passes.

Applying it across `lib/` surfaced three spots the helper cannot cover,
which keep `?? []` with a disable comment or a narrower fix:

- `package-files.ts` destructures a tuple, which `coerceArray()` widens
- `generate.spec.ts` defaults `matchAll()`, an iterator rather than an array
- `mise/upgradeable-tooling.ts` declares a `Record` return, so its
  `?? []` fallback was wrong to begin with and becomes `coerceObject()`

* refactor(platform/github): hoist the GHE version header lookup

`Object.entries(...).find()` returns a tuple, and defaulting it with
`?? []` only to destructure the second element reads as an array
default when it is not one. Name the found entry instead, which drops
the `?? []` rather than routing a tuple through `coerceArray()`.
2026-08-28 09:27:58 +00:00

747 lines
23 KiB
TypeScript

// TODO #22198
import type { Ecosystem, Osv } from '@renovatebot/osv-offline';
import { OsvOffline } from '@renovatebot/osv-offline';
import {
isEmptyArray,
isNonEmptyString,
isNullOrUndefined,
isTruthy,
} from '@sindresorhus/is';
import type { CvssVector } from 'ae-cvss-calculator';
import * as _aeCvss from 'ae-cvss-calculator';
import { z } from 'zod/v4';
import { getManagerConfig, mergeChildConfig } from '../../../config/index.ts';
import type { PackageRule, RenovateConfig } from '../../../config/types.ts';
import { instrument } from '../../../instrumentation/index.ts';
import { logger } from '../../../logger/index.ts';
import { getDefaultVersioning } from '../../../modules/datasource/common.ts';
import type {
PackageDependency,
PackageFile,
} from '../../../modules/manager/types.ts';
import type { VersioningApi } from '../../../modules/versioning/index.ts';
import { get as getVersioning } from '../../../modules/versioning/index.ts';
import { coerceArray } from '../../../util/array.ts';
import { sanitizeMarkdown } from '../../../util/markdown.ts';
import * as p from '../../../util/promises.ts';
import { regEx } from '../../../util/regex.ts';
import { titleCase } from '../../../util/string.ts';
import { datasourceToOsvEcosystem } from '../../../util/vulnerability/ecosystem.ts';
import {
getFixedVersionConstraint,
getLastAffectedVersionConstraint,
} from '../../../util/vulnerability/utils.ts';
import type {
DependencyVulnerabilities,
SeverityDetails,
Vulnerability,
} from './types.ts';
const { fromVector } = (_aeCvss as unknown as { default: typeof _aeCvss })
.default;
export class Vulnerabilities {
private static osvOffline: Promise<OsvOffline> | undefined;
private osvOffline: OsvOffline;
private static readonly datasourceEcosystemMap = datasourceToOsvEcosystem;
private constructor(osvOffline: OsvOffline) {
this.osvOffline = osvOffline;
}
private static initialize(): Promise<OsvOffline> {
// no async here, so osv promise will only be created once
Vulnerabilities.osvOffline ??= OsvOffline.create();
return Vulnerabilities.osvOffline;
}
static async create(): Promise<Vulnerabilities> {
// intialize osv only once
const osvOffline = await Vulnerabilities.initialize();
const instance = new Vulnerabilities(osvOffline);
return instance;
}
async appendVulnerabilityPackageRules(
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>,
): 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 (isNullOrUndefined(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 allManagerJobs = managers.map((manager) =>
this.fetchManagerVulnerabilities(config, packageFiles, manager),
);
return (await Promise.all(allManagerJobs)).flat();
}
private async fetchManagerVulnerabilities(
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>,
manager: string,
): Promise<DependencyVulnerabilities[]> {
const managerConfig = getManagerConfig(config, manager);
const queue = packageFiles[manager].map(
(pFile) => (): Promise<DependencyVulnerabilities[]> =>
this.fetchManagerPackageFileVulnerabilities(managerConfig, pFile),
);
logger.trace(
{ manager, queueLength: queue.length },
'fetchManagerVulnerabilities starting',
);
const result = (await p.all(queue)).flat();
logger.trace({ manager }, 'fetchManagerVulnerabilities finished');
return result;
}
private async fetchManagerPackageFileVulnerabilities(
managerConfig: RenovateConfig,
pFile: PackageFile,
): Promise<DependencyVulnerabilities[]> {
const { packageFile } = pFile;
const packageFileConfig = mergeChildConfig(managerConfig, pFile);
const { manager } = packageFileConfig;
const queue = pFile.deps.map(
(dep) => (): Promise<DependencyVulnerabilities | null> =>
this.fetchDependencyVulnerability(packageFileConfig, dep),
);
logger.trace(
{ manager, packageFile, queueLength: queue.length },
'fetchManagerPackageFileVulnerabilities starting with concurrency',
);
const result = await p.all(queue);
logger.trace(
{ packageFile },
'fetchManagerPackageFileVulnerabilities finished',
);
return result.filter(isTruthy);
}
private async fetchDependencyVulnerability(
packageFileConfig: RenovateConfig & PackageFile,
dep: PackageDependency,
): Promise<DependencyVulnerabilities | null> {
const ecosystem = Vulnerabilities.datasourceEcosystemMap[dep.datasource!];
if (!ecosystem) {
logger.trace(`Cannot map datasource ${dep.datasource!} to OSV ecosystem`);
return null;
}
const packageName = dep.packageName ?? dep.depName!;
let osvPackageName = packageName;
let depType: string | undefined;
if (ecosystem === 'PyPI') {
// https://peps.python.org/pep-0503/#normalized-names
osvPackageName = osvPackageName
.toLowerCase()
.replace(regEx(/[_.-]+/g), '-');
} else if (ecosystem === 'Go' && packageName === 'go') {
if (dep.depType !== 'toolchain') {
// The `go` directive is source compatibility, not the build toolchain, so we skip it
return null;
}
osvPackageName = 'stdlib';
depType = dep.depType;
}
try {
const osvVulnerabilities = await instrument(
'get OSV vulnerabilities',
() => this.osvOffline.getVulnerabilities(ecosystem, osvPackageName),
{
attributes: {
osvPackageName,
ecosystem,
},
},
);
if (
isNullOrUndefined(osvVulnerabilities) ||
isEmptyArray(osvVulnerabilities)
) {
logger.trace(
`No vulnerabilities found in OSV database for ${packageName}`,
);
return null;
}
const depVersion =
dep.lockedVersion ?? dep.currentVersion ?? dep.currentValue!;
const versioning = dep.versioning ?? getDefaultVersioning(dep.datasource);
const versioningApi = getVersioning(versioning);
if (!versioningApi.isVersion(depVersion)) {
logger.debug(
`Skipping vulnerability lookup for package ${packageName} due to unsupported version ${depVersion}`,
);
return null;
}
const vulnerabilities: Vulnerability[] = [];
for (const osvVulnerability of osvVulnerabilities) {
if (osvVulnerability.withdrawn) {
logger.trace(
`Skipping withdrawn vulnerability ${osvVulnerability.id}`,
);
continue;
}
this.skipMaliciousPackages(
ecosystem,
osvPackageName,
depVersion,
versioningApi,
dep,
packageFileConfig.manager,
packageFileConfig.packageFile,
osvVulnerability,
);
for (const affected of coerceArray(osvVulnerability.affected)) {
const isVulnerable = this.isPackageVulnerable(
ecosystem,
osvPackageName,
depVersion,
affected,
versioningApi,
);
if (!isVulnerable) {
continue;
}
logger.debug(
`Vulnerability ${osvVulnerability.id} affects ${packageName} ${depVersion}`,
);
const fixedVersion = this.getFixedVersion(
ecosystem,
depVersion,
affected,
versioningApi,
);
vulnerabilities.push({
packageName,
osvPackageName,
vulnerability: osvVulnerability,
affected,
depType,
depVersion,
fixedVersion,
datasource: dep.datasource!,
packageFileConfig,
});
}
}
return { vulnerabilities, versioningApi };
} catch (err) {
logger.warn(
{ err, packageName },
'Error fetching vulnerability information for package',
);
return null;
}
}
private skipMaliciousPackages(
ecosystem: Ecosystem,
osvPackageName: string,
depVersion: string,
versioningApi: VersioningApi,
dep: PackageDependency,
manager: string | undefined,
packageFile: string,
osvVulnerability: Osv.Vulnerability,
): void {
// the OpenSSF's Malicious Packages (https://github.com/ossf/malicious-packages) is a source of advisories through osv.dev, which takes various sources of advisories, and will re-publish them with more specific information about their malicious usage
if (osvVulnerability.id.startsWith('MAL-')) {
// is the current dependency vulnerable?
for (const affected of coerceArray(osvVulnerability.affected)) {
// is the current dependency vulnerable?
const isVulnerable = this.isPackageVulnerable(
ecosystem,
osvPackageName,
depVersion,
affected,
versioningApi,
);
if (isVulnerable) {
logger.debug(
{
packageFile: packageFile,
depName: dep.depName,
packageName: dep.packageName,
manager: manager,
datasource: dep.datasource,
currentVersion: depVersion,
},
`Marking ${dep.depName} as skipReason=malicious-version-in-use, as it is affected by ${osvVulnerability.id}`,
);
dep.skipReason = 'malicious-version-in-use';
dep.skipStage = 'lookup';
}
// or are any of the updates vulnerable?
for (const update of coerceArray(dep.updates)) {
const newVersion = update.newVersion ?? update.newValue!;
const isUpdateVulnerable = this.isPackageVulnerable(
ecosystem,
osvPackageName,
newVersion,
affected,
versioningApi,
);
if (isUpdateVulnerable) {
logger.debug(
{
packageFile: packageFile,
depName: dep.depName,
packageName: dep.packageName,
manager: manager,
datasource: dep.datasource,
currentVersion: depVersion,
newVersion,
},
`Marking ${dep.depName}'s update to ${newVersion} as skipReason=malicious-update-proposed, as it is affected by ${osvVulnerability.id}`,
);
dep.skipReason = 'malicious-update-proposed';
dep.skipStage = 'lookup';
}
}
}
}
}
private sortByFixedVersion(
packageRules: PackageRule[],
versioningApi: VersioningApi,
): void {
const versionsCleaned: Record<string, string> = {};
for (const rule of packageRules) {
const version = rule.allowedVersions!;
versionsCleaned[version] = version.replace(regEx(/[(),=> ]+/g), '');
}
packageRules.sort((a, b) =>
versioningApi.sortVersions(
versionsCleaned[a.allowedVersions!],
versionsCleaned[b.allowedVersions!],
),
);
}
// https://ossf.github.io/osv-schema/#affectedrangesevents-fields
private sortEvents(
events: Osv.Event[],
versioningApi: VersioningApi,
): Osv.Event[] {
const sortedCopy: Osv.Event[] = [];
let zeroEvent: Osv.Event | null = null;
for (const event of events) {
if (event.introduced === '0') {
zeroEvent = event;
} else if (versioningApi.isVersion(Object.values(event)[0])) {
sortedCopy.push(event);
} else {
logger.debug({ event }, 'Skipping OSV event with invalid version');
}
}
sortedCopy.sort((a, b) =>
// no pre-processing, as there are only very few values to sort
versioningApi.sortVersions(Object.values(a)[0], Object.values(b)[0]),
);
if (zeroEvent) {
sortedCopy.unshift(zeroEvent);
}
return sortedCopy;
}
private isPackageAffected(
ecosystem: Ecosystem,
osvPackageName: string,
affected: Osv.Affected,
): boolean {
const pkg = affected.package;
if (pkg?.name !== osvPackageName) {
return false;
}
// Match exact ecosystems and OSV sub-ecosystems (e.g. Packagist:https://packages.drupal.org/8).
return (
pkg.ecosystem === ecosystem || pkg.ecosystem.startsWith(`${ecosystem}:`)
);
}
private includedInVersions(
depVersion: string,
affected: Osv.Affected,
): boolean {
return !!affected.versions?.includes(depVersion);
}
private includedInRanges(
depVersion: string,
affected: Osv.Affected,
versioningApi: VersioningApi,
): boolean {
for (const range of coerceArray(affected.ranges)) {
if (range.type === 'GIT') {
continue;
}
let vulnerable = false;
for (const event of this.sortEvents(range.events, versioningApi)) {
if (
isNonEmptyString(event.introduced) &&
(event.introduced === '0' ||
this.isVersionGtOrEq(depVersion, event.introduced, versioningApi))
) {
vulnerable = true;
} else if (
isNonEmptyString(event.fixed) &&
this.isVersionGtOrEq(depVersion, event.fixed, versioningApi)
) {
vulnerable = false;
} else if (
isNonEmptyString(event.last_affected) &&
this.isVersionGt(depVersion, event.last_affected, versioningApi)
) {
vulnerable = false;
}
}
if (vulnerable) {
return true;
}
}
return false;
}
// https://ossf.github.io/osv-schema/#evaluation
private isPackageVulnerable(
ecosystem: Ecosystem,
osvPackageName: string,
depVersion: string,
affected: Osv.Affected,
versioningApi: VersioningApi,
): boolean {
return (
this.isPackageAffected(ecosystem, osvPackageName, affected) &&
(this.includedInVersions(depVersion, affected) ||
this.includedInRanges(depVersion, affected, versioningApi))
);
}
private getFixedVersion(
ecosystem: Ecosystem,
depVersion: string,
affected: Osv.Affected,
versioningApi: VersioningApi,
): string | null {
const fixedVersions: string[] = [];
const lastAffectedVersions: string[] = [];
for (const range of coerceArray(affected.ranges)) {
if (range.type === 'GIT') {
continue;
}
for (const event of range.events) {
if (
isNonEmptyString(event.fixed) &&
versioningApi.isVersion(event.fixed)
) {
fixedVersions.push(event.fixed);
} else if (
isNonEmptyString(event.last_affected) &&
versioningApi.isVersion(event.last_affected)
) {
lastAffectedVersions.push(event.last_affected);
}
}
}
fixedVersions.sort((a, b) => versioningApi.sortVersions(a, b));
const fixedVersion = fixedVersions.find((version) =>
this.isVersionGt(version, depVersion, versioningApi),
);
if (fixedVersion) {
return this.getFixedVersionByEcosystem(fixedVersion, ecosystem);
}
lastAffectedVersions.sort((a, b) => versioningApi.sortVersions(a, b));
const lastAffected = lastAffectedVersions.find((version) =>
this.isVersionGtOrEq(version, depVersion, versioningApi),
);
if (lastAffected) {
return this.getLastAffectedByEcosystem(lastAffected, ecosystem);
}
return null;
}
private getFixedVersionByEcosystem(
fixedVersion: string,
ecosystem: Ecosystem,
): string {
return getFixedVersionConstraint(fixedVersion, ecosystem);
}
private getLastAffectedByEcosystem(
lastAffected: string,
ecosystem: Ecosystem,
): string {
return getLastAffectedVersionConstraint(lastAffected, ecosystem);
}
private isVersionGt(
version: string,
other: string,
versioningApi: VersioningApi,
): boolean {
return (
versioningApi.isVersion(version) &&
versioningApi.isVersion(other) &&
versioningApi.isGreaterThan(version, other)
);
}
private isVersionGtOrEq(
version: string,
other: string,
versioningApi: VersioningApi,
): boolean {
return (
versioningApi.isVersion(version) &&
versioningApi.isVersion(other) &&
(versioningApi.equals(version, other) ||
versioningApi.isGreaterThan(version, other))
);
}
private vulnerabilityToPackageRules(vul: Vulnerability): PackageRule | null {
const {
vulnerability,
affected,
packageName,
depType,
depVersion,
fixedVersion,
datasource,
packageFileConfig,
} = vul;
if (isNullOrUndefined(fixedVersion)) {
logger.debug(
`No fixed version available for vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`,
);
return null;
}
// we don't know if the dependency has a `versioning` applied to it already, so we have to use the default for the datasource
const versioning = getDefaultVersioning(datasource);
logger.debug(
{
datasource,
versioning,
},
`Setting allowed version ${fixedVersion} to fix vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`,
);
const severityDetails = this.extractSeverityDetails(
vulnerability,
affected,
);
return {
matchDatasources: [datasource],
matchPackageNames: [packageName],
...(depType ? { matchDepTypes: [depType] } : {}),
matchCurrentVersion: depVersion,
versioning,
allowedVersions: fixedVersion,
isVulnerabilityAlert: true,
vulnerabilitySeverity: severityDetails.severityLevel,
prBodyNotes: this.generatePrBodyNotes(vulnerability, affected),
force: {
...packageFileConfig.vulnerabilityAlerts,
},
};
}
static evaluateCvssVector(vector: string): [string, string] {
const CvssJson = z.object({
baseScore: z.number().default(0.0),
baseSeverity: z.string().toUpperCase().default('UNKNOWN'),
});
try {
const parsedCvssScore: CvssVector<any> | null = fromVector(vector);
const res = CvssJson.parse(parsedCvssScore?.createJsonSchema());
return [res.baseScore.toFixed(1), res.baseSeverity];
} catch {
logger.debug(`Error processing CVSS vector ${vector}`);
}
return ['', ''];
}
private generatePrBodyNotes(
vulnerability: Osv.Vulnerability,
affected: Osv.Affected,
): string[] {
let aliases = [vulnerability.id]
.concat(coerceArray(vulnerability.aliases))
.sort();
aliases = aliases.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})`;
}
if (id.startsWith('GO-')) {
return `[${id}](https://pkg.go.dev/vuln/${id})`;
}
if (id.startsWith('RUSTSEC-')) {
return `[${id}](https://rustsec.org/advisories/${id}.html)`;
}
return id;
});
let content = '\n\n---\n\n### ';
content += vulnerability.summary ? `${vulnerability.summary}\n` : '';
content += `${aliases.join(' / ')}\n`;
content += `\n<details>\n<summary>More information</summary>\n`;
const details = vulnerability.details?.replace(
regEx(/^#{1,4} /gm),
'##### ',
);
content += `#### Details\n${details ?? 'No details.'}\n`;
content += '#### Severity\n';
const severityDetails = this.extractSeverityDetails(
vulnerability,
affected,
);
if (severityDetails.cvssVector) {
content += `- CVSS Score: ${severityDetails.score}\n`;
content += `- Vector String: \`${severityDetails.cvssVector}\`\n`;
} else {
content += `${titleCase(severityDetails.severityLevel)}\n`;
}
content += `\n#### References\n${
vulnerability.references
?.map((ref) => {
return `- [${ref.url}](${ref.url})`;
})
.join('\n') ?? 'No references.'
}`;
let attribution = '';
if (vulnerability.id.startsWith('GHSA-')) {
attribution = ` and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md))`;
} else if (vulnerability.id.startsWith('GO-')) {
attribution = ` and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license))`;
} else if (vulnerability.id.startsWith('PYSEC-')) {
attribution = ` and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE))`;
} else if (vulnerability.id.startsWith('RUSTSEC-')) {
attribution = ` and the [Rust Advisory Database](https://github.com/RustSec/advisory-db) ([CC0 1.0](https://github.com/rustsec/advisory-db/blob/main/LICENSE.txt))`;
}
content += `\n\nThis data is provided by [OSV](https://osv.dev/vulnerability/${vulnerability.id})${attribution}.\n`;
content += `</details>`;
return [sanitizeMarkdown(content)];
}
private extractSeverityDetails(
vulnerability: Osv.Vulnerability,
affected: Osv.Affected,
): SeverityDetails {
let severityLevel = 'UNKNOWN';
let score = 'Unknown';
const cvssVector =
vulnerability.severity?.find((e) => e.type === 'CVSS_V4')?.score ??
vulnerability.severity?.find((e) => e.type === 'CVSS_V3')?.score ??
(affected.database_specific?.cvss as string); // RUSTSEC
if (cvssVector) {
const [baseScore, severity] =
Vulnerabilities.evaluateCvssVector(cvssVector);
severityLevel = severity ? severity.toUpperCase() : 'UNKNOWN';
score = baseScore
? `${baseScore} / 10 (${titleCase(severityLevel)})`
: 'Unknown';
} else if (
vulnerability.id.startsWith('GHSA-') &&
vulnerability.database_specific?.severity
) {
const severity = vulnerability.database_specific.severity as string;
severityLevel = severity.toUpperCase();
}
return {
cvssVector,
score,
severityLevel,
};
}
}