Commit fbc099e2 authored by Mark Florian's avatar Mark Florian

Merge branch '300753-mount-graphql-vulnerabilities-for-pipeline' into 'master'

Mount vulnerability list on pipeline dashboard

See merge request gitlab-org/gitlab!61536
parents 5b3df8b5 bbd0ef54
<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 }) {
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="gl-mb-6"
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="gl-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;
......@@ -120,11 +116,17 @@ export default {
},
fields() {
const baseFields = [
{
key: 'checkbox',
class: 'checkbox',
skip: !this.shouldShowSelection,
},
{
key: 'detected',
label: s__('Vulnerability|Detected'),
class: 'detected',
sortable: this.isSortable,
skip: this.isPipelineDashboard,
},
{
key: 'state',
......@@ -160,15 +162,9 @@ export default {
label: s__('Vulnerability|Activity'),
thClass: 'gl-text-right',
class: 'activity',
skip: this.isPipelineDashboard,
},
];
if (this.shouldShowSelection) {
baseFields.unshift({
key: 'checkbox',
class: 'checkbox',
});
}
].filter((f) => !f.skip);
// Apply gl-bg-white! to every header.
baseFields.forEach((field) => {
......@@ -182,8 +178,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 +310,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 +322,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 +365,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 +411,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 PipelineFindings 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,
PipelineFindings,
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-findings 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 createWrapperWithApollo = (resolver) => {
return createWrapper({
apolloProvider: createMockApollo([[pipelineFindingsQuery, resolver]]),
});
};
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(() => {
createWrapperWithApollo(jest.fn().mockResolvedValue(mockPipelineFindingsResponse()));
});
it('passes false as the loading state prop', () => {
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(() => {
createWrapperWithApollo(
jest.fn().mockResolvedValue(mockPipelineFindingsResponse({ hasNextPage: true })),
);
});
it('shows the insersection loader', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
});
describe('with failed query', () => {
beforeEach(() => {
createWrapperWithApollo(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