Commit 887b3f1a authored by Phil Hughes's avatar Phil Hughes

Merge branch '4310-security-reports-step-2' into 'master'

Groups reports in MR view and splits reports in CI view

Closes #4310, #5105, #4464, and #5129

See merge request gitlab-org/gitlab-ee!5065
parents a990e800 edeab723
...@@ -7,8 +7,9 @@ import pipelineGraph from './components/graph/graph_component.vue'; ...@@ -7,8 +7,9 @@ import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue'; import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub'; import eventHub from './event_hub';
import SecurityReportApp from 'ee/pipelines/components/security_reports/security_report_app.vue'; // eslint-disable-line import/first import SecurityReportApp from 'ee/vue_shared/security_reports/split_security_reports_app.vue'; // eslint-disable-line import/first
import SastSummaryWidget from 'ee/pipelines/components/security_reports/report_summary_widget.vue'; // eslint-disable-line import/first import SastSummaryWidget from 'ee/pipelines/components/security_reports/report_summary_widget.vue'; // eslint-disable-line import/first
import store from 'ee/vue_shared/security_reports/store'; // eslint-disable-line import/first
Vue.use(Translate); Vue.use(Translate);
...@@ -99,54 +100,23 @@ export default () => { ...@@ -99,54 +100,23 @@ export default () => {
const blobPath = datasetOptions.blobPath; const blobPath = datasetOptions.blobPath;
const dependencyScanningEndpoint = datasetOptions.dependencyScanningEndpoint; const dependencyScanningEndpoint = datasetOptions.dependencyScanningEndpoint;
if (endpoint) {
mediator.fetchSastReport(endpoint, blobPath)
.then(() => {
// update the badge
if (mediator.store.state.securityReports.sast.newIssues.length) {
updateBadgeCount(mediator.store.state.securityReports.sast.newIssues.length);
}
})
.catch(() => {
Flash(__('Something went wrong while fetching SAST.'));
});
}
if (dependencyScanningEndpoint) {
mediator.fetchDependencyScanningReport(dependencyScanningEndpoint)
.then(() => {
// update the badge
if (mediator.store.state.securityReports.dependencyScanning.newIssues.length) {
updateBadgeCount(
mediator.store.state.securityReports.dependencyScanning.newIssues.length,
);
}
})
.catch(() => {
Flash(__('Something went wrong while fetching Dependency Scanning.'));
});
}
// Widget summary // Widget summary
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: sastSummary, el: sastSummary,
store,
components: { components: {
SastSummaryWidget, SastSummaryWidget,
}, },
data() { methods: {
return { updateBadge(count) {
mediator, updateBadgeCount(count);
}; },
}, },
render(createElement) { render(createElement) {
return createElement('sast-summary-widget', { return createElement('sast-summary-widget', {
props: { on: {
hasDependencyScanning: dependencyScanningEndpoint !== undefined, updateBadgeCount: this.updateBadge,
hasSast: endpoint !== undefined,
sastIssues: this.mediator.store.state.securityReports.sast.newIssues.length,
dependencyScanningIssues:
this.mediator.store.state.securityReports.dependencyScanning.newIssues.length,
}, },
}); });
}, },
...@@ -156,20 +126,24 @@ export default () => { ...@@ -156,20 +126,24 @@ export default () => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: securityTab, el: securityTab,
store,
components: { components: {
SecurityReportApp, SecurityReportApp,
}, },
data() { methods: {
return { updateBadge(count) {
mediator, updateBadgeCount(count);
}; },
}, },
render(createElement) { render(createElement) {
return createElement('security-report-app', { return createElement('security-report-app', {
props: { props: {
securityReports: this.mediator.store.state.securityReports, headBlobPath: blobPath,
hasDependencyScanning: dependencyScanningEndpoint !== undefined, sastHeadPath: endpoint,
hasSast: endpoint !== undefined, dependencyScanningHeadPath: dependencyScanningEndpoint,
},
on: {
updateBadgeCount: this.updateBadge,
}, },
}); });
}, },
......
...@@ -56,23 +56,4 @@ export default class pipelinesMediator { ...@@ -56,23 +56,4 @@ export default class pipelinesMediator {
.then(response => this.successCallback(response)) .then(response => this.successCallback(response))
.catch(() => this.errorCallback()); .catch(() => this.errorCallback());
} }
/**
* EE only
*/
fetchSastReport(endpoint, blobPath) {
return PipelineService.getSecurityReport(endpoint)
.then(response => response.json())
.then((data) => {
this.store.storeSastReport(data, blobPath);
});
}
fetchDependencyScanningReport(endpoint, blobPath) {
return PipelineService.getSecurityReport(endpoint)
.then(response => response.json())
.then((data) => {
this.store.storeDependencyScanningReport(data, blobPath);
});
}
} }
import securityState from 'ee/vue_shared/security_reports/helpers/state';
import {
setSastReport,
} from 'ee/vue_shared/security_reports/helpers/utils';
export default class PipelineStore { export default class PipelineStore {
constructor() { constructor() {
this.state = {}; this.state = {};
this.state.pipeline = {}; this.state.pipeline = {};
/* EE only */
this.state.securityReports = securityState;
} }
storePipeline(pipeline = {}) { storePipeline(pipeline = {}) {
this.state.pipeline = pipeline; this.state.pipeline = pipeline;
} }
/**
* EE only
*/
storeSastReport(data, blobPath) {
Object.assign(
this.state.securityReports.sast,
setSastReport({ head: data, headBlobPath: blobPath }),
);
}
storeDependencyScanningReport(data, blobPath) {
Object.assign(
this.state.securityReports.dependencyScanning,
setSastReport({ head: data, headBlobPath: blobPath }),
);
}
} }
...@@ -28,6 +28,10 @@ ...@@ -28,6 +28,10 @@
border-top: solid 1px $border-color; border-top: solid 1px $border-color;
} }
.mr-widget-border-top {
border-top: 1px solid $border-color;
}
.mr-widget-footer { .mr-widget-footer {
padding: 0; padding: 0;
} }
......
...@@ -27,6 +27,11 @@ ...@@ -27,6 +27,11 @@
window.gl.mrWidgetData.enable_squash_before_merge = '#{@merge_request.project.feature_available?(:merge_request_squash)}' === 'true'; window.gl.mrWidgetData.enable_squash_before_merge = '#{@merge_request.project.feature_available?(:merge_request_squash)}' === 'true';
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
window.gl.mrWidgetData.sast_help_path = '#{help_page_path("user/project/merge_requests/sast")}';
window.gl.mrWidgetData.sast_container_help_path = '#{help_page_path("user/project/merge_requests/container_scanning")}';
window.gl.mrWidgetData.dast_help_path = '#{help_page_path("user/project/merge_requests/dast")}';
window.gl.mrWidgetData.dependency_scanning_help_path = '#{help_page_path("user/project/merge_requests/dependency_scanning")}';
#js-vue-mr-widget.mr-widget #js-vue-mr-widget.mr-widget
.content-block.content-block-small.emoji-list-container.js-noteable-awards .content-block.content-block-small.emoji-list-container.js-noteable-awards
......
...@@ -65,4 +65,6 @@ ...@@ -65,4 +65,6 @@
#js-tab-security.build-security.tab-pane #js-tab-security.build-security.tab-pane
#js-security-report-app{ data: { endpoint: expose_sast_data ? sast_artifact_url(@pipeline) : nil, #js-security-report-app{ data: { endpoint: expose_sast_data ? sast_artifact_url(@pipeline) : nil,
blob_path: blob_path, blob_path: blob_path,
dependency_scanning_endpoint: expose_dependency_data ? dependency_scanning_artifact_url(@pipeline) : nil} } dependency_scanning_endpoint: expose_dependency_data ? dependency_scanning_artifact_url(@pipeline) : nil,
sast_help_path: help_page_path('user/project/merge_requests/sast'),
dependency_scanning_help_path: help_page_path('user/project/merge_requests/dependency_scanning')} }
<script> <script>
import $ from 'jquery'; import { mapState } from 'vuex';
import { n__, s__ } from '~/locale'; import $ from 'jquery';
import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { n__, s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
export default { export default {
name: 'SummaryReport', name: 'SummaryReport',
components: { components: {
CiIcon, CiIcon,
LoadingIcon,
},
computed: {
...mapState(['sast', 'dependencyScanning']),
sastLink() {
return this.link(this.sast.newIssues.length);
}, },
props: { dependencyScanningLink() {
sastIssues: { return this.link(this.dependencyScanning.newIssues.length);
type: Number,
required: false,
default: 0,
},
dependencyScanningIssues: {
type: Number,
required: false,
default: 0,
},
hasDependencyScanning: {
type: Boolean,
required: false,
default: false,
},
hasSast: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { sastIcon() {
sastLink() { return this.statusIcon(this.hasSastError, this.sast.newIssues.length);
return this.link(this.sastIssues);
},
dependencyScanningLink() {
return this.link(this.dependencyScanningIssues);
},
sastIcon() {
return this.statusIcon(this.sastIssues);
},
dependencyScanningIcon() {
return this.statusIcon(this.dependencyScanningIssues);
},
}, },
methods: { dependencyScanningIcon() {
openTab() { return this.statusIcon(
// This opens a tab outside of this Vue application this.hasDependencyScanningError,
// It opens the securty report tab in the pipelines page and updates the URL this.dependencyScanning.newIssues.length,
// This is needed because the tabs are built in haml+jquery );
$('.pipelines-tabs a[data-action="security"]').tab('show'); },
}, hasSast() {
link(issues) { return this.sast.paths.head !== null;
if (issues > 0) { },
return n__( hasDependencyScanning() {
'%d vulnerability', return this.dependencyScanning.paths.head !== null;
'%d vulnerabilities', },
issues, isLoadingSast() {
); return this.sast.isLoading;
} },
return s__('ciReport|no vulnerabilities'); isLoadingDependencyScanning() {
}, return this.dependencyScanning.isLoading;
statusIcon(issues) { },
if (issues > 0) { hasSastError() {
return { return this.sast.hasError;
group: 'warning', },
icon: 'status_warning', hasDependencyScanningError() {
}; return this.dependencyScanning.hasError;
} },
},
methods: {
openTab() {
// This opens a tab outside of this Vue application
// It opens the securty report tab in the pipelines page and updates the URL
// This is needed because the tabs are built in haml+jquery
$('.pipelines-tabs a[data-action="security"]').tab('show');
},
link(issuesCount = 0) {
if (issuesCount > 0) {
return n__('%d vulnerability', '%d vulnerabilities', issuesCount);
}
return s__('ciReport|no vulnerabilities');
},
statusIcon(failed = true, issuesCount = 0) {
if (issuesCount > 0 || failed) {
return { return {
group: 'success', group: 'warning',
icon: 'status_success', icon: 'status_warning',
}; };
}, }
return {
group: 'success',
icon: 'status_success',
};
}, },
}; },
};
</script> </script>
<template> <template>
<div> <div>
...@@ -82,7 +81,12 @@ ...@@ -82,7 +81,12 @@
class="well-segment flex js-sast-summary" class="well-segment flex js-sast-summary"
v-if="hasSast" v-if="hasSast"
> >
<loading-icon
v-if="isLoadingSast"
/>
<ci-icon <ci-icon
v-else
:status="sastIcon" :status="sastIcon"
class="flex flex-align-self-center" class="flex flex-align-self-center"
/> />
...@@ -90,21 +94,33 @@ ...@@ -90,21 +94,33 @@
<span <span
class="prepend-left-10 flex flex-align-self-center" class="prepend-left-10 flex flex-align-self-center"
> >
{{ s__('ciReport|SAST detected') }} <template v-if="hasSastError">
<button {{ s__('ciReport|SAST resulted in error while loading results') }}
type="button" </template>
class="btn-link btn-blank prepend-left-5" <template v-else-if="isLoadingSast">
@click="openTab" {{ s__('ciReport|SAST is loading') }}
> </template>
{{ sastLink }} <template v-else>
</button> {{ s__('ciReport|SAST detected') }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ sastLink }}
</button>
</template>
</span> </span>
</div> </div>
<div <div
class="well-segment flex js-dss-summary" class="well-segment flex js-dss-summary"
v-if="hasDependencyScanning" v-if="hasDependencyScanning"
> >
<loading-icon
v-if="dependencyScanning.isLoading"
/>
<ci-icon <ci-icon
v-else
:status="dependencyScanningIcon" :status="dependencyScanningIcon"
class="flex flex-align-self-center" class="flex flex-align-self-center"
/> />
...@@ -112,14 +128,22 @@ ...@@ -112,14 +128,22 @@
<span <span
class="prepend-left-10 flex flex-align-self-center" class="prepend-left-10 flex flex-align-self-center"
> >
{{ s__('ciReport|Dependency scanning detected') }} <template v-if="hasDependencyScanningError">
<button {{ s__('ciReport|Dependency scanning resulted in error while loading results') }}
type="button" </template>
class="btn-link btn-blank prepend-left-5" <template v-else-if="isLoadingDependencyScanning">
@click="openTab" {{ s__('ciReport|Dependency scanning is loading') }}
> </template>
{{ dependencyScanningLink }} <template v-else>
</button> {{ s__('ciReport|Dependency scanning detected') }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ dependencyScanningLink }}
</button>
</template>
</span> </span>
</div> </div>
</div> </div>
......
<script>
import ReportSection from 'ee/vue_shared/security_reports/components/report_section.vue';
import securityMixin from 'ee/vue_shared/security_reports/mixins/security_report_mixin';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import { SAST } from 'ee/vue_shared/security_reports/helpers/constants';
export default {
name: 'SecurityReportTab',
components: {
LoadingIcon,
ReportSection,
},
mixins: [securityMixin],
sast: SAST,
props: {
securityReports: {
type: Object,
required: true,
},
hasDependencyScanning: {
type: Boolean,
required: false,
default: false,
},
hasSast: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div class="pipeline-tab-content">
<report-section
v-if="hasSast"
class="js-sast-widget"
:type="$options.sast"
:status="checkReportStatus(securityReports.sast.isLoading, securityReports.sast.hasError)"
:loading-text="translateText('security').loading"
:error-text="translateText('security').error"
:success-text="sastText(securityReports.sast.newIssues, securityReports.sast.resolvedIssues)"
:unresolved-issues="securityReports.sast.newIssues"
:resolved-issues="securityReports.sast.resolvedIssues"
:all-issues="securityReports.sast.allIssues"
/>
<report-section
v-if="hasDependencyScanning"
class="js-dependency-scanning-widget"
:class="{ 'prepend-top-20': hasSast }"
:type="$options.sast"
:status="checkReportStatus(
securityReports.dependencyScanning.isLoading,
securityReports.dependencyScanning.hasError
)"
:loading-text="translateText('dependency scanning').loading"
:error-text="translateText('dependency scanning').error"
:success-text="depedencyScanningText(
securityReports.dependencyScanning.newIssues,
securityReports.dependencyScanning.resolvedIssues
)"
:unresolved-issues="securityReports.dependencyScanning.newIssues"
:resolved-issues="securityReports.dependencyScanning.resolvedIssues"
:all-issues="securityReports.dependencyScanning.allIssues"
/>
</div>
</template>
import CEMergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; import CEMergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
import { import { filterByKey } from '../../vue_shared/security_reports/store/utils';
parseCodeclimateMetrics,
filterByKey,
setSastContainerReport,
setSastReport,
setDastReport,
} from '../../vue_shared/security_reports/helpers/utils';
export default class MergeRequestStore extends CEMergeRequestStore { export default class MergeRequestStore extends CEMergeRequestStore {
constructor(data) { constructor(data) {
...@@ -14,13 +8,17 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -14,13 +8,17 @@ export default class MergeRequestStore extends CEMergeRequestStore {
const blobPath = data.blob_path || {}; const blobPath = data.blob_path || {};
this.headBlobPath = blobPath.head_path || ''; this.headBlobPath = blobPath.head_path || '';
this.baseBlobPath = blobPath.base_path || ''; this.baseBlobPath = blobPath.base_path || '';
this.sast = data.sast || {};
this.sastContainer = data.sast_container || {};
this.dast = data.dast || {};
this.dependencyScanning = data.dependency_scanning || {};
this.sastHelp = data.sast_help_path;
this.sastContainerHelp = data.sast_container_help_path;
this.dastHelp = data.dast_help_path;
this.dependencyScanningHelp = data.dependency_scanning_help_path;
this.initCodeclimate(data); this.initCodeclimate(data);
this.initPerformanceReport(data); this.initPerformanceReport(data);
this.initSecurityReport(data);
this.initDockerReport(data);
this.initDastReport(data);
this.initDependencyScanningReport(data);
} }
setData(data) { setData(data) {
...@@ -70,69 +68,13 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -70,69 +68,13 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.performanceMetrics = { this.performanceMetrics = {
improved: [], improved: [],
degraded: [], degraded: [],
neutral: [],
}; };
} }
initSecurityReport(data) {
this.sast = data.sast;
this.securityReport = {
newIssues: [],
resolvedIssues: [],
allIssues: [],
};
}
initDockerReport(data) {
this.sastContainer = data.sast_container;
this.dockerReport = {
approved: [],
unapproved: [],
vulnerabilities: [],
};
}
initDastReport(data) {
this.dast = data.dast;
this.dastReport = [];
}
initDependencyScanningReport(data) {
this.dependencyScanning = data.dependency_scanning;
this.dependencyScanningReport = {
newIssues: [],
resolvedIssues: [],
allIssues: [],
};
}
setSecurityReport(data) {
const report = setSastReport(data);
this.securityReport.newIssues = report.newIssues;
this.securityReport.resolvedIssues = report.resolvedIssues;
this.securityReport.allIssues = report.allIssues;
}
setDockerReport(data = {}) {
const report = setSastContainerReport(data);
this.dockerReport.approved = report.approved;
this.dockerReport.unapproved = report.unapproved;
this.dockerReport.vulnerabilities = report.vulnerabilities;
}
setDastReport(data) {
this.dastReport = setDastReport(data);
}
setDependencyScanningReport(data) {
const report = setSastReport(data);
this.dependencyScanningReport.newIssues = report.newIssues;
this.dependencyScanningReport.resolvedIssues = report.resolvedIssues;
this.dependencyScanningReport.allIssues = report.allIssues;
}
compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) { compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) {
const parsedHeadIssues = parseCodeclimateMetrics(headIssues, headBlobPath); const parsedHeadIssues = MergeRequestStore.parseCodeclimateMetrics(headIssues, headBlobPath);
const parsedBaseIssues = parseCodeclimateMetrics(baseIssues, baseBlobPath); const parsedBaseIssues = MergeRequestStore.parseCodeclimateMetrics(baseIssues, baseBlobPath);
this.codeclimateMetrics.newIssues = filterByKey( this.codeclimateMetrics.newIssues = filterByKey(
parsedHeadIssues, parsedHeadIssues,
...@@ -202,4 +144,30 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -202,4 +144,30 @@ export default class MergeRequestStore extends CEMergeRequestStore {
return indexedSubjects; return indexedSubjects;
} }
static parseCodeclimateMetrics(issues = [], path = '') {
return issues.map(issue => {
const parsedIssue = {
...issue,
name: issue.description,
};
if (issue.location) {
let parseCodeQualityUrl;
if (issue.location.path) {
parseCodeQualityUrl = `${path}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
if (issue.location.lines && issue.location.lines.begin) {
parsedIssue.line = issue.location.lines.begin;
parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
}
parsedIssue.urlPath = parseCodeQualityUrl;
}
}
return parsedIssue;
});
}
} }
<script> <script>
/** /**
* Renders DAST body text * Renders DAST body text
* [priority]: [name] * [priority]: [name]
*/ */
export default {
name: 'SastIssueBody',
props: {
issue: {
type: Object,
required: true,
},
issueIndex: { export default {
type: Number, name: 'SastIssueBody',
required: true, props: {
}, issue: {
type: Object,
required: true,
},
issueIndex: {
type: Number,
required: true,
},
modalTargetId: { modalTargetId: {
type: String, type: String,
required: true, required: true,
},
}, },
},
methods: { methods: {
openDastModal() { openDastModal() {
this.$emit('openDastModal', this.issue, this.issueIndex); this.$emit('openDastModal', this.issue, this.issueIndex);
},
}, },
}; },
};
</script> </script>
<template> <template>
<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">
......
<script>
import CiIcon from '~/vue_shared/components/ci_icon.vue';
/**
* Renders the error row for each security report
*/
export default {
name: 'SecurityErrorRow',
components: {
CiIcon,
},
computed: {
iconStatus() {
return {
group: 'warning',
icon: 'status_warning',
};
},
},
};
</script>
<template>
<div class="report-block-list-issue prepend-left-default append-right-default">
<div class="report-block-list-icon append-right-10 prepend-left-5">
<ci-icon :status="iconStatus" />
</div>
<div class="report-block-list-issue-description">
{{ __("There was an error loading results") }}
</div>
</div>
</template>
<script> <script>
import $ from 'jquery';
import _ from 'underscore';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import popover from '~/vue_shared/directives/popover'; import {
togglePopover,
inserted,
mouseenter,
mouseleave,
} from '~/feature_highlight/feature_highlight_helper';
export default { export default {
name: 'SecurityReportsHelpPopover', name: 'SecurityReportsHelpPopover',
components: { components: {
Icon, Icon,
}, },
directives: {
popover,
},
props: { props: {
options: { options: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
computed: { mounted() {
popoverOptions() { $(this.$el)
return { .popover({
html: true, html: true,
trigger: 'focus', trigger: 'focus',
container: 'body',
placement: 'top', placement: 'top',
template: template:
'<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-title"></p><div class="popover-content"></div></div>', '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-title"></p><div class="popover-content"></div></div>',
...this.options, ...this.options,
}; })
}, .on('mouseenter', mouseenter)
.on('mouseleave', _.debounce(mouseleave, 300))
.on('inserted.bs.popover', inserted)
.on('show.bs.popover', () => {
window.addEventListener('scroll', togglePopover.bind(this.$el, false), { once: true });
});
}, },
}; };
</script> </script>
<template> <template>
<button <button
type="button" type="button"
class="btn btn-transparent" class="btn btn-blank btn-transparent btn-help"
v-popover="popoverOptions"
tabindex="0" tabindex="0"
> >
<icon name="question" /> <icon name="question" />
......
<script>
import IssuesBlock from './report_issues.vue';
import SastContainerInfo from './sast_container_info.vue';
import { SAST_CONTAINER } from '../store/constants';
/**
* Renders block of issues
*/
export default {
components: {
IssuesBlock,
SastContainerInfo,
},
sastContainer: SAST_CONTAINER,
props: {
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
},
neutralIssues: {
type: Array,
required: false,
default: () => [],
},
allIssues: {
type: Array,
required: false,
default: () => [],
},
isFullReportVisible: {
type: Boolean,
required: false,
default: false,
},
type: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="report-block-container">
<sast-container-info v-if="type === $options.sastContainer" />
<issues-block
class="js-mr-code-new-issues"
v-if="unresolvedIssues.length"
:type="type"
status="failed"
:issues="unresolvedIssues"
/>
<issues-block
class="js-mr-code-all-issues"
v-if="isFullReportVisible"
:type="type"
status="failed"
:issues="allIssues"
/>
<issues-block
class="js-mr-code-non-issues"
v-if="neutralIssues.length"
:type="type"
status="neutral"
:issues="neutralIssues"
/>
<issues-block
class="js-mr-code-resolved-issues"
v-if="resolvedIssues.length"
:type="type"
status="success"
:issues="resolvedIssues"
/>
</div>
</template>
<script>
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
/**
* Renders the loading row for each security report
*/
export default {
name: 'SecurityLoadingRow',
components: {
LoadingIcon,
},
};
</script>
<template>
<div class="report-block-list-issue prepend-left-default append-right-default">
<div class="report-block-list-icon append-right-10 prepend-left-5">
<loading-icon />
</div>
<div class="report-block-list-issue-description">
{{ __("in progress") }}
</div>
</div>
</template>
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import Modal from '~/vue_shared/components/gl_modal.vue'; import Modal from '~/vue_shared/components/gl_modal.vue';
import ExpandButton from '~/vue_shared/components/expand_button.vue'; import ExpandButton from '~/vue_shared/components/expand_button.vue';
import PerformanceIssue from 'ee/vue_merge_request_widget/components/performance_issue_body.vue'; import PerformanceIssue from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import CodequalityIssue from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue'; import CodequalityIssue from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue';
import SastIssue from './sast_issue_body.vue'; import SastIssue from './sast_issue_body.vue';
import SastContainerIssue from './sast_container_issue_body.vue'; import SastContainerIssue from './sast_container_issue_body.vue';
import DastIssue from './dast_issue_body.vue'; import DastIssue from './dast_issue_body.vue';
import { SAST, DAST, SAST_CONTAINER } from '../helpers/constants'; import { SAST, DAST, SAST_CONTAINER } from '../store/constants';
const modalDefaultData = { const modalDefaultData = {
modalId: 'modal-mrwidget-issue', modalId: 'modal-mrwidget-issue',
modalDesc: '', modalDesc: '',
modalTitle: '', modalTitle: '',
modalInstances: [], modalInstances: [],
modalTargetId: '#modal-mrwidget-issue', modalTargetId: '#modal-mrwidget-issue',
}; };
export default { export default {
name: 'ReportIssues', name: 'ReportIssues',
components: { components: {
Modal, Modal,
Icon, Icon,
ExpandButton, ExpandButton,
SastIssue, SastIssue,
SastContainerIssue, SastContainerIssue,
DastIssue, DastIssue,
PerformanceIssue, PerformanceIssue,
CodequalityIssue, CodequalityIssue,
}, },
props: { props: {
issues: { issues: {
type: Array, type: Array,
required: true, required: true,
}, },
// security || codequality || performance || docker || dast // security || codequality || performance || docker || dast
type: { type: {
type: String, type: String,
required: true, required: true,
}, },
// failed || success // failed || success
status: { status: {
type: String, type: String,
required: true, required: true,
}, },
}, },
data() { data() {
return modalDefaultData; return modalDefaultData;
}, },
computed: { computed: {
iconName() { iconName() {
if (this.isStatusFailed) { if (this.isStatusFailed) {
return 'status_failed_borderless'; return 'status_failed_borderless';
} else if (this.isStatusSuccess) { } else if (this.isStatusSuccess) {
return 'status_success_borderless'; return 'status_success_borderless';
} }
return 'status_created_borderless'; return 'status_created_borderless';
}, },
isStatusFailed() { isStatusFailed() {
return this.status === 'failed'; return this.status === 'failed';
}, },
isStatusSuccess() { isStatusSuccess() {
return this.status === 'success'; return this.status === 'success';
}, },
isStatusNeutral() { isStatusNeutral() {
return this.status === 'neutral'; return this.status === 'neutral';
}, },
isTypeCodequality() { isTypeCodequality() {
return this.type === 'codequality'; return this.type === 'codequality';
}, },
isTypePerformance() { isTypePerformance() {
return this.type === 'performance'; return this.type === 'performance';
}, },
isTypeSast() { isTypeSast() {
return this.type === SAST; return this.type === SAST;
}, },
isTypeSastContainer() { isTypeSastContainer() {
return this.type === SAST_CONTAINER; return this.type === SAST_CONTAINER;
}, },
isTypeDast() { isTypeDast() {
return this.type === DAST; return this.type === DAST;
}, },
}, },
mounted() { mounted() {
$(this.$refs.modal).on('hidden.bs.modal', () => { $(this.$refs.modal).on('hidden.bs.modal', () => {
this.clearModalData(); this.clearModalData();
}); });
}, },
methods: { methods: {
getmodalId(index) { getmodalId(index) {
return `modal-mrwidget-issue-${index}`; return `modal-mrwidget-issue-${index}`;
}, },
modalIdTarget(index) { modalIdTarget(index) {
return `#${this.getmodalId(index)}`; return `#${this.getmodalId(index)}`;
}, },
openDastModal(issue, index) { openDastModal(issue, index) {
this.modalId = this.getmodalId(index); this.modalId = this.getmodalId(index);
this.modalTitle = `${issue.priority}: ${issue.name}`; this.modalTitle = `${issue.priority}: ${issue.name}`;
this.modalTargetId = `#${this.getmodalId(index)}`; this.modalTargetId = `#${this.getmodalId(index)}`;
this.modalInstances = issue.instances; this.modalInstances = issue.instances;
this.modalDesc = issue.parsedDescription; this.modalDesc = issue.parsedDescription;
}, },
/** /**
* Because of https://vuejs.org/v2/guide/list.html#Caveats * Because of https://vuejs.org/v2/guide/list.html#Caveats
* we need to clear the instances to make sure everything is properly reset. * we need to clear the instances to make sure everything is properly reset.
*/ */
clearModalData() { clearModalData() {
this.modalId = modalDefaultData.modalId; this.modalId = modalDefaultData.modalId;
this.modalDesc = modalDefaultData.modalDesc; this.modalDesc = modalDefaultData.modalDesc;
this.modalTitle = modalDefaultData.modalTitle; this.modalTitle = modalDefaultData.modalTitle;
this.modalInstances = modalDefaultData.modalInstances; this.modalInstances = modalDefaultData.modalInstances;
this.modalTargetId = modalDefaultData.modalTargetId; this.modalTargetId = modalDefaultData.modalTargetId;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
......
<script> <script>
export default { export default {
name: 'ReportIssueLink', name: 'ReportIssueLink',
props: { props: {
issue: { issue: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}; },
};
</script> </script>
<template> <template>
<div class="report-block-list-issue-description-link"> <div class="report-block-list-issue-description-link">
......
<script> <script>
import { __ } from '~/locale'; import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import IssuesBlock from './report_issues.vue'; import IssuesList from './issues_list.vue';
import Popover from './help_popover.vue';
export default { import { LOADING, ERROR, SUCCESS } from '../store/constants';
name: 'ReportSection',
components: { export default {
IssuesBlock, name: 'ReportSection',
LoadingIcon, components: {
StatusIcon, IssuesList,
}, LoadingIcon,
props: { StatusIcon,
isCollapsible: { Popover,
type: Boolean, },
required: false, props: {
default: true, type: {
}, type: String,
// security | codequality | performance | docker required: false,
type: { default: '',
type: String, },
required: true, status: {
}, type: String,
// loading | success | error required: true,
status: { },
type: String, loadingText: {
required: true, type: String,
}, required: false,
loadingText: { default: '',
type: String, },
required: true, errorText: {
}, type: String,
errorText: { required: false,
type: String, default: '',
required: true, },
}, successText: {
successText: { type: String,
type: String, required: true,
required: true, },
}, unresolvedIssues: {
unresolvedIssues: { type: Array,
type: Array, required: false,
required: false, default: () => [],
default: () => ([]), },
}, resolvedIssues: {
resolvedIssues: { type: Array,
type: Array, required: false,
required: false, default: () => [],
default: () => ([]), },
}, neutralIssues: {
neutralIssues: { type: Array,
type: Array, required: false,
required: false, default: () => [],
default: () => ([]), },
}, allIssues: {
allIssues: { type: Array,
type: Array, required: false,
required: false, default: () => [],
default: () => ([]), },
}, infoText: {
infoText: { type: [String, Boolean],
type: [String, Boolean], required: false,
required: false, default: false,
default: false, },
}, hasIssues: {
hasPriority: { type: Boolean,
type: Boolean, required: true,
required: false, },
default: false, popoverOptions: {
}, type: Object,
default: () => ({}),
required: false,
},
},
data() {
return {
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
};
},
computed: {
isLoading() {
return this.status === LOADING;
},
loadingFailed() {
return this.status === ERROR;
}, },
isSuccess() {
return this.status === SUCCESS;
},
statusIconName() {
if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
return 'warning';
}
return 'success';
},
headerText() {
if (this.isLoading) {
return this.loadingText;
}
data() { if (this.isSuccess) {
if (this.isCollapsible) { return this.successText;
return {
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
};
} }
return { if (this.loadingFailed) {
isFullReportVisible: true, return this.errorText;
}; }
},
computed: { return '';
isLoading() { },
return this.status === 'loading'; hasPopover() {
}, return Object.keys(this.popoverOptions).length > 0;
loadingFailed() {
return this.status === 'error';
},
isSuccess() {
return this.status === 'success';
},
statusIconName() {
if (this.loadingFailed ||
this.unresolvedIssues.length ||
this.neutralIssues.length) {
return 'warning';
}
return 'success';
},
hasIssues() {
return this.unresolvedIssues.length ||
this.resolvedIssues.length ||
this.allIssues.length ||
this.neutralIssues.length;
},
}, },
},
methods: { methods: {
toggleCollapsed() { toggleCollapsed() {
this.isCollapsed = !this.isCollapsed; this.isCollapsed = !this.isCollapsed;
const text = this.isCollapsed ? __('Expand') : __('Collapse'); const text = this.isCollapsed ? __('Expand') : __('Collapse');
this.collapseText = text; this.collapseText = text;
}, },
openFullReport() { openFullReport() {
this.isFullReportVisible = true; this.isFullReportVisible = true;
}, },
}, },
}; };
</script> </script>
<template> <template>
<section class="report-block mr-widget-section"> <section>
<div <div
v-if="isLoading" class="media prepend-top-default prepend-left-default
class="media" append-right-default append-bottom-default"
> >
<div <loading-icon
class="mr-widget-icon" class="mr-widget-icon"
> v-if="isLoading"
<loading-icon /> />
</div>
<div
class="media-body"
>
{{ loadingText }}
</div>
</div>
<div
v-else-if="isSuccess"
class="media"
>
<status-icon <status-icon
v-else
:status="statusIconName" :status="statusIconName"
/> />
<div <div
class="media-body space-children" class="media-body space-children"
> >
<span <span
class="js-code-text code-text" class="js-code-text code-text"
> >
{{ successText }} {{ headerText }}
<popover
v-if="hasPopover"
class="prepend-left-5"
:options="popoverOptions"
/>
</span> </span>
<button <button
type="button" type="button"
class="js-collapse-btn btn bt-default pull-right btn-sm" class="js-collapse-btn btn bt-default pull-right btn-sm"
v-if="isCollapsible && hasIssues" v-if="hasIssues"
@click="toggleCollapsed" @click="toggleCollapsed"
> >
{{ collapseText }} {{ collapseText }}
...@@ -172,71 +172,28 @@ ...@@ -172,71 +172,28 @@
</div> </div>
<div <div
class="report-block-container" class="js-report-section-container"
v-if="hasIssues" v-if="hasIssues"
v-show="!isCollapsible || (isCollapsible && !isCollapsed)" v-show="!isCollapsed"
> >
<slot name="body">
<issues-list
:unresolved-issues="unresolvedIssues"
:resolved-issues="resolvedIssues"
:all-issues="allIssues"
:type="type"
:is-full-report-visible="isFullReportVisible"
/>
<p <button
v-if="infoText" v-if="allIssues.length && !isFullReportVisible"
v-html="infoText" type="button"
class="js-mr-code-quality-info prepend-left-10 report-block-info" class="btn-link btn-blank prepend-left-10 js-expand-full-list break-link"
> @click="openFullReport"
</p> >
{{ s__("ciReport|Show complete code vulnerabilities report") }}
<issues-block </button>
class="js-mr-code-new-issues" </slot>
v-if="unresolvedIssues.length"
:type="type"
status="failed"
:issues="unresolvedIssues"
:has-priority="hasPriority"
/>
<issues-block
class="js-mr-code-all-issues"
v-if="isFullReportVisible"
:type="type"
status="failed"
:issues="allIssues"
:has-priority="hasPriority"
/>
<issues-block
class="js-mr-code-non-issues"
v-if="neutralIssues.length"
:type="type"
status="neutral"
:issues="neutralIssues"
:has-priority="hasPriority"
/>
<issues-block
class="js-mr-code-resolved-issues"
v-if="resolvedIssues.length"
:type="type"
status="success"
:issues="resolvedIssues"
:has-priority="hasPriority"
/>
<button
v-if="allIssues.length && !isFullReportVisible"
type="button"
class="btn-link btn-blank prepend-left-10 js-expand-full-list break-link"
@click="openFullReport"
>
{{ s__("ciReport|Show complete code vulnerabilities report") }}
</button>
</div>
<div
v-else-if="loadingFailed"
class="media"
>
<status-icon status="notfound" />
<div class="media-body">
{{ errorText }}
</div>
</div> </div>
</section> </section>
</template> </template>
<script>
export default {
name: 'SastContainerInfo',
};
</script>
<template>
<p
class="prepend-top-10 prepend-left-10 report-block-info js-mr-code-quality-info"
>
{{ s__('ciReport|Unapproved vulnerabilities (red) can be marked as approved.') }}
<a
href="https://gitlab.com/gitlab-org/clair-scanner#example-whitelist-yaml-file"
target="_blank"
rel="noopener noreferrer nofollow"
>
{{ s__('ciReport|Learn more about whitelisting') }}
</a>
</p>
</template>
<script> <script>
/** /**
* Renders SAST CONTAINER body text * Renders SAST CONTAINER body text
* [priority]: [name|link] in [link]:[line] * [priority]: [name|link] in [link]:[line]
*/ */
import ReportLink from './report_link.vue'; import ReportLink from './report_link.vue';
export default { export default {
name: 'SastContainerIssueBody', name: 'SastContainerIssueBody',
components: { components: {
ReportLink, ReportLink,
}, },
props: { props: {
issue: { issue: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}; },
};
</script> </script>
<template> <template>
<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">
......
<script> <script>
/** /**
* Renders SAST body text * Renders SAST body text
* [priority]: [name] in [link] : [line] * [priority]: [name] in [link] : [line]
*/ */
import ReportLink from './report_link.vue'; import ReportLink from './report_link.vue';
export default { export default {
name: 'SastIssueBody', name: 'SastIssueBody',
components: { components: {
ReportLink, ReportLink,
}, },
props: { props: {
issue: { issue: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}; },
};
</script> </script>
<template> <template>
<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">
......
<script> <script>
import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Popover from './help_popover.vue'; import Popover from './help_popover.vue';
/** /**
...@@ -10,6 +11,7 @@ export default { ...@@ -10,6 +11,7 @@ export default {
name: 'SecuritySummaryRow', name: 'SecuritySummaryRow',
components: { components: {
CiIcon, CiIcon,
LoadingIcon,
Popover, Popover,
}, },
props: { props: {
...@@ -37,12 +39,19 @@ export default { ...@@ -37,12 +39,19 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="report-block-list-issue prepend-left-default append-right-default"> <div class="report-block-list-issue report-block-list-issue-parent">
<div class="report-block-list-icon append-right-10 prepend-left-5"> <div class="report-block-list-icon append-right-10 prepend-left-5">
<ci-icon :status="iconStatus" /> <loading-icon
v-if="statusIcon === 'loading'"
css-class="report-block-list-loading-icon"
/>
<ci-icon
v-else
:status="iconStatus"
/>
</div> </div>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> <div class="report-block-list-issue-description">
<div class="report-block-list-issue-description-text append-right-5"> <div class="report-block-list-issue-description-text append-right-5">
{{ summary }} {{ summary }}
</div> </div>
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { SAST, DAST, SAST_CONTAINER } from './store/constants';
import store from './store';
import ReportSection from './components/report_section.vue';
import SummaryRow from './components/summary_row.vue';
import IssuesList from './components/issues_list.vue';
import securityReportsMixin from './mixins/security_report_mixin';
export default {
store,
components: {
ReportSection,
SummaryRow,
IssuesList,
},
mixins: [securityReportsMixin],
props: {
headBlobPath: {
type: String,
required: true,
},
baseBlobPath: {
type: String,
required: false,
default: null,
},
sastHeadPath: {
type: String,
required: false,
default: null,
},
sastBasePath: {
type: String,
required: false,
default: null,
},
dastHeadPath: {
type: String,
required: false,
default: null,
},
dastBasePath: {
type: String,
required: false,
default: null,
},
sastContainerHeadPath: {
type: String,
required: false,
default: null,
},
sastContainerBasePath: {
type: String,
required: false,
default: null,
},
dependencyScanningHeadPath: {
type: String,
required: false,
default: null,
},
dependencyScanningBasePath: {
type: String,
required: false,
default: null,
},
sastHelpPath: {
type: String,
required: false,
default: '',
},
sastContainerHelpPath: {
type: String,
required: false,
default: '',
},
dastHelpPath: {
type: String,
required: false,
default: '',
},
dependencyScanningHelpPath: {
type: String,
required: false,
default: '',
},
},
sast: SAST,
dast: DAST,
sastContainer: SAST_CONTAINER,
computed: {
...mapState(['sast', 'sastContainer', 'dast', 'dependencyScanning', 'summaryCounts']),
...mapGetters([
'groupedSastText',
'groupedSummaryText',
'summaryStatus',
'groupedSastContainerText',
'groupedDastText',
'groupedDependencyText',
'sastStatusIcon',
'sastContainerStatusIcon',
'dastStatusIcon',
'dependencyScanningStatusIcon',
]),
},
created() {
this.setHeadBlobPath(this.headBlobPath);
this.setBaseBlobPath(this.baseBlobPath);
if (this.sastHeadPath) {
this.setSastHeadPath(this.sastHeadPath);
if (this.sastBasePath) {
this.setSastBasePath(this.sastBasePath);
}
this.fetchSastReports();
}
if (this.sastContainerHeadPath) {
this.setSastContainerHeadPath(this.sastContainerHeadPath);
if (this.sastContainerBasePath) {
this.setSastContainerBasePath(this.sastContainerBasePath);
}
this.fetchSastContainerReports();
}
if (this.dastHeadPath) {
this.setDastHeadPath(this.dastHeadPath);
if (this.dastBasePath) {
this.setDastBasePath(this.dastBasePath);
}
this.fetchDastReports();
}
if (this.dependencyScanningHeadPath) {
this.setDependencyScanningHeadPath(this.dependencyScanningHeadPath);
if (this.dependencyScanningBasePath) {
this.setDependencyScanningBasePath(this.dependencyScanningBasePath);
}
this.fetchDependencyScanningReports();
}
},
methods: {
...mapActions([
'setAppType',
'setHeadBlobPath',
'setBaseBlobPath',
'setSastHeadPath',
'setSastBasePath',
'setSastContainerHeadPath',
'setSastContainerBasePath',
'setDastHeadPath',
'setDastBasePath',
'setDependencyScanningHeadPath',
'setDependencyScanningBasePath',
'fetchSastReports',
'fetchSastContainerReports',
'fetchDastReports',
'fetchDependencyScanningReports',
]),
},
};
</script>
<template>
<report-section
class="mr-widget-border-top"
:status="summaryStatus"
:success-text="groupedSummaryText"
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="true"
>
<div
slot="body"
class="mr-widget-grouped-section report-block"
>
<template v-if="sastHeadPath">
<summary-row
class="js-sast-widget"
:summary="groupedSastText"
:status-icon="sastStatusIcon"
:popover-options="sastPopover"
/>
<issues-list
class="report-block-group-list"
v-if="sast.newIssues.length"
:unresolved-issues="sast.newIssues"
:resolved-issues="sast.resolvedIssues"
:all-issues="sast.allIssues"
:type="$options.sast"
/>
</template>
<template v-if="dependencyScanningHeadPath">
<summary-row
class="js-dependency-scanning-widget"
:summary="groupedDependencyText"
:status-icon="dependencyScanningStatusIcon"
:popover-options="dependencyScanningPopover"
/>
<issues-list
class="report-block-group-list"
v-if="dependencyScanning.newIssues.length"
:unresolved-issues="dependencyScanning.newIssues"
:resolved-issues="dependencyScanning.resolvedIssues"
:all-issues="dependencyScanning.allIssues"
:type="$options.sast"
/>
</template>
<template v-if="sastContainerHeadPath">
<summary-row
class="js-sast-container"
:summary="groupedSastContainerText"
:status-icon="sastContainerStatusIcon"
:popover-options="sastContainerPopover"
/>
<issues-list
class="report-block-group-list"
v-if="sastContainer.newIssues.length"
:unresolved-issues="sastContainer.newIssues"
:neutral-issues="sastContainer.resolvedIssues"
:type="$options.sastContainer"
/>
</template>
<template v-if="dastHeadPath">
<summary-row
class="js-dast-widget"
:summary="groupedDastText"
:status-icon="dastStatusIcon"
:popover-options="dastPopover"
/>
<issues-list
class="report-block-group-list"
v-if="dast.newIssues.length"
:unresolved-issues="dast.newIssues"
:resolved-issues="dast.resolvedIssues"
:type="$options.dast"
/>
</template>
</div>
</report-section>
</template>
export default {
sast: {
isLoading: false,
hasError: false,
newIssues: [],
resolvedIssues: [],
allIssues: [],
},
sastContainer: {
approved: [],
unapproved: [],
vulnerabilities: [],
},
dast: [],
codeclimate: {
newIssues: [],
resolvedIssues: [],
},
dependencyScanning: {
isLoading: false,
hasError: false,
newIssues: [],
resolvedIssues: [],
allIssues: [],
},
};
import { stripHtml } from '~/lib/utils/text_utility';
export const parseCodeclimateMetrics = (issues = [], path = '') =>
issues.map(issue => {
const parsedIssue = {
...issue,
name: issue.description,
};
if (issue.location) {
let parseCodeQualityUrl;
if (issue.location.path) {
parseCodeQualityUrl = `${path}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
if (issue.location.lines && issue.location.lines.begin) {
parsedIssue.line = issue.location.lines.begin;
parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
}
parsedIssue.urlPath = parseCodeQualityUrl;
}
}
return parsedIssue;
});
/**
* Maps SAST & Dependency scanning issues:
* { tool: String, message: String, url: String , cve: String ,
* file: String , solution: String, priority: String }
* to contain:
* { name: String, path: String, line: String, urlPath: String, priority: String }
* @param {Array} issues
* @param {String} path
*/
export const parseSastIssues = (issues = [], path = '') =>
issues.map(issue =>
Object.assign({}, issue, {
name: issue.message,
path: issue.file,
urlPath: issue.line
? `${path}/${issue.file}#L${issue.line}`
: `${path}/${issue.file}`,
}),
);
/**
* Compares two arrays by the given key and returns the difference
*
* @param {Array} firstArray
* @param {Array} secondArray
* @param {String} key
* @returns {Array}
*/
export const filterByKey = (firstArray = [], secondArray = [], key = '') => firstArray
.filter(item => !secondArray.find(el => el[key] === item[key]));
/**
* Parses DAST results into a common format to allow to use the same Vue component
* And adds an external link
*
* @param {Array} data
* @returns {Array}
*/
export const parseSastContainer = (data = []) => data.map(el => ({
name: el.vulnerability,
priority: el.severity,
path: el.namespace,
// external link to provide better description
nameLink: `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${el.vulnerability}`,
...el,
}));
/**
* Utils functions to set the reports
*/
/**
* Compares sast results and returns the formatted report
*
* Security report has 3 types of issues, newIssues, resolvedIssues and allIssues.
*
* When we have both base and head:
* - newIssues = head - base
* - resolvedIssues = base - head
* - allIssues = head - newIssues - resolvedIssues
*
* When we only have head
* - newIssues = head
* - resolvedIssues = 0
* - allIssues = 0
* @param {*} data
* @returns {Object}
*/
export const setSastReport = (data = {}) => {
const securityReport = {};
if (data.base) {
const filterKey = 'cve';
const parsedHead = parseSastIssues(data.head, data.headBlobPath);
const parsedBase = parseSastIssues(data.base, data.baseBlobPath);
securityReport.newIssues = filterByKey(
parsedHead,
parsedBase,
filterKey,
);
securityReport.resolvedIssues = filterByKey(
parsedBase,
parsedHead,
filterKey,
);
// Remove the new Issues and the added issues
securityReport.allIssues = filterByKey(
parsedHead,
securityReport.newIssues.concat(securityReport.resolvedIssues),
filterKey,
);
} else {
securityReport.newIssues = parseSastIssues(data.head, data.headBlobPath);
}
return securityReport;
};
export const setSastContainerReport = (data = {}) => {
const unapproved = data.unapproved || [];
const parsedVulnerabilities = parseSastContainer(data.vulnerabilities);
// Approved can be calculated by subtracting unapproved from vulnerabilities.
return {
vulnerabilities: parsedVulnerabilities || [],
approved: parsedVulnerabilities
.filter(item => !unapproved.find(el => el === item.vulnerability)) || [],
unapproved: parsedVulnerabilities
.filter(item => unapproved.find(el => el === item.vulnerability)) || [],
};
};
/**
* Dast Report sends some keys in HTML, we need to strip the `<p>` tags.
* This should be moved to the backend.
*
* @param {Array} data
* @returns {Array}
*/
export const setDastReport = data => data.site.alerts.map(alert => ({
name: alert.name,
parsedDescription: stripHtml(alert.desc, ' '),
priority: alert.riskdesc,
...alert,
}));
import {
LOADING,
ERROR,
SUCCESS,
} from '../store/constants';
export default {
methods: {
checkReportStatus(loading, error) {
if (loading) {
return LOADING;
} else if (error) {
return ERROR;
}
return SUCCESS;
},
},
};
import { s__, n__, __, sprintf } from '~/locale'; import { sprintf, s__ } from '~/locale';
export default { export default {
methods: { computed: {
sastText(newIssues = [], resolvedIssues = [], allIssues = []) { sastPopover() {
const text = [];
if (!newIssues.length && !resolvedIssues.length && !allIssues.length) {
text.push(s__('ciReport|SAST detected no security vulnerabilities'));
} else if (!newIssues.length && !resolvedIssues.length && allIssues.length) {
text.push(s__('ciReport|SAST detected no new security vulnerabilities'));
} else if (newIssues.length || resolvedIssues.length) {
text.push(s__('ciReport|SAST'));
}
if (resolvedIssues.length) {
text.push(n__(
' improved on %d security vulnerability',
' improved on %d security vulnerabilities',
resolvedIssues.length,
));
}
if (newIssues.length > 0 && resolvedIssues.length > 0) {
text.push(__(' and'));
}
if (newIssues.length) {
text.push(n__(
' degraded on %d security vulnerability',
' degraded on %d security vulnerabilities',
newIssues.length,
));
}
return text.join('');
},
depedencyScanningText(newIssues = [], resolvedIssues = [], allIssues = []) {
const text = [];
if (!newIssues.length && !resolvedIssues.length && !allIssues.length) {
text.push(s__('ciReport|Dependency scanning detected no security vulnerabilities'));
} else if (!newIssues.length && !resolvedIssues.length && allIssues.length) {
text.push(s__('ciReport|Dependency scanning detected no new security vulnerabilities'));
} else if (newIssues.length || resolvedIssues.length) {
text.push(s__('ciReport|Dependency scanning'));
}
if (resolvedIssues.length) {
text.push(n__(
' improved on %d security vulnerability',
' improved on %d security vulnerabilities',
resolvedIssues.length,
));
}
if (newIssues.length > 0 && resolvedIssues.length > 0) {
text.push(__(' and'));
}
if (newIssues.length) {
text.push(n__(
' degraded on %d security vulnerability',
' degraded on %d security vulnerabilities',
newIssues.length,
));
}
return text.join('');
},
translateText(type) {
return { return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), { reportName: type }), title: s__('ciReport|Static Application Security Testing (SAST) detects known vulnerabilities in your source code.'),
loading: sprintf(s__('ciReport|Loading %{reportName} report'), { reportName: type }), content: sprintf(
s__('ciReport|%{linkStartTag}Learn more about SAST %{linkEndTag}'),
{
linkStartTag: `<a href="${this.sastHelpPath}">`,
linkEndTag: '</a>',
},
false,
),
}; };
}, },
sastContainerPopover() {
checkReportStatus(loading, error) { return {
if (loading) { title: s__('ciReport|Container scanning detects known vulnerabilities in your docker images.'),
return 'loading'; content: sprintf(
} else if (error) { s__('ciReport|%{linkStartTag}Learn more about SAST image %{linkEndTag}'),
return 'error'; {
} linkStartTag: `<a href="${this.sastContainerHelpPath}">`,
linkEndTag: '</a>',
return 'success'; },
}, false,
),
sastContainerText(vulnerabilities = [], approved = [], unapproved = []) { };
if (!vulnerabilities.length) {
return s__('ciReport|SAST:container no vulnerabilities were found');
}
if (!unapproved.length && approved.length) {
return n__(
'SAST:container found %d approved vulnerability',
'SAST:container found %d approved vulnerabilities',
approved.length,
);
} else if (unapproved.length && !approved.length) {
return n__(
'SAST:container found %d vulnerability',
'SAST:container found %d vulnerabilities',
unapproved.length,
);
}
return `${n__(
'SAST:container found %d vulnerability,',
'SAST:container found %d vulnerabilities,',
vulnerabilities.length,
)} ${n__(
'of which %d is approved',
'of which %d are approved',
approved.length,
)}`;
}, },
dastPopover() {
dastText(dast = []) { return {
if (dast.length) { title: s__('ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application.'),
return n__( content: sprintf(
'DAST detected %d alert by analyzing the review app', s__('ciReport|%{linkStartTag}Learn more about DAST %{linkEndTag}'),
'DAST detected %d alerts by analyzing the review app', {
dast.length, linkStartTag: `<a href="${this.dastHelpPath}">`,
); linkEndTag: '</a>',
} },
false,
return s__('ciReport|DAST detected no alerts by analyzing the review app'); ),
};
}, },
dependencyScanningPopover() {
sastContainerInformationText() { return {
return sprintf( title: s__('ciReport|Dependency Scanning detects known vulnerabilities in your source code\'s dependencies.'),
s__('ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}'), { content: sprintf(
helpLink: `<a href="https://gitlab.com/gitlab-org/clair-scanner#example-whitelist-yaml-file" target="_blank" rel="noopener noreferrer nofollow"> s__('ciReport|%{linkStartTag}Learn more about Dependency Scanning %{linkEndTag}'),
${s__('ciReport|Learn more about whitelisting')} {
</a>`, linkStartTag: `<a href="${this.dependencyScanningHelpPath}">`,
}, linkEndTag: '</a>',
false, },
); false,
),
};
}, },
}, },
}; };
<script>
import { mapActions, mapState } from 'vuex';
import { s__, sprintf, n__ } from '~/locale';
import createFlash from '~/flash';
import { SAST } from './store/constants';
import store from './store';
import ReportSection from './components/report_section.vue';
import mixin from './mixins/security_report_mixin';
import reportsMixin from './mixins/reports_mixin';
export default {
store,
components: {
ReportSection,
},
mixins: [mixin, reportsMixin],
props: {
headBlobPath: {
type: String,
required: true,
},
sastHeadPath: {
type: String,
required: false,
default: null,
},
dependencyScanningHeadPath: {
type: String,
required: false,
default: null,
},
sastHelpPath: {
type: String,
required: false,
default: null,
},
dependencyScanningHelpPath: {
type: String,
required: false,
default: null,
},
},
sast: SAST,
computed: {
...mapState(['sast', 'dependencyScanning']),
sastText() {
return this.summaryTextBuilder('SAST', this.sast.newIssues.length);
},
dependencyScanningText() {
return this.summaryTextBuilder(
'Dependency scanning',
this.dependencyScanning.newIssues.length,
);
},
},
created() {
// update the store with the received props
this.setHeadBlobPath(this.headBlobPath);
if (this.sastHeadPath) {
this.setSastHeadPath(this.sastHeadPath);
this.fetchSastReports()
.then(() => {
this.$emit('updateBadgeCount', this.sast.newIssues.length);
})
.catch(() => createFlash(s__('ciReport|There was an error loading SAST report')));
}
if (this.dependencyScanningHeadPath) {
this.setDependencyScanningHeadPath(this.dependencyScanningHeadPath);
this.fetchDependencyScanningReports()
.then(() => {
this.$emit('updateBadgeCount', this.dependencyScanning.newIssues.length);
})
.catch(() =>
createFlash(s__('ciReport|There was an error loading dependency scanning report')),
);
}
},
methods: {
...mapActions([
'setHeadBlobPath',
'setSastHeadPath',
'setDependencyScanningHeadPath',
'fetchSastReports',
'fetchDependencyScanningReports',
]),
summaryTextBuilder(type, issuesCount = 0) {
if (issuesCount === 0) {
return sprintf(s__('ciReport|%{type} detected no vulnerabilities'), {
type,
});
}
return sprintf(
n__('%{type} detected %d vulnerability', '%{type} detected %d vulnerabilities', issuesCount),
{ type },
);
},
translateText(type) {
return {
error: sprintf(s__('ciReport|%{reportName} resulted in error while loading results'), {
reportName: type,
}),
loading: sprintf(s__('ciReport|%{reportName} is loading'), {
reportName: type,
}),
};
},
},
};
</script>
<template>
<div>
<report-section
v-if="sastHeadPath"
class="js-sast-widget split-report-section"
:type="$options.sast"
:status="checkReportStatus(sast.isLoading, sast.hasError)"
:loading-text="translateText('SAST').loading"
:error-text="translateText('SAST').error"
:success-text="sastText"
:unresolved-issues="sast.newIssues"
:has-issues="sast.newIssues.length > 0"
:popover-options="sastPopover"
/>
<report-section
v-if="dependencyScanningHeadPath"
class="js-dss-widget split-report-section"
:type="$options.sast"
:status="checkReportStatus(dependencyScanning.isLoading, dependencyScanning.hasError)"
:loading-text="translateText('Dependency scanning').loading"
:error-text="translateText('Dependency scanning').error"
:success-text="dependencyScanningText"
:unresolved-issues="dependencyScanning.newIssues"
:has-issues="dependencyScanning.newIssues.length > 0"
:popover-options="dependencyScanningPopover"
/>
</div>
</template>
...@@ -26,14 +26,14 @@ export const fetchSastReports = ({ state, dispatch }) => { ...@@ -26,14 +26,14 @@ export const fetchSastReports = ({ state, dispatch }) => {
dispatch('requestSastReports'); dispatch('requestSastReports');
Promise.all([ return Promise.all([
head ? axios.get(head) : Promise.resolve(), head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(), base ? axios.get(base) : Promise.resolve(),
]) ])
.then(values => { .then(values => {
dispatch('receiveSastReports', { dispatch('receiveSastReports', {
head: values[0] ? values[0].data : null, head: values && values[0] ? values[0].data : null,
base: values[1] ? values[1].data : null, base: values && values[1] ? values[1].data : null,
}); });
}) })
.catch(() => { .catch(() => {
...@@ -65,7 +65,7 @@ export const fetchSastContainerReports = ({ state, dispatch }) => { ...@@ -65,7 +65,7 @@ export const fetchSastContainerReports = ({ state, dispatch }) => {
dispatch('requestSastContainerReports'); dispatch('requestSastContainerReports');
Promise.all([ return Promise.all([
head ? axios.get(head) : Promise.resolve(), head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(), base ? axios.get(base) : Promise.resolve(),
]) ])
...@@ -100,14 +100,14 @@ export const fetchDastReports = ({ state, dispatch }) => { ...@@ -100,14 +100,14 @@ export const fetchDastReports = ({ state, dispatch }) => {
dispatch('requestDastReports'); dispatch('requestDastReports');
Promise.all([ return Promise.all([
head ? axios.get(head) : Promise.resolve(), head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(), base ? axios.get(base) : Promise.resolve(),
]) ])
.then(values => { .then(values => {
dispatch('receiveDastReports', { dispatch('receiveDastReports', {
head: values[0] ? values[0].data : null, head: values && values[0] ? values[0].data : null,
base: values[1] ? values[1].data : null, base: values && values[1] ? values[1].data : null,
}); });
}) })
.catch(() => { .catch(() => {
...@@ -139,7 +139,7 @@ export const fetchDependencyScanningReports = ({ state, dispatch }) => { ...@@ -139,7 +139,7 @@ export const fetchDependencyScanningReports = ({ state, dispatch }) => {
dispatch('requestDependencyScanningReports'); dispatch('requestDependencyScanningReports');
Promise.all([ return Promise.all([
head ? axios.get(head) : Promise.resolve(), head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(), base ? axios.get(base) : Promise.resolve(),
]) ])
......
export const SAST = 'SAST'; export const SAST = 'SAST';
export const DAST = 'DAST'; export const DAST = 'DAST';
export const SAST_CONTAINER = 'SAST_CONTAINER'; export const SAST_CONTAINER = 'SAST_CONTAINER';
export const LOADING = 'LOADING';
export const ERROR = 'ERROR';
export const SUCCESS = 'SUCCESS';
import { n__, s__ } from '~/locale'; import { n__, s__ } from '~/locale';
import { textBuilder, statusIcon } from './utils'; import { textBuilder, statusIcon } from './utils';
import { LOADING, ERROR, SUCCESS } from './constants';
export const groupedSastText = ({ sast }) => export const groupedSastText = ({ sast }) => {
textBuilder( if (sast.hasError) {
return s__('ciReport|SAST resulted in error while loading results');
}
if (sast.isLoading) {
return s__('ciReport|SAST is loading');
}
return textBuilder(
'SAST', 'SAST',
sast.paths, sast.paths,
sast.newIssues.length, sast.newIssues.length,
sast.resolvedIssues.length, sast.resolvedIssues.length,
sast.allIssues.length, sast.allIssues.length,
); );
};
export const groupedSastContainerText = ({ sastContainer }) => {
if (sastContainer.hasError) {
return s__('ciReport|Container scanning resulted in error while loading results');
}
if (sastContainer.isLoading) {
return s__('ciReport|Container scanning is loading');
}
export const groupedSastContainerText = ({ sastContainer }) => return textBuilder(
textBuilder(
'Container scanning', 'Container scanning',
sastContainer.paths, sastContainer.paths,
sastContainer.newIssues.length, sastContainer.newIssues.length,
sastContainer.resolvedIssues.length, sastContainer.resolvedIssues.length,
); );
};
export const groupedDastText = ({ dast }) => {
if (dast.hasError) {
return s__('ciReport|DAST resulted in error while loading results');
}
if (dast.isLoading) {
return s__('ciReport|DAST is loading');
}
return textBuilder('DAST', dast.paths, dast.newIssues.length, dast.resolvedIssues.length);
};
export const groupedDependencyText = ({ dependencyScanning }) => {
if (dependencyScanning.hasError) {
return s__('ciReport|Dependency scanning resulted in error while loading results');
}
export const groupedDastText = ({ dast }) => if (dependencyScanning.isLoading) {
textBuilder('DAST', dast.paths, dast.newIssues.length, dast.resolvedIssues.length); return s__('ciReport|Dependency scanning is loading');
}
export const groupedDependencyText = ({ dependencyScanning }) => return textBuilder(
textBuilder(
'Dependency scanning', 'Dependency scanning',
dependencyScanning.paths, dependencyScanning.paths,
dependencyScanning.newIssues.length, dependencyScanning.newIssues.length,
dependencyScanning.resolvedIssues.length, dependencyScanning.resolvedIssues.length,
dependencyScanning.allIssues.length,
); );
};
export const groupedSummaryText = (state, getters) => { export const groupedSummaryText = (state, getters) => {
const { added, fixed } = state.summaryCounts; const { added, fixed } = state.summaryCounts;
// All reports are loading
if (getters.areAllReportsLoading) {
return s__('ciReport|Security scanning is loading');
}
// All reports returned error // All reports returned error
if (getters.allReportsHaveError) { if (getters.allReportsHaveError) {
return s__('ciReport|Security scanning failed loading any results'); return s__('ciReport|Security scanning failed loading any results');
...@@ -40,21 +84,25 @@ export const groupedSummaryText = (state, getters) => { ...@@ -40,21 +84,25 @@ export const groupedSummaryText = (state, getters) => {
if (getters.noBaseInAllReports) { if (getters.noBaseInAllReports) {
if (added > 0) { if (added > 0) {
return n__( return n__(
'Security scanning was unable to compare existing and new vulnerabilities. It detected %d vulnerability', 'Security scanning detected %d vulnerability for the source branch only',
'Security scanning was unable to compare existing and new vulnerabilities. It detected %d vulnerabilities', 'Security scanning detected %d vulnerabilities for the source branch only',
added, added,
); );
} }
return s__( return s__(
'Security scanning was unable to compare existing and new vulnerabilities. It detected no vulnerabilities.', 'Security scanning detected no vulnerabilities for the source branch only',
); );
} }
const text = [s__('ciReport|Security scanning')]; const text = [s__('ciReport|Security scanning')];
if (getters.areReportsLoading) { if (getters.areReportsLoading && getters.anyReportHasError) {
text.push('(in progress)'); text.push('(is loading, errors when loading results)');
} else if (getters.areReportsLoading && !getters.anyReportHasError) {
text.push('(is loading)');
} else if (!getters.areReportsLoading && getters.anyReportHasError) {
text.push('(errors when loading results)');
} }
if (added > 0 && fixed === 0) { if (added > 0 && fixed === 0) {
...@@ -81,15 +129,33 @@ export const groupedSummaryText = (state, getters) => { ...@@ -81,15 +129,33 @@ export const groupedSummaryText = (state, getters) => {
return text.join(' '); return text.join(' ');
}; };
export const sastStatusIcon = ({ sast }) => statusIcon(sast.hasError, sast.newIssues.length); export const summaryStatus = (state, getters) => {
if (getters.areReportsLoading) {
return LOADING;
}
if (getters.anyReportHasError || getters.anyReportHasIssues) {
return ERROR;
}
return SUCCESS;
};
export const sastStatusIcon = ({ sast }) =>
statusIcon(sast.isLoading, sast.hasError, sast.newIssues.length);
export const sastContainerStatusIcon = ({ sastContainer }) => export const sastContainerStatusIcon = ({ sastContainer }) =>
statusIcon(sastContainer.hasError, sastContainer.newIssues.length); statusIcon(sastContainer.isLoading, sastContainer.hasError, sastContainer.newIssues.length);
export const dastStatusIcon = ({ dast }) => statusIcon(dast.hasError, dast.newIssues.length); export const dastStatusIcon = ({ dast }) =>
statusIcon(dast.isLoading, dast.hasError, dast.newIssues.length);
export const dependencyScanningStatusIcon = ({ dependencyScanning }) => export const dependencyScanningStatusIcon = ({ dependencyScanning }) =>
statusIcon(dependencyScanning.hasError, dependencyScanning.newIssues.length); statusIcon(
dependencyScanning.isLoading,
dependencyScanning.hasError,
dependencyScanning.newIssues.length,
);
export const areReportsLoading = state => export const areReportsLoading = state =>
state.sast.isLoading || state.sast.isLoading ||
...@@ -97,6 +163,12 @@ export const areReportsLoading = state => ...@@ -97,6 +163,12 @@ export const areReportsLoading = state =>
state.sastContainer.isLoading || state.sastContainer.isLoading ||
state.dependencyScanning.isLoading; state.dependencyScanning.isLoading;
export const areAllReportsLoading = state =>
state.sast.isLoading &&
state.dast.isLoading &&
state.sastContainer.isLoading &&
state.dependencyScanning.isLoading;
export const allReportsHaveError = state => export const allReportsHaveError = state =>
state.sast.hasError && state.sast.hasError &&
state.dast.hasError && state.dast.hasError &&
...@@ -114,3 +186,9 @@ export const noBaseInAllReports = state => ...@@ -114,3 +186,9 @@ export const noBaseInAllReports = state =>
!state.dast.paths.base && !state.dast.paths.base &&
!state.sastContainer.paths.base && !state.sastContainer.paths.base &&
!state.dependencyScanning.paths.base; !state.dependencyScanning.paths.base;
export const anyReportHasIssues = state =>
state.sast.newIssues.length > 0 ||
state.dast.newIssues.length > 0 ||
state.sastContainer.newIssues.length > 0 ||
state.dependencyScanning.newIssues.length > 0;
/* eslint-disable no-param-reassign */
import * as types from './mutation_types'; import * as types from './mutation_types';
import { import {
parseSastIssues, parseSastIssues,
...@@ -9,24 +11,24 @@ import { ...@@ -9,24 +11,24 @@ import {
export default { export default {
[types.SET_HEAD_BLOB_PATH](state, path) { [types.SET_HEAD_BLOB_PATH](state, path) {
Object.assign(state.blobPath, { head: path }); state.blobPath.head = path;
}, },
[types.SET_BASE_BLOB_PATH](state, path) { [types.SET_BASE_BLOB_PATH](state, path) {
Object.assign(state.blobPath, { base: path }); state.blobPath.base = path;
}, },
// SAST // SAST
[types.SET_SAST_HEAD_PATH](state, path) { [types.SET_SAST_HEAD_PATH](state, path) {
Object.assign(state.sast.paths, { head: path }); state.sast.paths.head = path;
}, },
[types.SET_SAST_BASE_PATH](state, path) { [types.SET_SAST_BASE_PATH](state, path) {
Object.assign(state.sast.paths, { base: path }); state.sast.paths.base = path;
}, },
[types.REQUEST_SAST_REPORTS](state) { [types.REQUEST_SAST_REPORTS](state) {
Object.assign(state.sast, { isLoading: true }); state.sast.isLoading = true;
}, },
/** /**
...@@ -52,50 +54,39 @@ export default { ...@@ -52,50 +54,39 @@ export default {
const newIssues = filterByKey(parsedHead, parsedBase, filterKey); const newIssues = filterByKey(parsedHead, parsedBase, filterKey);
const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey); const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey);
const allIssues = filterByKey(parsedHead, newIssues.concat(resolvedIssues), filterKey); const allIssues = filterByKey(parsedHead, newIssues.concat(resolvedIssues), filterKey);
Object.assign(state, { state.sast.newIssues = newIssues;
sast: { state.sast.resolvedIssues = resolvedIssues;
...state.sast, state.sast.allIssues = allIssues;
newIssues, state.sast.isLoading = false;
resolvedIssues, state.summaryCounts.added += newIssues.length;
allIssues, state.summaryCounts.fixed += resolvedIssues.length;
isLoading: false,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
} else if (reports.head && !reports.base) { } else if (reports.head && !reports.base) {
const newIssues = parseSastIssues(reports.head, state.blobPath.head); const newIssues = parseSastIssues(reports.head, state.blobPath.head);
Object.assign(state.sast, { state.sast.newIssues = newIssues;
newIssues, state.sast.isLoading = false;
isLoading: false, state.summaryCounts.added += newIssues.length;
});
} }
}, },
[types.RECEIVE_SAST_REPORTS_ERROR](state) { [types.RECEIVE_SAST_REPORTS_ERROR](state) {
Object.assign(state.sast, { state.sast.isLoading = false;
isLoading: false, state.sast.hasError = true;
hasError: true,
});
}, },
// SAST CONTAINER // SAST CONTAINER
[types.SET_SAST_CONTAINER_HEAD_PATH](state, path) { [types.SET_SAST_CONTAINER_HEAD_PATH](state, path) {
Object.assign(state.sastContainer.paths, { head: path }); state.sastContainer.paths.head = path;
}, },
[types.SET_SAST_CONTAINER_BASE_PATH](state, path) { [types.SET_SAST_CONTAINER_BASE_PATH](state, path) {
Object.assign(state.sastContainer.paths, { base: path }); state.sastContainer.paths.base = path;
}, },
[types.REQUEST_SAST_CONTAINER_REPORTS](state) { [types.REQUEST_SAST_CONTAINER_REPORTS](state) {
Object.assign(state.sastContainer, { isLoading: true }); state.sastContainer.isLoading = true;
}, },
/** /**
...@@ -116,48 +107,40 @@ export default { ...@@ -116,48 +107,40 @@ export default {
const newIssues = filterByKey(headIssues, baseIssues, filterKey); const newIssues = filterByKey(headIssues, baseIssues, filterKey);
const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey); const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey);
Object.assign(state, { state.sastContainer.newIssues = newIssues;
sastContainer: { state.sastContainer.resolvedIssues = resolvedIssues;
...state.sastContainer, state.sastContainer.isLoading = false;
isLoading: false, state.summaryCounts.added += newIssues.length;
newIssues, state.summaryCounts.fixed += resolvedIssues.length;
resolvedIssues,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
} else if (reports.head && !reports.base) { } else if (reports.head && !reports.base) {
Object.assign(state.sastContainer, { const newIssues = getUnapprovedVulnerabilities(
isLoading: false, parseSastContainer(reports.head.vulnerabilities),
newIssues: getUnapprovedVulnerabilities( reports.head.unapproved,
parseSastContainer(reports.head.vulnerabilities), );
reports.head.unapproved,
), state.sastContainer.newIssues = newIssues;
}); state.sastContainer.isLoading = false;
state.summaryCounts.added += newIssues.length;
} }
}, },
[types.RECEIVE_SAST_CONTAINER_ERROR](state) { [types.RECEIVE_SAST_CONTAINER_ERROR](state) {
Object.assign(state.sastContainer, { state.sastContainer.isLoading = false;
isLoading: false, state.sastContainer.hasError = true;
hasError: true,
});
}, },
// DAST // DAST
[types.SET_DAST_HEAD_PATH](state, path) { [types.SET_DAST_HEAD_PATH](state, path) {
Object.assign(state.dast.paths, { head: path }); state.dast.paths.head = path;
}, },
[types.SET_DAST_BASE_PATH](state, path) { [types.SET_DAST_BASE_PATH](state, path) {
Object.assign(state.dast.paths, { base: path }); state.dast.paths.base = path;
}, },
[types.REQUEST_DAST_REPORTS](state) { [types.REQUEST_DAST_REPORTS](state) {
Object.assign(state.dast, { isLoading: true }); state.dast.isLoading = true;
}, },
[types.RECEIVE_DAST_REPORTS](state, reports) { [types.RECEIVE_DAST_REPORTS](state, reports) {
...@@ -168,45 +151,37 @@ export default { ...@@ -168,45 +151,37 @@ export default {
const newIssues = filterByKey(headIssues, baseIssues, filterKey); const newIssues = filterByKey(headIssues, baseIssues, filterKey);
const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey); const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey);
Object.assign(state, { state.dast.newIssues = newIssues;
dast: { state.dast.resolvedIssues = resolvedIssues;
...state.dast, state.dast.isLoading = false;
isLoading: false, state.summaryCounts.added += newIssues.length;
newIssues, state.summaryCounts.fixed += resolvedIssues.length;
resolvedIssues,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
} else if (reports.head && !reports.base) { } else if (reports.head && !reports.base) {
Object.assign(state.dast, { const newIssues = parseDastIssues(reports.head.site.alerts);
isLoading: false,
newIssues: parseDastIssues(reports.head.site.alerts), state.dast.newIssues = newIssues;
}); state.dast.isLoading = false;
state.summaryCounts.added += newIssues.length;
} }
}, },
[types.RECEIVE_DAST_ERROR](state) { [types.RECEIVE_DAST_ERROR](state) {
Object.assign(state.dast, { state.dast.isLoading = false;
isLoading: false, state.dast.hasError = true;
hasError: true,
});
}, },
// DEPENDECY SCANNING // DEPENDECY SCANNING
[types.SET_DEPENDENCY_SCANNING_HEAD_PATH](state, path) { [types.SET_DEPENDENCY_SCANNING_HEAD_PATH](state, path) {
Object.assign(state.dependencyScanning.paths, { head: path }); state.dependencyScanning.paths.head = path;
}, },
[types.SET_DEPENDENCY_SCANNING_BASE_PATH](state, path) { [types.SET_DEPENDENCY_SCANNING_BASE_PATH](state, path) {
Object.assign(state.dependencyScanning.paths, { base: path }); state.dependencyScanning.paths.base = path;
}, },
[types.REQUEST_DEPENDENCY_SCANNING_REPORTS](state) { [types.REQUEST_DEPENDENCY_SCANNING_REPORTS](state) {
Object.assign(state.dependencyScanning, { isLoading: true }); state.dependencyScanning.isLoading = true;
}, },
/** /**
...@@ -234,31 +209,24 @@ export default { ...@@ -234,31 +209,24 @@ export default {
const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey); const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey);
const allIssues = filterByKey(parsedHead, newIssues.concat(resolvedIssues), filterKey); const allIssues = filterByKey(parsedHead, newIssues.concat(resolvedIssues), filterKey);
Object.assign(state, { state.dependencyScanning.newIssues = newIssues;
dependencyScanning: { state.dependencyScanning.resolvedIssues = resolvedIssues;
...state.dependencyScanning, state.dependencyScanning.allIssues = allIssues;
newIssues, state.dependencyScanning.isLoading = false;
resolvedIssues, state.summaryCounts.added += newIssues.length;
allIssues, state.summaryCounts.fixed += resolvedIssues.length;
isLoading: false, }
},
summaryCounts: { if (reports.head && !reports.base) {
added: state.summaryCounts.added + newIssues.length, const newIssues = parseSastIssues(reports.head, state.blobPath.head);
fixed: state.summaryCounts.fixed + resolvedIssues.length, state.dependencyScanning.newIssues = newIssues;
}, state.dependencyScanning.isLoading = false;
}); state.summaryCounts.added += newIssues.length;
} else {
Object.assign(state.dependencyScanning, {
newIssues: parseSastIssues(reports.head, state.blobPath.head),
isLoading: false,
});
} }
}, },
[types.RECEIVE_DEPENDENCY_SCANNING_ERROR](state) { [types.RECEIVE_DEPENDENCY_SCANNING_ERROR](state) {
Object.assign(state.dependencyScanning, { state.dependencyScanning.isLoading = false;
isLoading: false, state.dependencyScanning.hasError = true;
hasError: true,
});
}, },
}; };
...@@ -64,30 +64,33 @@ export const textBuilder = ( ...@@ -64,30 +64,33 @@ export const textBuilder = (
resolvedIssues = 0, resolvedIssues = 0,
allIssues = 0, allIssues = 0,
) => { ) => {
// With no issues
if (newIssues === 0 && resolvedIssues === 0 && allIssues === 0) {
return sprintf(s__('ciReport|%{type} detected no security vulnerabilities'), { type });
}
// with no new or fixed but with vulnerabilities // with no new or fixed but with vulnerabilities
if (newIssues === 0 && resolvedIssues === 0 && allIssues) { if (newIssues === 0 && resolvedIssues === 0 && allIssues) {
return sprintf(s__('ciReport|%{type} detected no new security vulnerabilities'), { type }); return sprintf(s__('ciReport|%{type} detected no new security vulnerabilities'), { type });
} }
// with new issues and only head if (!paths.base) {
if (newIssues > 0 && !paths.base) { if (newIssues > 0) {
return sprintf(
n__(
'%{type} detected %d vulnerability for the source branch only',
'%{type} detected %d vulnerabilities for the source branch only',
newIssues,
),
{ type },
);
}
return sprintf( return sprintf(
n__( '%{type} detected no vulnerabilities for the source branch only',
'%{type} was unable to compare existing and new vulnerabilities. It detected %d vulnerability',
'%{type} was unable to compare existing and new vulnerabilities. It detected %d vulnerabilities',
newIssues,
),
{ type }, { type },
); );
} } else if (paths.base && paths.head) {
// With no issues
if (newIssues === 0 && resolvedIssues === 0 && allIssues === 0) {
return sprintf(s__('ciReport|%{type} detected no security vulnerabilities'), { type });
}
// with head + base
if (paths.base && paths.head) {
// with only new issues // with only new issues
if (newIssues > 0 && resolvedIssues === 0) { if (newIssues > 0 && resolvedIssues === 0) {
return sprintf( return sprintf(
...@@ -128,7 +131,11 @@ export const textBuilder = ( ...@@ -128,7 +131,11 @@ export const textBuilder = (
return ''; return '';
}; };
export const statusIcon = (failed = false, newIssues = 0, neutralIssues = 0) => { export const statusIcon = (loading = false, failed = false, newIssues = 0, neutralIssues = 0) => {
if (loading) {
return 'loading';
}
if (failed || newIssues > 0 || neutralIssues > 0) { if (failed || newIssues > 0 || neutralIssues > 0) {
return 'warning'; return 'warning';
} }
......
.pipeline-tab-content { .split-report-section {
border-bottom: 1px solid $gray-darker;
.report-block-list {
max-height: 500px;
overflow: auto;
}
.space-children, .space-children,
.space-children > span { .space-children > span {
display: flex; display: flex;
...@@ -14,11 +21,32 @@ ...@@ -14,11 +21,32 @@
} }
} }
.mr-widget-grouped-section {
.report-block-container {
max-height: 170px;
overflow: auto;
}
.report-block-list-issue-parent {
padding: $gl-padding-top $gl-padding;
border-top: 1px solid $border-color;
}
.report-block-list-icon .loading-container {
position: relative;
left: -2px;
// needed to make the next element align with the
// elements below that have a svg with 16px width
.fa-spinner {
width: 16px;
}
}
}
.report-block-container { .report-block-container {
border-top: 1px solid $gray-darker; border-top: 1px solid $border-color;
padding: $gl-padding-top; padding: $gl-padding-top;
background-color: $gray-light; background-color: $gray-light;
margin: $gl-padding #{-$gl-padding} #{-$gl-padding};
// Clean MR widget CSS // Clean MR widget CSS
line-height: 20px; line-height: 20px;
...@@ -44,6 +72,15 @@ ...@@ -44,6 +72,15 @@
&.neutral { &.neutral {
color: $theme-gray-700; color: $theme-gray-700;
} }
.ci-status-icon {
svg {
width: 16px;
height: 16px;
top: 3px;
left: -2px;
}
}
} }
.report-block-list-issue { .report-block-list-issue {
...@@ -65,6 +102,10 @@ ...@@ -65,6 +102,10 @@
word-wrap: break-word; word-wrap: break-word;
word-break: break-all; word-break: break-all;
} }
.btn-help svg {
top: 5px;
}
} }
.report-block-issue-code { .report-block-issue-code {
......
---
title: Renders grouped security reports in MR widget & split security reports in CI
view
merge_request:
author:
type: changed
...@@ -37,8 +37,8 @@ describe 'Pipeline', :js do ...@@ -37,8 +37,8 @@ describe 'Pipeline', :js do
expect(page).to have_css('#js-tab-security') expect(page).to have_css('#js-tab-security')
end end
it 'shows security report' do it 'shows security report section' do
expect(page).to have_content('SAST detected no security vulnerabilities') expect(page).to have_content('SAST is loading')
end end
end end
......
import _ from 'underscore'; import _ from 'underscore';
import Vue from 'vue'; import Vue from 'vue';
import PipelineMediator from '~/pipelines/pipeline_details_mediator'; import PipelineMediator from '~/pipelines/pipeline_details_mediator';
import { sastIssues, parsedSastIssuesStore } from '../vue_shared/security_reports/mock_data';
describe('PipelineMdediator', () => { describe('PipelineMdediator', () => {
let mediator; let mediator;
...@@ -40,29 +39,4 @@ describe('PipelineMdediator', () => { ...@@ -40,29 +39,4 @@ describe('PipelineMdediator', () => {
}); });
}); });
}); });
describe('security reports', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(sastIssues), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
});
it('fetches the requests endpoint and stores the data', (done) => {
mediator.fetchSastReport('sast.json', 'path');
setTimeout(() => {
expect(mediator.store.state.securityReports.sast.newIssues).toEqual(parsedSastIssuesStore);
done();
}, 0);
});
});
}); });
import PipelineStore from '~/pipelines/stores/pipeline_store'; import PipelineStore from '~/pipelines/stores/pipeline_store';
import securityState from 'ee/vue_shared/security_reports/helpers/state';
describe('Pipeline Store', () => { describe('Pipeline Store', () => {
let store; let store;
...@@ -24,11 +23,4 @@ describe('Pipeline Store', () => { ...@@ -24,11 +23,4 @@ describe('Pipeline Store', () => {
expect(store.state.pipeline).toEqual({ foo: 'bar' }); expect(store.state.pipeline).toEqual({ foo: 'bar' });
}); });
}); });
/**
* EE only
*/
it('should set default security state', () => {
expect(store.state.securityReports).toEqual(securityState);
});
}); });
import Vue from 'vue';
import store from 'ee/vue_shared/security_reports/store';
import state from 'ee/vue_shared/security_reports/store/state';
import reportSummary from 'ee/pipelines/components/security_reports/report_summary_widget.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { sastIssues } from '../../vue_shared/security_reports/mock_data';
describe('Report summary widget', () => {
const Component = Vue.extend(reportSummary);
let vm;
beforeEach(() => {
vm = createComponentWithStore(Component, store).$mount();
});
afterEach(() => {
vm.$destroy();
// clean up the error state
vm.$store.replaceState(state());
});
describe('without paths', () => {
it('does not render any summary', () => {
expect(vm.$el.querySelector('.js-sast-summary')).toBeNull();
expect(vm.$el.querySelector('.js-dss-summary')).toBeNull();
});
});
describe('while loading', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('requestSastReports');
vm.$store.dispatch('requestDependencyScanningReports');
});
it('renders loading icon and text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST is loading');
expect(vm.$el.querySelector('.js-sast-summary .fa-spinner')).not.toBeNull();
done();
});
});
it('renders loading icon and text for dependency scanning', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning is loading');
expect(vm.$el.querySelector('.js-dss-summary .fa-spinner')).not.toBeNull();
done();
});
});
});
describe('with error', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('receiveSastError');
vm.$store.dispatch('receiveDependencyScanningError');
});
it('renders warning icon and error text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST resulted in error while loading results');
expect(vm.$el.querySelector('.js-sast-summary .js-ci-status-icon-warning')).not.toBeNull();
done();
});
});
it('renders warnin icon and error text for dependency scanning', done => {
vm.$nextTick()
.then(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning resulted in error while loading results');
expect(vm.$el.querySelector('.js-dss-summary .js-ci-status-icon-warning')).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
});
describe('with vulnerabilities', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('receiveSastReports', {
head: sastIssues,
});
vm.$store.dispatch('receiveDependencyScanningReports', {
head: sastIssues,
});
});
it('renders warning icon and vulnerabilities text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST detected 3 vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary .js-ci-status-icon-warning')).not.toBeNull();
done();
});
});
it('renders warning icon and vulnerabilities text for dependency scanning', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning detected 3 vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary .js-ci-status-icon-warning')).not.toBeNull();
done();
});
});
});
describe('without vulnerabilities', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('receiveSastReports', {
head: [],
});
vm.$store.dispatch('receiveDependencyScanningReports', {
head: [],
});
});
it('renders success icon and vulnerabilities text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST detected no vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary .js-ci-status-icon-success')).not.toBeNull();
done();
});
});
it('renders success icon and vulnerabilities text for dependency scanning', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning detected no vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary .js-ci-status-icon-success')).not.toBeNull();
done();
});
});
});
});
import Vue from 'vue';
import reportSummary from 'ee/pipelines/components/security_reports/report_summary_widget.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Report summary widget', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(reportSummary);
});
afterEach(() => {
vm.$destroy();
});
describe('with vulnerabilities', () => {
beforeEach(() => {
vm = mountComponent(Component, {
sastIssues: 2,
dependencyScanningIssues: 4,
hasSast: true,
hasDependencyScanning: true,
});
});
it('renders summary text with warning icon for sast', () => {
expect(vm.$el.querySelector('.js-sast-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('SAST detected 2 vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary span').classList).toContain('ci-status-icon-warning');
});
it('renders summary text with warning icon for dependency scanning', () => {
expect(vm.$el.querySelector('.js-dss-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('Dependency scanning detected 4 vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary span').classList).toContain('ci-status-icon-warning');
});
});
describe('without vulnerabilities', () => {
beforeEach(() => {
vm = mountComponent(Component, {
hasSast: true,
hasDependencyScanning: true,
});
});
it('render summary text with success icon for sast', () => {
expect(vm.$el.querySelector('.js-sast-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('SAST detected no vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary span').classList).toContain('ci-status-icon-success');
});
it('render summary text with success icon for dependecy scanning', () => {
expect(vm.$el.querySelector('.js-dss-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('Dependency scanning detected no vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary span').classList).toContain('ci-status-icon-success');
});
});
});
import Vue from 'vue';
import securityReportApp from 'ee/pipelines/components/security_reports/security_report_app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { parsedSastIssuesHead } from 'spec/vue_shared/security_reports/mock_data';
describe('Security Report App', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(securityReportApp);
});
afterEach(() => {
vm.$destroy();
});
describe('sast report', () => {
beforeEach(() => {
vm = mountComponent(Component, {
securityReports: {
sast: {
isLoading: false,
hasError: false,
newIssues: parsedSastIssuesHead,
resolvedIssues: [],
allIssues: [],
},
dependencyScanning: {
isLoading: false,
hasError: false,
newIssues: parsedSastIssuesHead,
resolvedIssues: [],
allIssues: [],
},
},
hasDependencyScanning: true,
hasSast: true,
});
});
it('renders the sast report', () => {
expect(vm.$el.querySelector('.js-sast-widget .js-code-text').textContent.trim()).toEqual('SAST degraded on 2 security vulnerabilities');
expect(vm.$el.querySelectorAll('.js-sast-widget .js-mr-code-new-issues li').length).toEqual(parsedSastIssuesHead.length);
const issue = vm.$el.querySelector('.js-sast-widget .js-mr-code-new-issues li').textContent;
expect(issue).toContain(parsedSastIssuesHead[0].message);
expect(issue).toContain(parsedSastIssuesHead[0].path);
});
it('renders the dependency scanning report', () => {
expect(vm.$el.querySelector('.js-dependency-scanning-widget .js-code-text').textContent.trim()).toEqual('Dependency scanning degraded on 2 security vulnerabilities');
expect(vm.$el.querySelectorAll('.js-dependency-scanning-widget .js-mr-code-new-issues li').length).toEqual(parsedSastIssuesHead.length);
const issue = vm.$el.querySelector('.js-dependency-scanning-widget .js-mr-code-new-issues li').textContent;
expect(issue).toContain(parsedSastIssuesHead[0].message);
expect(issue).toContain(parsedSastIssuesHead[0].path);
});
});
});
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/error_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('loading row', () => {
const Component = Vue.extend(component);
let vm;
beforeEach(() => {
vm = mountComponent(Component);
});
afterEach(() => {
vm.$destroy();
});
it('renders warning icon with error message', () => {
expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain(
'js-ci-status-icon-warning',
);
expect(vm.$el.querySelector('.report-block-list-issue-description').textContent.trim()).toEqual(
'There was an error loading results',
);
});
});
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/loading_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('loading row', () => {
const Component = Vue.extend(component);
let vm;
beforeEach(() => {
vm = mountComponent(Component);
});
afterEach(() => {
vm.$destroy();
});
it('renders loading icon with message', () => {
expect(vm.$el.querySelector('.report-block-list-icon i').classList).toContain('fa-spin');
expect(vm.$el.querySelector('.report-block-list-issue-description').textContent.trim()).toEqual(
'in progress',
);
});
});
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