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,
props: {
sastIssues: {
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: { computed: {
...mapState(['sast', 'dependencyScanning']),
sastLink() { sastLink() {
return this.link(this.sastIssues); return this.link(this.sast.newIssues.length);
}, },
dependencyScanningLink() { dependencyScanningLink() {
return this.link(this.dependencyScanningIssues); return this.link(this.dependencyScanning.newIssues.length);
}, },
sastIcon() { sastIcon() {
return this.statusIcon(this.sastIssues); return this.statusIcon(this.hasSastError, this.sast.newIssues.length);
}, },
dependencyScanningIcon() { dependencyScanningIcon() {
return this.statusIcon(this.dependencyScanningIssues); return this.statusIcon(
this.hasDependencyScanningError,
this.dependencyScanning.newIssues.length,
);
},
hasSast() {
return this.sast.paths.head !== null;
},
hasDependencyScanning() {
return this.dependencyScanning.paths.head !== null;
},
isLoadingSast() {
return this.sast.isLoading;
},
isLoadingDependencyScanning() {
return this.dependencyScanning.isLoading;
},
hasSastError() {
return this.sast.hasError;
},
hasDependencyScanningError() {
return this.dependencyScanning.hasError;
}, },
}, },
methods: { methods: {
...@@ -51,18 +54,14 @@ ...@@ -51,18 +54,14 @@
// This is needed because the tabs are built in haml+jquery // This is needed because the tabs are built in haml+jquery
$('.pipelines-tabs a[data-action="security"]').tab('show'); $('.pipelines-tabs a[data-action="security"]').tab('show');
}, },
link(issues) { link(issuesCount = 0) {
if (issues > 0) { if (issuesCount > 0) {
return n__( return n__('%d vulnerability', '%d vulnerabilities', issuesCount);
'%d vulnerability',
'%d vulnerabilities',
issues,
);
} }
return s__('ciReport|no vulnerabilities'); return s__('ciReport|no vulnerabilities');
}, },
statusIcon(issues) { statusIcon(failed = true, issuesCount = 0) {
if (issues > 0) { if (issuesCount > 0 || failed) {
return { return {
group: 'warning', group: 'warning',
icon: 'status_warning', icon: 'status_warning',
...@@ -74,7 +73,7 @@ ...@@ -74,7 +73,7 @@
}; };
}, },
}, },
}; };
</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,6 +94,13 @@ ...@@ -90,6 +94,13 @@
<span <span
class="prepend-left-10 flex flex-align-self-center" class="prepend-left-10 flex flex-align-self-center"
> >
<template v-if="hasSastError">
{{ s__('ciReport|SAST resulted in error while loading results') }}
</template>
<template v-else-if="isLoadingSast">
{{ s__('ciReport|SAST is loading') }}
</template>
<template v-else>
{{ s__('ciReport|SAST detected') }} {{ s__('ciReport|SAST detected') }}
<button <button
type="button" type="button"
...@@ -98,13 +109,18 @@ ...@@ -98,13 +109,18 @@
> >
{{ sastLink }} {{ sastLink }}
</button> </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,6 +128,13 @@ ...@@ -112,6 +128,13 @@
<span <span
class="prepend-left-10 flex flex-align-self-center" class="prepend-left-10 flex flex-align-self-center"
> >
<template v-if="hasDependencyScanningError">
{{ s__('ciReport|Dependency scanning resulted in error while loading results') }}
</template>
<template v-else-if="isLoadingDependencyScanning">
{{ s__('ciReport|Dependency scanning is loading') }}
</template>
<template v-else>
{{ s__('ciReport|Dependency scanning detected') }} {{ s__('ciReport|Dependency scanning detected') }}
<button <button
type="button" type="button"
...@@ -120,6 +143,7 @@ ...@@ -120,6 +143,7 @@
> >
{{ dependencyScanningLink }} {{ dependencyScanningLink }}
</button> </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 {
export default {
name: 'SastIssueBody', name: 'SastIssueBody',
props: { props: {
issue: { issue: {
...@@ -27,7 +28,7 @@ ...@@ -27,7 +28,7 @@
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,
...@@ -116,7 +116,7 @@ ...@@ -116,7 +116,7 @@
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: {
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
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';
export default {
name: 'ReportSection', name: 'ReportSection',
components: { components: {
IssuesBlock, IssuesList,
LoadingIcon, LoadingIcon,
StatusIcon, StatusIcon,
Popover,
}, },
props: { props: {
isCollapsible: {
type: Boolean,
required: false,
default: true,
},
// security | codequality | performance | docker
type: { type: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
// loading | success | error
status: { status: {
type: String, type: String,
required: true, required: true,
}, },
loadingText: { loadingText: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
errorText: { errorText: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
successText: { successText: {
type: String, type: String,
...@@ -42,72 +41,80 @@ ...@@ -42,72 +41,80 @@
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,
}, },
hasPriority: { hasIssues: {
type: Boolean, type: Boolean,
required: true,
},
popoverOptions: {
type: Object,
default: () => ({}),
required: false, required: false,
default: false,
}, },
}, },
data() { data() {
if (this.isCollapsible) {
return { return {
collapseText: __('Expand'), collapseText: __('Expand'),
isCollapsed: true, isCollapsed: true,
isFullReportVisible: false, isFullReportVisible: false,
}; };
}
return {
isFullReportVisible: true,
};
}, },
computed: { computed: {
isLoading() { isLoading() {
return this.status === 'loading'; return this.status === LOADING;
}, },
loadingFailed() { loadingFailed() {
return this.status === 'error'; return this.status === ERROR;
}, },
isSuccess() { isSuccess() {
return this.status === 'success'; return this.status === SUCCESS;
}, },
statusIconName() { statusIconName() {
if (this.loadingFailed || if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
this.unresolvedIssues.length ||
this.neutralIssues.length) {
return 'warning'; return 'warning';
} }
return 'success'; return 'success';
}, },
hasIssues() { headerText() {
return this.unresolvedIssues.length || if (this.isLoading) {
this.resolvedIssues.length || return this.loadingText;
this.allIssues.length || }
this.neutralIssues.length;
if (this.isSuccess) {
return this.successText;
}
if (this.loadingFailed) {
return this.errorText;
}
return '';
},
hasPopover() {
return Object.keys(this.popoverOptions).length > 0;
}, },
}, },
...@@ -122,48 +129,41 @@ ...@@ -122,48 +129,41 @@
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,52 +172,17 @@ ...@@ -172,52 +172,17 @@
</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"
>
<p
v-if="infoText"
v-html="infoText"
class="js-mr-code-quality-info prepend-left-10 report-block-info"
> >
</p> <slot name="body">
<issues-list
<issues-block :unresolved-issues="unresolvedIssues"
class="js-mr-code-new-issues" :resolved-issues="resolvedIssues"
v-if="unresolvedIssues.length" :all-issues="allIssues"
: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" :type="type"
status="neutral" :is-full-report-visible="isFullReportVisible"
: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 <button
...@@ -228,15 +193,7 @@ ...@@ -228,15 +193,7 @@
> >
{{ s__("ciReport|Show complete code vulnerabilities report") }} {{ s__("ciReport|Show complete code vulnerabilities report") }}
</button> </button>
</div> </slot>
<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: {
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
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: {
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
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 = []; return {
title: s__('ciReport|Static Application Security Testing (SAST) detects known vulnerabilities in your source code.'),
if (!newIssues.length && !resolvedIssues.length && !allIssues.length) { content: sprintf(
text.push(s__('ciReport|SAST detected no security vulnerabilities')); s__('ciReport|%{linkStartTag}Learn more about SAST %{linkEndTag}'),
} else if (!newIssues.length && !resolvedIssues.length && allIssues.length) { {
text.push(s__('ciReport|SAST detected no new security vulnerabilities')); linkStartTag: `<a href="${this.sastHelpPath}">`,
} else if (newIssues.length || resolvedIssues.length) { linkEndTag: '</a>',
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('');
}, },
false,
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('');
}, },
sastContainerPopover() {
translateText(type) {
return { return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), { reportName: type }), title: s__('ciReport|Container scanning detects known vulnerabilities in your docker images.'),
loading: sprintf(s__('ciReport|Loading %{reportName} report'), { reportName: type }), content: sprintf(
}; s__('ciReport|%{linkStartTag}Learn more about SAST image %{linkEndTag}'),
{
linkStartTag: `<a href="${this.sastContainerHelpPath}">`,
linkEndTag: '</a>',
}, },
false,
checkReportStatus(loading, error) { ),
if (loading) { };
return 'loading';
} else if (error) {
return 'error';
}
return 'success';
}, },
dastPopover() {
sastContainerText(vulnerabilities = [], approved = [], unapproved = []) { return {
if (!vulnerabilities.length) { title: s__('ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application.'),
return s__('ciReport|SAST:container no vulnerabilities were found'); content: sprintf(
} s__('ciReport|%{linkStartTag}Learn more about DAST %{linkEndTag}'),
{
if (!unapproved.length && approved.length) { linkStartTag: `<a href="${this.dastHelpPath}">`,
return n__( linkEndTag: '</a>',
'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,
)}`;
}, },
false,
dastText(dast = []) { ),
if (dast.length) { };
return n__(
'DAST detected %d alert by analyzing the review app',
'DAST detected %d alerts by analyzing the review app',
dast.length,
);
}
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,
newIssues: getUnapprovedVulnerabilities(
parseSastContainer(reports.head.vulnerabilities), parseSastContainer(reports.head.vulnerabilities),
reports.head.unapproved, 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( return sprintf(
n__( n__(
'%{type} was unable to compare existing and new vulnerabilities. It detected %d vulnerability', '%{type} detected %d vulnerability for the source branch only',
'%{type} was unable to compare existing and new vulnerabilities. It detected %d vulnerabilities', '%{type} detected %d vulnerabilities for the source branch only',
newIssues, newIssues,
), ),
{ type }, { type },
); );
} }
// with head + base return sprintf(
if (paths.base && paths.head) { '%{type} detected no vulnerabilities for the source branch only',
{ 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 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);
});
});
});
...@@ -6,18 +6,6 @@ import mockData, { ...@@ -6,18 +6,6 @@ import mockData, {
parsedBaseIssues, parsedBaseIssues,
parsedHeadIssues, parsedHeadIssues,
} from '../mock_data'; } from '../mock_data';
import {
sastIssues,
sastIssuesBase,
parsedSastBaseStore,
parsedSastIssuesHead,
parsedSastIssuesStore,
allIssuesParsed,
dockerReport,
dockerReportParsed,
dast,
parsedDast,
} from '../../vue_shared/security_reports/mock_data';
describe('MergeRequestStore', () => { describe('MergeRequestStore', () => {
let store; let store;
...@@ -98,43 +86,12 @@ describe('MergeRequestStore', () => { ...@@ -98,43 +86,12 @@ describe('MergeRequestStore', () => {
}); });
}); });
describe('setSecurityReport', () => { describe('parseCodeclimateMetrics', () => {
it('should set security issues with head', () => { it('should parse the received issues', () => {
store.setSecurityReport({ head: sastIssues, headBlobPath: 'path' }); const codequality = MergeRequestStore.parseCodeclimateMetrics(baseIssues, 'path')[0];
expect(store.securityReport.newIssues).toEqual(parsedSastIssuesStore); expect(codequality.name).toEqual(baseIssues[0].check_name);
}); expect(codequality.path).toEqual(baseIssues[0].location.path);
expect(codequality.line).toEqual(baseIssues[0].location.lines.begin);
it('should set security issues with head and base', () => {
store.setSecurityReport({
head: sastIssues,
headBlobPath: 'path',
base: sastIssuesBase,
baseBlobPath: 'path',
});
expect(store.securityReport.newIssues).toEqual(parsedSastIssuesHead);
expect(store.securityReport.resolvedIssues).toEqual(parsedSastBaseStore);
expect(store.securityReport.allIssues).toEqual(allIssuesParsed);
});
});
describe('setDependencyScanningReport', () => {
it('should set security issues with head', () => {
store.setDependencyScanningReport({ head: sastIssues, headBlobPath: 'path' });
expect(store.dependencyScanningReport.newIssues).toEqual(parsedSastIssuesStore);
});
it('should set security issues with head and base', () => {
store.setDependencyScanningReport({
head: sastIssues,
headBlobPath: 'path',
base: sastIssuesBase,
baseBlobPath: 'path',
});
expect(store.dependencyScanningReport.newIssues).toEqual(parsedSastIssuesHead);
expect(store.dependencyScanningReport.resolvedIssues).toEqual(parsedSastBaseStore);
expect(store.dependencyScanningReport.allIssues).toEqual(allIssuesParsed);
}); });
}); });
...@@ -149,43 +106,4 @@ describe('MergeRequestStore', () => { ...@@ -149,43 +106,4 @@ describe('MergeRequestStore', () => {
expect(store.isNothingToMergeState).toEqual(false); expect(store.isNothingToMergeState).toEqual(false);
}); });
}); });
describe('initDockerReport', () => {
it('sets the defaults', () => {
store.initDockerReport({ sast_container: { path: 'gl-sast-container.json' } });
expect(store.sastContainer).toEqual({ path: 'gl-sast-container.json' });
expect(store.dockerReport).toEqual({
approved: [],
unapproved: [],
vulnerabilities: [],
});
});
});
describe('setDockerReport', () => {
it('sets docker report with approved and unapproved vulnerabilities parsed', () => {
store.setDockerReport(dockerReport);
expect(store.dockerReport.vulnerabilities).toEqual(dockerReportParsed.vulnerabilities);
expect(store.dockerReport.approved).toEqual(dockerReportParsed.approved);
expect(store.dockerReport.unapproved).toEqual(dockerReportParsed.unapproved);
});
});
describe('initDastReport', () => {
it('sets the defaults', () => {
store.initDastReport({ dast: { path: 'dast.json' } });
expect(store.dast).toEqual({ path: 'dast.json' });
expect(store.dastReport).toEqual([]);
});
});
describe('setDastReport', () => {
it('parsed data and sets the report', () => {
store.setDastReport(dast);
expect(store.dastReport).toEqual(parsedDast);
});
});
}); });
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',
);
});
});
...@@ -19,10 +19,11 @@ describe('Report section', () => { ...@@ -19,10 +19,11 @@ describe('Report section', () => {
it('should render loading indicator', () => { it('should render loading indicator', () => {
vm = mountComponent(ReportSection, { vm = mountComponent(ReportSection, {
type: 'codequality', type: 'codequality',
status: 'loading', status: 'LOADING',
loadingText: 'Loading codeclimate report', loadingText: 'Loading codeclimate report',
errorText: 'foo', errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point', successText: 'Code quality improved on 1 point and degraded on 1 point',
hasIssues: false,
}); });
expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report'); expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report');
}); });
...@@ -32,11 +33,12 @@ describe('Report section', () => { ...@@ -32,11 +33,12 @@ describe('Report section', () => {
it('should render provided data', () => { it('should render provided data', () => {
vm = mountComponent(ReportSection, { vm = mountComponent(ReportSection, {
type: 'codequality', type: 'codequality',
status: 'success', status: 'SUCCESS',
loadingText: 'Loading codeclimate report', loadingText: 'Loading codeclimate report',
errorText: 'foo', errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point', successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues, resolvedIssues: codequalityParsedIssues,
hasIssues: true,
}); });
expect( expect(
...@@ -52,18 +54,19 @@ describe('Report section', () => { ...@@ -52,18 +54,19 @@ describe('Report section', () => {
it('toggles issues', (done) => { it('toggles issues', (done) => {
vm = mountComponent(ReportSection, { vm = mountComponent(ReportSection, {
type: 'codequality', type: 'codequality',
status: 'success', status: 'SUCCESS',
loadingText: 'Loading codeclimate report', loadingText: 'Loading codeclimate report',
errorText: 'foo', errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point', successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues, resolvedIssues: codequalityParsedIssues,
hasIssues: true,
}); });
vm.$el.querySelector('button').click(); vm.$el.querySelector('button').click();
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(
vm.$el.querySelector('.report-block-container').getAttribute('style'), vm.$el.querySelector('.js-report-section-container').getAttribute('style'),
).toEqual(''); ).toEqual('');
expect( expect(
vm.$el.querySelector('button').textContent.trim(), vm.$el.querySelector('button').textContent.trim(),
...@@ -73,7 +76,7 @@ describe('Report section', () => { ...@@ -73,7 +76,7 @@ describe('Report section', () => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(
vm.$el.querySelector('.report-block-container').getAttribute('style'), vm.$el.querySelector('.js-report-section-container').getAttribute('style'),
).toEqual('display: none;'); ).toEqual('display: none;');
expect( expect(
vm.$el.querySelector('button').textContent.trim(), vm.$el.querySelector('button').textContent.trim(),
...@@ -90,10 +93,11 @@ describe('Report section', () => { ...@@ -90,10 +93,11 @@ describe('Report section', () => {
it('should render error indicator', () => { it('should render error indicator', () => {
vm = mountComponent(ReportSection, { vm = mountComponent(ReportSection, {
type: 'codequality', type: 'codequality',
status: 'error', status: 'ERROR',
loadingText: 'Loading codeclimate report', loadingText: 'Loading codeclimate report',
errorText: 'Failed to load codeclimate report', errorText: 'Failed to load codeclimate report',
successText: 'Code quality improved on 1 point and degraded on 1 point', successText: 'Code quality improved on 1 point and degraded on 1 point',
hasIssues: false,
}); });
expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report'); expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report');
}); });
...@@ -102,11 +106,11 @@ describe('Report section', () => { ...@@ -102,11 +106,11 @@ describe('Report section', () => {
describe('With full report', () => { describe('With full report', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(ReportSection, { vm = mountComponent(ReportSection, {
status: 'success', status: 'SUCCESS',
successText: 'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability', successText: 'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
type: 'SAST', type: 'SAST',
errorText: 'Failed to load security report', errorText: 'Failed to load security report',
hasPriority: true, hasIssues: true,
loadingText: 'Loading security report', loadingText: 'Loading security report',
resolvedIssues: [{ resolvedIssues: [{
cve: 'CVE-2016-9999', cve: 'CVE-2016-9999',
...@@ -172,28 +176,4 @@ describe('Report section', () => { ...@@ -172,28 +176,4 @@ describe('Report section', () => {
}); });
}); });
}); });
describe('When it is not collapsible', () => {
beforeEach(() => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
isCollapsible: false,
});
});
it('should not render collapse button', () => {
expect(vm.$el.querySelector('.js-collapse-btn')).toBe(null);
});
it('should show the report by default', () => {
expect(
vm.$el.querySelectorAll('.report-block-list .report-block-list-issue').length,
).toEqual(codequalityParsedIssues.length);
});
});
}); });
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import component from 'ee/vue_shared/security_reports/grouped_security_reports_app.vue';
import state from 'ee/vue_shared/security_reports/store/state';
import mountComponent from '../../helpers/vue_mount_component_helper';
import {
sastIssues,
sastIssuesBase,
dockerReport,
dockerBaseReport,
dast,
dastBase,
} from './mock_data';
describe('Grouped security reports app', () => {
let vm;
let mock;
const Component = Vue.extend(component);
function removeBreakLine(data) {
return data
.replace(/\r?\n|\r/g, '')
.replace(/\s\s+/g, ' ')
.trim();
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
vm.$destroy();
mock.restore();
vm.$store.replaceState(state());
});
describe('with error', () => {
beforeEach(() => {
mock.onGet('sast_head.json').reply(500);
mock.onGet('sast_base.json').reply(500);
mock.onGet('dast_head.json').reply(500);
mock.onGet('dast_base.json').reply(500);
mock.onGet('sast_container_head.json').reply(500);
mock.onGet('sast_container_base.json').reply(500);
mock.onGet('dss_head.json').reply(500);
mock.onGet('dss_base.json').reply(500);
vm = mountComponent(Component, {
headBlobPath: 'path',
baseBlobPath: 'path',
sastHeadPath: 'sast_head.json',
sastBasePath: 'sast_base.json',
dastHeadPath: 'dast_head.json',
dastBasePath: 'dast_base.json',
sastContainerHeadPath: 'sast_container_head.json',
sastContainerBasePath: 'sast_container_base.json',
dependencyScanningHeadPath: 'dss_head.json',
dependencyScanningBasePath: 'dss_base.json',
sastHelpPath: 'path',
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
});
});
it('renders loading state', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Security scanning failed loading any results',
);
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
expect(removeBreakLine(vm.$el.textContent)).toContain('SAST resulted in error while loading results');
expect(removeBreakLine(vm.$el.textContent)).toContain('Dependency scanning resulted in error while loading results');
expect(vm.$el.textContent).toContain('Container scanning resulted in error while loading results');
expect(vm.$el.textContent).toContain('DAST resulted in error while loading results');
done();
}, 0);
});
});
describe('while loading', () => {
beforeEach(() => {
mock.onGet('sast_head.json').reply(200, sastIssues);
mock.onGet('sast_base.json').reply(200, sastIssuesBase);
mock.onGet('dast_head.json').reply(200, dast);
mock.onGet('dast_base.json').reply(200, dastBase);
mock.onGet('sast_container_head.json').reply(200, dockerReport);
mock.onGet('sast_container_base.json').reply(200, dockerBaseReport);
mock.onGet('dss_head.json').reply(200, sastIssues);
mock.onGet('dss_base.json').reply(200, sastIssuesBase);
vm = mountComponent(Component, {
headBlobPath: 'path',
baseBlobPath: 'path',
sastHeadPath: 'sast_head.json',
sastBasePath: 'sast_base.json',
dastHeadPath: 'dast_head.json',
dastBasePath: 'dast_base.json',
sastContainerHeadPath: 'sast_container_head.json',
sastContainerBasePath: 'sast_container_base.json',
dependencyScanningHeadPath: 'dss_head.json',
dependencyScanningBasePath: 'dss_base.json',
sastHelpPath: 'path',
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
});
});
it('renders loading summary text + spinner', () => {
expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Security scanning is loading',
);
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
expect(vm.$el.textContent).toContain('SAST is loading');
expect(vm.$el.textContent).toContain('Dependency scanning is loading');
expect(vm.$el.textContent).toContain('Container scanning is loading');
expect(vm.$el.textContent).toContain('DAST is loading');
});
});
describe('with all reports', () => {
beforeEach(() => {
mock.onGet('sast_head.json').reply(200, sastIssues);
mock.onGet('sast_base.json').reply(200, sastIssuesBase);
mock.onGet('dast_head.json').reply(200, dast);
mock.onGet('dast_base.json').reply(200, dastBase);
mock.onGet('sast_container_head.json').reply(200, dockerReport);
mock.onGet('sast_container_base.json').reply(200, dockerBaseReport);
mock.onGet('dss_head.json').reply(200, sastIssues);
mock.onGet('dss_base.json').reply(200, sastIssuesBase);
vm = mountComponent(Component, {
headBlobPath: 'path',
baseBlobPath: 'path',
sastHeadPath: 'sast_head.json',
sastBasePath: 'sast_base.json',
dastHeadPath: 'dast_head.json',
dastBasePath: 'dast_base.json',
sastContainerHeadPath: 'sast_container_head.json',
sastContainerBasePath: 'sast_container_base.json',
dependencyScanningHeadPath: 'dss_head.json',
dependencyScanningBasePath: 'dss_base.json',
sastHelpPath: 'path',
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
});
});
it('renders reports', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Security scanning detected 12 new vulnerabilities and 4 fixed vulnerabilities',
);
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
expect(removeBreakLine(vm.$el.textContent)).toContain('SAST detected 2 new vulnerabilities and 1 fixed vulnerability');
expect(removeBreakLine(vm.$el.textContent)).toContain('Dependency scanning detected 2 new vulnerabilities and 1 fixed vulnerability');
expect(vm.$el.textContent).toContain('Container scanning detected 1 new vulnerability');
expect(vm.$el.textContent).toContain('DAST detected 1 new vulnerability');
done();
}, 0);
});
});
});
import {
parseSastIssues,
parseCodeclimateMetrics,
parseSastContainer,
setSastReport,
setDastReport,
} from 'ee/vue_shared/security_reports/helpers/utils';
import {
baseIssues,
sastIssues,
sastIssuesBase,
parsedSastIssuesStore,
parsedSastBaseStore,
allIssuesParsed,
parsedSastIssuesHead,
dockerReport,
dockerReportParsed,
dast,
parsedDast,
} from '../mock_data';
describe('security reports utils', () => {
describe('parseSastIssues', () => {
it('should parse the received issues', () => {
const security = parseSastIssues(sastIssues, 'path')[0];
expect(security.name).toEqual(sastIssues[0].message);
expect(security.path).toEqual(sastIssues[0].file);
});
});
describe('parseCodeclimateMetrics', () => {
it('should parse the received issues', () => {
const codequality = parseCodeclimateMetrics(baseIssues, 'path')[0];
expect(codequality.name).toEqual(baseIssues[0].check_name);
expect(codequality.path).toEqual(baseIssues[0].location.path);
expect(codequality.line).toEqual(baseIssues[0].location.lines.begin);
});
});
describe('setSastReport', () => {
it('should set security issues with head', () => {
const securityReport = setSastReport({ head: sastIssues, headBlobPath: 'path' });
expect(securityReport.newIssues).toEqual(parsedSastIssuesStore);
});
it('should set security issues with head and base', () => {
const securityReport = setSastReport({
head: sastIssues,
headBlobPath: 'path',
base: sastIssuesBase,
baseBlobPath: 'path',
});
expect(securityReport.newIssues).toEqual(parsedSastIssuesHead);
expect(securityReport.resolvedIssues).toEqual(parsedSastBaseStore);
expect(securityReport.allIssues).toEqual(allIssuesParsed);
});
});
describe('parseSastContainer', () => {
it('parses sast container report', () => {
expect(parseSastContainer(dockerReport.vulnerabilities)).toEqual(
dockerReportParsed.vulnerabilities,
);
});
});
describe('dastReport', () => {
it('parsed dast report', () => {
expect(setDastReport(dast)).toEqual(parsedDast);
});
});
});
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