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 {
);
Vue.set(state.modal.data.severity, 'value', vulnerability.severity);
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, 'hasIssue', Boolean(vulnerability.issue_feedback));
Vue.set(state.modal.vulnerability, 'isDismissed', Boolean(vulnerability.dismissal_feedback));
......
......@@ -27,7 +27,6 @@ export default () => ({
severity: { text: s__('Vulnerability|Severity') },
confidence: { text: s__('Vulnerability|Confidence') },
className: { text: s__('Vulnerability|Class') },
solution: { text: s__('Vulnerability|Solution') },
links: { text: s__('Vulnerability|Links') },
instances: { text: s__('Vulnerability|Instances') },
},
......
......@@ -5,9 +5,11 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import ExpandButton from '~/vue_shared/components/expand_button.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 {
components: {
SolutionCard,
SafeLink,
Modal,
LoadingButton,
......@@ -49,6 +51,15 @@ export default {
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.
*/
......@@ -93,88 +104,95 @@ export default {
class="modal-security-report-dast"
>
<slot>
<div
v-for="(field, key, index) in modal.data"
v-if="field.value"
:key="index"
class="row prepend-top-10 append-bottom-10"
>
<label class="col-sm-3 text-right font-weight-bold"> {{ field.text }}: </label>
<div class="col-sm-9 text-secondary">
<div v-if="hasInstances(field, key)" class="info-well">
<ul class="report-block-list">
<li v-for="(instance, i) in field.value" :key="i" class="report-block-list-issue">
<div class="report-block-list-icon append-right-5 failed">
<icon :size="32" name="status_failed_borderless" />
</div>
<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-link">
<safe-link
:href="instance.uri"
target="_blank"
rel="noopener noreferrer nofollow"
class="break-link"
>
{{ instance.uri }}
</safe-link>
<div class="border-white mb-0 px-3">
<div
v-for="(field, key, index) in modal.data"
v-if="field.value"
:key="index"
class="d-flex my-2"
>
<label class="col-2 text-right font-weight-bold pl-0">{{ field.text }}:</label>
<div class="col-10 pl-0 text-secondary">
<div v-if="hasInstances(field, key)" class="info-well">
<ul class="report-block-list">
<li v-for="(instance, i) in field.value" :key="i" class="report-block-list-issue">
<div class="report-block-list-icon append-right-5 failed">
<icon :size="32" name="status_failed_borderless" />
</div>
<expand-button v-if="instance.evidence">
<pre
slot="expanded"
class="block report-block-dast-code prepend-top-10 report-block-issue-code"
>
<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-link">
<safe-link
:href="instance.uri"
target="_blank"
rel="noopener noreferrer nofollow"
class="break-link"
>
{{ instance.uri }}
</safe-link>
</div>
<expand-button v-if="instance.evidence">
<pre
slot="expanded"
class="block report-block-dast-code prepend-top-10 report-block-issue-code"
>
{{ instance.evidence }}</pre
>
</expand-button>
</div>
</li>
</ul>
</div>
<template v-else-if="hasIdentifiers(field, key)">
<span v-for="(identifier, i) in field.value" :key="i">
<safe-link
v-if="identifier.url"
:class="`js-link-${key}`"
:href="identifier.url"
target="_blank"
rel="noopener noreferrer"
>
{{ identifier.name }}
</safe-link>
<span v-else> {{ identifier.name }} </span>
<span v-if="isLastValue(i, field.value)">,&nbsp;</span>
</span>
</template>
<template v-else-if="hasLinks(field, key)">
<span v-for="(link, i) in field.value" :key="i">
>
</expand-button>
</div>
</li>
</ul>
</div>
<template v-else-if="hasIdentifiers(field, key)">
<span v-for="(identifier, i) in field.value" :key="i">
<safe-link
v-if="identifier.url"
:class="`js-link-${key}`"
:href="identifier.url"
target="_blank"
rel="noopener noreferrer"
>
{{ identifier.name }}
</safe-link>
<span v-else> {{ identifier.name }} </span>
<span v-if="isLastValue(i, field.value)">,&nbsp;</span>
</span>
</template>
<template v-else-if="hasLinks(field, key)">
<span v-for="(link, i) in field.value" :key="i">
<safe-link
:class="`js-link-${key}`"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
>
{{ link.value || link.url }}
</safe-link>
<span v-if="isLastValue(i, field.value)">,&nbsp;</span>
</span>
</template>
<template v-else>
<safe-link
v-if="field.isLink"
:class="`js-link-${key}`"
:href="link.url"
:href="field.url"
target="_blank"
rel="noopener noreferrer"
>
{{ link.value || link.url }}
{{ field.value }}
</safe-link>
<span v-if="isLastValue(i, field.value)">,&nbsp;</span>
</span>
</template>
<template v-else>
<safe-link
v-if="field.isLink"
:class="`js-link-${key}`"
:href="field.url"
target="_blank"
>
{{ field.value }}
</safe-link>
<span v-else> {{ field.value }} </span>
</template>
<span v-else> {{ field.value }} </span>
</template>
</div>
</div>
</div>
<div class="row prepend-top-20 append-bottom-10">
<div class="col-sm-9 offset-sm-3 text-secondary">
<solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" />
<hr v-else />
<div class="prepend-top-20 append-bottom-10">
<div class="col-sm-12 text-secondary">
<template v-if="hasDismissedBy">
{{ s__('ciReport|Dismissed by') }}
<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 {
Vue.set(state.modal.data.severity, 'value', issue.severity);
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) {
Vue.set(state.modal.data.links, 'value', issue.links);
......
......@@ -116,11 +116,6 @@ export default () => ({
text: s__('ciReport|Confidence'),
isLink: false,
},
solution: {
value: null,
text: s__('ciReport|Solution'),
isLink: false,
},
links: {
value: [],
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', () => {
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', () => {
expect(state.modal.data.links.value).toEqual(vulnerability.links);
});
......
......@@ -149,8 +149,6 @@ describe('Security Reports modal', () => {
vulnerabilityFeedbackHelpPath: 'feedbacksHelpPath',
};
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.url = `${TEST_HOST}/path/Gemfile.lock`;
vm = mountComponent(Component, props);
......@@ -158,9 +156,6 @@ describe('Security Reports modal', () => {
it('renders keys in `data`', () => {
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', () => {
......@@ -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', () => {
// eslint-disable-next-line no-script-url
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', () => {
expect(stateCopy.modal.data.confidence.text).toEqual('Confidence');
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.text).toEqual('Links');
expect(stateCopy.modal.data.links.isLink).toEqual(false);
......@@ -450,7 +446,6 @@ describe('security reports mutations', () => {
expect(stateCopy.modal.data.identifiers.value).toEqual(issue.identifiers);
expect(stateCopy.modal.data.severity.value).toEqual(issue.severity);
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.instances.value).toEqual(issue.instances);
expect(stateCopy.modal.vulnerability).toEqual(issue);
......
......@@ -9703,9 +9703,6 @@ msgstr ""
msgid "Vulnerability|Severity"
msgstr ""
msgid "Vulnerability|Solution"
msgstr ""
msgid "Want to see the data? Please ask an administrator for access."
msgstr ""
......@@ -10233,6 +10230,12 @@ msgstr ""
msgid "ciReport|Dismissed by"
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."
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