From 3fccfbe9277f59e01e31ea074f6a90772d04d637 Mon Sep 17 00:00:00 2001 From: Sebastian Poxhofer Date: Wed, 17 May 2023 10:15:12 +0200 Subject: [PATCH] feat(vulnerabilities): add option to add summary to dashboard (#21766) Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com> Co-authored-by: Rhys Arkins --- docs/usage/configuration-options.md | 13 ++ lib/config/options/index.ts | 9 + lib/config/types.ts | 1 + .../repository/dependency-dashboard.spec.ts | 185 ++++++++++++++++++ .../repository/dependency-dashboard.ts | 105 ++++++++++ 5 files changed, 313 insertions(+) diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 2ba14ca9de3e8e..dce0ff59865d63 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -703,6 +703,19 @@ You can configure this to `true` if you prefer Renovate to close an existing Dep The labels only get updated when the Dependency Dashboard issue updates its content and/or title. It is pointless to edit the labels, as Renovate bot restores the labels on each run. +## dependencyDashboardOSVVulnerabilitySummary + +Use this option to control if the Dependency Dashboard lists the OSV-sourced CVEs for your repository. +You can choose from: + +- `none` (default) do not list any CVEs +- `unresolved` list CVEs that have no fixes +- `all` list all CVEs + +This feature is independent of the `osvVulnerabilityAlerts` option. + +The source of these CVEs is [OSV.dev](https://osv.dev/). + ## dependencyDashboardTitle Configure this option if you prefer a different title for the Dependency Dashboard. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 0e05c187ba51cf..a41fb0fdd7056b 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -520,6 +520,15 @@ const options: RenovateOptions[] = [ subType: 'string', default: null, }, + { + name: 'dependencyDashboardOSVVulnerabilitySummary', + description: + 'Control if the Dependency Dashboard issue lists CVEs supplied by [osv.dev](https://osv.dev).', + type: 'string', + allowedValues: ['none', 'all', 'unresolved'], + default: 'none', + experimental: true, + }, { name: 'configWarningReuseIssue', description: diff --git a/lib/config/types.ts b/lib/config/types.ts index ad5908b00e24b9..5c390b09c825a0 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -232,6 +232,7 @@ export interface RenovateConfig dependencyDashboardHeader?: string; dependencyDashboardFooter?: string; dependencyDashboardLabels?: string[]; + dependencyDashboardOSVVulnerabilitySummary?: 'none' | 'all' | 'unresolved'; packageFile?: string; packageRules?: PackageRule[]; postUpdateOptions?: string[]; diff --git a/lib/workers/repository/dependency-dashboard.spec.ts b/lib/workers/repository/dependency-dashboard.spec.ts index a2d9b97782cf83..809669c610824b 100644 --- a/lib/workers/repository/dependency-dashboard.spec.ts +++ b/lib/workers/repository/dependency-dashboard.spec.ts @@ -1,4 +1,5 @@ import { ERROR, WARN } from 'bunyan'; +import { codeBlock } from 'common-tags'; import { mock } from 'jest-mock-extended'; import { Fixtures } from '../../../test/fixtures'; import { @@ -21,8 +22,21 @@ import { import { regEx } from '../../util/regex'; import type { BranchConfig, BranchUpgradeConfig } from '../types'; import * as dependencyDashboard from './dependency-dashboard'; +import { getDashboardMarkdownVulnerabilities } from './dependency-dashboard'; import { PackageFiles } from './package-files'; +const createVulnerabilitiesMock = jest.fn(); +jest.mock('./process/vulnerabilities', () => { + return { + __esModule: true, + Vulnerabilities: class { + static create() { + return createVulnerabilitiesMock(); + } + }, + }; +}); + type PrUpgrade = BranchUpgradeConfig; const massageMdSpy = platform.massageMarkdown; @@ -1019,4 +1033,175 @@ describe('workers/repository/dependency-dashboard', () => { }); }); }); + + describe('getDashboardMarkdownVulnerabilities()', () => { + const packageFiles = Fixtures.getJson>( + './package-files.json' + ); + + it('return empty string if summary is empty', async () => { + const result = await getDashboardMarkdownVulnerabilities( + config, + packageFiles + ); + expect(result).toBeEmpty(); + }); + + it('return empty string if summary is set to none', async () => { + const result = await getDashboardMarkdownVulnerabilities( + { + ...config, + dependencyDashboardOSVVulnerabilitySummary: 'none', + }, + packageFiles + ); + expect(result).toBeEmpty(); + }); + + it('return no data section if summary is set to all and no vulnerabilities', async () => { + const fetchVulnerabilitiesMock = jest.fn(); + createVulnerabilitiesMock.mockResolvedValueOnce({ + fetchVulnerabilities: fetchVulnerabilitiesMock, + }); + + fetchVulnerabilitiesMock.mockResolvedValueOnce([]); + const result = await getDashboardMarkdownVulnerabilities( + { + ...config, + dependencyDashboardOSVVulnerabilitySummary: 'all', + }, + {} + ); + expect(result).toBe( + `## Vulnerabilities\n\nRenovate has not found any CVEs on [osv.dev](https://osv.dev).\n\n` + ); + }); + + it('return all vulnerabilities if set to all and disabled osvVulnerabilities', async () => { + const fetchVulnerabilitiesMock = jest.fn(); + createVulnerabilitiesMock.mockResolvedValueOnce({ + fetchVulnerabilities: fetchVulnerabilitiesMock, + }); + + fetchVulnerabilitiesMock.mockResolvedValueOnce([ + { + packageName: 'express', + depVersion: '4.17.3', + fixedVersion: '4.18.1', + packageFileConfig: { + manager: 'npm', + }, + vulnerability: { + id: 'GHSA-29mw-wpgm-hmr9', + }, + }, + { + packageName: 'cookie-parser', + depVersion: '1.4.6', + packageFileConfig: { + manager: 'npm', + }, + vulnerability: { + id: 'GHSA-35jh-r3h4-6jhm', + }, + }, + ]); + const result = await getDashboardMarkdownVulnerabilities( + { + ...config, + dependencyDashboardOSVVulnerabilitySummary: 'all', + osvVulnerabilityAlerts: true, + }, + packageFiles + ); + expect(result.trimEnd()).toBe(codeBlock`## Vulnerabilities + +\`1\`/\`2\` CVEs have Renovate fixes. +
npm +
+ +
undefined +
+ +
express +
+ +- [GHSA-29mw-wpgm-hmr9](https://osv.dev/vulnerability/GHSA-29mw-wpgm-hmr9) (fixed in 4.18.1) +
+
+ +
cookie-parser +
+ +- [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) +
+
+ +
+
+ +
+
`); + }); + + it('return unresolved vulnerabilities if set to "unresolved"', async () => { + const fetchVulnerabilitiesMock = jest.fn(); + createVulnerabilitiesMock.mockResolvedValueOnce({ + fetchVulnerabilities: fetchVulnerabilitiesMock, + }); + + fetchVulnerabilitiesMock.mockResolvedValueOnce([ + { + packageName: 'express', + depVersion: '4.17.3', + fixedVersion: '4.18.1', + packageFileConfig: { + manager: 'npm', + }, + vulnerability: { + id: 'GHSA-29mw-wpgm-hmr9', + }, + }, + { + packageName: 'cookie-parser', + depVersion: '1.4.6', + packageFileConfig: { + manager: 'npm', + }, + vulnerability: { + id: 'GHSA-35jh-r3h4-6jhm', + }, + }, + ]); + const result = await getDashboardMarkdownVulnerabilities( + { + ...config, + dependencyDashboardOSVVulnerabilitySummary: 'unresolved', + }, + packageFiles + ); + expect(result.trimEnd()).toBe(codeBlock`## Vulnerabilities + +\`1\`/\`2\` CVEs have possible Renovate fixes. +See [\`osvVulnerabilityAlerts\`](https://docs.renovatebot.com/configuration-options/#osvvulnerabilityalerts) to allow Renovate to supply fixes. +
npm +
+ +
undefined +
+ +
cookie-parser +
+ +- [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) +
+
+ +
+
+ +
+
`); + }); + }); }); diff --git a/lib/workers/repository/dependency-dashboard.ts b/lib/workers/repository/dependency-dashboard.ts index 6054f0e58e9573..15759ff691a181 100644 --- a/lib/workers/repository/dependency-dashboard.ts +++ b/lib/workers/repository/dependency-dashboard.ts @@ -11,6 +11,8 @@ import * as template from '../../util/template'; import type { BranchConfig, SelectAllConfig } from '../types'; import { getDepWarningsDashboard } from './errors-warnings'; import { PackageFiles } from './package-files'; +import type { Vulnerability } from './process/types'; +import { Vulnerabilities } from './process/vulnerabilities'; interface DependencyDashboard { dependencyDashboardChecks: Record; @@ -411,6 +413,9 @@ export async function ensureDependencyDashboard( 'This repository currently has no open or pending branches.\n\n'; } + // add CVE section + issueBody += await getDashboardMarkdownVulnerabilities(config, packageFiles); + // fit the detected dependencies section const footer = getFooter(config); issueBody += PackageFiles.getDashboardMarkdown( @@ -468,3 +473,103 @@ function getFooter(config: RenovateConfig): string { return footer; } + +export async function getDashboardMarkdownVulnerabilities( + config: RenovateConfig, + packageFiles: Record +): Promise { + let result = ''; + + if ( + is.nullOrUndefined(config.dependencyDashboardOSVVulnerabilitySummary) || + config.dependencyDashboardOSVVulnerabilitySummary === 'none' + ) { + return result; + } + + result += '## Vulnerabilities\n\n'; + + const vulnerabilityFetcher = await Vulnerabilities.create(); + const vulnerabilities = await vulnerabilityFetcher.fetchVulnerabilities( + config, + packageFiles + ); + + if (vulnerabilities.length === 0) { + result += + 'Renovate has not found any CVEs on [osv.dev](https://osv.dev).\n\n'; + return result; + } + + const unresolvedVulnerabilities = vulnerabilities.filter((value) => + is.nullOrUndefined(value.fixedVersion) + ); + const resolvedVulnerabilitiesLength = + vulnerabilities.length - unresolvedVulnerabilities.length; + + result += `\`${resolvedVulnerabilitiesLength}\`/\`${vulnerabilities.length}\``; + if (is.truthy(config.osvVulnerabilityAlerts)) { + result += ' CVEs have Renovate fixes.\n'; + } else { + result += + ' CVEs have possible Renovate fixes.\nSee [`osvVulnerabilityAlerts`](https://docs.renovatebot.com/configuration-options/#osvvulnerabilityalerts) to allow Renovate to supply fixes.\n'; + } + + let renderedVulnerabilities: Vulnerability[]; + switch (config.dependencyDashboardOSVVulnerabilitySummary) { + // filter vulnerabilities to display based on configuration + case 'unresolved': + renderedVulnerabilities = unresolvedVulnerabilities; + break; + default: + renderedVulnerabilities = vulnerabilities; + } + + const managerRecords: Record< + string, + Record> + > = {}; + for (const vulnerability of renderedVulnerabilities) { + const { manager, packageFile } = vulnerability.packageFileConfig; + if (is.nullOrUndefined(managerRecords[manager!])) { + managerRecords[manager!] = {}; + } + if (is.nullOrUndefined(managerRecords[manager!][packageFile])) { + managerRecords[manager!][packageFile] = {}; + } + if ( + is.nullOrUndefined( + managerRecords[manager!][packageFile][vulnerability.packageName] + ) + ) { + managerRecords[manager!][packageFile][vulnerability.packageName] = []; + } + managerRecords[manager!][packageFile][vulnerability.packageName].push( + vulnerability + ); + } + + for (const [manager, packageFileRecords] of Object.entries(managerRecords)) { + result += `
${manager}\n
\n\n`; + for (const [packageFile, packageNameRecords] of Object.entries( + packageFileRecords + )) { + result += `
${packageFile}\n
\n\n`; + for (const [packageName, cves] of Object.entries(packageNameRecords)) { + result += `
${packageName}\n
\n\n`; + for (const vul of cves) { + const id = vul.vulnerability.id; + const suffix = is.nonEmptyString(vul.fixedVersion) + ? ` (fixed in ${vul.fixedVersion})` + : ''; + result += `- [${id}](https://osv.dev/vulnerability/${id})${suffix}\n`; + } + result += `
\n
\n\n`; + } + result += `
\n
\n\n`; + } + result += `
\n
\n\n`; + } + + return result; +}