Commit 9b2b04d1 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '235558-add-chart-to-project-security-dashboard' into 'master'

Introduce vulnerabilities trends chart

See merge request gitlab-org/gitlab!46591
parents 11a03556 62f8e259
......@@ -65,11 +65,24 @@ the analyzer outputs an
## Project Security Dashboard
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/235558) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.6.
At the project level, the Security Dashboard displays a chart with the number of vulnerabilities over time.
Access it by navigating to **Security & Compliance > Security Dashboard**. Currently, we display historical
data up to 365 days.
![Project Security Dashboard](img/project_security_dashboard_chart_v13_6.png)
Filter the historical data by clicking on the corresponding legend name. The image above, for example, shows
only the graph for vulnerabilities with **high** severity.
### Vulnerability Report
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/6165) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.1.
At the project level, the Security Dashboard displays the vulnerabilities that exist in your project's
[default branch](../../project/repository/branches/index.md#default-branch). Access it by navigating
to **Security & Compliance > Security Dashboard**. By default, the Security Dashboard is filtered to
The vulnerabilities that exist in your project's
[default branch](../../project/repository/branches/index.md#default-branch) are accessed by navigating to
**Security & Compliance > Vulnerability Report**. By default, the Security Dashboard is filtered to
display all detected and confirmed vulnerabilities.
The Security Dashboard first displays the time at which the last pipeline completed on the project's
......
import initFirstClassSecurityDashboard from 'ee/security_dashboard/first_class_init';
import initSecurityCharts from 'ee/security_dashboard/security_charts_init';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
document.addEventListener('DOMContentLoaded', () => {
initFirstClassSecurityDashboard(
document.getElementById('js-security-report-app'),
waitForCSSLoaded(() => {
initSecurityCharts(
document.getElementById('js-project-security-dashboard'),
DASHBOARD_TYPES.PROJECT,
);
});
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { s__, __ } from '~/locale';
import createFlash from '~/flash';
import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/reports_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue';
import projectsHistoryQuery from '../graphql/project_vulnerabilities_by_day_and_count.graphql';
const MAX_DAYS = 100;
const ISO_DATE = 'isoDate';
const SEVERITIES = [
{ key: 'critical', name: s__('severity|Critical'), color: '#660e00' },
{ key: 'high', name: s__('severity|High'), color: '#ae1800' },
{ key: 'medium', name: s__('severity|Medium'), color: '#9e5400' },
{ key: 'low', name: s__('severity|Low'), color: '#c17d10' },
{ key: 'unknown', name: s__('severity|Unknown'), color: '#868686' },
{ key: 'info', name: s__('severity|Info'), color: '#428fdc' },
];
export default {
components: {
DashboardNotConfigured,
SecurityChartsLayout,
GlLoadingIcon,
GlLineChart,
},
props: {
projectFullPath: {
type: String,
required: false,
default: '',
},
hasVulnerabilities: {
type: Boolean,
required: false,
default: false,
},
helpPath: {
type: String,
required: true,
},
},
apollo: {
trendsByDay: {
query: projectsHistoryQuery,
variables() {
return {
fullPath: this.projectFullPath,
endDate: this.endDate,
startDate: this.startDate,
};
},
update(data) {
return data?.project?.vulnerabilitiesCountByDay?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
skip() {
return !this.hasVulnerabilities;
},
},
},
data() {
return {
chartWidth: 0,
trendsByDay: [],
};
},
computed: {
startDate() {
return formatDate(getDateInPast(new Date(), MAX_DAYS), ISO_DATE);
},
endDate() {
return formatDate(new Date(), ISO_DATE);
},
dataSeries() {
const series = SEVERITIES.map(({ key, name, color }) => ({
key,
name,
data: [],
itemStyle: {
color,
},
lineStyle: {
color,
},
}));
this.trendsByDay.forEach(trend => {
const { date, ...severities } = trend;
SEVERITIES.forEach(({ key }) => {
series.find(s => s.key === key).data.push([date, severities[key]]);
});
});
return series;
},
isLoadingTrends() {
return this.$apollo.queries.trendsByDay.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingTrends && this.trendsByDay.length) && this.chartWidth > 0;
},
shouldShowEmptyState() {
return !this.hasVulnerabilities;
},
},
mounted() {
this.chartWidth = this.$refs.layout.$el.clientWidth;
},
chartOptions: {
xAxis: {
name: __('Time'),
key: 'time',
type: 'category',
},
yAxis: {
name: __('Vulnerabilities'),
key: 'vulnerabilities',
type: 'value',
minInterval: 1,
},
},
};
</script>
<template>
<security-charts-layout ref="layout">
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured :help-path="helpPath" />
</template>
<template v-else-if="shouldShowCharts">
<gl-line-chart
class="gl-mt-6"
:width="chartWidth"
:data="dataSeries"
:option="$options.chartOptions"
:include-legend-avg-max="false"
/>
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout>
</template>
query project($fullPath: ID!, $startDate: ISO8601Date!, $endDate: ISO8601Date!) {
project(fullPath: $fullPath) {
vulnerabilitiesCountByDay(startDate: $startDate, endDate: $endDate) {
nodes {
date
critical
high
info
low
medium
unknown
}
}
}
}
import Vue from 'vue';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import UnavailableState from './components/unavailable_state.vue';
import createStore from './store';
import createRouter from './router';
import apolloProvider from './graphql/provider';
import ProjectSecurityCharts from './components/project_security_charts.vue';
import GroupSecurityCharts from './components/group_security_charts.vue';
import InstanceSecurityCharts from './components/instance_security_charts.vue';
......@@ -41,6 +42,11 @@ export default (el, dashboardType) => {
} else if (dashboardType === DASHBOARD_TYPES.INSTANCE) {
component = InstanceSecurityCharts;
provide.instanceDashboardSettingsPath = el.dataset.instanceDashboardSettingsPath;
} else if (dashboardType === DASHBOARD_TYPES.PROJECT) {
component = ProjectSecurityCharts;
props.projectFullPath = el.dataset.projectFullPath;
props.hasVulnerabilities = parseBoolean(el.dataset.hasVulnerabilities);
props.helpPath = el.dataset.securityDashboardHelpPath;
}
const router = createRouter();
......
......@@ -153,6 +153,7 @@ module EE
projects/security/configuration#show
projects/security/sast_configuration#show
projects/security/vulnerabilities#show
projects/security/vulnerability_report#index
projects/security/dashboard#index
projects/on_demand_scans#index
projects/dast_profiles#index
......
......@@ -21,6 +21,10 @@
= link_to project_security_dashboard_index_path(@project), title: _('Security Dashboard') do
%span= _('Security Dashboard')
= nav_link(path: 'projects/security/vulnerability_report#index') do
= link_to project_security_vulnerability_report_index_path(@project), title: _('Vulnerability Report') do
%span= _('Vulnerability Report')
- if project_nav_tab?(:on_demand_scans)
= nav_link(path: sidebar_on_demand_scans_paths) do
= link_to project_on_demand_scans_path(@project), title: s_('OnDemandScans|On-demand Scans'), data: { qa_selector: 'on_demand_scans_link' } do
......
......@@ -2,4 +2,4 @@
- page_title _("Security Dashboard")
- add_page_specific_style 'page_bundles/reports'
#js-security-report-app{ data: project_security_dashboard_config(@project) }
#js-project-security-dashboard{ data: project_security_dashboard_config(@project) }
---
title: Introduce vulnerabilities trends chart to the security dashboard
merge_request: 46591
author:
type: added
......@@ -36,7 +36,7 @@ RSpec.describe Projects::Security::DashboardController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(response.body).to have_css('div#js-security-report-app[data-has-vulnerabilities="false"]')
expect(response.body).to have_css('div#js-project-security-dashboard[data-has-vulnerabilities="false"]')
end
end
......@@ -50,7 +50,7 @@ RSpec.describe Projects::Security::DashboardController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(response.body).to have_css('div#js-security-report-app[data-has-vulnerabilities="true"]')
expect(response.body).to have_css('div#js-project-security-dashboard[data-has-vulnerabilities="true"]')
end
end
end
......
......@@ -43,6 +43,7 @@ RSpec.describe 'Project navbar' do
nav_item: _('Security & Compliance'),
nav_sub_items: [
_('Security Dashboard'),
_('Vulnerability Report'),
s_('OnDemandScans|On-demand Scans'),
_('Configuration')
]
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Project Security Charts component when there is history data should display the chart with data 1`] = `
Array [
Object {
"data": Array [
Array [
"2020-07-22",
4,
],
Array [
"2020-07-23",
2,
],
Array [
"2020-07-24",
2,
],
Array [
"2020-07-25",
2,
],
Array [
"2020-07-26",
2,
],
Array [
"2020-07-27",
2,
],
],
"itemStyle": Object {
"color": "#660e00",
},
"key": "critical",
"lineStyle": Object {
"color": "#660e00",
},
"name": "Critical",
},
Object {
"data": Array [
Array [
"2020-07-22",
3,
],
Array [
"2020-07-23",
3,
],
Array [
"2020-07-24",
3,
],
Array [
"2020-07-25",
3,
],
Array [
"2020-07-26",
3,
],
Array [
"2020-07-27",
3,
],
],
"itemStyle": Object {
"color": "#ae1800",
},
"key": "high",
"lineStyle": Object {
"color": "#ae1800",
},
"name": "High",
},
Object {
"data": Array [
Array [
"2020-07-22",
2,
],
Array [
"2020-07-23",
2,
],
Array [
"2020-07-24",
2,
],
Array [
"2020-07-25",
2,
],
Array [
"2020-07-26",
2,
],
Array [
"2020-07-27",
2,
],
],
"itemStyle": Object {
"color": "#9e5400",
},
"key": "medium",
"lineStyle": Object {
"color": "#9e5400",
},
"name": "Medium",
},
Object {
"data": Array [
Array [
"2020-07-22",
10,
],
Array [
"2020-07-23",
10,
],
Array [
"2020-07-24",
10,
],
Array [
"2020-07-25",
10,
],
Array [
"2020-07-26",
10,
],
Array [
"2020-07-27",
10,
],
],
"itemStyle": Object {
"color": "#c17d10",
},
"key": "low",
"lineStyle": Object {
"color": "#c17d10",
},
"name": "Low",
},
Object {
"data": Array [
Array [
"2020-07-22",
1,
],
Array [
"2020-07-23",
1,
],
Array [
"2020-07-24",
1,
],
Array [
"2020-07-25",
1,
],
Array [
"2020-07-26",
1,
],
Array [
"2020-07-27",
1,
],
],
"itemStyle": Object {
"color": "#868686",
},
"key": "unknown",
"lineStyle": Object {
"color": "#868686",
},
"name": "Unknown",
},
Object {
"data": Array [
Array [
"2020-07-22",
2,
],
Array [
"2020-07-23",
2,
],
Array [
"2020-07-24",
2,
],
Array [
"2020-07-25",
2,
],
Array [
"2020-07-26",
2,
],
Array [
"2020-07-27",
2,
],
],
"itemStyle": Object {
"color": "#428fdc",
},
"key": "info",
"lineStyle": Object {
"color": "#428fdc",
},
"name": "Info",
},
]
`;
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlLoadingIcon } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/reports_not_configured.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
import ProjectSecurityCharts from 'ee/security_dashboard/components/project_security_charts.vue';
import projectsHistoryQuery from 'ee/security_dashboard/graphql/project_vulnerabilities_by_day_and_count.graphql';
import {
mockProjectSecurityChartsWithData,
mockProjectSecurityChartsWithoutData,
} from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Project Security Charts component', () => {
let wrapper;
const projectFullPath = 'project/path';
const helpPath = 'docs/security/dashboard';
const findLineChart = () => wrapper.find(GlLineChart);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findEmptyState = () => wrapper.find(DashboardNotConfigured);
const createApolloProvider = (...queries) => {
return createMockApollo([...queries]);
};
const createComponent = ({ query, propsData, chartWidth = 1024 }) => {
const component = shallowMount(ProjectSecurityCharts, {
localVue,
apolloProvider: createApolloProvider([
projectsHistoryQuery,
jest.fn().mockResolvedValue(query),
]),
propsData: {
projectFullPath,
helpPath,
...propsData,
},
stubs: {
SecurityChartsLayout,
},
});
// Need to setData after the component is mounted
component.setData({ chartWidth });
return component;
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('when chartWidth is 0', () => {
beforeEach(() => {
wrapper = createComponent({
query: mockProjectSecurityChartsWithData(),
propsData: { hasVulnerabilities: true },
chartWidth: 0,
});
return wrapper.vm.$nextTick();
});
it('should not display the line chart', () => {
expect(findLineChart().exists()).toBe(false);
});
it('should display a loading icon instead', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when there is history data', () => {
beforeEach(() => {
wrapper = createComponent({
query: mockProjectSecurityChartsWithData(),
propsData: { hasVulnerabilities: true },
});
return wrapper.vm.$nextTick();
});
it('should display the chart with data', async () => {
expect(findLineChart().props('data')).toMatchSnapshot();
});
it('should not display the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('when there is no history data', () => {
beforeEach(() => {
wrapper = createComponent({
query: mockProjectSecurityChartsWithoutData(),
propsData: { hasVulnerabilities: false },
});
return wrapper.vm.$nextTick();
});
it('should display the empty state', () => {
expect(findEmptyState().props()).toEqual({ helpPath });
});
it('should not display the chart', () => {
expect(findLineChart().exists()).toBe(false);
});
it('should not display the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('when the query is loading', () => {
beforeEach(() => {
wrapper = createComponent({
query: () => ({}),
propsData: { hasVulnerabilities: true },
});
});
it('should not display the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
it('should not display the chart', () => {
expect(findLineChart().exists()).toBe(false);
});
it('should display the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
});
......@@ -118,3 +118,78 @@ export const mockInstanceVulnerabilityGrades = () => ({
},
},
});
export const mockProjectSecurityChartsWithoutData = () => ({
data: {
project: {
vulnerabilitiesCountByDay: {
edges: [],
},
},
},
});
export const mockProjectSecurityChartsWithData = () => ({
data: {
project: {
vulnerabilitiesCountByDay: {
nodes: [
{
date: '2020-07-22',
critical: 4,
high: 3,
info: 2,
low: 10,
medium: 2,
unknown: 1,
},
{
date: '2020-07-23',
critical: 2,
high: 3,
info: 2,
low: 10,
medium: 2,
unknown: 1,
},
{
date: '2020-07-24',
critical: 2,
high: 3,
info: 2,
low: 10,
medium: 2,
unknown: 1,
},
{
date: '2020-07-25',
critical: 2,
high: 3,
info: 2,
low: 10,
medium: 2,
unknown: 1,
},
{
date: '2020-07-26',
critical: 2,
high: 3,
info: 2,
low: 10,
medium: 2,
unknown: 1,
},
{
date: '2020-07-27',
critical: 2,
high: 3,
info: 2,
low: 10,
medium: 2,
unknown: 1,
},
],
},
},
},
});
......@@ -206,6 +206,7 @@ RSpec.describe ProjectsHelper do
projects/security/configuration#show
projects/security/sast_configuration#show
projects/security/vulnerabilities#show
projects/security/vulnerability_report#index
projects/security/dashboard#index
projects/on_demand_scans#index
projects/dast_profiles#index
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment