diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 5b6716525cf65c2bed201516d35742d34969a3f4..c89cd097d2cd384f1f73bb608db3050c6cd60116 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -1492,6 +1492,24 @@ A use case for the latter is if you are a Renovate bot admin and wish to provide If `false` (default), it means that defining `config.npmrc` will result in any `.npmrc` file in the repo being overridden and its values ignored. If configured to `true`, it means that any `.npmrc` file in the repo will have `config.npmrc` prepended to it before running `npm`. +## osvVulnerabilityAlerts + +Renovate integrates with [OSV](https://osv.dev/), an open-source vulnerability database, to check if extracted dependencies have known vulnerabilities. +Set `osvVulnerabilityAlerts` to `true` to get pull requests with vulnerability fixes (once they are available). + +You will only get OSV-based vulnerability alerts for _direct_ dependencies. +Renovate only queries the OSV database for dependencies that use one of these datasources: + +- [`crate`](https://docs.renovatebot.com/modules/datasource/crate/) +- [`go`](https://docs.renovatebot.com/modules/datasource/go/) +- [`hex`](https://docs.renovatebot.com/modules/datasource/hex/) +- [`maven`](https://docs.renovatebot.com/modules/datasource/maven/) +- [`npm`](https://docs.renovatebot.com/modules/datasource/npm/) +- [`nuget`](https://docs.renovatebot.com/modules/datasource/nuget/) +- [`packagist`](https://docs.renovatebot.com/modules/datasource/packagist/) +- [`pypi`](https://docs.renovatebot.com/modules/datasource/pypi/) +- [`rubygems`](https://docs.renovatebot.com/modules/datasource/rubygems/) + ## packageRules `packageRules` is a powerful feature that lets you apply rules to individual packages or to groups of packages using regex pattern matching. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 3fbac96d6a37a1d259890437e37e236622a6d1d4..898292ff8089eac64e2b591fd76cbabfd8e8bc88 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1670,6 +1670,14 @@ const options: RenovateOptions[] = [ env: false, supportedPlatforms: ['github'], }, + { + name: 'osvVulnerabilityAlerts', + description: 'Use vulnerability alerts from `osv.dev`.', + type: 'boolean', + default: false, + experimental: true, + experimentalIssues: [6562], + }, { name: 'pruneBranchAfterAutomerge', description: 'Set to `true` to enable branch pruning after automerging.', diff --git a/lib/config/types.ts b/lib/config/types.ts index 5e67b80df5683b1489b52d5bb31e8da131a9f02d..3c2c8ef3757484a2f308d0bf7c537ef7105f97c6 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -243,6 +243,7 @@ export interface RenovateConfig warnings?: ValidationMessage[]; vulnerabilityAlerts?: RenovateSharedConfig; + osvVulnerabilityAlerts?: boolean; regexManagers?: RegExManager[]; fetchReleaseNotes?: boolean; diff --git a/lib/workers/repository/process/extract-update.spec.ts b/lib/workers/repository/process/extract-update.spec.ts index 42380976007b7e4ba7cb69917bb47a5a536e87f5..08f8d09343d1df16cb10ca464207d810590ba7b9 100644 --- a/lib/workers/repository/process/extract-update.spec.ts +++ b/lib/workers/repository/process/extract-update.spec.ts @@ -7,9 +7,21 @@ import { generateFingerprintConfig } from '../extract/extract-fingerprint-config import * as _branchify from '../updates/branchify'; import { extract, isCacheExtractValid, lookup, update } from './extract-update'; +const createVulnerabilitiesMock = jest.fn(); + jest.mock('./write'); jest.mock('./sort'); jest.mock('./fetch'); +jest.mock('./vulnerabilities', () => { + return { + __esModule: true, + Vulnerabilities: class { + static create() { + return createVulnerabilitiesMock(); + } + }, + }; +}); jest.mock('../updates/branchify'); jest.mock('../extract'); jest.mock('../../../util/cache/repository'); @@ -18,7 +30,7 @@ jest.mock('../../../util/git'); const branchify = mocked(_branchify); const repositoryCache = mocked(_repositoryCache); -branchify.branchifyUpgrades.mockResolvedValueOnce({ +branchify.branchifyUpgrades.mockResolvedValue({ branches: [ { manager: 'some-manager', @@ -97,6 +109,42 @@ describe('workers/repository/process/extract-update', () => { const res = await extract(config); expect(res).toEqual(packageFiles); }); + + it('fetches vulnerabilities', async () => { + const config = { + repoIsOnboarded: true, + suppressNotifications: ['deprecationWarningIssues'], + osvVulnerabilityAlerts: true, + }; + const fetchVulnerabilitiesMock = jest.fn(); + createVulnerabilitiesMock.mockResolvedValueOnce({ + fetchVulnerabilities: fetchVulnerabilitiesMock, + }); + repositoryCache.getCache.mockReturnValueOnce({ scan: {} }); + git.checkoutBranch.mockResolvedValueOnce('123test'); + + const packageFiles = await extract(config); + await lookup(config, packageFiles); + + expect(createVulnerabilitiesMock).toHaveBeenCalledOnce(); + expect(fetchVulnerabilitiesMock).toHaveBeenCalledOnce(); + }); + + it('handles exception when fetching vulnerabilities', async () => { + const config = { + repoIsOnboarded: true, + suppressNotifications: ['deprecationWarningIssues'], + osvVulnerabilityAlerts: true, + }; + createVulnerabilitiesMock.mockRejectedValueOnce(new Error()); + repositoryCache.getCache.mockReturnValueOnce({ scan: {} }); + git.checkoutBranch.mockResolvedValueOnce('123test'); + + const packageFiles = await extract(config); + await lookup(config, packageFiles); + + expect(createVulnerabilitiesMock).toHaveBeenCalledOnce(); + }); }); describe('isCacheExtractValid()', () => { diff --git a/lib/workers/repository/process/extract-update.ts b/lib/workers/repository/process/extract-update.ts index 763aadd539ff2484bb43a4497b9fd1c0fed884cd..e806b545281090fec8348a4df3af0f31c9d590b0 100644 --- a/lib/workers/repository/process/extract-update.ts +++ b/lib/workers/repository/process/extract-update.ts @@ -15,6 +15,7 @@ import { branchifyUpgrades } from '../updates/branchify'; import { raiseDeprecationWarnings } from './deprecated'; import { fetchUpdates } from './fetch'; import { sortBranches } from './sort'; +import { Vulnerabilities } from './vulnerabilities'; import { WriteUpdateResult, writeUpdates } from './write'; export interface ExtractResult { @@ -166,10 +167,25 @@ export async function extract( return packageFiles; } +async function fetchVulnerabilities( + config: RenovateConfig, + packageFiles: Record<string, PackageFile[]> +): Promise<void> { + if (config.osvVulnerabilityAlerts) { + try { + const vulnerabilities = await Vulnerabilities.create(); + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + } catch (err) { + logger.warn({ err }, 'Unable to read vulnerability information'); + } + } +} + export async function lookup( config: RenovateConfig, packageFiles: Record<string, PackageFile[]> ): Promise<ExtractResult> { + await fetchVulnerabilities(config, packageFiles); await fetchUpdates(config, packageFiles); await raiseDeprecationWarnings(config, packageFiles); const { branches, branchList } = await branchifyUpgrades( diff --git a/lib/workers/repository/process/lookup/index.ts b/lib/workers/repository/process/lookup/index.ts index 9e55551e00923157df056fa47a9d60eaa3598170..49763a3336b82930ee420bd330e520345c6dc24c 100644 --- a/lib/workers/repository/process/lookup/index.ts +++ b/lib/workers/repository/process/lookup/index.ts @@ -252,7 +252,7 @@ export async function lookupUpdates( // Leave only compatible versions unconstrainedValue || versioning.isCompatible(v.version, currentValue) ); - if (isVulnerabilityAlert) { + if (isVulnerabilityAlert && !config.osvVulnerabilityAlerts) { filteredReleases = filteredReleases.slice(0, 1); } const buckets: Record<string, [Release]> = {}; diff --git a/lib/workers/repository/process/vulnerabilities.spec.ts b/lib/workers/repository/process/vulnerabilities.spec.ts index 775901e43270a91127ec6125f1a2d9cb88dc1414..8a03529383d8e78d172981b441aa88b4d66f6f12 100644 --- a/lib/workers/repository/process/vulnerabilities.spec.ts +++ b/lib/workers/repository/process/vulnerabilities.spec.ts @@ -1,6 +1,7 @@ -import type { Ecosystem, OsvOffline } from '@renovatebot/osv-offline'; +import type { Osv, OsvOffline } from '@renovatebot/osv-offline'; +import { codeBlock } from 'common-tags'; import { mockFn } from 'jest-mock-extended'; -import { getConfig } from '../../../../test/util'; +import { RenovateConfig, getConfig, logger } from '../../../../test/util'; import type { PackageFile } from '../../../modules/manager/types'; import { Vulnerabilities } from './vulnerabilities'; @@ -33,37 +34,730 @@ describe('workers/repository/process/vulnerabilities', () => { }); describe('fetchVulnerabilities()', () => { - const config = getConfig(); - const packageFiles: Record<string, PackageFile[]> = { - npm: [{ deps: [{ depName: 'lodash' }] }], - }; + let config: RenovateConfig; let vulnerabilities: Vulnerabilities; + const lodashVulnerability: Osv.Vulnerability = { + id: 'GHSA-x5rq-j2xg-h7qm', + modified: '', + affected: [ + { + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '0.0.0' }, { fixed: '4.17.11' }], + }, + ], + package: { name: 'lodash', ecosystem: 'npm' }, + }, + ], + references: [ + { + type: 'ADVISORY', + url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-1010266', + }, + ], + }; beforeAll(async () => { createMock.mockResolvedValue({ - getVulnerabilities: (ecosystem: Ecosystem, packageName: string) => - getVulnerabilitiesMock(ecosystem, packageName), + getVulnerabilities: getVulnerabilitiesMock, }); vulnerabilities = await Vulnerabilities.create(); }); - it('works', async () => { - getVulnerabilitiesMock.mockResolvedValue([ + beforeEach(() => { + config = getConfig(); + config.packageRules = []; + }); + + it('unsupported datasource', async () => { + const packageFiles: Record<string, PackageFile[]> = { + dockerfile: [{ deps: [{ depName: 'node', datasource: 'docker' }] }], + }; + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.trace).toHaveBeenCalledWith( + 'Cannot map datasource docker to OSV ecosystem' + ); + }); + + it('package found but no vulnerabilities', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [{ deps: [{ depName: 'lodash', datasource: 'npm' }] }], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.trace).toHaveBeenCalledWith( + 'No vulnerabilities found in OSV database for lodash' + ); + }); + + it('vulnerability without affected field', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'lodash', currentValue: '4.17.11', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-p6mc-m468-83gw', + modified: '', + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(config.packageRules).toHaveLength(0); + }); + + it('invalid dep version', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { + depName: 'lodash', + currentValue: '#4.17.11', + datasource: 'npm', + }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([lodashVulnerability]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'Skipping vulnerability lookup for package lodash due to unsupported version #4.17.11' + ); + }); + + it('exception due to invalid version upon comparison', async () => { + const err = new TypeError('Invalid Version: ^1.1.0'); + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { + depName: 'lodash', + currentValue: '4.17.11', + datasource: 'npm', + }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '', + affected: [ + { + package: { + name: 'lodash', + ecosystem: 'npm', + purl: 'pkg:npm/lodash', + }, + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '^0' }, { fixed: '^1.1.0' }], + }, + ], + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.debug).toHaveBeenCalledWith( + { err }, + 'Error fetching vulnerability information for lodash' + ); + }); + + it('no version or range affected', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'fake', currentValue: '4.17.11', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '', + affected: [ + { + package: { name: 'fake', ecosystem: 'npm', purl: 'pkg:npm/fake' }, + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(config.packageRules).toHaveLength(0); + }); + + it('no fixed version available', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'fake', currentValue: '4.17.11', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '', + affected: [ + { + package: { name: 'fake', ecosystem: 'npm', purl: 'pkg:npm/fake' }, + versions: ['4.17.11'], + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.info).toHaveBeenCalledWith( + 'No fixed version available for vulnerability GHSA-xxxx-yyyy-zzzz in fake 4.17.11' + ); + }); + + it('does not accidentally downgrade versions due to fixed version for other range', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'fake', currentValue: '1.5.1', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '', + affected: [ + { + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '0' }, { fixed: '1.1.0' }], + }, + { + type: 'SEMVER', + events: [{ introduced: '1.3.0' }], + }, + ], + package: { name: 'fake', ecosystem: 'npm' }, + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.info).toHaveBeenCalledWith( + 'No fixed version available for vulnerability GHSA-xxxx-yyyy-zzzz in fake 1.5.1' + ); + }); + + it('vulnerability with multiple unsorted events', async () => { + const packageFiles: Record<string, PackageFile[]> = { + gomod: [ + { + deps: [ + { depName: 'stdlib', currentValue: '1.7.5', datasource: 'go' }, + ], + }, + ], + }; + + getVulnerabilitiesMock.mockResolvedValueOnce([ { - id: 'ABCD', + id: 'GO-2022-0187', modified: '', + aliases: ['CVE-2017-8932'], affected: [ { - ranges: [{ type: 'SEMVER', events: [{ fixed: '1.2.3' }] }], + package: { + name: 'stdlib', + ecosystem: 'Go', + purl: 'pkg:golang/stdlib', + }, + ranges: [ + { + type: 'SEMVER', + events: [ + { introduced: '1.6.0' }, + { fixed: '1.8.5' }, + { introduced: '1.8.3' }, + { fixed: '1.7.6' }, + ], + }, + ], + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'Vulnerability GO-2022-0187 affects stdlib 1.7.5' + ); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'Setting allowed version 1.7.6 to fix vulnerability GO-2022-0187 in stdlib 1.7.5' + ); + expect(config.packageRules).toHaveLength(1); + expect(config.packageRules).toMatchObject([ + { + matchDatasources: ['go'], + matchPackageNames: ['stdlib'], + matchCurrentVersion: '1.7.5', + allowedVersions: '1.7.6', + isVulnerabilityAlert: true, + }, + ]); + }); + + it('vulnerability with multiple affected entries and version ranges', async () => { + const packageFiles: Record<string, PackageFile[]> = { + poetry: [ + { + deps: [ + { depName: 'django', currentValue: '3.2', datasource: 'pypi' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-qrw5-5h28-modded', + modified: '', + affected: [ + { + package: { + name: 'django', + ecosystem: 'PyPI', + purl: 'pkg:pypi/django', + }, + ranges: [ + { + type: 'GIT', + repo: 'https://github.com/django/django', + events: [ + { introduced: '0' }, + { fixed: '5b6b257fa7ec37ff27965358800c67e2dd11c924' }, + ], + }, + { + type: 'ECOSYSTEM', + events: [{ introduced: '3.2' }, { fixed: '3.2.16' }], + }, + ], + versions: ['3.2.1', '3.2.10', '3.2.9'], + }, + { + package: { + name: 'django', + ecosystem: 'PyPI', + purl: 'pkg:pypi/django', + }, + ranges: [ + { + type: 'ECOSYSTEM', + events: [{ introduced: '4.0' }, { fixed: '4.0.8' }], + }, + ], + versions: ['4.0', '4.0.1', '4.0.6', '4.0.7'], + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(config.packageRules).toHaveLength(1); + expect(config.packageRules).toMatchObject([ + { + matchDatasources: ['pypi'], + matchPackageNames: ['django'], + matchCurrentVersion: '3.2', + allowedVersions: '==3.2.16', + isVulnerabilityAlert: true, + }, + ]); + }); + + it('filters not applicable vulnerability', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'lodash', currentValue: '4.17.11', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([lodashVulnerability]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(config.packageRules).toHaveLength(0); + }); + + it('returns a single packageRule for regex manager', async () => { + const packageFiles: Record<string, PackageFile[]> = { + regex: [ + { + deps: [ + { + depName: 'tiny_http', + currentValue: '0.1.2', + datasource: 'crate', + }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'RUSTSEC-2020-0031', + summary: + 'HTTP Request smuggling through malformed Transfer Encoding headers', + details: + 'HTTP pipelining issues and request smuggling attacks are possible due to incorrect Transfer encoding header parsing.\n\nIt is possible conduct HTTP request smuggling attacks (CL:TE/TE:TE) by sending invalid Transfer Encoding headers.\n\nBy manipulating the HTTP response the attacker could poison a web-cache, perform an XSS attack, or obtain sensitive information from requests other than their own.', + aliases: ['CVE-2020-35884', 'SOME-1234-5678'], + modified: '', + affected: [ + { + package: { + name: 'tiny_http', + ecosystem: 'crates.io', + purl: 'pkg:cargo/tiny_http', + }, + ranges: [ + { + type: 'SEMVER', + events: [ + { introduced: '0' }, + { fixed: '0.6.3' }, + { introduced: '0.7.0-0' }, + { fixed: '0.8.0' }, + ], + }, + ], + }, + ], + severity: [ + { + type: 'CVSS_V2', + score: 'AV:N/AC:L/Au:N/C:P/I:P/A:N', + }, + { + type: 'CVSS_V3', + score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N', + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + + expect(config.packageRules).toHaveLength(1); + expect(config.packageRules).toMatchObject([ + { + matchDatasources: ['crate'], + matchPackageNames: ['tiny_http'], + matchCurrentVersion: '0.1.2', + allowedVersions: '0.6.3', + isVulnerabilityAlert: true, + prBodyNotes: [ + '\n\n' + + codeBlock` + --- + + ### HTTP Request smuggling through malformed Transfer Encoding headers + [CVE-2020-35884](https://nvd.nist.gov/vuln/detail/CVE-2020-35884) / [RUSTSEC-2020-0031](https://rustsec.org/advisories/RUSTSEC-2020-0031.html) / SOME-1234-5678 + + <details> + <summary>More information</summary> + + ### Details + HTTP pipelining issues and request smuggling attacks are possible due to incorrect Transfer encoding header parsing. + + It is possible conduct HTTP request smuggling attacks (CL:TE/TE:TE) by sending invalid Transfer Encoding headers. + + By manipulating the HTTP response the attacker could poison a web-cache, perform an XSS attack, or obtain sensitive information from requests other than their own. + + ### Severity + - Score: 6.5 / 10 (Medium) + - Vector: \`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N\` + + ### References + No references. + + This data is provided by [OSV](https://osv.dev/vulnerability/RUSTSEC-2020-0031) and the [Rust Advisory Database](https://github.com/RustSec/advisory-db) ([CC0 1.0](https://github.com/rustsec/advisory-db/blob/main/LICENSE.txt)). + </details> + `, + ], + }, + ]); + }); + + it('returns multiple packageRules for different vulnerabilities', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'lodash', currentValue: '4.17.10', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + lodashVulnerability, + { + id: 'GHSA-p6mc-m468-83gw', + modified: '', + affected: [ + { + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '0' }, { fixed: '4.17.20' }], + }, + ], package: { name: 'lodash', ecosystem: 'npm' }, }, ], + severity: [ + { + type: 'CVSS_V3', + score: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H', + }, + ], }, ]); await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(config.packageRules).toHaveLength(2); + expect(config.packageRules).toMatchObject([ + { + matchDatasources: ['npm'], + matchPackageNames: ['lodash'], + matchCurrentVersion: '4.17.10', + allowedVersions: '4.17.11', + isVulnerabilityAlert: true, + }, + { + matchDatasources: ['npm'], + matchPackageNames: ['lodash'], + matchCurrentVersion: '4.17.10', + allowedVersions: '4.17.20', + isVulnerabilityAlert: true, + }, + ]); + }); + + it('filters not applicable vulnerability based on last_affected version', async () => { + const packageFiles: Record<string, PackageFile[]> = { + poetry: [ + { + deps: [ + { depName: 'quokka', currentValue: '1.2.3', datasource: 'pypi' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '', + affected: [ + { + package: { + name: 'quokka', + ecosystem: 'PyPI', + purl: 'pkg:pypi/quokka', + }, + ranges: [ + { + type: 'ECOSYSTEM', + events: [{ introduced: '0' }, { last_affected: '0.4.0' }], + }, + ], + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(logger.logger.debug).not.toHaveBeenCalledWith( + 'OSV advisory GHSA-xxxx-yyyy-zzzz lists quokka 1.2.3 as vulnerable' + ); + expect(config.packageRules).toHaveLength(0); + }); + + it('returns packageRule based on last_affected version', async () => { + const packageFiles: Record<string, PackageFile[]> = { + npm: [ + { + deps: [ + { depName: 'lodash', currentValue: '0.5.0', datasource: 'npm' }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'GHSA-xxxx-yyyy-zzzz', + modified: '', + affected: [ + { + package: { + name: 'lodash', + ecosystem: 'npm', + purl: 'pkg:npm/lodash', + }, + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '0' }, { last_affected: '0.2.0' }], + }, + { + type: 'SEMVER', + events: [{ introduced: '0.4.0' }, { last_affected: '0.8.0' }], + }, + ], + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + expect(config.packageRules).toHaveLength(1); + expect(config.packageRules).toMatchObject([ + { + matchDatasources: ['npm'], + matchPackageNames: ['lodash'], + matchCurrentVersion: '0.5.0', + allowedVersions: '> 0.8.0', + isVulnerabilityAlert: true, + prBodyNotes: [ + '\n\n' + + codeBlock` + --- + + ### [GHSA-xxxx-yyyy-zzzz](https://github.com/advisories/GHSA-xxxx-yyyy-zzzz) + + <details> + <summary>More information</summary> + + ### Details + No details. + + ### Severity + Unknown severity. + + ### References + No references. + + This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xxxx-yyyy-zzzz) 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)). + </details> + `, + ], + }, + ]); + }); + + it('handles invalid CVSS scores gracefully', async () => { + const packageFiles: Record<string, PackageFile[]> = { + poetry: [ + { + deps: [ + { + depName: 'django-mfa2', + currentValue: '2.5.0', + datasource: 'pypi', + }, + ], + }, + ], + }; + getVulnerabilitiesMock.mockResolvedValueOnce([ + { + id: 'PYSEC-2022-303', + modified: '', + affected: [ + { + ranges: [ + { + type: 'ECOSYSTEM', + events: [{ introduced: '0' }, { fixed: '2.5.1' }], + }, + ], + package: { name: 'django-mfa2', ecosystem: 'PyPI' }, + }, + ], + severity: [ + { + type: 'CVSS_V3', + score: 'some-invalid-score', + }, + ], + }, + ]); + + await vulnerabilities.fetchVulnerabilities(config, packageFiles); + + expect(logger.logger.debug).toHaveBeenCalledWith( + 'Error processing CVSS vector some-invalid-score' + ); expect(config.packageRules).toHaveLength(1); + expect(config.packageRules).toMatchObject([ + { + matchDatasources: ['pypi'], + matchPackageNames: ['django-mfa2'], + matchCurrentVersion: '2.5.0', + allowedVersions: '==2.5.1', + isVulnerabilityAlert: true, + prBodyNotes: [ + '\n\n' + + codeBlock` + --- + + ### PYSEC-2022-303 + + <details> + <summary>More information</summary> + + ### Details + No details. + + ### Severity + - Score: Unknown + - Vector: \`some-invalid-score\` + + ### References + No references. + + This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2022-303) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). + </details> + `, + ], + }, + ]); }); }); }); diff --git a/lib/workers/repository/process/vulnerabilities.ts b/lib/workers/repository/process/vulnerabilities.ts index 019895789b3d59033ef674ece06bb74fbf8f0a84..3ab53afe7af2f3a36b562a2f6c59ad9bc122d30e 100644 --- a/lib/workers/repository/process/vulnerabilities.ts +++ b/lib/workers/repository/process/vulnerabilities.ts @@ -1,36 +1,40 @@ // TODO #7154 import { Ecosystem, Osv, OsvOffline } from '@renovatebot/osv-offline'; +import is from '@sindresorhus/is'; +import type { CvssScore } from 'vuln-vects'; +import { parseCvssVector } from 'vuln-vects'; import { getManagerConfig, mergeChildConfig } from '../../../config'; import type { PackageRule, RenovateConfig } from '../../../config/types'; import { logger } from '../../../logger'; +import { getDefaultVersioning } from '../../../modules/datasource'; import type { PackageDependency, PackageFile, } from '../../../modules/manager/types'; +import { + VersioningApi, + get as getVersioning, +} from '../../../modules/versioning'; +import { sanitizeMarkdown } from '../../../util/markdown'; import * as p from '../../../util/promises'; +import { regEx } from '../../../util/regex'; export class Vulnerabilities { private osvOffline: OsvOffline | undefined; - private static readonly managerEcosystemMap: Record< + private static readonly datasourceEcosystemMap: Record< string, Ecosystem | undefined > = { - bundler: 'RubyGems', - cargo: 'crates.io', - gomod: 'Go', - gradle: 'Maven', + crate: 'crates.io', + go: 'Go', + hex: 'Hex', maven: 'Maven', - meteor: 'npm', npm: 'npm', nuget: 'NuGet', - 'pip-compile': 'PyPI', - pip_requirements: 'PyPI', - pip_setup: 'PyPI', - pipenv: 'PyPI', - poetry: 'PyPI', - 'setup-cfg': 'PyPI', - sbt: 'Maven', + packagist: 'Packagist', + pypi: 'PyPI', + rubygems: 'RubyGems', }; // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -50,9 +54,7 @@ export class Vulnerabilities { config: RenovateConfig, packageFiles: Record<string, PackageFile[]> ): Promise<void> { - const managers = Object.keys(packageFiles).filter( - (manager) => Vulnerabilities.managerEcosystemMap[manager] !== undefined - ); + const managers = Object.keys(packageFiles); const allManagerJobs = managers.map((manager) => this.fetchManagerVulnerabilities(config, packageFiles, manager) ); @@ -67,7 +69,11 @@ export class Vulnerabilities { const managerConfig = getManagerConfig(config, manager); const queue = packageFiles[manager].map( (pFile) => (): Promise<void> => - this.fetchManagerPackagerFileUpdates(config, managerConfig, pFile) + this.fetchManagerPackageFileVulnerabilities( + config, + managerConfig, + pFile + ) ); logger.trace( { manager, queueLength: queue.length }, @@ -77,7 +83,7 @@ export class Vulnerabilities { logger.trace({ manager }, 'fetchManagerUpdates finished'); } - private async fetchManagerPackagerFileUpdates( + private async fetchManagerPackageFileVulnerabilities( config: RenovateConfig, managerConfig: RenovateConfig, pFile: PackageFile @@ -91,51 +97,393 @@ export class Vulnerabilities { ); logger.trace( { manager, packageFile, queueLength: queue.length }, - 'fetchManagerPackagerFileUpdates starting with concurrency' + 'fetchManagerPackageFileVulnerabilities starting with concurrency' ); config.packageRules?.push(...(await p.all(queue)).flat()); - logger.trace({ packageFile }, 'fetchManagerPackagerFileUpdates finished'); + logger.trace( + { packageFile }, + 'fetchManagerPackageFileVulnerabilities finished' + ); } private async fetchDependencyVulnerabilities( packageFileConfig: RenovateConfig & PackageFile, - packageDependency: PackageDependency + dep: PackageDependency ): Promise<PackageRule[]> { - const ecosystem = - Vulnerabilities.managerEcosystemMap[packageFileConfig.manager!]; + const ecosystem = Vulnerabilities.datasourceEcosystemMap[dep.datasource!]; + if (!ecosystem) { + logger.trace(`Cannot map datasource ${dep.datasource!} to OSV ecosystem`); + return []; + } + + let packageName = dep.packageName ?? dep.depName!; + if (ecosystem === 'PyPI') { + // https://peps.python.org/pep-0503/#normalized-names + packageName = packageName.toLowerCase().replace(regEx(/[_.-]+/g), '-'); + } + + const packageRules: PackageRule[] = []; + try { + const vulnerabilities = await this.osvOffline?.getVulnerabilities( + ecosystem, + packageName + ); + if ( + is.nullOrUndefined(vulnerabilities) || + is.emptyArray(vulnerabilities) + ) { + logger.trace( + `No vulnerabilities found in OSV database for ${packageName}` + ); + return []; + } + + 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 []; + } + + for (const vulnerability of vulnerabilities) { + for (const affected of vulnerability.affected ?? []) { + const isVulnerable = this.isPackageVulnerable( + ecosystem, + packageName, + depVersion, + affected, + versioningApi + ); + if (!isVulnerable) { + continue; + } + + logger.debug( + `Vulnerability ${vulnerability.id} affects ${packageName} ${depVersion}` + ); + const fixedVersion = this.getFixedVersion( + ecosystem, + depVersion, + affected, + versioningApi + ); + if (is.nullOrUndefined(fixedVersion)) { + logger.info( + `No fixed version available for vulnerability ${vulnerability.id} in ${packageName} ${depVersion}` + ); + continue; + } + + logger.debug( + `Setting allowed version ${fixedVersion} to fix vulnerability ${vulnerability.id} in ${packageName} ${depVersion}` + ); + const rule = this.convertToPackageRule( + packageFileConfig, + dep, + packageName, + depVersion, + fixedVersion, + vulnerability + ); + packageRules.push(rule); + } + } + + this.sortByFixedVersion(packageRules, versioningApi); + } catch (err) { + logger.debug( + { err }, + `Error fetching vulnerability information for ${packageName}` + ); + } + + return packageRules; + } + + private sortByFixedVersion( + packageRules: PackageRule[], + versioningApi: VersioningApi + ): void { + const versionsCleaned: Record<string, string> = {}; + for (const rule of packageRules) { + const version = rule.allowedVersions as string; + versionsCleaned[version] = version.replace(regEx(/[=> ]+/g), ''); + } - const vulnerabilities = await this.osvOffline?.getVulnerabilities( - ecosystem!, - packageDependency.depName! + packageRules.sort((a, b) => + versioningApi.sortVersions( + versionsCleaned[a.allowedVersions as string], + versionsCleaned[b.allowedVersions as string] + ) ); - return this.convertToPackageRule( - vulnerabilities ?? [], - packageDependency.depName!, - ecosystem! + } + + // 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; + continue; + } + sortedCopy.push(event); + } + + 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, + packageName: string, + affected: Osv.Affected + ): boolean { + return ( + affected.package?.name === packageName && + affected.package?.ecosystem === 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 affected.ranges ?? []) { + if (range.type === 'GIT') { + continue; + } + + let vulnerable = false; + for (const event of this.sortEvents(range.events, versioningApi)) { + if ( + is.nonEmptyString(event.introduced) && + (event.introduced === '0' || + this.isVersionGtOrEq(depVersion, event.introduced, versioningApi)) + ) { + vulnerable = true; + } else if ( + is.nonEmptyString(event.fixed) && + this.isVersionGtOrEq(depVersion, event.fixed, versioningApi) + ) { + vulnerable = false; + } else if ( + is.nonEmptyString(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, + packageName: string, + depVersion: string, + affected: Osv.Affected, + versioningApi: VersioningApi + ): boolean { + return ( + this.isPackageAffected(ecosystem, packageName, 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 affected.ranges ?? []) { + if (range.type === 'GIT') { + continue; + } + + for (const event of range.events) { + if (is.nonEmptyString(event.fixed)) { + fixedVersions.push(event.fixed); + } else if (is.nonEmptyString(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 ecosystem === 'PyPI' ? `==${fixedVersion}` : fixedVersion; + } + + lastAffectedVersions.sort((a, b) => versioningApi.sortVersions(a, b)); + const lastAffected = lastAffectedVersions.find((version) => + this.isVersionGtOrEq(version, depVersion, versioningApi) + ); + if (lastAffected) { + return `> ${lastAffected}`; + } + + return null; + } + + 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 convertToPackageRule( - vulnerabilities: Osv.Vulnerability[], - dependencyName: string, - ecosystem: Ecosystem - ): PackageRule[] { - return vulnerabilities - .flatMap((vulnerability) => vulnerability.affected) - .filter( - (vulnerability) => - vulnerability?.package?.name === dependencyName && - vulnerability?.package?.ecosystem === ecosystem - ) - .map( - (affected): PackageRule => ({ - matchPackageNames: [dependencyName], - allowedVersions: affected?.ranges?.[0].events.find( - (event) => event.fixed !== undefined - )!.fixed, - isVulnerabilityAlert: true, + packageFileConfig: RenovateConfig & PackageFile, + dep: PackageDependency, + packageName: string, + depVersion: string, + fixedVersion: string, + vulnerability: Osv.Vulnerability + ): PackageRule { + return { + matchDatasources: [dep.datasource!], + matchPackageNames: [packageName], + matchCurrentVersion: depVersion, + allowedVersions: fixedVersion, + isVulnerabilityAlert: true, + prBodyNotes: this.generatePrBodyNotes(vulnerability), + force: { + ...packageFileConfig.vulnerabilityAlerts, + }, + }; + } + + private evaluateCvssVector(vector: string): [string, string] { + try { + const parsedCvss: CvssScore = parseCvssVector(vector); + const severityLevel = + parsedCvss.cvss3OverallSeverityText.charAt(0).toUpperCase() + + parsedCvss.cvss3OverallSeverityText.slice(1); + + return [parsedCvss.baseScore.toFixed(1), severityLevel]; + } catch (err) { + logger.debug(`Error processing CVSS vector ${vector}`); + } + + return ['', '']; + } + + private generatePrBodyNotes(vulnerability: Osv.Vulnerability): string[] { + let aliases = [vulnerability.id].concat(vulnerability.aliases ?? []).sort(); + aliases = aliases.map((id) => { + if (id.startsWith('CVE-')) { + return `[${id}](https://nvd.nist.gov/vuln/detail/${id})`; + } else if (id.startsWith('GHSA-')) { + return `[${id}](https://github.com/advisories/${id})`; + } else if (id.startsWith('GO-')) { + return `[${id}](https://pkg.go.dev/vuln/${id})`; + } else 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`; + content += `### Details\n${vulnerability.details ?? 'No details.'}\n`; + + content += '### Severity\n'; + const cvssVector = + vulnerability.severity?.find((e) => e.type === 'CVSS_V3')?.score ?? + vulnerability.severity?.[0]?.score; + if (cvssVector) { + const [baseScore, severity] = this.evaluateCvssVector(cvssVector); + const score = baseScore ? `${baseScore} / 10 (${severity})` : 'Unknown'; + content += `- Score: ${score}\n`; + content += `- Vector: \`${cvssVector}\`\n`; + } else { + content += 'Unknown severity.\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)]; } } diff --git a/package.json b/package.json index 1549d63474fad6f0918daaab38bfee445fda539a..74a56adb68f9aacbb7229a3149cc42a83d48bd73 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "upath": "2.0.1", "url-join": "4.0.1", "validate-npm-package-name": "5.0.0", + "vuln-vects": "1.1.0", "xmldoc": "1.2.0", "zod": "3.20.2" }, diff --git a/yarn.lock b/yarn.lock index b9956f9b511f278dd766c8cad5dee68f7f0d2a87..47d8626137efea12d98c9b7f313ac5417354dcb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10301,6 +10301,11 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +vuln-vects@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/vuln-vects/-/vuln-vects-1.1.0.tgz#537d403615610446c1d687934584ea9dfb2a63ed" + integrity sha512-LGDwn9nRz94YoeqOn2TZqQXzyonBc5FJppSgH34S/1U+3bgPONq/vvfiCbCQ4MeBll58xx+kDmhS73ac+EHBBw== + walk-up-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/walk-up-path/-/walk-up-path-1.0.0.tgz#d4745e893dd5fd0dbb58dd0a4c6a33d9c9fec53e"