Commit 21ce652e authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Andrew Fontaine

Add DAST URLS modal in MR Widget

This adds a new modal to show the scanned urls
for DAST in the MR Widget, and it also provides
a way to download them as CSV
parent 6c97d4ed
import Vue from 'vue'; import Vue from 'vue';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => { export default () => {
if (gl.mrWidget) return; if (gl.mrWidget) return;
...@@ -10,7 +17,7 @@ export default () => { ...@@ -10,7 +17,7 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
const vm = new Vue(MrWidgetOptions); const vm = new Vue({ ...MrWidgetOptions, apolloProvider });
window.gl.mrWidget = { window.gl.mrWidget = {
checkStatus: vm.checkStatus, checkStatus: vm.checkStatus,
......
...@@ -431,6 +431,8 @@ export default { ...@@ -431,6 +431,8 @@ export default {
:create-vulnerability-feedback-dismissal-path="mr.createVulnerabilityFeedbackDismissalPath" :create-vulnerability-feedback-dismissal-path="mr.createVulnerabilityFeedbackDismissalPath"
:pipeline-path="mr.pipeline.path" :pipeline-path="mr.pipeline.path"
:pipeline-id="mr.securityReportsPipelineId" :pipeline-id="mr.securityReportsPipelineId"
:pipeline-iid="mr.securityReportsPipelineIid"
:project-full-path="mr.sourceProjectFullPath"
:diverged-commits-count="mr.divergedCommitsCount" :diverged-commits-count="mr.divergedCommitsCount"
:mr-state="mr.state" :mr-state="mr.state"
:target-branch-tree-path="mr.targetBranchTreePath" :target-branch-tree-path="mr.targetBranchTreePath"
......
...@@ -22,6 +22,7 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -22,6 +22,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.approvalsHelpPath = data.approvals_help_path; this.approvalsHelpPath = data.approvals_help_path;
this.codequalityHelpPath = data.codequality_help_path; this.codequalityHelpPath = data.codequality_help_path;
this.securityReportsPipelineId = data.pipeline_id; this.securityReportsPipelineId = data.pipeline_id;
this.securityReportsPipelineIid = data.pipeline_iid;
this.createVulnerabilityFeedbackIssuePath = data.create_vulnerability_feedback_issue_path; this.createVulnerabilityFeedbackIssuePath = data.create_vulnerability_feedback_issue_path;
this.createVulnerabilityFeedbackMergeRequestPath = this.createVulnerabilityFeedbackMergeRequestPath =
data.create_vulnerability_feedback_merge_request_path; data.create_vulnerability_feedback_merge_request_path;
......
<script>
import { GlModal, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__, __ } from '~/locale';
export default {
components: { GlModal, GlIcon, GlSprintf },
props: {
scannedUrls: {
required: true,
type: Array,
},
scannedResourcesCount: {
required: true,
type: Number,
},
downloadLink: {
required: true,
type: String,
},
},
modal: {
modalId: 'dastUrl',
actionPrimary: {
text: __('Close'),
attributes: { variant: 'success' },
},
},
computed: {
title() {
return n__('%d Scanned URL', '%d Scanned URLs', this.scannedResourcesCount);
},
limitedScannedUrls() {
// show only 15 scanned urls
return this.scannedUrls.slice(0, 15);
},
downloadButton() {
const buttonAttrs = {
text: __('Download as CSV'),
attributes: {
variant: 'success',
class: 'btn-secondary gl-button',
href: this.downloadLink,
download: true,
'data-testid': 'download-button',
},
};
return this.downloadLink ? buttonAttrs : null;
},
},
};
</script>
<template>
<gl-modal
:title="title"
title-tag="h5"
v-bind="$options.modal"
:action-secondary="downloadButton"
>
<div class="gl-px-3">
<!-- heading -->
<div class="gl-display-flex gl-text-gray-600">
<div class="gl-w-11">{{ __('Method') }}</div>
<div class="gl-flex-fill-1">{{ __('URL') }}</div>
</div>
<hr class="gl-my-3" />
<!-- rows -->
<div v-for="(url, index) in limitedScannedUrls" :key="index" class="gl-display-flex gl-my-2">
<div class="gl-w-11">{{ url.requestMethod.toUpperCase() }}</div>
<div
class="gl-flex-fill-1 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis"
data-testid="dast-scanned-url"
>
{{ url.url }}
</div>
</div>
<!-- banner -->
<div
v-if="downloadLink"
class="gl-display-inline-block gl-bg-gray-50 gl-my-3 gl-pl-3 gl-pr-7 gl-py-5"
>
<gl-icon name="bulb" class="gl-vertical-align-middle gl-mr-5" />
<b class="gl-vertical-align-middle">
<gl-sprintf
:message="
__('To view all %{scannedResourcesCount} scanned URLs, please download the CSV file')
"
>
<template #scannedResourcesCount>
{{ scannedResourcesCount }}
</template>
</gl-sprintf>
</b>
</div>
</div>
</gl-modal>
</template>
query($fullPath: ID!, $pipelineIid: ID!) {
project(fullPath: $fullPath) {
pipeline(iid: $pipelineIid) {
securityReportSummary {
dast {
scannedResourcesCsvPath
scannedResourcesCount
scannedResources {
nodes {
requestMethod
url
}
}
}
}
}
}
}
...@@ -9,11 +9,14 @@ import Tracking from '~/tracking'; ...@@ -9,11 +9,14 @@ import Tracking from '~/tracking';
import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue'; import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import IssueModal from './components/modal.vue'; import IssueModal from './components/modal.vue';
import DastModal from './components/dast_modal.vue';
import securityReportsMixin from './mixins/security_report_mixin'; import securityReportsMixin from './mixins/security_report_mixin';
import createStore from './store'; import createStore from './store';
import { GlSprintf, GlLink } from '@gitlab/ui'; import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
import { mrStates } from '~/mr_popover/constants'; import { mrStates } from '~/mr_popover/constants';
import { trackMrSecurityReportDetails } from 'ee/vue_shared/security_reports/store/constants'; import { trackMrSecurityReportDetails } from 'ee/vue_shared/security_reports/store/constants';
import { fetchPolicies } from '~/lib/graphql';
import securityReportSummaryQuery from './graphql/mr_security_report_summary.graphql';
export default { export default {
store: createStore(), store: createStore(),
...@@ -25,8 +28,28 @@ export default { ...@@ -25,8 +28,28 @@ export default {
Icon, Icon,
GlSprintf, GlSprintf,
GlLink, GlLink,
DastModal,
},
directives: {
'gl-modal': GlModalDirective,
}, },
mixins: [securityReportsMixin, glFeatureFlagsMixin()], mixins: [securityReportsMixin, glFeatureFlagsMixin()],
apollo: {
dastSummary: {
query: securityReportSummaryQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return {
fullPath: this.projectFullPath,
pipelineIid: this.pipelineIid,
};
},
update(data) {
const dast = data?.project?.pipeline?.securityReportSummary?.dast;
return dast && Object.keys(dast).length ? dast : null;
},
},
},
props: { props: {
enabledReports: { enabledReports: {
type: Object, type: Object,
...@@ -112,6 +135,11 @@ export default { ...@@ -112,6 +135,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
pipelineIid: {
type: Number,
required: false,
default: null,
},
pipelinePath: { pipelinePath: {
type: String, type: String,
required: false, required: false,
...@@ -137,6 +165,10 @@ export default { ...@@ -137,6 +165,10 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
projectFullPath: {
type: String,
required: true,
},
}, },
componentNames, componentNames,
computed: { computed: {
...@@ -202,6 +234,9 @@ export default { ...@@ -202,6 +234,9 @@ export default {
Tracking.event(category, action); Tracking.event(category, action);
}); });
}, },
dastDownloadLink() {
return this.dastSummary?.scannedResourcesCsvPath || '';
},
}, },
created() { created() {
...@@ -420,16 +455,17 @@ export default { ...@@ -420,16 +455,17 @@ export default {
<div class="text-nowrap"> <div class="text-nowrap">
{{ n__('%d URL scanned', '%d URLs scanned', dastScans[0].scanned_resources_count) }} {{ n__('%d URL scanned', '%d URLs scanned', dastScans[0].scanned_resources_count) }}
</div> </div>
<gl-link <gl-link v-gl-modal.dastUrl class="ml-2" data-qa-selector="dast-ci-job-link">
class="ml-2"
data-qa-selector="dast-ci-job-link"
:href="dastScans[0].job_path"
>
{{ __('View details') }} {{ __('View details') }}
</gl-link> </gl-link>
<dast-modal
v-if="dastSummary"
:scanned-urls="dastSummary.scannedResources.nodes"
:scanned-resources-count="dastSummary.scannedResourcesCount"
:download-link="dastDownloadLink"
/>
</template> </template>
</summary-row> </summary-row>
<grouped-issues-list <grouped-issues-list
v-if="dast.newIssues.length || dast.resolvedIssues.length" v-if="dast.newIssues.length || dast.resolvedIssues.length"
:unresolved-issues="dast.newIssues" :unresolved-issues="dast.newIssues"
......
...@@ -81,6 +81,10 @@ module EE ...@@ -81,6 +81,10 @@ module EE
merge_request.head_pipeline.id merge_request.head_pipeline.id
end end
expose :pipeline_iid, if: -> (mr, _) { mr.head_pipeline } do |merge_request|
merge_request.head_pipeline.iid
end
expose :can_read_vulnerability_feedback do |merge_request| expose :can_read_vulnerability_feedback do |merge_request|
can?(current_user, :read_vulnerability_feedback, merge_request.project) can?(current_user, :read_vulnerability_feedback, merge_request.project)
end end
......
---
title: Add DAST URL MR Widget modal
merge_request: 35945
author:
type: added
import { shallowMount } from '@vue/test-utils';
import Component from 'ee/vue_shared/security_reports/components/dast_modal.vue';
import { GlModal } from '@gitlab/ui';
describe('DAST Modal', () => {
let wrapper;
const defaultProps = {
scannedUrls: [{ requestMethod: 'POST', url: 'https://gitlab.com' }],
scannedResourcesCount: 1,
downloadLink: 'https://gitlab.com',
};
const findDownloadButton = () => wrapper.find('[data-testid="download-button"]');
const createWrapper = propsData => {
wrapper = shallowMount(Component, {
propsData: {
...defaultProps,
...propsData,
},
stubs: {
GlModal,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('has the download button with required attrs', () => {
expect(findDownloadButton().exists()).toBe(true);
expect(findDownloadButton().attributes('href')).toBe(defaultProps.downloadLink);
expect(findDownloadButton().attributes('download')).toBeDefined();
});
it('should contain the dynamic title', () => {
createWrapper({ scannedResourcesCount: 20 });
expect(wrapper.attributes('title')).toBe('20 Scanned URLs');
});
it('should not show download button when link is not present', () => {
createWrapper({ downloadLink: '' });
expect(findDownloadButton().exists()).toBe(false);
});
it('scanned urls should be limited to 15', () => {
createWrapper({
scannedUrls: Array(20).fill(defaultProps.scannedUrls[0]),
});
expect(wrapper.findAll('[data-testid="dast-scanned-url"]')).toHaveLength(15);
});
});
...@@ -49,12 +49,27 @@ describe('Grouped security reports app', () => { ...@@ -49,12 +49,27 @@ describe('Grouped security reports app', () => {
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json', vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path', vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123, pipelineId: 123,
projectFullPath: 'path',
}; };
const glModalDirective = jest.fn();
const createWrapper = (propsData, provide = {}) => { const createWrapper = (propsData, provide = {}) => {
wrapper = mount(GroupedSecurityReportsApp, { wrapper = mount(GroupedSecurityReportsApp, {
propsData, propsData,
data() {
return {
dastSummary: null,
};
},
provide, provide,
directives: {
glModal: {
bind(el, { value }) {
glModalDirective(value);
},
},
},
}); });
}; };
...@@ -263,6 +278,7 @@ describe('Grouped security reports app', () => { ...@@ -263,6 +278,7 @@ describe('Grouped security reports app', () => {
createWrapper({ createWrapper({
headBlobPath: 'path', headBlobPath: 'path',
pipelinePath, pipelinePath,
projectFullPath: 'path',
}); });
}); });
...@@ -365,13 +381,17 @@ describe('Grouped security reports app', () => { ...@@ -365,13 +381,17 @@ describe('Grouped security reports app', () => {
expect(wrapper.vm.$el.textContent).toContain('DAST detected 1 vulnerability'); expect(wrapper.vm.$el.textContent).toContain('DAST detected 1 vulnerability');
}); });
it('shows the scanned URLs count and a link to the CI job if available', () => { it('shows the scanned URLs count and opens a modal', async () => {
const jobLink = wrapper.find('[data-qa-selector="dast-ci-job-link"]'); const jobLink = wrapper.find('[data-qa-selector="dast-ci-job-link"]');
expect(wrapper.text()).toContain('211 URLs scanned'); expect(wrapper.text()).toContain('211 URLs scanned');
expect(jobLink.exists()).toBe(true); expect(jobLink.exists()).toBe(true);
expect(jobLink.text()).toBe('View details'); expect(jobLink.text()).toBe('View details');
expect(jobLink.attributes('href')).toBe(scanUrl);
jobLink.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(glModalDirective).toHaveBeenCalled();
}); });
it('does not show scanned resources info if there is 0 scanned URL', () => { it('does not show scanned resources info if there is 0 scanned URL', () => {
......
...@@ -71,6 +71,11 @@ msgstr "" ...@@ -71,6 +71,11 @@ msgstr ""
msgid "\"%{path}\" did not exist on \"%{ref}\"" msgid "\"%{path}\" did not exist on \"%{ref}\""
msgstr "" msgstr ""
msgid "%d Scanned URL"
msgid_plural "%d Scanned URLs"
msgstr[0] ""
msgstr[1] ""
msgid "%d URL scanned" msgid "%d URL scanned"
msgid_plural "%d URLs scanned" msgid_plural "%d URLs scanned"
msgstr[0] "" msgstr[0] ""
...@@ -8305,6 +8310,9 @@ msgstr "" ...@@ -8305,6 +8310,9 @@ msgstr ""
msgid "Download as" msgid "Download as"
msgstr "" msgstr ""
msgid "Download as CSV"
msgstr ""
msgid "Download asset" msgid "Download asset"
msgstr "" msgstr ""
...@@ -24518,6 +24526,9 @@ msgstr "" ...@@ -24518,6 +24526,9 @@ msgstr ""
msgid "To this GitLab instance" msgid "To this GitLab instance"
msgstr "" msgstr ""
msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file"
msgstr ""
msgid "To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown." msgid "To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown."
msgstr "" msgstr ""
......
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