mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 02:00:10 +00:00
97327c3d28
* 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()`.
747 lines
23 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|