Commit 9e4c7c41 authored by Lukas Eipert's avatar Lukas Eipert

Add solution card to the vulnerability modal

This adds the solution card for solutions and auto-remediate to the
vulnerability modal.
parent 6b2191ff
...@@ -78,7 +78,6 @@ export default { ...@@ -78,7 +78,6 @@ export default {
); );
Vue.set(state.modal.data.severity, 'value', vulnerability.severity); Vue.set(state.modal.data.severity, 'value', vulnerability.severity);
Vue.set(state.modal.data.confidence, 'value', vulnerability.confidence); Vue.set(state.modal.data.confidence, 'value', vulnerability.confidence);
Vue.set(state.modal.data.solution, 'value', vulnerability.solution);
Vue.set(state.modal, 'vulnerability', vulnerability); Vue.set(state.modal, 'vulnerability', vulnerability);
Vue.set(state.modal.vulnerability, 'hasIssue', Boolean(vulnerability.issue_feedback)); Vue.set(state.modal.vulnerability, 'hasIssue', Boolean(vulnerability.issue_feedback));
Vue.set(state.modal.vulnerability, 'isDismissed', Boolean(vulnerability.dismissal_feedback)); Vue.set(state.modal.vulnerability, 'isDismissed', Boolean(vulnerability.dismissal_feedback));
......
...@@ -27,7 +27,6 @@ export default () => ({ ...@@ -27,7 +27,6 @@ export default () => ({
severity: { text: s__('Vulnerability|Severity') }, severity: { text: s__('Vulnerability|Severity') },
confidence: { text: s__('Vulnerability|Confidence') }, confidence: { text: s__('Vulnerability|Confidence') },
className: { text: s__('Vulnerability|Class') }, className: { text: s__('Vulnerability|Class') },
solution: { text: s__('Vulnerability|Solution') },
links: { text: s__('Vulnerability|Links') }, links: { text: s__('Vulnerability|Links') },
instances: { text: s__('Vulnerability|Instances') }, instances: { text: s__('Vulnerability|Instances') },
}, },
......
...@@ -5,9 +5,11 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue'; ...@@ -5,9 +5,11 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import ExpandButton from '~/vue_shared/components/expand_button.vue'; import ExpandButton from '~/vue_shared/components/expand_button.vue';
import SafeLink from 'ee/vue_shared/components/safe_link.vue'; import SafeLink from 'ee/vue_shared/components/safe_link.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
export default { export default {
components: { components: {
SolutionCard,
SafeLink, SafeLink,
Modal, Modal,
LoadingButton, LoadingButton,
...@@ -49,6 +51,15 @@ export default { ...@@ -49,6 +51,15 @@ export default {
this.modal.vulnerability.dismissalFeedback.author this.modal.vulnerability.dismissalFeedback.author
); );
}, },
solution() {
return this.modal.vulnerability && this.modal.vulnerability.solution;
},
remediation() {
return this.modal.vulnerability && this.modal.vulnerability.remediation;
},
renderSolutionCard() {
return this.solution || this.remediation;
},
/** /**
* The slot for the footer should be rendered if any of the conditions is true. * The slot for the footer should be rendered if any of the conditions is true.
*/ */
...@@ -93,14 +104,15 @@ export default { ...@@ -93,14 +104,15 @@ export default {
class="modal-security-report-dast" class="modal-security-report-dast"
> >
<slot> <slot>
<div class="border-white mb-0 px-3">
<div <div
v-for="(field, key, index) in modal.data" v-for="(field, key, index) in modal.data"
v-if="field.value" v-if="field.value"
:key="index" :key="index"
class="row prepend-top-10 append-bottom-10" class="d-flex my-2"
> >
<label class="col-sm-3 text-right font-weight-bold"> {{ field.text }}: </label> <label class="col-2 text-right font-weight-bold pl-0">{{ field.text }}:</label>
<div class="col-sm-9 text-secondary"> <div class="col-10 pl-0 text-secondary">
<div v-if="hasInstances(field, key)" class="info-well"> <div v-if="hasInstances(field, key)" class="info-well">
<ul class="report-block-list"> <ul class="report-block-list">
<li v-for="(instance, i) in field.value" :key="i" class="report-block-list-issue"> <li v-for="(instance, i) in field.value" :key="i" class="report-block-list-issue">
...@@ -108,7 +120,9 @@ export default { ...@@ -108,7 +120,9 @@ export default {
<icon :size="32" name="status_failed_borderless" /> <icon :size="32" name="status_failed_borderless" />
</div> </div>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> <div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
<div class="report-block-list-issue-description-text">{{ instance.method }}</div> <div class="report-block-list-issue-description-text">
{{ instance.method }}
</div>
<div class="report-block-list-issue-description-link"> <div class="report-block-list-issue-description-link">
<safe-link <safe-link
:href="instance.uri" :href="instance.uri"
...@@ -172,9 +186,13 @@ export default { ...@@ -172,9 +186,13 @@ export default {
</template> </template>
</div> </div>
</div> </div>
</div>
<solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" />
<hr v-else />
<div class="row prepend-top-20 append-bottom-10"> <div class="prepend-top-20 append-bottom-10">
<div class="col-sm-9 offset-sm-3 text-secondary"> <div class="col-sm-12 text-secondary">
<template v-if="hasDismissedBy"> <template v-if="hasDismissedBy">
{{ s__('ciReport|Dismissed by') }} {{ s__('ciReport|Dismissed by') }}
<a :href="modal.vulnerability.dismissalFeedback.author.web_url" class="pipeline-id"> <a :href="modal.vulnerability.dismissalFeedback.author.web_url" class="pipeline-id">
......
<script>
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'SolutionCard',
components: { GlButton, Icon },
props: {
solution: {
type: String,
default: '',
},
remediation: {
type: Object,
default: null,
},
},
computed: {
solutionText() {
return (this.remediation && this.remediation.summary) || this.solution;
},
remediationDiff() {
return this.remediation && this.remediation.diff;
},
downloadUrl() {
return `data:text/plain;base64,${this.remediationDiff}`;
},
hasDiff() {
return (this.remediationDiff && this.remediationDiff.length > 0) || false;
},
},
};
</script>
<template>
<div class="card js-solution-card my-4">
<div class="card-body d-flex align-items-center">
<div class="col-2 d-flex align-items-center pl-0">
<div class="circle-icon-container" aria-hidden="true"><icon name="bulb" /></div>
<strong class="text-right flex-grow-1">{{ s__('ciReport|Solution') }}:</strong>
</div>
<span class="col-10 flex-shrink-1 pl-0">{{ solutionText }}</span>
<gl-button v-if="hasDiff" :href="downloadUrl" download="remediation.patch">
<icon name="download" /> {{ s__('ciReport|Download patch') }}
</gl-button>
</div>
<div v-if="hasDiff" class="card-footer">
<em class="text-secondary">
{{ s__('ciReport|Download and apply the patch to fix this vulnerability.') }}
</em>
</div>
</div>
</template>
...@@ -293,7 +293,6 @@ export default { ...@@ -293,7 +293,6 @@ export default {
Vue.set(state.modal.data.severity, 'value', issue.severity); Vue.set(state.modal.data.severity, 'value', issue.severity);
Vue.set(state.modal.data.confidence, 'value', issue.confidence); Vue.set(state.modal.data.confidence, 'value', issue.confidence);
Vue.set(state.modal.data.solution, 'value', issue.solution);
if (issue.links && issue.links.length > 0) { if (issue.links && issue.links.length > 0) {
Vue.set(state.modal.data.links, 'value', issue.links); Vue.set(state.modal.data.links, 'value', issue.links);
......
...@@ -116,11 +116,6 @@ export default () => ({ ...@@ -116,11 +116,6 @@ export default () => ({
text: s__('ciReport|Confidence'), text: s__('ciReport|Confidence'),
isLink: false, isLink: false,
}, },
solution: {
value: null,
text: s__('ciReport|Solution'),
isLink: false,
},
links: { links: {
value: [], value: [],
text: s__('ciReport|Links'), text: s__('ciReport|Links'),
......
---
title: Add solution card to the vulnerability modal
merge_request: 9030
author:
type: added
...@@ -236,10 +236,6 @@ describe('vulnerabilities module mutations', () => { ...@@ -236,10 +236,6 @@ describe('vulnerabilities module mutations', () => {
expect(state.modal.data.className.value).toEqual(vulnerability.location.class); expect(state.modal.data.className.value).toEqual(vulnerability.location.class);
}); });
it('should set the modal solution', () => {
expect(state.modal.data.solution.value).toEqual(vulnerability.solution);
});
it('should set the modal links', () => { it('should set the modal links', () => {
expect(state.modal.data.links.value).toEqual(vulnerability.links); expect(state.modal.data.links.value).toEqual(vulnerability.links);
}); });
......
...@@ -149,8 +149,6 @@ describe('Security Reports modal', () => { ...@@ -149,8 +149,6 @@ describe('Security Reports modal', () => {
vulnerabilityFeedbackHelpPath: 'feedbacksHelpPath', vulnerabilityFeedbackHelpPath: 'feedbacksHelpPath',
}; };
props.modal.title = 'Arbitrary file existence disclosure in Action Pack'; props.modal.title = 'Arbitrary file existence disclosure in Action Pack';
props.modal.data.solution.value =
'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8';
props.modal.data.file.value = 'Gemfile.lock'; props.modal.data.file.value = 'Gemfile.lock';
props.modal.data.file.url = `${TEST_HOST}/path/Gemfile.lock`; props.modal.data.file.url = `${TEST_HOST}/path/Gemfile.lock`;
vm = mountComponent(Component, props); vm = mountComponent(Component, props);
...@@ -158,9 +156,6 @@ describe('Security Reports modal', () => { ...@@ -158,9 +156,6 @@ describe('Security Reports modal', () => {
it('renders keys in `data`', () => { it('renders keys in `data`', () => {
expect(vm.$el.textContent).toContain('Arbitrary file existence disclosure in Action Pack'); expect(vm.$el.textContent).toContain('Arbitrary file existence disclosure in Action Pack');
expect(vm.$el.textContent).toContain(
'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
);
}); });
it('renders link fields with link', () => { it('renders link fields with link', () => {
...@@ -209,6 +204,51 @@ describe('Security Reports modal', () => { ...@@ -209,6 +204,51 @@ describe('Security Reports modal', () => {
}); });
}); });
describe('Solution Card', () => {
it('is rendered if the vulnerability has a solution', () => {
const props = {
modal: createState().modal,
};
const solution = 'Upgrade to XYZ';
props.modal.vulnerability.solution = solution;
vm = mountComponent(Component, props);
const solutionCard = vm.$el.querySelector('.js-solution-card');
expect(solutionCard).not.toBeNull();
expect(solutionCard.textContent).toContain(solution);
expect(vm.$el.querySelector('hr')).toBeNull();
});
it('is rendered if the vulnerability has a remediation', () => {
const props = {
modal: createState().modal,
};
const summary = 'Upgrade to 123';
props.modal.vulnerability.remediation = { summary };
vm = mountComponent(Component, props);
const solutionCard = vm.$el.querySelector('.js-solution-card');
expect(solutionCard).not.toBeNull();
expect(solutionCard.textContent).toContain(summary);
expect(vm.$el.querySelector('hr')).toBeNull();
});
it('is not rendered if the vulnerability has neither a remediation nor a solution but renders a HR instead.', () => {
const props = {
modal: createState().modal,
};
vm = mountComponent(Component, props);
const solutionCard = vm.$el.querySelector('.js-solution-card');
expect(solutionCard).toBeNull();
expect(vm.$el.querySelector('hr')).not.toBeNull();
});
});
describe('does not render XSS links', () => { describe('does not render XSS links', () => {
// eslint-disable-next-line no-script-url // eslint-disable-next-line no-script-url
const badUrl = 'javascript:alert("")'; const badUrl = 'javascript:alert("")';
......
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/solution_card.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { trimText } from 'spec/helpers/vue_component_helper';
describe('Solution Card', () => {
const Component = Vue.extend(component);
const solution = 'Upgrade to XYZ';
const remediation = { summary: 'Update to 123', fixes: [], diff: 'SGVsbG8gR2l0TGFi' };
let vm;
afterEach(() => {
vm.$destroy();
});
describe('computed properties', () => {
describe('solutionText', () => {
it('takes the value of solution', () => {
const props = { solution };
vm = mountComponent(Component, props);
expect(vm.solutionText).toEqual(solution);
});
it('takes the summary from a remediation', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.solutionText).toEqual(remediation.summary);
});
it('takes the summary from a remediation, if both are defined', () => {
const props = { remediation, solution };
vm = mountComponent(Component, props);
expect(vm.solutionText).toEqual(remediation.summary);
});
});
describe('remediationDiff', () => {
it('returns the base64 diff from a remediation', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.remediationDiff).toEqual(remediation.diff);
});
});
describe('hasDiff', () => {
it('is false if only the solution is defined', () => {
const props = { solution };
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(false);
});
it('is false if remediation misses a diff', () => {
const props = { remediation: { summary: 'XYZ' } };
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(false);
});
it('is true if remediation has a diff', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(true);
});
});
describe('downloadUrl', () => {
it('returns dataUrl for a remediation diff ', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.downloadUrl).toBe('data:text/plain;base64,SGVsbG8gR2l0TGFi');
});
});
});
describe('rendering', () => {
describe('with solution', () => {
beforeEach(() => {
const props = { solution };
vm = mountComponent(Component, props);
});
it('renders the solution text and label', () => {
expect(trimText(vm.$el.querySelector('.card-body').textContent)).toContain(
`Solution: ${solution}`,
);
});
it('does not render the card footer', () => {
expect(vm.$el.querySelector('.card-footer')).toBeNull();
});
it('does not render the download link', () => {
expect(vm.$el.querySelector('a')).toBeNull();
});
});
describe('with remediation', () => {
beforeEach(() => {
const props = { remediation };
vm = mountComponent(Component, props);
});
it('renders the solution text and label', () => {
expect(trimText(vm.$el.querySelector('.card-body').textContent)).toContain(
`Solution: ${remediation.summary}`,
);
});
it('renders the card footer', () => {
expect(vm.$el.querySelector('.card-footer')).not.toBeNull();
});
it('renders the download link', () => {
const linkEl = vm.$el.querySelector('a');
expect(linkEl).not.toBeNull();
expect(linkEl.getAttribute('href')).toEqual(vm.downloadUrl);
expect(linkEl.getAttribute('download')).toEqual('remediation.patch');
});
});
});
});
...@@ -374,10 +374,6 @@ describe('security reports mutations', () => { ...@@ -374,10 +374,6 @@ describe('security reports mutations', () => {
expect(stateCopy.modal.data.confidence.text).toEqual('Confidence'); expect(stateCopy.modal.data.confidence.text).toEqual('Confidence');
expect(stateCopy.modal.data.confidence.isLink).toEqual(false); expect(stateCopy.modal.data.confidence.isLink).toEqual(false);
expect(stateCopy.modal.data.solution.value).toEqual(null);
expect(stateCopy.modal.data.solution.text).toEqual('Solution');
expect(stateCopy.modal.data.solution.isLink).toEqual(false);
expect(stateCopy.modal.data.links.value).toEqual([]); expect(stateCopy.modal.data.links.value).toEqual([]);
expect(stateCopy.modal.data.links.text).toEqual('Links'); expect(stateCopy.modal.data.links.text).toEqual('Links');
expect(stateCopy.modal.data.links.isLink).toEqual(false); expect(stateCopy.modal.data.links.isLink).toEqual(false);
...@@ -450,7 +446,6 @@ describe('security reports mutations', () => { ...@@ -450,7 +446,6 @@ describe('security reports mutations', () => {
expect(stateCopy.modal.data.identifiers.value).toEqual(issue.identifiers); expect(stateCopy.modal.data.identifiers.value).toEqual(issue.identifiers);
expect(stateCopy.modal.data.severity.value).toEqual(issue.severity); expect(stateCopy.modal.data.severity.value).toEqual(issue.severity);
expect(stateCopy.modal.data.confidence.value).toEqual(issue.confidence); expect(stateCopy.modal.data.confidence.value).toEqual(issue.confidence);
expect(stateCopy.modal.data.solution.value).toEqual(issue.solution);
expect(stateCopy.modal.data.links.value).toEqual(issue.links); expect(stateCopy.modal.data.links.value).toEqual(issue.links);
expect(stateCopy.modal.data.instances.value).toEqual(issue.instances); expect(stateCopy.modal.data.instances.value).toEqual(issue.instances);
expect(stateCopy.modal.vulnerability).toEqual(issue); expect(stateCopy.modal.vulnerability).toEqual(issue);
......
...@@ -9703,9 +9703,6 @@ msgstr "" ...@@ -9703,9 +9703,6 @@ msgstr ""
msgid "Vulnerability|Severity" msgid "Vulnerability|Severity"
msgstr "" msgstr ""
msgid "Vulnerability|Solution"
msgstr ""
msgid "Want to see the data? Please ask an administrator for access." msgid "Want to see the data? Please ask an administrator for access."
msgstr "" msgstr ""
...@@ -10233,6 +10230,12 @@ msgstr "" ...@@ -10233,6 +10230,12 @@ msgstr ""
msgid "ciReport|Dismissed by" msgid "ciReport|Dismissed by"
msgstr "" msgstr ""
msgid "ciReport|Download and apply the patch to fix this vulnerability."
msgstr ""
msgid "ciReport|Download patch"
msgstr ""
msgid "ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application." msgid "ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application."
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