Commit 238373c5 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Mayra Cabrera

Show security reports summary in pipelines' security dashboard

- Created the :pipelines_security_report_summary feature flag
- Added GraphQL query for fetching security report summary
- Render filters with additional information
- Updated tests
parent 42fce179
...@@ -14,6 +14,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -14,6 +14,7 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:junit_pipeline_view, project) push_frontend_feature_flag(:junit_pipeline_view, project)
push_frontend_feature_flag(:filter_pipelines_search, default_enabled: true) push_frontend_feature_flag(:filter_pipelines_search, default_enabled: true)
push_frontend_feature_flag(:dag_pipeline_tab) push_frontend_feature_flag(:dag_pipeline_tab)
push_frontend_feature_flag(:pipelines_security_report_summary, project)
end end
before_action :ensure_pipeline, only: [:show] before_action :ensure_pipeline, only: [:show]
......
...@@ -113,9 +113,10 @@ export default { ...@@ -113,9 +113,10 @@ export default {
class="flex-shrink-0 js-check" class="flex-shrink-0 js-check"
name="mobile-issue-close" name="mobile-issue-close"
/> />
<span :class="isSelected(option) ? 'prepend-left-4' : 'prepend-left-20'">{{ <span class="gl-white-space-nowrap gl-ml-2" :class="{ 'gl-pl-5': !isSelected(option) }">
option.name {{ option.name }}
}}</span> </span>
<slot v-bind="{ filter, option }"></slot>
</span> </span>
</button> </button>
</div> </div>
......
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { n__ } from '~/locale';
import { camelCase } from 'lodash';
import DashboardFilter from './filter.vue'; import DashboardFilter from './filter.vue';
import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue'; import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue';
...@@ -8,11 +10,43 @@ export default { ...@@ -8,11 +10,43 @@ export default {
DashboardFilter, DashboardFilter,
GlToggleVuex, GlToggleVuex,
}, },
props: {
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: { computed: {
...mapGetters('filters', ['visibleFilters']), ...mapGetters('filters', ['visibleFilters']),
}, },
methods: { methods: {
...mapActions('filters', ['setFilter']), ...mapActions('filters', ['setFilter']),
/**
* This method lets us match some data coming from the API with values that are currently
* hardcoded in the frontend.
* We are considering moving the whole thing to the backend so that we can rely on a SSoT.
* https://gitlab.com/gitlab-org/gitlab/-/issues/217373
*/
getOptionEnrichedData(filter, option) {
if (filter.id === 'report_type') {
const { id: optionId } = option;
const optionData = this.securityReportSummary[camelCase(optionId)];
if (!optionData) {
return null;
}
const { vulnerabilitiesCount, scannedResourcesCount } = optionData;
const enrichedData = [];
if (vulnerabilitiesCount !== undefined) {
enrichedData.push(n__('%d vulnerability', '%d vulnerabilities', vulnerabilitiesCount));
}
if (scannedResourcesCount !== undefined) {
enrichedData.push(n__('%d url scanned', '%d urls scanned', scannedResourcesCount));
}
return enrichedData.join(', ');
}
return null;
},
}, },
}; };
</script> </script>
...@@ -24,9 +58,19 @@ export default { ...@@ -24,9 +58,19 @@ export default {
v-for="filter in visibleFilters" v-for="filter in visibleFilters"
:key="filter.id" :key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter" class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter"
:class="`js-filter-${filter.id}`"
:filter="filter" :filter="filter"
@setFilter="setFilter" @setFilter="setFilter"
/> >
<template #default="{ option }">
<span
v-if="getOptionEnrichedData(filter, option)"
class="gl-text-gray-500 gl-white-space-nowrap"
>
&nbsp;({{ getOptionEnrichedData(filter, option) }})
</span>
</template>
</dashboard-filter>
<div class="ml-lg-auto p-2"> <div class="ml-lg-auto p-2">
<strong>{{ s__('SecurityReports|Hide dismissed') }}</strong> <strong>{{ s__('SecurityReports|Hide dismissed') }}</strong>
<gl-toggle-vuex <gl-toggle-vuex
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import SecurityDashboard from './security_dashboard_vuex.vue'; import SecurityDashboard from './security_dashboard_vuex.vue';
import { fetchPolicies } from '~/lib/graphql';
import pipelineSecurityReportSummaryQuery from '../graphql/pipeline_security_report_summary.query.graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
name: 'PipelineSecurityDashboard', name: 'PipelineSecurityDashboard',
...@@ -9,6 +12,25 @@ export default { ...@@ -9,6 +12,25 @@ export default {
GlEmptyState, GlEmptyState,
SecurityDashboard, SecurityDashboard,
}, },
mixins: [glFeatureFlagsMixin()],
apollo: {
securityReportSummary: {
query: pipelineSecurityReportSummaryQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return {
fullPath: this.projectFullPath,
pipelineId: this.pipelineId,
};
},
update(data) {
return data?.project?.pipelines?.nodes?.[0]?.securityReportSummary;
},
skip() {
return !this.glFeatures.pipelinesSecurityReportSummary;
},
},
},
props: { props: {
dashboardDocumentation: { dashboardDocumentation: {
type: String, type: String,
...@@ -42,6 +64,11 @@ export default { ...@@ -42,6 +64,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
projectFullPath: {
type: String,
required: false,
default: '',
},
}, },
created() { created() {
this.setSourceBranch(this.sourceBranch); this.setSourceBranch(this.sourceBranch);
...@@ -59,6 +86,7 @@ export default { ...@@ -59,6 +86,7 @@ export default {
:lock-to-project="{ id: projectId }" :lock-to-project="{ id: projectId }"
:pipeline-id="pipelineId" :pipeline-id="pipelineId"
:loading-error-illustrations="loadingErrorIllustrations" :loading-error-illustrations="loadingErrorIllustrations"
:security-report-summary="securityReportSummary"
> >
<template #emptyState> <template #emptyState>
<gl-empty-state <gl-empty-state
......
...@@ -61,6 +61,11 @@ export default { ...@@ -61,6 +61,11 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { computed: {
...mapState('vulnerabilities', [ ...mapState('vulnerabilities', [
...@@ -163,7 +168,7 @@ export default { ...@@ -163,7 +168,7 @@ export default {
<security-dashboard-layout> <security-dashboard-layout>
<template #header> <template #header>
<vulnerability-count-list v-if="shouldShowCountList" /> <vulnerability-count-list v-if="shouldShowCountList" />
<filters /> <filters :security-report-summary="securityReportSummary" />
</template> </template>
<security-dashboard-table> <security-dashboard-table>
......
query ($fullPath: ID!, $pipelineId: ID!) {
project(fullPath: $fullPath) {
pipelines(id:$pipelineId) {
nodes {
securityReportSummary {
dast {
vulnerabilitiesCount
scannedResourcesCount
}
sast {
scannedResourcesCount
}
containerScanning {
vulnerabilitiesCount
}
dependencyScanning {
vulnerabilitiesCount
}
}
}
}
}
}
\ No newline at end of file
...@@ -3,6 +3,7 @@ import createDashboardStore from './store'; ...@@ -3,6 +3,7 @@ import createDashboardStore from './store';
import PipelineSecurityDashboard from './components/pipeline_security_dashboard.vue'; import PipelineSecurityDashboard from './components/pipeline_security_dashboard.vue';
import { DASHBOARD_TYPES } from './store/constants'; import { DASHBOARD_TYPES } from './store/constants';
import { LOADING_VULNERABILITIES_ERROR_CODES } from './store/modules/vulnerabilities/constants'; import { LOADING_VULNERABILITIES_ERROR_CODES } from './store/modules/vulnerabilities/constants';
import apolloProvider from './graphql/provider';
export default () => { export default () => {
const el = document.getElementById('js-security-report-app'); const el = document.getElementById('js-security-report-app');
...@@ -21,6 +22,7 @@ export default () => { ...@@ -21,6 +22,7 @@ export default () => {
vulnerabilityFeedbackHelpPath, vulnerabilityFeedbackHelpPath,
emptyStateUnauthorizedSvgPath, emptyStateUnauthorizedSvgPath,
emptyStateForbiddenSvgPath, emptyStateForbiddenSvgPath,
projectFullPath,
} = el.dataset; } = el.dataset;
const loadingErrorIllustrations = { const loadingErrorIllustrations = {
...@@ -30,6 +32,7 @@ export default () => { ...@@ -30,6 +32,7 @@ export default () => {
return new Vue({ return new Vue({
el, el,
apolloProvider,
store: createDashboardStore({ store: createDashboardStore({
dashboardType: DASHBOARD_TYPES.PIPELINE, dashboardType: DASHBOARD_TYPES.PIPELINE,
}), }),
...@@ -44,6 +47,7 @@ export default () => { ...@@ -44,6 +47,7 @@ export default () => {
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
loadingErrorIllustrations, loadingErrorIllustrations,
projectFullPath,
}, },
}); });
}, },
......
...@@ -18,7 +18,8 @@ ...@@ -18,7 +18,8 @@
vulnerability_exports_endpoint: vulnerability_exports_endpoint_path, vulnerability_exports_endpoint: vulnerability_exports_endpoint_path,
vulnerability_feedback_help_path: help_page_path('user/application_security/index'), vulnerability_feedback_help_path: help_page_path('user/application_security/index'),
empty_state_unauthorized_svg_path: image_path('illustrations/user-not-logged-in.svg'), empty_state_unauthorized_svg_path: image_path('illustrations/user-not-logged-in.svg'),
empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg') } } empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg'),
project_full_path: project.path_with_namespace } }
- if pipeline.expose_license_scanning_data? - if pipeline.expose_license_scanning_data?
#js-tab-licenses.tab-pane #js-tab-licenses.tab-pane
......
...@@ -8,15 +8,15 @@ describe('Filter component', () => { ...@@ -8,15 +8,15 @@ describe('Filter component', () => {
const store = createStore(); const store = createStore();
const Component = Vue.extend(component); const Component = Vue.extend(component);
afterEach(() => {
vm.$destroy();
});
describe('severity', () => { describe('severity', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponentWithStore(Component, { store }); vm = mountComponentWithStore(Component, { store });
}); });
afterEach(() => {
vm.$destroy();
});
it('should display all filters', () => { it('should display all filters', () => {
expect(vm.$el.querySelectorAll('.js-filter')).toHaveLength(3); expect(vm.$el.querySelectorAll('.js-filter')).toHaveLength(3);
}); });
...@@ -25,4 +25,29 @@ describe('Filter component', () => { ...@@ -25,4 +25,29 @@ describe('Filter component', () => {
expect(vm.$el.querySelectorAll('.js-toggle')).toHaveLength(1); expect(vm.$el.querySelectorAll('.js-toggle')).toHaveLength(1);
}); });
}); });
describe('Report type', () => {
const findReportTypeFilter = () => vm.$el.querySelector('.js-filter-report_type');
it.each`
dastProps | string
${{ vulnerabilitiesCount: 0, scannedResourcesCount: 123 }} | ${'(0 vulnerabilities, 123 urls scanned)'}
${{ vulnerabilitiesCount: 481, scannedResourcesCount: 0 }} | ${'(481 vulnerabilities, 0 urls scanned)'}
${{ vulnerabilitiesCount: 1, scannedResourcesCount: 1 }} | ${'(1 vulnerability, 1 url scanned)'}
${{ vulnerabilitiesCount: 321 }} | ${'(321 vulnerabilities)'}
${{ scannedResourcesCount: 890 }} | ${'(890 urls scanned)'}
${{ vulnerabilitiesCount: 0 }} | ${'(0 vulnerabilities)'}
${{ scannedResourcesCount: 0 }} | ${'(0 urls scanned)'}
`('shows security report summary $string', ({ dastProps, string }) => {
vm = mountComponentWithStore(Component, {
store,
props: {
securityReportSummary: {
dast: dastProps,
},
},
});
expect(findReportTypeFilter().textContent).toContain(string);
});
});
}); });
...@@ -39,6 +39,9 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -39,6 +39,9 @@ describe('Pipeline Security Dashboard component', () => {
wrapper = shallowMount(PipelineSecurityDashboard, { wrapper = shallowMount(PipelineSecurityDashboard, {
localVue, localVue,
store, store,
data() {
return { securityReportSummary: {} };
},
propsData: { propsData: {
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
......
...@@ -219,6 +219,16 @@ msgid_plural "%d unresolved threads" ...@@ -219,6 +219,16 @@ msgid_plural "%d unresolved threads"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d url scanned"
msgid_plural "%d urls scanned"
msgstr[0] ""
msgstr[1] ""
msgid "%d vulnerability"
msgid_plural "%d vulnerabilities"
msgstr[0] ""
msgstr[1] ""
msgid "%d vulnerability dismissed" msgid "%d vulnerability dismissed"
msgid_plural "%d vulnerabilities dismissed" msgid_plural "%d vulnerabilities dismissed"
msgstr[0] "" msgstr[0] ""
......
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