Commit 373b481c authored by Savas Vedova's avatar Savas Vedova

Mount vulnerability list on pipeline dashboard

This is part of an ongoing development process. It mounts the vulnerability
list and provides the basic functionality like displaying vulnerabilities
and fetching more. Currently the sorting and filtering does not work yet.
Subsequent MRs will take care of that functionality. This feature is
being developed behind a feature flag.
parent aa389f6a
<script>
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { produce } from 'immer';
import findingsQuery from '../graphql/queries/pipeline_findings.query.graphql';
import { preparePageInfo } from '../helpers';
import { VULNERABILITIES_PER_PAGE } from '../store/constants';
import VulnerabilityList from './vulnerability_list.vue';
export default {
name: 'PipelineFindings',
components: {
GlAlert,
GlIntersectionObserver,
GlLoadingIcon,
VulnerabilityList,
},
inject: ['pipeline', 'projectFullPath'],
props: {
filters: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
pageInfo: {},
findings: [],
errorLoadingFindings: false,
sortBy: 'severity',
sortDirection: 'desc',
};
},
computed: {
isLoadingQuery() {
return this.$apollo.queries.findings.loading;
},
isLoadingFirstResult() {
return this.isLoadingQuery && this.findings.length === 0;
},
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
},
apollo: {
findings: {
query: findingsQuery,
variables() {
return {
pipelineId: this.pipeline.iid,
fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE,
};
},
update: ({ project }) =>
project?.pipeline?.securityReportFindings?.nodes?.map((finding) => ({
...finding,
// vulnerabilties and findings are different but similar entities. Vulnerabilities have
// ids, findings have uuid. To make the selection work with the vulnerability list, we're
// going to massage the data and add an `id` field to the finding.
id: finding.uuid,
})),
result({ data }) {
if (data.project) {
this.pageInfo = preparePageInfo(data.project.pipeline.securityReportFindings?.pageInfo);
}
},
error() {
this.errorLoadingFindings = true;
},
skip() {
return !this.filters;
},
},
},
watch: {
filters() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.findings = [];
this.pageInfo = {};
},
sort() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.findings = [];
},
},
methods: {
onErrorDismiss() {
this.errorLoadingFindings = false;
},
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.findings.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.project.pipeline.securityReportFindings.nodes = [
...previousResult.project.pipeline.securityReportFindings.nodes,
...draftData.project.pipeline.securityReportFindings.nodes,
];
});
},
});
}
},
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
},
};
</script>
<template>
<div>
<gl-alert v-if="errorLoadingFindings" class="mb-4" variant="danger" @dismiss="onErrorDismiss">
{{
s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
)
}}
</gl-alert>
<vulnerability-list
v-else
:filters="filters"
:is-loading="isLoadingFirstResult"
:vulnerabilities="findings"
@sort-changed="handleSortChange"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingQuery" size="md" />
</gl-intersection-observer>
</div>
</template>
......@@ -20,7 +20,7 @@ import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
import { VULNERABILITIES_PER_PAGE } from '../store/constants';
import { VULNERABILITIES_PER_PAGE, DASHBOARD_TYPES } from '../store/constants';
import IssuesBadge from './issues_badge.vue';
import SelectionSummary from './selection_summary.vue';
......@@ -51,6 +51,7 @@ export default {
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
dashboardType: {},
},
props: {
......@@ -87,27 +88,22 @@ export default {
};
},
computed: {
// This is a workaround to remove vulnerabilities from the list when their state has changed
// through the bulk update feature, but no longer matches the filters. For more details:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43468#note_420050017
filteredVulnerabilities() {
return this.vulnerabilities.filter((x) =>
this.filters.state?.length ? this.filters.state.includes(x.state) : true,
);
},
isSortable() {
return Boolean(this.$listeners['sort-changed']);
},
isPipelineDashboard() {
return this.dashboardType === DASHBOARD_TYPES.PIPELINE;
},
hasAnyScannersOtherThanGitLab() {
return this.filteredVulnerabilities.some(
return this.vulnerabilities.some(
(v) => v.scanner?.vendor !== 'GitLab' && v.scanner?.vendor !== '',
);
},
hasSelectedAllVulnerabilities() {
if (!this.filteredVulnerabilities.length) {
if (!this.vulnerabilities.length) {
return false;
}
return this.numOfSelectedVulnerabilities === this.filteredVulnerabilities.length;
return this.numOfSelectedVulnerabilities === this.vulnerabilities.length;
},
numOfSelectedVulnerabilities() {
return Object.keys(this.selectedVulnerabilities).length;
......@@ -125,6 +121,7 @@ export default {
label: s__('Vulnerability|Detected'),
class: 'detected',
sortable: this.isSortable,
skip: this.isPipelineDashboard,
},
{
key: 'state',
......@@ -160,8 +157,9 @@ export default {
label: s__('Vulnerability|Activity'),
thClass: 'gl-text-right',
class: 'activity',
skip: this.isPipelineDashboard,
},
];
].filter((f) => !f.skip);
if (this.shouldShowSelection) {
baseFields.unshift({
......@@ -182,8 +180,8 @@ export default {
filters() {
this.selectedVulnerabilities = {};
},
filteredVulnerabilities() {
const ids = new Set(this.filteredVulnerabilities.map((v) => v.id));
vulnerabilities() {
const ids = new Set(this.vulnerabilities.map((v) => v.id));
Object.keys(this.selectedVulnerabilities).forEach((vulnerabilityId) => {
if (!ids.has(vulnerabilityId)) {
......@@ -314,7 +312,7 @@ export default {
v-if="filters"
:busy="isLoading"
:fields="fields"
:items="filteredVulnerabilities"
:items="vulnerabilities"
:thead-class="theadClass"
:sort-desc="sortDesc"
:sort-by="sortBy"
......@@ -326,7 +324,7 @@ export default {
responsive
hover
primary-key="id"
:tbody-tr-class="{ 'gl-cursor-pointer': filteredVulnerabilities.length }"
:tbody-tr-class="{ 'gl-cursor-pointer': vulnerabilities.length }"
@sort-changed="handleSortChange"
@row-clicked="toggleVulnerability"
>
......@@ -369,10 +367,10 @@ export default {
<gl-link
class="gl-text-body vulnerability-title js-description"
:href="item.vulnerabilityPath"
:data-qa-vulnerability-description="item.title"
:data-qa-vulnerability-description="item.title || item.name"
data-qa-selector="vulnerability"
>
{{ item.title }}
{{ item.title || item.name }}
</gl-link>
<vulnerability-comment-icon v-if="hasComments(item)" :vulnerability="item" />
</div>
......@@ -415,7 +413,7 @@ export default {
{{ useConvertReportType(item.reportType) }}
</div>
<div
v-if="hasAnyScannersOtherThanGitLab"
v-if="hasAnyScannersOtherThanGitLab && item.scanner"
data-testid="vulnerability-vendor"
class="gl-text-gray-300"
>
......
......@@ -14,10 +14,11 @@ import CsvExportButton from './csv_export_button.vue';
import DashboardNotConfiguredGroup from './empty_states/group_dashboard_not_configured.vue';
import DashboardNotConfiguredInstance from './empty_states/instance_dashboard_not_configured.vue';
import DashboardNotConfiguredProject from './empty_states/reports_not_configured.vue';
import GroupSecurityVulnerabilities from './first_class_group_security_dashboard_vulnerabilities.vue';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import GroupVulnerabilities from './first_class_group_security_dashboard_vulnerabilities.vue';
import InstanceVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import PipelineVulnerabilities from './pipeline_findings.vue';
import ProjectPipelineStatus from './project_pipeline_status.vue';
import ProjectSecurityVulnerabilities from './project_vulnerabilities.vue';
import ProjectVulnerabilities from './project_vulnerabilities.vue';
import SurveyRequestBanner from './survey_request_banner.vue';
import VulnerabilitiesCountList from './vulnerability_count_list.vue';
......@@ -25,9 +26,10 @@ export default {
components: {
AutoFixUserCallout,
SecurityDashboardLayout,
GroupSecurityVulnerabilities,
InstanceSecurityVulnerabilities,
ProjectSecurityVulnerabilities,
GroupVulnerabilities,
InstanceVulnerabilities,
ProjectVulnerabilities,
PipelineVulnerabilities,
Filters,
CsvExportButton,
SurveyRequestBanner,
......@@ -105,7 +107,7 @@ export default {
return !this.isPipeline;
},
isDashboardConfigured() {
return this.isProject
return this.isProject || this.isPipeline
? Boolean(this.pipeline?.id)
: this.projects.length > 0 && this.projectsWereFetched;
},
......@@ -144,7 +146,7 @@ export default {
@close="handleAutoFixUserCalloutClose"
/>
<security-dashboard-layout>
<template #header>
<template v-if="!isPipeline" #header>
<survey-request-banner class="gl-mt-5" />
<header class="gl-my-6 gl-display-flex gl-align-items-center">
<h2 class="gl-flex-grow-1 gl-my-0">
......@@ -158,9 +160,10 @@ export default {
<template #sticky>
<filters :projects="projects" @filterChange="handleFilterChange" />
</template>
<group-security-vulnerabilities v-if="isGroup" :filters="filters" />
<instance-security-vulnerabilities v-else-if="isInstance" :filters="filters" />
<project-security-vulnerabilities v-else-if="isProject" :filters="filters" />
<group-vulnerabilities v-if="isGroup" :filters="filters" />
<instance-vulnerabilities v-else-if="isInstance" :filters="filters" />
<project-vulnerabilities v-else-if="isProject" :filters="filters" />
<pipeline-vulnerabilities v-else-if="isPipeline" :filters="filters" />
</security-dashboard-layout>
</template>
</div>
......
#import "./vulnerability_location.fragment.graphql"
fragment Vulnerability on Vulnerability {
id
title
......@@ -23,26 +25,7 @@ fragment Vulnerability on Vulnerability {
name
}
location {
... on VulnerabilityLocationContainerScanning {
image
}
... on VulnerabilityLocationDependencyScanning {
blobPath
file
}
... on VulnerabilityLocationSast {
blobPath
file
startLine
}
... on VulnerabilityLocationSecretDetection {
blobPath
file
startLine
}
... on VulnerabilityLocationDast {
path
}
...VulnerabilityLocation
}
project {
nameWithNamespace
......
fragment VulnerabilityLocation on VulnerabilityLocation {
... on VulnerabilityLocationContainerScanning {
image
}
... on VulnerabilityLocationDependencyScanning {
blobPath
file
}
... on VulnerabilityLocationSast {
blobPath
file
startLine
}
... on VulnerabilityLocationSecretDetection {
blobPath
file
startLine
}
... on VulnerabilityLocationDast {
path
}
}
#import "~/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql"
#import "../fragments/vulnerability_location.fragment.graphql"
query pipelineFindings(
$fullPath: ID!
$pipelineId: ID!
$first: Int
$after: String
$severity: [String!]
$reportType: [String!]
$scanner: [String!]
) {
project(fullPath: $fullPath) {
pipeline(iid: $pipelineId) {
securityReportFindings(
after: $after
first: $first
severity: $severity
reportType: $reportType
scanner: $scanner
) {
nodes {
uuid
name
description
confidence
identifiers {
externalType
name
}
scanner {
vendor
}
severity
location {
...VulnerabilityLocation
}
}
pageInfo {
...PageInfo
}
}
}
}
}
import Vue from 'vue';
import PipelineSecurityDashboard from './components/pipeline_security_dashboard.vue';
import apolloProvider from './graphql/provider';
import createRouter from './router';
import createDashboardStore from './store';
import { DASHBOARD_TYPES } from './store/constants';
import { LOADING_VULNERABILITIES_ERROR_CODES } from './store/modules/vulnerabilities/constants';
......@@ -31,8 +32,11 @@ export default () => {
[LOADING_VULNERABILITIES_ERROR_CODES.FORBIDDEN]: emptyStateForbiddenSvgPath,
};
const router = createRouter();
return new Vue({
el,
router,
apolloProvider,
store: createDashboardStore({
dashboardType: DASHBOARD_TYPES.PIPELINE,
......
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import PipelineFindings from 'ee/security_dashboard/components/pipeline_findings.vue';
import VulnerabilityList from 'ee/security_dashboard/components/vulnerability_list.vue';
import pipelineFindingsQuery from 'ee/security_dashboard/graphql/queries/pipeline_findings.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockPipelineFindingsResponse } from '../mock_data';
describe('Pipeline findings', () => {
let wrapper;
const apolloMock = {
queries: { findings: { loading: true } },
};
const createWrapper = ({ props = {}, mocks, apolloProvider } = {}) => {
const localVue = createLocalVue();
if (apolloProvider) {
localVue.use(VueApollo);
}
wrapper = shallowMount(PipelineFindings, {
localVue,
apolloProvider,
provide: {
projectFullPath: 'gitlab/security-reports',
pipeline: {
id: 77,
iid: 8,
},
},
propsData: {
filters: {},
...props,
},
mocks,
});
};
const findIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findAlert = () => wrapper.find(GlAlert);
const findVulnerabilityList = () => wrapper.find(VulnerabilityList);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
afterEach(() => {
wrapper.destroy();
});
describe('when the findings are loading', () => {
beforeEach(() => {
createWrapper({ mocks: { $apollo: apolloMock } });
});
it('should show the initial loading state', () => {
expect(findVulnerabilityList().props('isLoading')).toBe(true);
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('with findings', () => {
beforeEach(() => {
createWrapper({
apolloProvider: createMockApollo([
[pipelineFindingsQuery, jest.fn().mockResolvedValue(mockPipelineFindingsResponse())],
]),
});
});
it('does not show the loading state', () => {
expect(findVulnerabilityList().props('isLoading')).toBe(false);
});
it('passes down findings', () => {
expect(findVulnerabilityList().props('vulnerabilities')).toMatchObject([
{ confidence: 'unknown', id: '322ace94-2d2a-5efa-bd62-a04c927a4b9a', severity: 'HIGH' },
{ location: { file: 'package.json' }, id: '31ad79c6-b545-5408-89af-c4e90fc21eb4' },
]);
});
it('does not show the insersection loader when there is no next page', () => {
expect(findIntersectionObserver().exists()).toBe(false);
});
});
describe('with multiple page findings', () => {
beforeEach(() => {
createWrapper({
apolloProvider: createMockApollo([
[
pipelineFindingsQuery,
jest.fn().mockResolvedValue(mockPipelineFindingsResponse({ hasNextPage: true })),
],
]),
});
});
it('shows the insersection loader', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
});
describe('with failed query', () => {
beforeEach(() => {
createWrapper({
apolloProvider: createMockApollo([
[pipelineFindingsQuery, jest.fn().mockRejectedValue(new Error('GrahpQL error'))],
]),
});
});
it('does not show the vulnerability list', () => {
expect(findVulnerabilityList().exists()).toBe(false);
});
it('shows the error', () => {
expect(findAlert().exists()).toBe(true);
});
});
});
......@@ -6,6 +6,7 @@ import IssuesBadge from 'ee/security_dashboard/components/issues_badge.vue';
import SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import VulnerabilityCommentIcon from 'ee/security_dashboard/components/vulnerability_comment_icon.vue';
import VulnerabilityList from 'ee/security_dashboard/components/vulnerability_list.vue';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import { trimText } from 'helpers/text_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
......@@ -14,7 +15,7 @@ import { generateVulnerabilities, vulnerabilities } from './mock_data';
describe('Vulnerability list component', () => {
let wrapper;
const createWrapper = ({ props = {}, listeners, provide = {} } = {}) => {
const createWrapper = ({ props = {}, listeners, provide = {}, stubs } = {}) => {
return mountExtended(VulnerabilityList, {
propsData: {
vulnerabilities: [],
......@@ -22,9 +23,11 @@ describe('Vulnerability list component', () => {
},
stubs: {
GlPopover: true,
...stubs,
},
listeners,
provide: () => ({
dashboardType: DASHBOARD_TYPES.PROJECT,
noVulnerabilitiesSvgPath: '#',
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
......@@ -43,6 +46,7 @@ describe('Vulnerability list component', () => {
const findCell = (label) => wrapper.find(`.js-${label}`);
const findRows = () => wrapper.findAll('tbody tr');
const findRow = (index = 0) => findRows().at(index);
const findColumn = (className) => wrapper.find(`[role="columnheader"].${className}`);
const findRowById = (id) => wrapper.find(`tbody tr[data-pk="${id}"`);
const findAutoFixBulbInRow = (row) => row.find('[data-testid="vulnerability-solutions-bulb"]');
const findIssuesBadge = (index = 0) => wrapper.findAllComponents(IssuesBadge).at(index);
......@@ -61,7 +65,6 @@ describe('Vulnerability list component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with vulnerabilities', () => {
......@@ -567,4 +570,27 @@ describe('Vulnerability list component', () => {
expectRowCheckboxesToBe(() => false);
});
});
describe('when it is the pipeline dashboard', () => {
beforeEach(() => {
wrapper = createWrapper({
props: { vulnerabilities },
provide: { dashboardType: DASHBOARD_TYPES.PIPELINE },
stubs: {
GlTable,
},
});
});
it.each([['detected'], ['activity']])('does not render %s column', (className) => {
expect(findColumn(className).exists()).toBe(false);
});
it.each([['status'], ['severity'], ['description'], ['identifier'], ['scanner']])(
'renders %s column',
(className) => {
expect(findColumn(className).exists()).toBe(true);
},
);
});
});
......@@ -243,3 +243,73 @@ export const mockVulnerabilitySeveritiesGraphQLResponse = ({ dashboardType }) =>
],
},
});
export const mockPipelineFindingsResponse = ({ hasNextPage } = {}) => ({
data: {
project: {
pipeline: {
securityReportFindings: {
nodes: [
{
uuid: '322ace94-2d2a-5efa-bd62-a04c927a4b9a',
name: 'growl_command-injection in growl',
description: null,
confidence: 'unknown',
identifiers: [
{
externalType: 'npm',
name: 'NPM-146',
__typename: 'VulnerabilityIdentifier',
},
],
scanner: null,
severity: 'HIGH',
location: {
__typename: 'VulnerabilityLocationDependencyScanning',
blobPath: null,
file: 'package.json',
image: null,
startLine: null,
path: null,
},
__typename: 'PipelineSecurityReportFinding',
},
{
uuid: '31ad79c6-b545-5408-89af-c4e90fc21eb4',
name:
'A prototype pollution vulnerability in handlebars may lead to remote code execution if an attacker can control the template in handlebars',
description: null,
confidence: 'unknown',
identifiers: [
{
externalType: 'retire.js',
name: 'RETIRE-JS-baf1b2b5f9a7c1dc0fb152365126e6c3',
__typename: 'VulnerabilityIdentifier',
},
],
scanner: null,
severity: 'HIGH',
location: {
__typename: 'VulnerabilityLocationDependencyScanning',
blobPath: null,
file: 'package.json',
image: null,
startLine: null,
path: null,
},
__typename: 'PipelineSecurityReportFinding',
},
],
pageInfo: {
__typename: 'PageInfo',
startCursor: 'MQ',
endCursor: hasNextPage ? 'MjA' : false,
},
__typename: 'PipelineSecurityReportFindingConnection',
},
__typename: 'Pipeline',
},
__typename: 'Project',
},
},
});
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