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';
import pipelineHeader from './components/header_component.vue';
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 store from 'ee/vue_shared/security_reports/store'; // eslint-disable-line import/first
Vue.use(Translate);
......@@ -99,54 +100,23 @@ export default () => {
const blobPath = datasetOptions.blobPath;
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
// eslint-disable-next-line no-new
new Vue({
el: sastSummary,
store,
components: {
SastSummaryWidget,
},
data() {
return {
mediator,
};
methods: {
updateBadge(count) {
updateBadgeCount(count);
},
},
render(createElement) {
return createElement('sast-summary-widget', {
props: {
hasDependencyScanning: dependencyScanningEndpoint !== undefined,
hasSast: endpoint !== undefined,
sastIssues: this.mediator.store.state.securityReports.sast.newIssues.length,
dependencyScanningIssues:
this.mediator.store.state.securityReports.dependencyScanning.newIssues.length,
on: {
updateBadgeCount: this.updateBadge,
},
});
},
......@@ -156,20 +126,24 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el: securityTab,
store,
components: {
SecurityReportApp,
},
data() {
return {
mediator,
};
methods: {
updateBadge(count) {
updateBadgeCount(count);
},
},
render(createElement) {
return createElement('security-report-app', {
props: {
securityReports: this.mediator.store.state.securityReports,
hasDependencyScanning: dependencyScanningEndpoint !== undefined,
hasSast: endpoint !== undefined,
headBlobPath: blobPath,
sastHeadPath: endpoint,
dependencyScanningHeadPath: dependencyScanningEndpoint,
},
on: {
updateBadgeCount: this.updateBadge,
},
});
},
......
......@@ -56,23 +56,4 @@ export default class pipelinesMediator {
.then(response => this.successCallback(response))
.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 {
constructor() {
this.state = {};
this.state.pipeline = {};
/* EE only */
this.state.securityReports = securityState;
}
storePipeline(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 @@
border-top: solid 1px $border-color;
}
.mr-widget-border-top {
border-top: 1px solid $border-color;
}
.mr-widget-footer {
padding: 0;
}
......
......@@ -27,6 +27,11 @@
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.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
.content-block.content-block-small.emoji-list-container.js-noteable-awards
......
......@@ -65,4 +65,6 @@
#js-tab-security.build-security.tab-pane
#js-security-report-app{ data: { endpoint: expose_sast_data ? sast_artifact_url(@pipeline) : nil,
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>
import $ from 'jquery';
import { n__, s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { mapState } from 'vuex';
import $ from 'jquery';
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 {
name: 'SummaryReport',
components: {
CiIcon,
export default {
name: 'SummaryReport',
components: {
CiIcon,
LoadingIcon,
},
computed: {
...mapState(['sast', 'dependencyScanning']),
sastLink() {
return this.link(this.sast.newIssues.length);
},
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,
},
dependencyScanningLink() {
return this.link(this.dependencyScanning.newIssues.length);
},
computed: {
sastLink() {
return this.link(this.sastIssues);
},
dependencyScanningLink() {
return this.link(this.dependencyScanningIssues);
},
sastIcon() {
return this.statusIcon(this.sastIssues);
},
dependencyScanningIcon() {
return this.statusIcon(this.dependencyScanningIssues);
},
sastIcon() {
return this.statusIcon(this.hasSastError, this.sast.newIssues.length);
},
methods: {
openTab() {
// This opens a tab outside of this Vue application
// It opens the securty report tab in the pipelines page and updates the URL
// This is needed because the tabs are built in haml+jquery
$('.pipelines-tabs a[data-action="security"]').tab('show');
},
link(issues) {
if (issues > 0) {
return n__(
'%d vulnerability',
'%d vulnerabilities',
issues,
);
}
return s__('ciReport|no vulnerabilities');
},
statusIcon(issues) {
if (issues > 0) {
return {
group: 'warning',
icon: 'status_warning',
};
}
dependencyScanningIcon() {
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: {
openTab() {
// This opens a tab outside of this Vue application
// It opens the securty report tab in the pipelines page and updates the URL
// This is needed because the tabs are built in haml+jquery
$('.pipelines-tabs a[data-action="security"]').tab('show');
},
link(issuesCount = 0) {
if (issuesCount > 0) {
return n__('%d vulnerability', '%d vulnerabilities', issuesCount);
}
return s__('ciReport|no vulnerabilities');
},
statusIcon(failed = true, issuesCount = 0) {
if (issuesCount > 0 || failed) {
return {
group: 'success',
icon: 'status_success',
group: 'warning',
icon: 'status_warning',
};
},
}
return {
group: 'success',
icon: 'status_success',
};
},
};
},
};
</script>
<template>
<div>
......@@ -82,7 +81,12 @@
class="well-segment flex js-sast-summary"
v-if="hasSast"
>
<loading-icon
v-if="isLoadingSast"
/>
<ci-icon
v-else
:status="sastIcon"
class="flex flex-align-self-center"
/>
......@@ -90,21 +94,33 @@
<span
class="prepend-left-10 flex flex-align-self-center"
>
{{ s__('ciReport|SAST detected') }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ sastLink }}
</button>
<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') }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ sastLink }}
</button>
</template>
</span>
</div>
<div
class="well-segment flex js-dss-summary"
v-if="hasDependencyScanning"
>
<loading-icon
v-if="dependencyScanning.isLoading"
/>
<ci-icon
v-else
:status="dependencyScanningIcon"
class="flex flex-align-self-center"
/>
......@@ -112,14 +128,22 @@
<span
class="prepend-left-10 flex flex-align-self-center"
>
{{ s__('ciReport|Dependency scanning detected') }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ dependencyScanningLink }}
</button>
<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') }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ dependencyScanningLink }}
</button>
</template>
</span>
</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 {
parseCodeclimateMetrics,
filterByKey,
setSastContainerReport,
setSastReport,
setDastReport,
} from '../../vue_shared/security_reports/helpers/utils';
import { filterByKey } from '../../vue_shared/security_reports/store/utils';
export default class MergeRequestStore extends CEMergeRequestStore {
constructor(data) {
......@@ -14,13 +8,17 @@ export default class MergeRequestStore extends CEMergeRequestStore {
const blobPath = data.blob_path || {};
this.headBlobPath = blobPath.head_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.initPerformanceReport(data);
this.initSecurityReport(data);
this.initDockerReport(data);
this.initDastReport(data);
this.initDependencyScanningReport(data);
}
setData(data) {
......@@ -70,69 +68,13 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.performanceMetrics = {
improved: [],
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) {
const parsedHeadIssues = parseCodeclimateMetrics(headIssues, headBlobPath);
const parsedBaseIssues = parseCodeclimateMetrics(baseIssues, baseBlobPath);
const parsedHeadIssues = MergeRequestStore.parseCodeclimateMetrics(headIssues, headBlobPath);
const parsedBaseIssues = MergeRequestStore.parseCodeclimateMetrics(baseIssues, baseBlobPath);
this.codeclimateMetrics.newIssues = filterByKey(
parsedHeadIssues,
......@@ -202,4 +144,30 @@ export default class MergeRequestStore extends CEMergeRequestStore {
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>
/**
* Renders DAST body text
* [priority]: [name]
*/
export default {
name: 'SastIssueBody',
props: {
issue: {
type: Object,
required: true,
},
/**
* Renders DAST body text
* [priority]: [name]
*/
issueIndex: {
type: Number,
required: true,
},
export default {
name: 'SastIssueBody',
props: {
issue: {
type: Object,
required: true,
},
issueIndex: {
type: Number,
required: true,
},
modalTargetId: {
type: String,
required: true,
},
modalTargetId: {
type: String,
required: true,
},
},
methods: {
openDastModal() {
this.$emit('openDastModal', this.issue, this.issueIndex);
},
methods: {
openDastModal() {
this.$emit('openDastModal', this.issue, this.issueIndex);
},
};
},
};
</script>
<template>
<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>
import $ from 'jquery';
import _ from 'underscore';
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 {
name: 'SecurityReportsHelpPopover',
components: {
Icon,
},
directives: {
popover,
},
props: {
options: {
type: Object,
required: true,
},
},
computed: {
popoverOptions() {
return {
mounted() {
$(this.$el)
.popover({
html: true,
trigger: 'focus',
container: 'body',
placement: 'top',
template:
'<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-title"></p><div class="popover-content"></div></div>',
...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>
<template>
<button
type="button"
class="btn btn-transparent"
v-popover="popoverOptions"
class="btn btn-blank btn-transparent btn-help"
tabindex="0"
>
<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>
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import Modal from '~/vue_shared/components/gl_modal.vue';
import ExpandButton from '~/vue_shared/components/expand_button.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 SastIssue from './sast_issue_body.vue';
import SastContainerIssue from './sast_container_issue_body.vue';
import DastIssue from './dast_issue_body.vue';
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import Modal from '~/vue_shared/components/gl_modal.vue';
import ExpandButton from '~/vue_shared/components/expand_button.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 SastIssue from './sast_issue_body.vue';
import SastContainerIssue from './sast_container_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 = {
modalId: 'modal-mrwidget-issue',
modalDesc: '',
modalTitle: '',
modalInstances: [],
modalTargetId: '#modal-mrwidget-issue',
};
const modalDefaultData = {
modalId: 'modal-mrwidget-issue',
modalDesc: '',
modalTitle: '',
modalInstances: [],
modalTargetId: '#modal-mrwidget-issue',
};
export default {
name: 'ReportIssues',
components: {
Modal,
Icon,
ExpandButton,
SastIssue,
SastContainerIssue,
DastIssue,
PerformanceIssue,
CodequalityIssue,
},
props: {
issues: {
type: Array,
required: true,
},
// security || codequality || performance || docker || dast
type: {
type: String,
required: true,
},
// failed || success
status: {
type: String,
required: true,
},
},
data() {
return modalDefaultData;
},
computed: {
iconName() {
if (this.isStatusFailed) {
return 'status_failed_borderless';
} else if (this.isStatusSuccess) {
return 'status_success_borderless';
}
export default {
name: 'ReportIssues',
components: {
Modal,
Icon,
ExpandButton,
SastIssue,
SastContainerIssue,
DastIssue,
PerformanceIssue,
CodequalityIssue,
},
props: {
issues: {
type: Array,
required: true,
},
// security || codequality || performance || docker || dast
type: {
type: String,
required: true,
},
// failed || success
status: {
type: String,
required: true,
},
},
data() {
return modalDefaultData;
},
computed: {
iconName() {
if (this.isStatusFailed) {
return 'status_failed_borderless';
} else if (this.isStatusSuccess) {
return 'status_success_borderless';
}
return 'status_created_borderless';
},
isStatusFailed() {
return this.status === 'failed';
},
isStatusSuccess() {
return this.status === 'success';
},
isStatusNeutral() {
return this.status === 'neutral';
},
isTypeCodequality() {
return this.type === 'codequality';
},
isTypePerformance() {
return this.type === 'performance';
},
isTypeSast() {
return this.type === SAST;
},
isTypeSastContainer() {
return this.type === SAST_CONTAINER;
},
isTypeDast() {
return this.type === DAST;
},
},
mounted() {
$(this.$refs.modal).on('hidden.bs.modal', () => {
this.clearModalData();
});
},
methods: {
getmodalId(index) {
return `modal-mrwidget-issue-${index}`;
},
modalIdTarget(index) {
return `#${this.getmodalId(index)}`;
},
openDastModal(issue, index) {
this.modalId = this.getmodalId(index);
this.modalTitle = `${issue.priority}: ${issue.name}`;
this.modalTargetId = `#${this.getmodalId(index)}`;
this.modalInstances = issue.instances;
this.modalDesc = issue.parsedDescription;
},
/**
* Because of https://vuejs.org/v2/guide/list.html#Caveats
* we need to clear the instances to make sure everything is properly reset.
*/
clearModalData() {
this.modalId = modalDefaultData.modalId;
this.modalDesc = modalDefaultData.modalDesc;
this.modalTitle = modalDefaultData.modalTitle;
this.modalInstances = modalDefaultData.modalInstances;
this.modalTargetId = modalDefaultData.modalTargetId;
},
},
};
return 'status_created_borderless';
},
isStatusFailed() {
return this.status === 'failed';
},
isStatusSuccess() {
return this.status === 'success';
},
isStatusNeutral() {
return this.status === 'neutral';
},
isTypeCodequality() {
return this.type === 'codequality';
},
isTypePerformance() {
return this.type === 'performance';
},
isTypeSast() {
return this.type === SAST;
},
isTypeSastContainer() {
return this.type === SAST_CONTAINER;
},
isTypeDast() {
return this.type === DAST;
},
},
mounted() {
$(this.$refs.modal).on('hidden.bs.modal', () => {
this.clearModalData();
});
},
methods: {
getmodalId(index) {
return `modal-mrwidget-issue-${index}`;
},
modalIdTarget(index) {
return `#${this.getmodalId(index)}`;
},
openDastModal(issue, index) {
this.modalId = this.getmodalId(index);
this.modalTitle = `${issue.priority}: ${issue.name}`;
this.modalTargetId = `#${this.getmodalId(index)}`;
this.modalInstances = issue.instances;
this.modalDesc = issue.parsedDescription;
},
/**
* Because of https://vuejs.org/v2/guide/list.html#Caveats
* we need to clear the instances to make sure everything is properly reset.
*/
clearModalData() {
this.modalId = modalDefaultData.modalId;
this.modalDesc = modalDefaultData.modalDesc;
this.modalTitle = modalDefaultData.modalTitle;
this.modalInstances = modalDefaultData.modalInstances;
this.modalTargetId = modalDefaultData.modalTargetId;
},
},
};
</script>
<template>
<div>
......
<script>
export default {
name: 'ReportIssueLink',
props: {
issue: {
type: Object,
required: true,
},
export default {
name: 'ReportIssueLink',
props: {
issue: {
type: Object,
required: true,
},
};
},
};
</script>
<template>
<div class="report-block-list-issue-description-link">
......
<script>
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import IssuesBlock from './report_issues.vue';
export default {
name: 'ReportSection',
components: {
IssuesBlock,
LoadingIcon,
StatusIcon,
},
props: {
isCollapsible: {
type: Boolean,
required: false,
default: true,
},
// security | codequality | performance | docker
type: {
type: String,
required: true,
},
// loading | success | error
status: {
type: String,
required: true,
},
loadingText: {
type: String,
required: true,
},
errorText: {
type: String,
required: true,
},
successText: {
type: String,
required: true,
},
unresolvedIssues: {
type: Array,
required: false,
default: () => ([]),
},
resolvedIssues: {
type: Array,
required: false,
default: () => ([]),
},
neutralIssues: {
type: Array,
required: false,
default: () => ([]),
},
allIssues: {
type: Array,
required: false,
default: () => ([]),
},
infoText: {
type: [String, Boolean],
required: false,
default: false,
},
hasPriority: {
type: Boolean,
required: false,
default: false,
},
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import IssuesList from './issues_list.vue';
import Popover from './help_popover.vue';
import { LOADING, ERROR, SUCCESS } from '../store/constants';
export default {
name: 'ReportSection',
components: {
IssuesList,
LoadingIcon,
StatusIcon,
Popover,
},
props: {
type: {
type: String,
required: false,
default: '',
},
status: {
type: String,
required: true,
},
loadingText: {
type: String,
required: false,
default: '',
},
errorText: {
type: String,
required: false,
default: '',
},
successText: {
type: String,
required: true,
},
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
},
neutralIssues: {
type: Array,
required: false,
default: () => [],
},
allIssues: {
type: Array,
required: false,
default: () => [],
},
infoText: {
type: [String, Boolean],
required: false,
default: false,
},
hasIssues: {
type: Boolean,
required: true,
},
popoverOptions: {
type: Object,
default: () => ({}),
required: false,
},
},
data() {
return {
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
};
},
computed: {
isLoading() {
return this.status === LOADING;
},
loadingFailed() {
return this.status === ERROR;
},
isSuccess() {
return this.status === SUCCESS;
},
statusIconName() {
if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
return 'warning';
}
return 'success';
},
headerText() {
if (this.isLoading) {
return this.loadingText;
}
data() {
if (this.isCollapsible) {
return {
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
};
if (this.isSuccess) {
return this.successText;
}
return {
isFullReportVisible: true,
};
},
if (this.loadingFailed) {
return this.errorText;
}
computed: {
isLoading() {
return this.status === 'loading';
},
loadingFailed() {
return this.status === 'error';
},
isSuccess() {
return this.status === 'success';
},
statusIconName() {
if (this.loadingFailed ||
this.unresolvedIssues.length ||
this.neutralIssues.length) {
return 'warning';
}
return 'success';
},
hasIssues() {
return this.unresolvedIssues.length ||
this.resolvedIssues.length ||
this.allIssues.length ||
this.neutralIssues.length;
},
return '';
},
hasPopover() {
return Object.keys(this.popoverOptions).length > 0;
},
},
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
const text = this.isCollapsed ? __('Expand') : __('Collapse');
this.collapseText = text;
},
openFullReport() {
this.isFullReportVisible = true;
},
},
};
const text = this.isCollapsed ? __('Expand') : __('Collapse');
this.collapseText = text;
},
openFullReport() {
this.isFullReportVisible = true;
},
},
};
</script>
<template>
<section class="report-block mr-widget-section">
<section>
<div
v-if="isLoading"
class="media"
class="media prepend-top-default prepend-left-default
append-right-default append-bottom-default"
>
<div
<loading-icon
class="mr-widget-icon"
>
<loading-icon />
</div>
<div
class="media-body"
>
{{ loadingText }}
</div>
</div>
<div
v-else-if="isSuccess"
class="media"
>
v-if="isLoading"
/>
<status-icon
v-else
:status="statusIconName"
/>
<div
class="media-body space-children"
>
<span
class="js-code-text code-text"
>
{{ successText }}
{{ headerText }}
<popover
v-if="hasPopover"
class="prepend-left-5"
:options="popoverOptions"
/>
</span>
<button
type="button"
class="js-collapse-btn btn bt-default pull-right btn-sm"
v-if="isCollapsible && hasIssues"
v-if="hasIssues"
@click="toggleCollapsed"
>
{{ collapseText }}
......@@ -172,71 +172,28 @@
</div>
<div
class="report-block-container"
class="js-report-section-container"
v-if="hasIssues"
v-show="!isCollapsible || (isCollapsible && !isCollapsed)"
v-show="!isCollapsed"
>
<slot name="body">
<issues-list
:unresolved-issues="unresolvedIssues"
:resolved-issues="resolvedIssues"
:all-issues="allIssues"
:type="type"
:is-full-report-visible="isFullReportVisible"
/>
<p
v-if="infoText"
v-html="infoText"
class="js-mr-code-quality-info prepend-left-10 report-block-info"
>
</p>
<issues-block
class="js-mr-code-new-issues"
v-if="unresolvedIssues.length"
:type="type"
status="failed"
:issues="unresolvedIssues"
:has-priority="hasPriority"
/>
<issues-block
class="js-mr-code-all-issues"
v-if="isFullReportVisible"
:type="type"
status="failed"
:issues="allIssues"
:has-priority="hasPriority"
/>
<issues-block
class="js-mr-code-non-issues"
v-if="neutralIssues.length"
:type="type"
status="neutral"
:issues="neutralIssues"
:has-priority="hasPriority"
/>
<issues-block
class="js-mr-code-resolved-issues"
v-if="resolvedIssues.length"
:type="type"
status="success"
:issues="resolvedIssues"
:has-priority="hasPriority"
/>
<button
v-if="allIssues.length && !isFullReportVisible"
type="button"
class="btn-link btn-blank prepend-left-10 js-expand-full-list break-link"
@click="openFullReport"
>
{{ s__("ciReport|Show complete code vulnerabilities report") }}
</button>
</div>
<div
v-else-if="loadingFailed"
class="media"
>
<status-icon status="notfound" />
<div class="media-body">
{{ errorText }}
</div>
<button
v-if="allIssues.length && !isFullReportVisible"
type="button"
class="btn-link btn-blank prepend-left-10 js-expand-full-list break-link"
@click="openFullReport"
>
{{ s__("ciReport|Show complete code vulnerabilities report") }}
</button>
</slot>
</div>
</section>
</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>
/**
* Renders SAST CONTAINER body text
* [priority]: [name|link] in [link]:[line]
*/
import ReportLink from './report_link.vue';
/**
* Renders SAST CONTAINER body text
* [priority]: [name|link] in [link]:[line]
*/
import ReportLink from './report_link.vue';
export default {
name: 'SastContainerIssueBody',
export default {
name: 'SastContainerIssueBody',
components: {
ReportLink,
},
components: {
ReportLink,
},
props: {
issue: {
type: Object,
required: true,
},
props: {
issue: {
type: Object,
required: true,
},
};
},
};
</script>
<template>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
......
<script>
/**
* Renders SAST body text
* [priority]: [name] in [link] : [line]
*/
import ReportLink from './report_link.vue';
/**
* Renders SAST body text
* [priority]: [name] in [link] : [line]
*/
import ReportLink from './report_link.vue';
export default {
name: 'SastIssueBody',
export default {
name: 'SastIssueBody',
components: {
ReportLink,
},
components: {
ReportLink,
},
props: {
issue: {
type: Object,
required: true,
},
props: {
issue: {
type: Object,
required: true,
},
};
},
};
</script>
<template>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
......
<script>
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Popover from './help_popover.vue';
/**
......@@ -10,6 +11,7 @@ export default {
name: 'SecuritySummaryRow',
components: {
CiIcon,
LoadingIcon,
Popover,
},
props: {
......@@ -37,12 +39,19 @@ export default {
};
</script>
<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">
<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 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">
{{ summary }}
</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 {
methods: {
sastText(newIssues = [], resolvedIssues = [], allIssues = []) {
const text = [];
if (!newIssues.length && !resolvedIssues.length && !allIssues.length) {
text.push(s__('ciReport|SAST detected no security vulnerabilities'));
} else if (!newIssues.length && !resolvedIssues.length && allIssues.length) {
text.push(s__('ciReport|SAST detected no new security vulnerabilities'));
} else if (newIssues.length || resolvedIssues.length) {
text.push(s__('ciReport|SAST'));
}
if (resolvedIssues.length) {
text.push(n__(
' improved on %d security vulnerability',
' improved on %d security vulnerabilities',
resolvedIssues.length,
));
}
if (newIssues.length > 0 && resolvedIssues.length > 0) {
text.push(__(' and'));
}
if (newIssues.length) {
text.push(n__(
' degraded on %d security vulnerability',
' degraded on %d security vulnerabilities',
newIssues.length,
));
}
return text.join('');
},
depedencyScanningText(newIssues = [], resolvedIssues = [], allIssues = []) {
const text = [];
if (!newIssues.length && !resolvedIssues.length && !allIssues.length) {
text.push(s__('ciReport|Dependency scanning detected no security vulnerabilities'));
} else if (!newIssues.length && !resolvedIssues.length && allIssues.length) {
text.push(s__('ciReport|Dependency scanning detected no new security vulnerabilities'));
} else if (newIssues.length || resolvedIssues.length) {
text.push(s__('ciReport|Dependency scanning'));
}
if (resolvedIssues.length) {
text.push(n__(
' improved on %d security vulnerability',
' improved on %d security vulnerabilities',
resolvedIssues.length,
));
}
if (newIssues.length > 0 && resolvedIssues.length > 0) {
text.push(__(' and'));
}
if (newIssues.length) {
text.push(n__(
' degraded on %d security vulnerability',
' degraded on %d security vulnerabilities',
newIssues.length,
));
}
return text.join('');
},
translateText(type) {
computed: {
sastPopover() {
return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), { reportName: type }),
loading: sprintf(s__('ciReport|Loading %{reportName} report'), { reportName: type }),
title: s__('ciReport|Static Application Security Testing (SAST) detects known vulnerabilities in your source code.'),
content: sprintf(
s__('ciReport|%{linkStartTag}Learn more about SAST %{linkEndTag}'),
{
linkStartTag: `<a href="${this.sastHelpPath}">`,
linkEndTag: '</a>',
},
false,
),
};
},
checkReportStatus(loading, error) {
if (loading) {
return 'loading';
} else if (error) {
return 'error';
}
return 'success';
},
sastContainerText(vulnerabilities = [], approved = [], unapproved = []) {
if (!vulnerabilities.length) {
return s__('ciReport|SAST:container no vulnerabilities were found');
}
if (!unapproved.length && approved.length) {
return n__(
'SAST:container found %d approved vulnerability',
'SAST:container found %d approved vulnerabilities',
approved.length,
);
} else if (unapproved.length && !approved.length) {
return n__(
'SAST:container found %d vulnerability',
'SAST:container found %d vulnerabilities',
unapproved.length,
);
}
return `${n__(
'SAST:container found %d vulnerability,',
'SAST:container found %d vulnerabilities,',
vulnerabilities.length,
)} ${n__(
'of which %d is approved',
'of which %d are approved',
approved.length,
)}`;
sastContainerPopover() {
return {
title: s__('ciReport|Container scanning detects known vulnerabilities in your docker images.'),
content: sprintf(
s__('ciReport|%{linkStartTag}Learn more about SAST image %{linkEndTag}'),
{
linkStartTag: `<a href="${this.sastContainerHelpPath}">`,
linkEndTag: '</a>',
},
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');
dastPopover() {
return {
title: s__('ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application.'),
content: sprintf(
s__('ciReport|%{linkStartTag}Learn more about DAST %{linkEndTag}'),
{
linkStartTag: `<a href="${this.dastHelpPath}">`,
linkEndTag: '</a>',
},
false,
),
};
},
sastContainerInformationText() {
return sprintf(
s__('ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}'), {
helpLink: `<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>`,
},
false,
);
dependencyScanningPopover() {
return {
title: s__('ciReport|Dependency Scanning detects known vulnerabilities in your source code\'s dependencies.'),
content: sprintf(
s__('ciReport|%{linkStartTag}Learn more about Dependency Scanning %{linkEndTag}'),
{
linkStartTag: `<a href="${this.dependencyScanningHelpPath}">`,
linkEndTag: '</a>',
},
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 }) => {
dispatch('requestSastReports');
Promise.all([
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
])
.then(values => {
dispatch('receiveSastReports', {
head: values[0] ? values[0].data : null,
base: values[1] ? values[1].data : null,
head: values && values[0] ? values[0].data : null,
base: values && values[1] ? values[1].data : null,
});
})
.catch(() => {
......@@ -65,7 +65,7 @@ export const fetchSastContainerReports = ({ state, dispatch }) => {
dispatch('requestSastContainerReports');
Promise.all([
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
])
......@@ -100,14 +100,14 @@ export const fetchDastReports = ({ state, dispatch }) => {
dispatch('requestDastReports');
Promise.all([
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
])
.then(values => {
dispatch('receiveDastReports', {
head: values[0] ? values[0].data : null,
base: values[1] ? values[1].data : null,
head: values && values[0] ? values[0].data : null,
base: values && values[1] ? values[1].data : null,
});
})
.catch(() => {
......@@ -139,7 +139,7 @@ export const fetchDependencyScanningReports = ({ state, dispatch }) => {
dispatch('requestDependencyScanningReports');
Promise.all([
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
])
......
export const SAST = 'SAST';
export const DAST = 'DAST';
export const SAST_CONTAINER = 'SAST_CONTAINER';
export const LOADING = 'LOADING';
export const ERROR = 'ERROR';
export const SUCCESS = 'SUCCESS';
import { n__, s__ } from '~/locale';
import { textBuilder, statusIcon } from './utils';
import { LOADING, ERROR, SUCCESS } from './constants';
export const groupedSastText = ({ sast }) =>
textBuilder(
export const groupedSastText = ({ sast }) => {
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.paths,
sast.newIssues.length,
sast.resolvedIssues.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 }) =>
textBuilder(
return textBuilder(
'Container scanning',
sastContainer.paths,
sastContainer.newIssues.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 }) =>
textBuilder('DAST', dast.paths, dast.newIssues.length, dast.resolvedIssues.length);
if (dependencyScanning.isLoading) {
return s__('ciReport|Dependency scanning is loading');
}
export const groupedDependencyText = ({ dependencyScanning }) =>
textBuilder(
return textBuilder(
'Dependency scanning',
dependencyScanning.paths,
dependencyScanning.newIssues.length,
dependencyScanning.resolvedIssues.length,
dependencyScanning.allIssues.length,
);
};
export const groupedSummaryText = (state, getters) => {
const { added, fixed } = state.summaryCounts;
// All reports are loading
if (getters.areAllReportsLoading) {
return s__('ciReport|Security scanning is loading');
}
// All reports returned error
if (getters.allReportsHaveError) {
return s__('ciReport|Security scanning failed loading any results');
......@@ -40,21 +84,25 @@ export const groupedSummaryText = (state, getters) => {
if (getters.noBaseInAllReports) {
if (added > 0) {
return n__(
'Security scanning was unable to compare existing and new vulnerabilities. It detected %d vulnerability',
'Security scanning was unable to compare existing and new vulnerabilities. It detected %d vulnerabilities',
'Security scanning detected %d vulnerability for the source branch only',
'Security scanning detected %d vulnerabilities for the source branch only',
added,
);
}
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')];
if (getters.areReportsLoading) {
text.push('(in progress)');
if (getters.areReportsLoading && getters.anyReportHasError) {
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) {
......@@ -81,15 +129,33 @@ export const groupedSummaryText = (state, getters) => {
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 }) =>
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 }) =>
statusIcon(dependencyScanning.hasError, dependencyScanning.newIssues.length);
statusIcon(
dependencyScanning.isLoading,
dependencyScanning.hasError,
dependencyScanning.newIssues.length,
);
export const areReportsLoading = state =>
state.sast.isLoading ||
......@@ -97,6 +163,12 @@ export const areReportsLoading = state =>
state.sastContainer.isLoading ||
state.dependencyScanning.isLoading;
export const areAllReportsLoading = state =>
state.sast.isLoading &&
state.dast.isLoading &&
state.sastContainer.isLoading &&
state.dependencyScanning.isLoading;
export const allReportsHaveError = state =>
state.sast.hasError &&
state.dast.hasError &&
......@@ -114,3 +186,9 @@ export const noBaseInAllReports = state =>
!state.dast.paths.base &&
!state.sastContainer.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 {
parseSastIssues,
......@@ -9,24 +11,24 @@ import {
export default {
[types.SET_HEAD_BLOB_PATH](state, path) {
Object.assign(state.blobPath, { head: path });
state.blobPath.head = path;
},
[types.SET_BASE_BLOB_PATH](state, path) {
Object.assign(state.blobPath, { base: path });
state.blobPath.base = path;
},
// SAST
[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) {
Object.assign(state.sast.paths, { base: path });
state.sast.paths.base = path;
},
[types.REQUEST_SAST_REPORTS](state) {
Object.assign(state.sast, { isLoading: true });
state.sast.isLoading = true;
},
/**
......@@ -52,50 +54,39 @@ export default {
const newIssues = filterByKey(parsedHead, parsedBase, filterKey);
const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey);
const allIssues = filterByKey(parsedHead, newIssues.concat(resolvedIssues), filterKey);
Object.assign(state, {
sast: {
...state.sast,
newIssues,
resolvedIssues,
allIssues,
isLoading: false,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
state.sast.newIssues = newIssues;
state.sast.resolvedIssues = resolvedIssues;
state.sast.allIssues = allIssues;
state.sast.isLoading = false;
state.summaryCounts.added += newIssues.length;
state.summaryCounts.fixed += resolvedIssues.length;
} else if (reports.head && !reports.base) {
const newIssues = parseSastIssues(reports.head, state.blobPath.head);
Object.assign(state.sast, {
newIssues,
isLoading: false,
});
state.sast.newIssues = newIssues;
state.sast.isLoading = false;
state.summaryCounts.added += newIssues.length;
}
},
[types.RECEIVE_SAST_REPORTS_ERROR](state) {
Object.assign(state.sast, {
isLoading: false,
hasError: true,
});
state.sast.isLoading = false;
state.sast.hasError = true;
},
// SAST CONTAINER
[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) {
Object.assign(state.sastContainer.paths, { base: path });
state.sastContainer.paths.base = path;
},
[types.REQUEST_SAST_CONTAINER_REPORTS](state) {
Object.assign(state.sastContainer, { isLoading: true });
state.sastContainer.isLoading = true;
},
/**
......@@ -116,48 +107,40 @@ export default {
const newIssues = filterByKey(headIssues, baseIssues, filterKey);
const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey);
Object.assign(state, {
sastContainer: {
...state.sastContainer,
isLoading: false,
newIssues,
resolvedIssues,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
state.sastContainer.newIssues = newIssues;
state.sastContainer.resolvedIssues = resolvedIssues;
state.sastContainer.isLoading = false;
state.summaryCounts.added += newIssues.length;
state.summaryCounts.fixed += resolvedIssues.length;
} else if (reports.head && !reports.base) {
Object.assign(state.sastContainer, {
isLoading: false,
newIssues: getUnapprovedVulnerabilities(
parseSastContainer(reports.head.vulnerabilities),
reports.head.unapproved,
),
});
const newIssues = getUnapprovedVulnerabilities(
parseSastContainer(reports.head.vulnerabilities),
reports.head.unapproved,
);
state.sastContainer.newIssues = newIssues;
state.sastContainer.isLoading = false;
state.summaryCounts.added += newIssues.length;
}
},
[types.RECEIVE_SAST_CONTAINER_ERROR](state) {
Object.assign(state.sastContainer, {
isLoading: false,
hasError: true,
});
state.sastContainer.isLoading = false;
state.sastContainer.hasError = true;
},
// DAST
[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) {
Object.assign(state.dast.paths, { base: path });
state.dast.paths.base = path;
},
[types.REQUEST_DAST_REPORTS](state) {
Object.assign(state.dast, { isLoading: true });
state.dast.isLoading = true;
},
[types.RECEIVE_DAST_REPORTS](state, reports) {
......@@ -168,45 +151,37 @@ export default {
const newIssues = filterByKey(headIssues, baseIssues, filterKey);
const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey);
Object.assign(state, {
dast: {
...state.dast,
isLoading: false,
newIssues,
resolvedIssues,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
state.dast.newIssues = newIssues;
state.dast.resolvedIssues = resolvedIssues;
state.dast.isLoading = false;
state.summaryCounts.added += newIssues.length;
state.summaryCounts.fixed += resolvedIssues.length;
} else if (reports.head && !reports.base) {
Object.assign(state.dast, {
isLoading: false,
newIssues: parseDastIssues(reports.head.site.alerts),
});
const newIssues = parseDastIssues(reports.head.site.alerts);
state.dast.newIssues = newIssues;
state.dast.isLoading = false;
state.summaryCounts.added += newIssues.length;
}
},
[types.RECEIVE_DAST_ERROR](state) {
Object.assign(state.dast, {
isLoading: false,
hasError: true,
});
state.dast.isLoading = false;
state.dast.hasError = true;
},
// DEPENDECY SCANNING
[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) {
Object.assign(state.dependencyScanning.paths, { base: path });
state.dependencyScanning.paths.base = path;
},
[types.REQUEST_DEPENDENCY_SCANNING_REPORTS](state) {
Object.assign(state.dependencyScanning, { isLoading: true });
state.dependencyScanning.isLoading = true;
},
/**
......@@ -234,31 +209,24 @@ export default {
const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey);
const allIssues = filterByKey(parsedHead, newIssues.concat(resolvedIssues), filterKey);
Object.assign(state, {
dependencyScanning: {
...state.dependencyScanning,
newIssues,
resolvedIssues,
allIssues,
isLoading: false,
},
summaryCounts: {
added: state.summaryCounts.added + newIssues.length,
fixed: state.summaryCounts.fixed + resolvedIssues.length,
},
});
} else {
Object.assign(state.dependencyScanning, {
newIssues: parseSastIssues(reports.head, state.blobPath.head),
isLoading: false,
});
state.dependencyScanning.newIssues = newIssues;
state.dependencyScanning.resolvedIssues = resolvedIssues;
state.dependencyScanning.allIssues = allIssues;
state.dependencyScanning.isLoading = false;
state.summaryCounts.added += newIssues.length;
state.summaryCounts.fixed += resolvedIssues.length;
}
if (reports.head && !reports.base) {
const newIssues = parseSastIssues(reports.head, state.blobPath.head);
state.dependencyScanning.newIssues = newIssues;
state.dependencyScanning.isLoading = false;
state.summaryCounts.added += newIssues.length;
}
},
[types.RECEIVE_DEPENDENCY_SCANNING_ERROR](state) {
Object.assign(state.dependencyScanning, {
isLoading: false,
hasError: true,
});
state.dependencyScanning.isLoading = false;
state.dependencyScanning.hasError = true;
},
};
......@@ -64,30 +64,33 @@ export const textBuilder = (
resolvedIssues = 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
if (newIssues === 0 && resolvedIssues === 0 && allIssues) {
return sprintf(s__('ciReport|%{type} detected no new security vulnerabilities'), { type });
}
// with new issues and only head
if (newIssues > 0 && !paths.base) {
if (!paths.base) {
if (newIssues > 0) {
return sprintf(
n__(
'%{type} detected %d vulnerability for the source branch only',
'%{type} detected %d vulnerabilities for the source branch only',
newIssues,
),
{ type },
);
}
return sprintf(
n__(
'%{type} was unable to compare existing and new vulnerabilities. It detected %d vulnerability',
'%{type} was unable to compare existing and new vulnerabilities. It detected %d vulnerabilities',
newIssues,
),
'%{type} 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 head + base
if (paths.base && paths.head) {
// with only new issues
if (newIssues > 0 && resolvedIssues === 0) {
return sprintf(
......@@ -128,7 +131,11 @@ export const textBuilder = (
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) {
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 > span {
display: flex;
......@@ -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 {
border-top: 1px solid $gray-darker;
border-top: 1px solid $border-color;
padding: $gl-padding-top;
background-color: $gray-light;
margin: $gl-padding #{-$gl-padding} #{-$gl-padding};
// Clean MR widget CSS
line-height: 20px;
......@@ -44,6 +72,15 @@
&.neutral {
color: $theme-gray-700;
}
.ci-status-icon {
svg {
width: 16px;
height: 16px;
top: 3px;
left: -2px;
}
}
}
.report-block-list-issue {
......@@ -65,6 +102,10 @@
word-wrap: break-word;
word-break: break-all;
}
.btn-help svg {
top: 5px;
}
}
.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
expect(page).to have_css('#js-tab-security')
end
it 'shows security report' do
expect(page).to have_content('SAST detected no security vulnerabilities')
it 'shows security report section' do
expect(page).to have_content('SAST is loading')
end
end
......
import _ from 'underscore';
import Vue from 'vue';
import PipelineMediator from '~/pipelines/pipeline_details_mediator';
import { sastIssues, parsedSastIssuesStore } from '../vue_shared/security_reports/mock_data';
describe('PipelineMdediator', () => {
let mediator;
......@@ -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 securityState from 'ee/vue_shared/security_reports/helpers/state';
describe('Pipeline Store', () => {
let store;
......@@ -24,11 +23,4 @@ describe('Pipeline Store', () => {
expect(store.state.pipeline).toEqual({ foo: 'bar' });
});
});
/**
* EE only
*/
it('should set default security state', () => {
expect(store.state.securityReports).toEqual(securityState);
});
});
import Vue from 'vue';
import store from 'ee/vue_shared/security_reports/store';
import state from 'ee/vue_shared/security_reports/store/state';
import reportSummary from 'ee/pipelines/components/security_reports/report_summary_widget.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { sastIssues } from '../../vue_shared/security_reports/mock_data';
describe('Report summary widget', () => {
const Component = Vue.extend(reportSummary);
let vm;
beforeEach(() => {
vm = createComponentWithStore(Component, store).$mount();
});
afterEach(() => {
vm.$destroy();
// clean up the error state
vm.$store.replaceState(state());
});
describe('without paths', () => {
it('does not render any summary', () => {
expect(vm.$el.querySelector('.js-sast-summary')).toBeNull();
expect(vm.$el.querySelector('.js-dss-summary')).toBeNull();
});
});
describe('while loading', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('requestSastReports');
vm.$store.dispatch('requestDependencyScanningReports');
});
it('renders loading icon and text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST is loading');
expect(vm.$el.querySelector('.js-sast-summary .fa-spinner')).not.toBeNull();
done();
});
});
it('renders loading icon and text for dependency scanning', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning is loading');
expect(vm.$el.querySelector('.js-dss-summary .fa-spinner')).not.toBeNull();
done();
});
});
});
describe('with error', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('receiveSastError');
vm.$store.dispatch('receiveDependencyScanningError');
});
it('renders warning icon and error text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST resulted in error while loading results');
expect(vm.$el.querySelector('.js-sast-summary .js-ci-status-icon-warning')).not.toBeNull();
done();
});
});
it('renders warnin icon and error text for dependency scanning', done => {
vm.$nextTick()
.then(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning resulted in error while loading results');
expect(vm.$el.querySelector('.js-dss-summary .js-ci-status-icon-warning')).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
});
describe('with vulnerabilities', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('receiveSastReports', {
head: sastIssues,
});
vm.$store.dispatch('receiveDependencyScanningReports', {
head: sastIssues,
});
});
it('renders warning icon and vulnerabilities text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST detected 3 vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary .js-ci-status-icon-warning')).not.toBeNull();
done();
});
});
it('renders warning icon and vulnerabilities text for dependency scanning', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning detected 3 vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary .js-ci-status-icon-warning')).not.toBeNull();
done();
});
});
});
describe('without vulnerabilities', () => {
beforeEach(() => {
vm.$store.dispatch('setSastHeadPath', 'head.json');
vm.$store.dispatch('setDependencyScanningHeadPath', 'head.json');
vm.$store.dispatch('receiveSastReports', {
head: [],
});
vm.$store.dispatch('receiveDependencyScanningReports', {
head: [],
});
});
it('renders success icon and vulnerabilities text for sast', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-sast-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('SAST detected no vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary .js-ci-status-icon-success')).not.toBeNull();
done();
});
});
it('renders success icon and vulnerabilities text for dependency scanning', done => {
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-dss-summary')
.textContent.trim()
.replace(/\s\s+/g, ' '),
).toEqual('Dependency scanning detected no vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary .js-ci-status-icon-success')).not.toBeNull();
done();
});
});
});
});
import Vue from 'vue';
import reportSummary from 'ee/pipelines/components/security_reports/report_summary_widget.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Report summary widget', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(reportSummary);
});
afterEach(() => {
vm.$destroy();
});
describe('with vulnerabilities', () => {
beforeEach(() => {
vm = mountComponent(Component, {
sastIssues: 2,
dependencyScanningIssues: 4,
hasSast: true,
hasDependencyScanning: true,
});
});
it('renders summary text with warning icon for sast', () => {
expect(vm.$el.querySelector('.js-sast-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('SAST detected 2 vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary span').classList).toContain('ci-status-icon-warning');
});
it('renders summary text with warning icon for dependency scanning', () => {
expect(vm.$el.querySelector('.js-dss-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('Dependency scanning detected 4 vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary span').classList).toContain('ci-status-icon-warning');
});
});
describe('without vulnerabilities', () => {
beforeEach(() => {
vm = mountComponent(Component, {
hasSast: true,
hasDependencyScanning: true,
});
});
it('render summary text with success icon for sast', () => {
expect(vm.$el.querySelector('.js-sast-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('SAST detected no vulnerabilities');
expect(vm.$el.querySelector('.js-sast-summary span').classList).toContain('ci-status-icon-success');
});
it('render summary text with success icon for dependecy scanning', () => {
expect(vm.$el.querySelector('.js-dss-summary').textContent.trim().replace(/\s\s+/g, ' ')).toEqual('Dependency scanning detected no vulnerabilities');
expect(vm.$el.querySelector('.js-dss-summary span').classList).toContain('ci-status-icon-success');
});
});
});
import Vue from 'vue';
import securityReportApp from 'ee/pipelines/components/security_reports/security_report_app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { parsedSastIssuesHead } from 'spec/vue_shared/security_reports/mock_data';
describe('Security Report App', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(securityReportApp);
});
afterEach(() => {
vm.$destroy();
});
describe('sast report', () => {
beforeEach(() => {
vm = mountComponent(Component, {
securityReports: {
sast: {
isLoading: false,
hasError: false,
newIssues: parsedSastIssuesHead,
resolvedIssues: [],
allIssues: [],
},
dependencyScanning: {
isLoading: false,
hasError: false,
newIssues: parsedSastIssuesHead,
resolvedIssues: [],
allIssues: [],
},
},
hasDependencyScanning: true,
hasSast: true,
});
});
it('renders the sast report', () => {
expect(vm.$el.querySelector('.js-sast-widget .js-code-text').textContent.trim()).toEqual('SAST degraded on 2 security vulnerabilities');
expect(vm.$el.querySelectorAll('.js-sast-widget .js-mr-code-new-issues li').length).toEqual(parsedSastIssuesHead.length);
const issue = vm.$el.querySelector('.js-sast-widget .js-mr-code-new-issues li').textContent;
expect(issue).toContain(parsedSastIssuesHead[0].message);
expect(issue).toContain(parsedSastIssuesHead[0].path);
});
it('renders the dependency scanning report', () => {
expect(vm.$el.querySelector('.js-dependency-scanning-widget .js-code-text').textContent.trim()).toEqual('Dependency scanning degraded on 2 security vulnerabilities');
expect(vm.$el.querySelectorAll('.js-dependency-scanning-widget .js-mr-code-new-issues li').length).toEqual(parsedSastIssuesHead.length);
const issue = vm.$el.querySelector('.js-dependency-scanning-widget .js-mr-code-new-issues li').textContent;
expect(issue).toContain(parsedSastIssuesHead[0].message);
expect(issue).toContain(parsedSastIssuesHead[0].path);
});
});
});
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/error_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('loading row', () => {
const Component = Vue.extend(component);
let vm;
beforeEach(() => {
vm = mountComponent(Component);
});
afterEach(() => {
vm.$destroy();
});
it('renders warning icon with error message', () => {
expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain(
'js-ci-status-icon-warning',
);
expect(vm.$el.querySelector('.report-block-list-issue-description').textContent.trim()).toEqual(
'There was an error loading results',
);
});
});
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/loading_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('loading row', () => {
const Component = Vue.extend(component);
let vm;
beforeEach(() => {
vm = mountComponent(Component);
});
afterEach(() => {
vm.$destroy();
});
it('renders loading icon with message', () => {
expect(vm.$el.querySelector('.report-block-list-icon i').classList).toContain('fa-spin');
expect(vm.$el.querySelector('.report-block-list-issue-description').textContent.trim()).toEqual(
'in progress',
);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment