Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vulnerabilities): add option to add summary to dashboard #21766

Merged
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/usage/configuration-options.md
Expand Up @@ -698,6 +698,18 @@ 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
secustor marked this conversation as resolved.
Show resolved Hide resolved

Use this option to control if the Dependency Dashboard should have a section that shows the CVEs for your repository.
secustor marked this conversation as resolved.
Show resolved Hide resolved
secustor marked this conversation as resolved.
Show resolved Hide resolved

The source of these CVEs is [OSV.dev](https://osv.dev/).

You can choose from:

- `none` (default) no section will be added
secustor marked this conversation as resolved.
Show resolved Hide resolved
- `unresolved` show vulnerabilities that have no fixes
secustor marked this conversation as resolved.
Show resolved Hide resolved
- `all` list all vulnerabilities
secustor marked this conversation as resolved.
Show resolved Hide resolved

## dependencyDashboardTitle

Configure this option if you prefer a different title for the Dependency Dashboard.
Expand Down
8 changes: 8 additions & 0 deletions lib/config/options/index.ts
Expand Up @@ -520,6 +520,14 @@ const options: RenovateOptions[] = [
subType: 'string',
default: null,
},
{
name: 'dependencyDashboardOSVVulnerabilitySummary',
description: 'Control if the Dependency Dashboard issue shows CVEs.',
secustor marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -231,6 +231,7 @@ export interface RenovateConfig
dependencyDashboardHeader?: string;
dependencyDashboardFooter?: string;
dependencyDashboardLabels?: string[];
dependencyDashboardOSVVulnerabilitySummary?: 'none' | 'all' | 'unresolved';
packageFile?: string;
packageRules?: PackageRule[];
postUpdateOptions?: string[];
Expand Down
181 changes: 181 additions & 0 deletions lib/workers/repository/dependency-dashboard.spec.ts
secustor marked this conversation as resolved.
Show resolved Hide resolved
@@ -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 @@ -994,4 +1008,171 @@ 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\nNone detected.\n\n`);
});

it('return all vulnerabilities if set to all', 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',
},
packageFiles
);
expect(result.trimEnd()).toBe(codeBlock`## Vulnerabilities

1 of a total of 2 CVEs have no fixes in this repository.
<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 of a total of 2 CVEs have no fixes in this repository.
<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>`);
});
});
});
96 changes: 96 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 @@ -396,6 +398,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 @@ -453,3 +458,94 @@ function getFooter(config: RenovateConfig): string {

return footer;
}

export async function getDashboardMarkdownVulnerabilities(
secustor marked this conversation as resolved.
Show resolved Hide resolved
config: RenovateConfig,
packageFiles: Record<string, PackageFile[]>
): Promise<string> {
let result = '';

if (
is.nullOrUndefined(config.dependencyDashboardOSVVulnerabilitySummary) ||
config.dependencyDashboardOSVVulnerabilitySummary === 'none'
) {
return result;
}

result += '## Vulnerabilities\n\n';
secustor marked this conversation as resolved.
Show resolved Hide resolved

const vulnerabilityFetcher = await Vulnerabilities.create();
const vulnerabilities = await vulnerabilityFetcher.fetchVulnerabilities(
config,
packageFiles
);
secustor marked this conversation as resolved.
Show resolved Hide resolved

if (vulnerabilities.length === 0) {
secustor marked this conversation as resolved.
Show resolved Hide resolved
result += 'None detected.\n\n';
secustor marked this conversation as resolved.
Show resolved Hide resolved
return result;
}

const unresolvedVulnerabilities = vulnerabilities.filter((value) =>
is.nullOrUndefined(value.fixedVersion)
);

result += `${unresolvedVulnerabilities.length} of a total of ${vulnerabilities.length} CVEs have no fixes in this repository.\n`;
secustor marked this conversation as resolved.
Show resolved Hide resolved

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})`
secustor marked this conversation as resolved.
Show resolved Hide resolved
: '';
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;
}