Skip to content

Commit

Permalink
feat(vulnerabilities): add option to add summary to dashboard (#21766)
Browse files Browse the repository at this point in the history
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
  • Loading branch information
3 people committed May 17, 2023
1 parent cd5abc8 commit 3fccfbe
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 0 deletions.
13 changes: 13 additions & 0 deletions docs/usage/configuration-options.md
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions lib/config/options/index.ts
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions lib/config/types.ts
Expand Up @@ -232,6 +232,7 @@ export interface RenovateConfig
dependencyDashboardHeader?: string;
dependencyDashboardFooter?: string;
dependencyDashboardLabels?: string[];
dependencyDashboardOSVVulnerabilitySummary?: 'none' | 'all' | 'unresolved';
packageFile?: string;
packageRules?: PackageRule[];
postUpdateOptions?: string[];
Expand Down
185 changes: 185 additions & 0 deletions 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 {
Expand All @@ -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;
Expand Down Expand Up @@ -1019,4 +1033,175 @@ describe('workers/repository/dependency-dashboard', () => {
});
});
});

describe('getDashboardMarkdownVulnerabilities()', () => {
const packageFiles = Fixtures.getJson<Record<string, PackageFile[]>>(
'./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.
<details><summary>npm</summary>
<blockquote>
<details><summary>undefined</summary>
<blockquote>
<details><summary>express</summary>
<blockquote>
- [GHSA-29mw-wpgm-hmr9](https://osv.dev/vulnerability/GHSA-29mw-wpgm-hmr9) (fixed in 4.18.1)
</blockquote>
</details>
<details><summary>cookie-parser</summary>
<blockquote>
- [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)
</blockquote>
</details>
</blockquote>
</details>
</blockquote>
</details>`);
});

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.
<details><summary>npm</summary>
<blockquote>
<details><summary>undefined</summary>
<blockquote>
<details><summary>cookie-parser</summary>
<blockquote>
- [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)
</blockquote>
</details>
</blockquote>
</details>
</blockquote>
</details>`);
});
});
});
105 changes: 105 additions & 0 deletions lib/workers/repository/dependency-dashboard.ts
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -468,3 +473,103 @@ function getFooter(config: RenovateConfig): string {

return footer;
}

export async function getDashboardMarkdownVulnerabilities(
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>
): Promise<string> {
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<string, Record<string, Vulnerability[]>>
> = {};
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 += `<details><summary>${manager}</summary>\n<blockquote>\n\n`;
for (const [packageFile, packageNameRecords] of Object.entries(
packageFileRecords
)) {
result += `<details><summary>${packageFile}</summary>\n<blockquote>\n\n`;
for (const [packageName, cves] of Object.entries(packageNameRecords)) {
result += `<details><summary>${packageName}</summary>\n<blockquote>\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 += `</blockquote>\n</details>\n\n`;
}
result += `</blockquote>\n</details>\n\n`;
}
result += `</blockquote>\n</details>\n\n`;
}

return result;
}

0 comments on commit 3fccfbe

Please sign in to comment.