From 7f63efdcd027ca44fb45d9efad29ee53dea6b2fa Mon Sep 17 00:00:00 2001 From: RahulGautamSingh Date: Fri, 10 May 2024 19:05:32 +0545 Subject: [PATCH] refactor(vulnerability-alerts): move to REST API (#27378) --- lib/modules/platform/github/graphql.ts | 28 -- lib/modules/platform/github/index.spec.ts | 195 ++++++------- lib/modules/platform/github/index.ts | 83 +++--- lib/modules/platform/github/schema.ts | 55 ++++ lib/types/vulnerability-alert.ts | 35 ++- .../__snapshots__/vulnerability.spec.ts.snap | 41 +-- .../repository/init/vulnerability.spec.ts | 274 ++++++++---------- lib/workers/repository/init/vulnerability.ts | 234 +++++++-------- pnpm-lock.yaml | 2 +- 9 files changed, 426 insertions(+), 521 deletions(-) create mode 100644 lib/modules/platform/github/schema.ts diff --git a/lib/modules/platform/github/graphql.ts b/lib/modules/platform/github/graphql.ts index 3931dd47d56257..337ea831208ccc 100644 --- a/lib/modules/platform/github/graphql.ts +++ b/lib/modules/platform/github/graphql.ts @@ -68,34 +68,6 @@ query( } `; -export const vulnerabilityAlertsQuery = (filterByState: boolean): string => ` -query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - vulnerabilityAlerts(last: 100, ${filterByState ? 'states: [OPEN]' : ''}) { - edges { - node { - dismissReason - vulnerableManifestFilename - vulnerableManifestPath - vulnerableRequirements - securityAdvisory { - description - identifiers { type value } - references { url } - severity - } - securityVulnerability { - package { name ecosystem } - firstPatchedVersion { identifier } - vulnerableVersionRange - } - } - } - } - } -} -`; - export const enableAutoMergeMutation = ` mutation EnablePullRequestAutoMerge( $pullRequestId: ID!, diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts index 4c1d2a51d0bbbb..7a10ef7c6c9139 100644 --- a/lib/modules/platform/github/index.spec.ts +++ b/lib/modules/platform/github/index.spec.ts @@ -3770,147 +3770,118 @@ describe('modules/platform/github/index', () => { }); it('returns empty if error', async () => { - httpMock.scope(githubApiHost).post('/graphql').reply(200, {}); + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100', + ) + .reply(200, {}); + await github.initRepo({ repository: 'some/repo' }); const res = await github.getVulnerabilityAlerts(); expect(res).toHaveLength(0); }); it('returns array if found', async () => { - httpMock - .scope(githubApiHost) - .post('/graphql') - .reply(200, { - data: { - repository: { - vulnerabilityAlerts: { - edges: [ - { - node: { - securityAdvisory: { severity: 'HIGH', references: [] }, - securityVulnerability: { - package: { - ecosystem: 'NPM', - name: 'left-pad', - range: '0.0.2', - }, - vulnerableVersionRange: '0.0.2', - firstPatchedVersion: { identifier: '0.0.3' }, - }, - vulnerableManifestFilename: 'foo', - vulnerableManifestPath: 'bar', - }, - }, - ], - }, + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100', + ) + .reply(200, [ + { + security_advisory: { + description: 'description', + identifiers: [{ type: 'type', value: 'value' }], + references: [], }, - }, - }); - const res = await github.getVulnerabilityAlerts(); - expect(res).toHaveLength(1); - }); - - it('returns array if found on GHE', async () => { - const gheApiHost = 'https://ghe.renovatebot.com'; - - httpMock - .scope(gheApiHost) - .head('/') - .reply(200, '', { 'x-github-enterprise-version': '3.0.15' }) - .get('/user') - .reply(200, { login: 'renovate-bot' }) - .get('/user/emails') - .reply(200, {}); - - httpMock - .scope(gheApiHost) - .post('/graphql') - .reply(200, { - data: { - repository: { - vulnerabilityAlerts: { - edges: [ - { - node: { - securityAdvisory: { severity: 'HIGH', references: [] }, - securityVulnerability: { - package: { - ecosystem: 'NPM', - name: 'left-pad', - range: '0.0.2', - }, - vulnerableVersionRange: '0.0.2', - firstPatchedVersion: { identifier: '0.0.3' }, - }, - vulnerableManifestFilename: 'foo', - vulnerableManifestPath: 'bar', - }, - }, - ], + security_vulnerability: { + package: { + ecosystem: 'npm', + name: 'left-pad', }, + vulnerable_version_range: '0.0.2', + first_patched_version: { identifier: '0.0.3' }, + }, + dependency: { + manifest_path: 'bar/foo', }, }, - }); - - await github.initPlatform({ - endpoint: gheApiHost, - token: '123test', - }); - + ]); + await github.initRepo({ repository: 'some/repo' }); const res = await github.getVulnerabilityAlerts(); expect(res).toHaveLength(1); }); it('returns empty if disabled', async () => { // prettier-ignore - httpMock.scope(githubApiHost).post('/graphql').reply(200, {data: {repository: {}}}); + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100', + ) + .reply(200, { data: { repository: {} } }); + await github.initRepo({ repository: 'some/repo' }); const res = await github.getVulnerabilityAlerts(); expect(res).toHaveLength(0); }); it('handles network error', async () => { // prettier-ignore - httpMock.scope(githubApiHost).post('/graphql').replyWithError('unknown error'); + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100', + ) + .replyWithError('unknown error'); + await github.initRepo({ repository: 'some/repo' }); const res = await github.getVulnerabilityAlerts(); expect(res).toHaveLength(0); }); it('calls logger.debug with only items that include securityVulnerability', async () => { - httpMock - .scope(githubApiHost) - .post('/graphql') - .reply(200, { - data: { - repository: { - vulnerabilityAlerts: { - edges: [ - { - node: { - securityAdvisory: { severity: 'HIGH', references: [] }, - securityVulnerability: { - package: { - ecosystem: 'NPM', - name: 'left-pad', - }, - vulnerableVersionRange: '0.0.2', - firstPatchedVersion: { identifier: '0.0.3' }, - }, - vulnerableManifestFilename: 'foo', - vulnerableManifestPath: 'bar', - }, - }, - { - node: { - securityAdvisory: { severity: 'HIGH', references: [] }, - securityVulnerability: null, - vulnerableManifestFilename: 'foo', - vulnerableManifestPath: 'bar', - }, - }, - ], + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100', + ) + .reply(200, [ + { + security_advisory: { + description: 'description', + identifiers: [{ type: 'type', value: 'value' }], + references: [], + }, + security_vulnerability: { + package: { + ecosystem: 'npm', + name: 'left-pad', }, + vulnerable_version_range: '0.0.2', + first_patched_version: { identifier: '0.0.3' }, + }, + dependency: { + manifest_path: 'bar/foo', }, }, - }); + + { + security_advisory: { + description: 'description', + identifiers: [{ type: 'type', value: 'value' }], + references: [], + }, + security_vulnerability: null, + dependency: { + manifest_path: 'bar/foo', + }, + }, + ]); + await github.initRepo({ repository: 'some/repo' }); await github.getVulnerabilityAlerts(); expect(logger.logger.debug).toHaveBeenCalledWith( { alerts: { 'npm/left-pad': { '0.0.2': '0.0.3' } } }, diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index a0873b7d0c09b6..34485b133ea0fd 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -71,11 +71,11 @@ import { enableAutoMergeMutation, getIssuesQuery, repoInfoQuery, - vulnerabilityAlertsQuery, } from './graphql'; import { GithubIssueCache, GithubIssue as Issue } from './issue'; import { massageMarkdownLinks } from './massage-markdown-links'; import { getPrCache, updatePrCache } from './pr'; +import { VulnerabilityAlertSchema } from './schema'; import type { BranchProtection, CombinedBranchStatus, @@ -1960,27 +1960,19 @@ export async function getVulnerabilityAlerts(): Promise { logger.debug('No vulnerability alerts enabled for repo'); return []; } - let vulnerabilityAlerts: { node: VulnerabilityAlert }[] | undefined; - - // TODO #22198 - const gheSupportsStateFilter = semver.satisfies( - // semver not null safe, accepts null and undefined - - platformConfig.gheVersion!, - '>=3.5', - ); - const filterByState = !platformConfig.isGhe || gheSupportsStateFilter; - const query = vulnerabilityAlertsQuery(filterByState); - + let vulnerabilityAlerts: VulnerabilityAlert[] | undefined; try { - vulnerabilityAlerts = await githubApi.queryRepoField<{ - node: VulnerabilityAlert; - }>(query, 'vulnerabilityAlerts', { - variables: { owner: config.repositoryOwner, name: config.repositoryName }, - paginate: false, - acceptHeader: 'application/vnd.github.vixen-preview+json', - readOnly: true, - }); + vulnerabilityAlerts = ( + await githubApi.getJson( + `/repos/${config.repositoryOwner}/${config.repositoryName}/dependabot/alerts?state=open&direction=asc&per_page=100`, + { + paginate: false, + headers: { accept: 'application/vnd.github+json' }, + cacheProvider: repoCacheProvider, + }, + VulnerabilityAlertSchema, + ) + ).body; } catch (err) { logger.debug({ err }, 'Error retrieving vulnerability alerts'); logger.warn( @@ -1990,42 +1982,41 @@ export async function getVulnerabilityAlerts(): Promise { 'Cannot access vulnerability alerts. Please ensure permissions have been granted.', ); } - let alerts: VulnerabilityAlert[] = []; try { if (vulnerabilityAlerts?.length) { - alerts = vulnerabilityAlerts.map((edge) => edge.node); const shortAlerts: AggregatedVulnerabilities = {}; - if (alerts.length) { - logger.trace({ alerts }, 'GitHub vulnerability details'); - for (const alert of alerts) { - if (alert.securityVulnerability === null) { - // As described in the documentation, there are cases in which - // GitHub API responds with `"securityVulnerability": null`. - // But it's may be faulty, so skip processing it here. - continue; - } - const { - package: { name, ecosystem }, - vulnerableVersionRange, - firstPatchedVersion, - } = alert.securityVulnerability; - const patch = firstPatchedVersion?.identifier; - - const key = `${ecosystem.toLowerCase()}/${name}`; - const range = vulnerableVersionRange; - const elem = shortAlerts[key] || {}; - elem[range] = coerceToNull(patch); - shortAlerts[key] = elem; + logger.trace( + { alerts: vulnerabilityAlerts }, + 'GitHub vulnerability details', + ); + for (const alert of vulnerabilityAlerts) { + if (alert.security_vulnerability === null) { + // As described in the documentation, there are cases in which + // GitHub API responds with `"securityVulnerability": null`. + // But it's may be faulty, so skip processing it here. + continue; } - logger.debug({ alerts: shortAlerts }, 'GitHub vulnerability details'); + const { + package: { name, ecosystem }, + vulnerable_version_range: vulnerableVersionRange, + first_patched_version: firstPatchedVersion, + } = alert.security_vulnerability; + const patch = firstPatchedVersion?.identifier; + + const key = `${ecosystem.toLowerCase()}/${name}`; + const range = vulnerableVersionRange; + const elem = shortAlerts[key] || {}; + elem[range] = coerceToNull(patch); + shortAlerts[key] = elem; } + logger.debug({ alerts: shortAlerts }, 'GitHub vulnerability details'); } else { logger.debug('No vulnerability alerts found'); } } catch (err) /* istanbul ignore next */ { logger.error({ err }, 'Error processing vulnerabity alerts'); } - return alerts; + return vulnerabilityAlerts ?? []; } async function pushFiles( diff --git a/lib/modules/platform/github/schema.ts b/lib/modules/platform/github/schema.ts new file mode 100644 index 00000000000000..01b867695ab929 --- /dev/null +++ b/lib/modules/platform/github/schema.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { logger } from '../../../logger'; +import { LooseArray } from '../../../util/schema-utils'; + +const PackageSchema = z.object({ + ecosystem: z.union([ + z.literal('maven'), + z.literal('npm'), + z.literal('nuget'), + z.literal('pip'), + z.literal('rubygems'), + z.literal('rust'), + z.literal('composer'), + z.literal('go'), + ]), + name: z.string(), +}); + +const SecurityVulnerabilitySchema = z + .object({ + first_patched_version: z.object({ identifier: z.string() }).optional(), + package: PackageSchema, + vulnerable_version_range: z.string(), + }) + .nullable(); + +const SecurityAdvisorySchema = z.object({ + description: z.string(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string(), + }), + ), + references: z.array(z.object({ url: z.string() })).optional(), +}); + +export const VulnerabilityAlertSchema = LooseArray( + z.object({ + dismissed_reason: z.string().nullish(), + security_advisory: SecurityAdvisorySchema, + security_vulnerability: SecurityVulnerabilitySchema, + dependency: z.object({ + manifest_path: z.string(), + }), + }), + { + onError: /* istanbul ignore next */ ({ error }) => { + logger.debug( + { error }, + 'Vulnerability Alert: Failed to parse some alerts', + ); + }, + }, +); diff --git a/lib/types/vulnerability-alert.ts b/lib/types/vulnerability-alert.ts index 727f818f873c0f..5dcd261e8eecf8 100644 --- a/lib/types/vulnerability-alert.ts +++ b/lib/types/vulnerability-alert.ts @@ -1,25 +1,30 @@ export interface VulnerabilityPackage { - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - ecosystem: 'MAVEN' | 'NPM' | 'NUGET' | 'PIP' | 'RUBYGEMS' | string; + ecosystem: + | 'maven' + | 'npm' + | 'nuget' + | 'pip' + | 'rubygems' + | 'rust' + | 'composer' + | 'go'; name: string; } export interface SecurityVulnerability { - firstPatchedVersion?: { identifier: string }; + first_patched_version?: { identifier: string }; package: VulnerabilityPackage; - vulnerableVersionRange: string; + vulnerable_version_range: string; } export interface SecurityAdvisory { - description?: string; - identifiers?: { type: string; value: string }[]; - references: { url: string }[]; - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - severity: 'HIGH' | 'MODERATE' | string; + description: string; + identifiers: { type: string; value: string }[]; + references?: { url: string }[]; } export interface VulnerabilityAlert { - dismissReason?: string | null; - securityAdvisory: SecurityAdvisory; - securityVulnerability: SecurityVulnerability; - vulnerableManifestFilename: string; - vulnerableManifestPath: string; - vulnerableRequirements?: string; + dismissed_reason?: string | null; + security_advisory: SecurityAdvisory; + security_vulnerability: SecurityVulnerability | null; + dependency: { + manifest_path: string; + }; } diff --git a/lib/workers/repository/init/__snapshots__/vulnerability.spec.ts.snap b/lib/workers/repository/init/__snapshots__/vulnerability.spec.ts.snap index 4d61c14b74a54a..b858569a3ee6b1 100644 --- a/lib/workers/repository/init/__snapshots__/vulnerability.spec.ts.snap +++ b/lib/workers/repository/init/__snapshots__/vulnerability.spec.ts.snap @@ -1,40 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`workers/repository/init/vulnerability detectVulnerabilityAlerts() returns github actions alerts 1`] = ` -[ - { - "allowedVersions": "1.8.3", - "force": { - "branchTopic": "{{{datasource}}}-{{{depNameSanitized}}}-vulnerability", - "commitMessageSuffix": "[SECURITY]", - "dependencyDashboardApproval": false, - "groupName": null, - "minimumReleaseAge": null, - "prCreation": "immediate", - "rangeStrategy": "update-lockfile", - "schedule": [], - }, - "isVulnerabilityAlert": true, - "matchCurrentVersion": "1.8.2", - "matchDatasources": [ - "github-tags", - ], - "matchFileNames": [ - ".github/workflows/build.yaml", - ], - "matchPackageNames": [ - "bar", - ], - "prBodyNotes": [ - "### GitHub Vulnerability Alerts", - "#### [def]() - -actions", - ], - }, -] -`; - exports[`workers/repository/init/vulnerability detectVulnerabilityAlerts() returns go alerts 1`] = ` [ { @@ -50,7 +15,7 @@ exports[`workers/repository/init/vulnerability detectVulnerabilityAlerts() retur "schedule": [], }, "isVulnerabilityAlert": true, - "matchCurrentVersion": "= 1.8.2", + "matchCurrentVersion": "< 1.8.3", "matchDatasources": [ "go", ], @@ -85,7 +50,7 @@ exports[`workers/repository/init/vulnerability detectVulnerabilityAlerts() retur "schedule": [], }, "isVulnerabilityAlert": true, - "matchCurrentVersion": "2.4.2", + "matchCurrentVersion": "(,2.7.9.4)", "matchDatasources": [ "maven", ], @@ -120,7 +85,7 @@ exports[`workers/repository/init/vulnerability detectVulnerabilityAlerts() retur "schedule": [], }, "isVulnerabilityAlert": true, - "matchCurrentVersion": "== 1.6.7", + "matchCurrentVersion": "< 2.2.1.0", "matchDatasources": [ "pypi", ], diff --git a/lib/workers/repository/init/vulnerability.spec.ts b/lib/workers/repository/init/vulnerability.spec.ts index 6bad660d8005e6..8e534d5adc8c8b 100644 --- a/lib/workers/repository/init/vulnerability.spec.ts +++ b/lib/workers/repository/init/vulnerability.spec.ts @@ -1,6 +1,7 @@ import { RenovateConfig, partial, platform } from '../../../../test/util'; import { getConfig } from '../../../config/defaults'; import { NO_VULNERABILITY_ALERTS } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; import type { VulnerabilityAlert } from '../../../types'; import { detectVulnerabilityAlerts } from './vulnerability'; @@ -38,42 +39,16 @@ describe('workers/repository/init/vulnerability', () => { ); }); - it('ignores yargs-parser special case', async () => { - // TODO #22198 - delete config.vulnerabilityAlerts!.enabled; - delete config.packageRules; // test coverage - platform.getVulnerabilityAlerts.mockResolvedValue([ - partial(), - { - // this will be ignored - dismissReason: null, - vulnerableManifestFilename: 'package-lock.json', - vulnerableManifestPath: 'backend/package-lock.json', - securityAdvisory: { - references: [], - severity: '', - }, - securityVulnerability: { - package: { ecosystem: 'NPM', name: 'yargs-parser' }, - vulnerableVersionRange: '>5.0.0-security.0', - }, - vulnerableRequirements: '= 5.0.1', - }, - ]); - const res = await detectVulnerabilityAlerts(config); - expect(res.packageRules).toHaveLength(0); - }); - - it('ignores alert if dismissReason is not null', async () => { + it('ignores alert if dismissed_reason is not nullish', async () => { // TODO #22198 delete config.vulnerabilityAlerts!.enabled; platform.getVulnerabilityAlerts.mockResolvedValue([ { - dismissReason: 'some reason', - vulnerableManifestFilename: 'package-lock.json', - vulnerableManifestPath: 'package-lock.json', - vulnerableRequirements: '= 1.8.2', - securityAdvisory: { + dismissed_reason: 'some reason', + dependency: { + manifest_path: 'package-lock.json', + }, + security_advisory: { description: 'GitHub Electron 1.7.15, 1.8.7, 2.0.7, and 3.0.0-beta.6, in certain scenarios involving IFRAME elements and "nativeWindowOpen: true" or "sandbox: true" options, is affected by a WebPreferences vulnerability that can be leveraged to perform remote code execution.', identifiers: [ @@ -83,12 +58,11 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2018-15685' }, ], - severity: 'HIGH', }, - securityVulnerability: { - package: { name: 'electron', ecosystem: 'NPM' }, - firstPatchedVersion: { identifier: '1.8.8' }, - vulnerableVersionRange: '>= 1.8.0, < 1.8.8', + security_vulnerability: { + package: { name: 'electron', ecosystem: 'npm' }, + first_patched_version: { identifier: '1.8.8' }, + vulnerable_version_range: '>= 1.8.0, < 1.8.8', }, }, ]); @@ -102,11 +76,11 @@ describe('workers/repository/init/vulnerability', () => { platform.getVulnerabilityAlerts.mockResolvedValue([ { // will be ignored - no firstPatchVersion - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: 'The create_script function in the lxc_container module in Ansible before 1.9.6-1 and 2.x before 2.0.2.0 allows local users to write to arbitrary files or gain privileges via a symlink attack on (1) /opt/.lxc-attach-script, (2) the archived container in the archive_path directory, or the (3) lxc-attach-script.log or (4) lxc-attach-script.err files in the temporary directory.', identifiers: [ @@ -116,11 +90,10 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2016-3096' }, ], - severity: 'HIGH', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - vulnerableVersionRange: '< 1.9.6.1', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + vulnerable_version_range: '< 1.9.6.1', }, }, ]); @@ -128,26 +101,26 @@ describe('workers/repository/init/vulnerability', () => { expect(res.packageRules).toHaveLength(0); }); - it('returns github actions alerts', async () => { + it('returns go alerts', async () => { + // TODO #22198 delete config.vulnerabilityAlerts!.enabled; + delete config.packageRules; // coverage platform.getVulnerabilityAlerts.mockResolvedValue([ partial(), - { - dismissReason: null, - vulnerableManifestFilename: '.github/workflows/build.yaml', - vulnerableManifestPath: '.github/workflows/build.yaml', - vulnerableRequirements: '= 1.8.2', - securityAdvisory: { - description: 'actions', - identifiers: [{ type: 'GHSA', value: 'def' }], + dismissed_reason: null, + dependency: { + manifest_path: 'go.sum', + }, + security_advisory: { + description: 'go', + identifiers: [{ type: 'GHSA', value: 'abc' }], references: [{ url: '' }], - severity: 'HIGH', }, - securityVulnerability: { - package: { name: 'bar', ecosystem: 'ACTIONS' }, - firstPatchedVersion: { identifier: '1.8.3' }, - vulnerableVersionRange: '>= 1.8, < 1.8.3', + security_vulnerability: { + package: { name: 'foo', ecosystem: 'go' }, + first_patched_version: { identifier: '1.8.3' }, + vulnerable_version_range: '>= 1.8, < 1.8.3', }, }, ]); @@ -156,32 +129,33 @@ describe('workers/repository/init/vulnerability', () => { expect(res.packageRules).toHaveLength(1); }); - it('returns go alerts', async () => { + // coverage + it('warns if any error occurs while parsing the alerts', async () => { // TODO #22198 delete config.vulnerabilityAlerts!.enabled; platform.getVulnerabilityAlerts.mockResolvedValue([ partial(), { - dismissReason: null, - vulnerableManifestFilename: 'go.sum', - vulnerableManifestPath: 'go.sum', - vulnerableRequirements: '= 1.8.2', - securityAdvisory: { + dismissed_reason: null, + // @ts-expect-error invalid options + dependency: {}, + security_advisory: { description: 'go', identifiers: [{ type: 'GHSA', value: 'abc' }], references: [{ url: '' }], - severity: 'HIGH', }, - securityVulnerability: { - package: { name: 'foo', ecosystem: 'GO' }, - firstPatchedVersion: { identifier: '1.8.3' }, - vulnerableVersionRange: '>= 1.8, < 1.8.3', + security_vulnerability: { + package: { name: 'foo', ecosystem: 'go' }, + first_patched_version: { identifier: '1.8.3' }, + vulnerable_version_range: '>= 1.8, < 1.8.3', }, }, ]); - const res = await detectVulnerabilityAlerts(config); - expect(res.packageRules).toMatchSnapshot(); - expect(res.packageRules).toHaveLength(1); + await detectVulnerabilityAlerts(config); + expect(logger.warn).toHaveBeenCalledWith( + { err: expect.any(Object) }, + 'Error parsing vulnerability alert', + ); }); it('returns maven alerts', async () => { @@ -189,11 +163,11 @@ describe('workers/repository/init/vulnerability', () => { delete config.vulnerabilityAlerts!.enabled; platform.getVulnerabilityAlerts.mockResolvedValue([ { - dismissReason: null, - vulnerableManifestFilename: 'pom.xml', - vulnerableManifestPath: 'pom.xml', - vulnerableRequirements: '= 2.4.2', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'pom.xml', + }, + security_advisory: { description: 'An issue was discovered in FasterXML jackson-databind prior to 2.7.9.4, 2.8.11.2, and 2.9.6. When Default Typing is enabled (either globally or for a specific property), the service has the Jodd-db jar (for database access for the Jodd framework) in the classpath, and an attacker can provide an LDAP service to access, it is possible to make the service execute a malicious payload.', identifiers: [ @@ -203,15 +177,14 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2018-12022' }, ], - severity: 'HIGH', }, - securityVulnerability: { + security_vulnerability: { package: { name: 'com.fasterxml.jackson.core:jackson-databind', - ecosystem: 'MAVEN', + ecosystem: 'maven', }, - firstPatchedVersion: { identifier: '2.7.9.4' }, - vulnerableVersionRange: '< 2.7.9.4', + first_patched_version: { identifier: '2.7.9.4' }, + vulnerable_version_range: '< 2.7.9.4', }, }, ]); @@ -225,11 +198,11 @@ describe('workers/repository/init/vulnerability', () => { delete config.vulnerabilityAlerts!.enabled; platform.getVulnerabilityAlerts.mockResolvedValue([ { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: "Ansible before versions 2.3.1.0 and 2.4.0.0 fails to properly mark lookup-plugin results as unsafe. If an attacker could control the results of lookup() calls, they could inject Unicode strings to be parsed by the jinja2 templating system, resulting in code execution. By default, the jinja2 templating language is now marked as 'unsafe' and is not evaluated.", identifiers: [ @@ -239,20 +212,19 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2017-7481' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: 'abc-2.3.1.0' }, - vulnerableVersionRange: '< 2.3.1.0', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + first_patched_version: { identifier: 'abc-2.3.1.0' }, + vulnerable_version_range: '< 2.3.1.0', }, }, { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: "Ansible before 1.9.2 does not verify that the server hostname matches a domain name in the subject's Common Name (CN) or subjectAltName field of the X.509 certificate, which allows man-in-the-middle attackers to spoof SSL servers via an arbitrary valid certificate.", identifiers: [ @@ -262,20 +234,19 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2015-3908' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: '1.9.2' }, - vulnerableVersionRange: '< 1.9.2', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + first_patched_version: { identifier: '1.9.2' }, + vulnerable_version_range: '< 1.9.2', }, }, { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: "An input validation vulnerability was found in Ansible's mysql_user module before 2.2.1.0, which may fail to correctly change a password in certain circumstances. Thus the previous password would still be active when it should have been changed.", identifiers: [ @@ -285,20 +256,19 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2016-8647' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: '2.2.1.0' }, - vulnerableVersionRange: '< 2.2.1.0', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + first_patched_version: { identifier: '2.2.1.0' }, + vulnerable_version_range: '< 2.2.1.0', }, }, { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: 'A flaw was found in Ansible before version 2.2.0. The apt_key module does not properly verify key fingerprints, allowing remote adversary to create an OpenPGP key which matches the short key ID and inject this key instead of the correct key.', identifiers: [ @@ -308,20 +278,19 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2016-8614' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: '2.2.0' }, - vulnerableVersionRange: '< 2.2.0', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + first_patched_version: { identifier: '2.2.0' }, + vulnerable_version_range: '< 2.2.0', }, }, { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: 'Ansible before version 2.2.0 fails to properly sanitize fact variables sent from the Ansible controller. An attacker with the ability to create special variables on the controller could execute arbitrary commands on Ansible clients as the user Ansible runs as.', identifiers: [ @@ -331,20 +300,19 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2016-8628' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: '2.2.0' }, - vulnerableVersionRange: '< 2.2.0', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + first_patched_version: { identifier: '2.2.0' }, + vulnerable_version_range: '< 2.2.0', }, }, { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: "Ansible before versions 2.1.4, 2.2.1 is vulnerable to an improper input validation in Ansible's handling of data sent from client systems. An attacker with control over a client system being managed by Ansible and the ability to send facts back to the Ansible server could use this flaw to execute arbitrary code on the Ansible server using the Ansible server privileges.", identifiers: [ @@ -354,12 +322,11 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2016-9587' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'ansible', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: '2.1.4' }, - vulnerableVersionRange: '< 2.1.4', + security_vulnerability: { + package: { name: 'ansible', ecosystem: 'pip' }, + first_patched_version: { identifier: '2.1.4' }, + vulnerable_version_range: '< 2.1.4', }, }, ]); @@ -373,11 +340,11 @@ describe('workers/repository/init/vulnerability', () => { delete config.vulnerabilityAlerts!.enabled; platform.getVulnerabilityAlerts.mockResolvedValue([ { - dismissReason: null, - vulnerableManifestFilename: 'requirements.txt', - vulnerableManifestPath: 'requirements.txt', - vulnerableRequirements: '= 1.6.7', - securityAdvisory: { + dismissed_reason: null, + dependency: { + manifest_path: 'requirements.txt', + }, + security_advisory: { description: 'Description', identifiers: [ { type: 'GHSA', value: 'GHSA-m956-frf4-m2wr' }, @@ -386,12 +353,11 @@ describe('workers/repository/init/vulnerability', () => { references: [ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2016-9587' }, ], - severity: 'MODERATE', }, - securityVulnerability: { - package: { name: 'Pillow', ecosystem: 'PIP' }, - firstPatchedVersion: { identifier: '2.1.4' }, - vulnerableVersionRange: '< 2.1.4', + security_vulnerability: { + package: { name: 'Pillow', ecosystem: 'pip' }, + first_patched_version: { identifier: '2.1.4' }, + vulnerable_version_range: '< 2.1.4', }, }, ]); diff --git a/lib/workers/repository/init/vulnerability.ts b/lib/workers/repository/init/vulnerability.ts index c4932c414351de..21067f009fdbb0 100644 --- a/lib/workers/repository/init/vulnerability.ts +++ b/lib/workers/repository/init/vulnerability.ts @@ -2,7 +2,6 @@ import type { PackageRule, RenovateConfig } from '../../../config/types'; import { NO_VULNERABILITY_ALERTS } from '../../../constants/error-messages'; import { logger } from '../../../logger'; import { CrateDatasource } from '../../../modules/datasource/crate'; -import { GithubTagsDatasource } from '../../../modules/datasource/github-tags'; import { GoDatasource } from '../../../modules/datasource/go'; import { MavenDatasource } from '../../../modules/datasource/maven'; import { NpmDatasource } from '../../../modules/datasource/npm'; @@ -26,7 +25,6 @@ import { regEx } from '../../../util/regex'; type Datasource = string; type DependencyName = string; type FileName = string; -type VulnerableRequirements = string; type CombinedAlert = Record< FileName, @@ -34,14 +32,11 @@ type CombinedAlert = Record< Datasource, Record< DependencyName, - Record< - VulnerableRequirements, - { - advisories: SecurityAdvisory[]; - fileType?: string; - firstPatchedVersion?: string; - } - > + { + advisories: SecurityAdvisory[]; + fileType?: string; + firstPatchedVersion?: string; + } > > >; @@ -78,18 +73,11 @@ export async function detectVulnerabilityAlerts( }; const combinedAlerts: CombinedAlert = {}; for (const alert of alerts) { - if ( - alert.securityVulnerability?.package?.name === 'yargs-parser' && - (alert.vulnerableRequirements === '= 5.0.0-security.0' || - alert.vulnerableRequirements === '= 5.0.1') - ) { - continue; - } try { - if (alert.dismissReason) { + if (alert.dismissed_reason) { continue; } - if (!alert.securityVulnerability.firstPatchedVersion) { + if (!alert.security_vulnerability?.first_patched_version) { logger.debug( { alert }, 'Vulnerability alert has no firstPatchedVersion - skipping', @@ -97,56 +85,30 @@ export async function detectVulnerabilityAlerts( continue; } const datasourceMapping: Record = { - ACTIONS: GithubTagsDatasource.id, - COMPOSER: PackagistDatasource.id, - GO: GoDatasource.id, - MAVEN: MavenDatasource.id, - NPM: NpmDatasource.id, - NUGET: NugetDatasource.id, - PIP: PypiDatasource.id, - RUBYGEMS: RubyGemsDatasource.id, - RUST: CrateDatasource.id, + composer: PackagistDatasource.id, + go: GoDatasource.id, + maven: MavenDatasource.id, + npm: NpmDatasource.id, + nuget: NugetDatasource.id, + pip: PypiDatasource.id, + rubygems: RubyGemsDatasource.id, + rust: CrateDatasource.id, }; const datasource = - datasourceMapping[alert.securityVulnerability.package.ecosystem]; - const depName = alert.securityVulnerability.package.name; - const fileName = alert.vulnerableManifestPath; - const fileType = alert.vulnerableManifestFilename; + datasourceMapping[alert.security_vulnerability.package.ecosystem]; + const depName = alert.security_vulnerability.package.name; + const fileName = alert.dependency.manifest_path; + const fileType = fileName.split('/').pop(); const firstPatchedVersion = - alert.securityVulnerability.firstPatchedVersion.identifier; - const advisory = alert.securityAdvisory; - // TODO #22198 - let vulnerableRequirements = alert.vulnerableRequirements!; - // istanbul ignore if - if (!vulnerableRequirements.length) { - if (datasource === MavenDatasource.id) { - vulnerableRequirements = `(,${firstPatchedVersion})`; - } else { - vulnerableRequirements = `< ${firstPatchedVersion}`; - } - } - if (datasource === PypiDatasource.id) { - vulnerableRequirements = vulnerableRequirements.replace( - regEx(/^= /), - '== ', - ); - } - if ( - datasource === GithubTagsDatasource.id || - datasource === MavenDatasource.id - ) { - // GitHub Actions uses docker versioning, which doesn't support `= 1.2.3` matching, so we strip the equals - vulnerableRequirements = vulnerableRequirements.replace(/^=\s*/, ''); - } + alert.security_vulnerability.first_patched_version.identifier; + const advisory = alert.security_advisory; + combinedAlerts[fileName] ||= {}; combinedAlerts[fileName][datasource] ||= {}; - combinedAlerts[fileName][datasource][depName] ||= {}; - combinedAlerts[fileName][datasource][depName][vulnerableRequirements] ||= - { - advisories: [], - }; - const alertDetails = - combinedAlerts[fileName][datasource][depName][vulnerableRequirements]; + combinedAlerts[fileName][datasource][depName] ||= { + advisories: [], + }; + const alertDetails = combinedAlerts[fileName][datasource][depName]; alertDetails.advisories.push(advisory); const version = allVersioning.get(versionings[datasource]); if (version.isVersion(firstPatchedVersion)) { @@ -171,72 +133,90 @@ export async function detectVulnerabilityAlerts( config.remediations = {} as never; for (const [fileName, files] of Object.entries(combinedAlerts)) { for (const [datasource, dependencies] of Object.entries(files)) { - for (const [depName, currentValues] of Object.entries(dependencies)) { - for (const [matchCurrentVersion, val] of Object.entries( - currentValues, - )) { - let prBodyNotes: string[] = []; - try { - prBodyNotes = ['### GitHub Vulnerability Alerts'].concat( - val.advisories.map((advisory) => { - const identifiers = advisory.identifiers!; - const description = advisory.description!; - let content = '#### '; - let heading: string; - if (identifiers.some((id) => id.type === 'CVE')) { - heading = identifiers - .filter((id) => id.type === 'CVE') - .map((id) => id.value) - .join(' / '); - } else { - heading = identifiers.map((id) => id.value).join(' / '); - } - if (advisory.references.length) { - heading = `[${heading}](${advisory.references[0].url})`; - } - content += heading; - content += '\n\n'; + for (const [depName, val] of Object.entries(dependencies)) { + let prBodyNotes: string[] = []; + try { + prBodyNotes = ['### GitHub Vulnerability Alerts'].concat( + val.advisories.map((advisory) => { + const identifiers = advisory.identifiers; + const description = advisory.description; + let content = '#### '; + let heading: string; + if (identifiers.some((id) => id.type === 'CVE')) { + heading = identifiers + .filter((id) => id.type === 'CVE') + .map((id) => id.value) + .join(' / '); + } else { + heading = identifiers.map((id) => id.value).join(' / '); + } + if (advisory.references?.length) { + heading = `[${heading}](${advisory.references[0].url})`; + } + content += heading; + content += '\n\n'; - content += sanitizeMarkdown(description); - return content; - }), - ); - } catch (err) /* istanbul ignore next */ { - logger.warn({ err }, 'Error generating vulnerability PR notes'); - } - // TODO: types (#22198) - const allowedVersions = - datasource === PypiDatasource.id - ? `==${val.firstPatchedVersion!}` - : val.firstPatchedVersion; - const matchFileNames = - datasource === GoDatasource.id - ? [fileName.replace('go.sum', 'go.mod')] - : [fileName]; - let matchRule: PackageRule = { - matchDatasources: [datasource], - matchPackageNames: [depName], - matchCurrentVersion, - matchFileNames, - }; - if ( - datasource === PypiDatasource.id && - normalizePythonDepName(depName) !== depName - ) { - matchRule.matchPackageNames?.push(normalizePythonDepName(depName)); + content += sanitizeMarkdown(description); + return content; + }), + ); + } catch (err) /* istanbul ignore next */ { + logger.warn({ err }, 'Error generating vulnerability PR notes'); + } + + const allowedVersions = + datasource === PypiDatasource.id + ? `==${val.firstPatchedVersion}` + : val.firstPatchedVersion; + const matchFileNames = + datasource === GoDatasource.id + ? [fileName.replace('go.sum', 'go.mod')] + : [fileName]; + let matchCurrentVersion = ''; + if (!matchCurrentVersion.length) { + if (datasource === MavenDatasource.id) { + matchCurrentVersion = `(,${val.firstPatchedVersion})`; + } else { + matchCurrentVersion = `< ${val.firstPatchedVersion}`; } - // Remediate only direct dependencies - matchRule = { - ...matchRule, - allowedVersions, - prBodyNotes, - isVulnerabilityAlert: true, - force: { - ...config.vulnerabilityAlerts, - }, - }; - alertPackageRules.push(matchRule); } + + switch (datasource) { + case PypiDatasource.id: + matchCurrentVersion = matchCurrentVersion.replace( + regEx(/^= /), + '== ', + ); + break; + case MavenDatasource.id: + matchCurrentVersion = matchCurrentVersion.replace(/^=\s*/, ''); + break; + } + + let matchRule: PackageRule = { + matchDatasources: [datasource], + matchPackageNames: [depName], + matchCurrentVersion, + matchFileNames, + }; + + if ( + datasource === PypiDatasource.id && + normalizePythonDepName(depName) !== depName + ) { + matchRule.matchPackageNames?.push(normalizePythonDepName(depName)); + } + // Remediate only direct dependencies + matchRule = { + ...matchRule, + allowedVersions, + prBodyNotes, + isVulnerabilityAlert: true, + force: { + ...config.vulnerabilityAlerts, + }, + }; + alertPackageRules.push(matchRule); } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73c02c138577de..d7d507ff3b591d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13190,4 +13190,4 @@ snapshots: zod@3.23.8: {} - zwitch@1.0.5: {} + zwitch@1.0.5: {} \ No newline at end of file