Commit 72ee0b60 authored by Phil Hughes's avatar Phil Hughes

Merge branch '3776-ci-view-for-sast' into 'master'

Resolve "CI view for SAST"

Closes #3776

See merge request gitlab-org/gitlab-ee!4505
parents 860536ff d485fd99
......@@ -157,6 +157,7 @@ var Dispatcher;
break;
case 'projects:pipelines:builds':
case 'projects:pipelines:failures':
case 'projects:pipelines:security':
case 'projects:pipelines:show':
import('./pages/projects/pipelines/builds')
.then(callDefault)
......
......@@ -66,7 +66,7 @@
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
<div class="pipeline-visualization pipeline-graph">
<div class="pipeline-visualization pipeline-graph pipeline-tab-content">
<div class="text-center">
<loading-icon
v-if="isLoading"
......
<script>
import { n__, s__ } from '~/locale';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
export default {
name: 'SastSummaryReport',
components: {
ciIcon,
},
props: {
unresolvedIssues: {
type: Array,
required: false,
default: () => ([]),
},
},
computed: {
sastText() {
if (this.unresolvedIssues.length) {
return s__('ciReport|SAST degraded on');
}
return s__('ciReport|SAST detected');
},
sastLink() {
if (this.unresolvedIssues.length) {
return n__(
'%d security vulnerability',
'%d security vulnerabilities',
this.unresolvedIssues.length,
);
}
return s__('ciReport|no security vulnerabilities');
},
statusIcon() {
if (this.unresolvedIssues.length) {
return {
group: 'warning',
icon: 'status_warning',
};
}
return {
group: 'success',
icon: 'status_success',
};
},
},
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');
},
},
};
</script>
<template>
<div class="well-segment flex">
<ci-icon
:status="statusIcon"
class="flex flex-align-self-center"
/>
<span
class="prepend-left-10 flex flex-align-self-center"
>
{{ sastText }}
<button
type="button"
class="btn-link btn-blank prepend-left-5"
@click="openTab"
>
{{ sastLink }}
</button>
</span>
</div>
</template>
<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';
export default {
name: 'SecurityReportTab',
components: {
LoadingIcon,
ReportSection,
},
mixins: [
securityMixin,
],
props: {
securityReports: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="pipeline-tab-content">
<report-section
class="js-sast-widget"
type="security"
: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"
:has-priority="true"
:is-collapsible="false"
/>
</div>
</template>
import Vue from 'vue';
import Flash from '../flash';
import PipelinesMediator from './pipeline_details_mediatior';
import Flash from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
import SecurityReportApp from './components/security_reports/security_report_app.vue';
import SastSummaryWidget from './components/security_reports/sast_report_summary_widget.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
......@@ -54,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
postAction(action) {
this.mediator.service.postAction(action.path)
.then(() => this.mediator.refreshPipeline())
.catch(() => new Flash('An error occurred while making the request.'));
.catch(() => Flash(__('An error occurred while making the request.')));
},
},
render(createElement) {
......@@ -66,4 +72,73 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
/**
* EE only
*/
const securityTab = document.getElementById('js-security-report-app');
const sastSummary = document.querySelector('.js-sast-summary');
// They are being rendered under the same condition
if (securityTab && sastSummary) {
const datasetOptions = securityTab.dataset;
const endpoint = datasetOptions.endpoint;
const blobPath = datasetOptions.blobPath;
mediator.fetchSastReport(endpoint, blobPath)
.then(() => {
// update the badge
if (mediator.store.state.securityReports.sast.newIssues.length) {
const badge = document.querySelector('.js-sast-counter');
badge.textContent = mediator.store.state.securityReports.sast.newIssues.length;
badge.classList.remove('hidden');
}
})
.catch(() => {
Flash(__('Something went wrong while fetching SAST.'));
});
// Widget summary
// eslint-disable-next-line no-new
new Vue({
el: sastSummary,
components: {
SastSummaryWidget,
},
data() {
return {
mediator,
};
},
render(createElement) {
return createElement('sast-summary-widget', {
props: {
unresolvedIssues: this.mediator.store.state.securityReports.sast.newIssues,
},
});
},
});
// Tab content
// eslint-disable-next-line no-new
new Vue({
el: securityTab,
components: {
SecurityReportApp,
},
data() {
return {
mediator,
};
},
render(createElement) {
return createElement('security-report-app', {
props: {
securityReports: this.mediator.store.state.securityReports,
},
});
},
});
}
});
import Visibility from 'visibilityjs';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import { __ } from '../locale';
import PipelineStore from './stores/pipeline_store';
import PipelineService from './services/pipeline_service';
......@@ -47,7 +48,7 @@ export default class pipelinesMediator {
errorCallback() {
this.state.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
Flash(__('An error occurred while fetching the pipeline.'));
}
refreshPipeline() {
......@@ -55,4 +56,15 @@ 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);
});
}
}
......@@ -16,4 +16,8 @@ export default class PipelineService {
postAction(endpoint) {
return Vue.http.post(`${endpoint}.json`);
}
static getSecurityReport(endpoint) {
return Vue.http.get(endpoint);
}
}
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 }),
);
}
}
......@@ -783,57 +783,3 @@
font-size: 22px;
margin: 0 10px 0 0;
}
.mr-widget-code-quality {
.code-quality-container {
border-top: 1px solid $gray-darker;
padding: $gl-padding-top;
background-color: $gray-light;
margin: $gl-padding -16px -16px;
.mr-widget-code-quality-info {
padding-left: 10px;
}
.mr-widget-dast-code {
margin-left: 26px;
}
.mr-widget-code-quality-list {
list-style: none;
padding: 0 1px;
margin: 0;
line-height: $code_line_height;
.btn-open-modal {
padding: 0 5px 4px;
}
.mr-widget-code-quality-list-item {
display: flex;
}
.mr-widget-code-quality-list-item-modal {
display: flex;
flex-wrap: wrap;
}
.failed .mr-widget-code-quality-icon {
color: $red-500;
}
.success .mr-widget-code-quality-icon {
color: $green-500;
}
.neutral .mr-widget-code-quality-icon {
color: $theme-gray-700;
}
.mr-widget-code-quality-icon {
margin: -5px 4px 0 0;
fill: currentColor;
}
}
}
}
......@@ -355,14 +355,17 @@
}
}
// Pipeline graph
.pipeline-graph {
.pipeline-tab-content {
width: 100%;
background-color: $gray-light;
padding: $gl-padding;
overflow: auto;
}
// Pipeline graph
.pipeline-graph {
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
overflow: auto;
.stage-column-list,
.builds-container > ul {
......
class Projects::PipelinesController < Projects::ApplicationController
prepend ::EE::Projects::PipelinesController
before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :commit, only: [:show, :builds, :failures]
......
#js-pipeline-header-vue.pipeline-header-container
- sast_artifact = @pipeline.sast_artifact
- if @commit.present?
.commit-box
......@@ -33,3 +34,6 @@
%span.js-details-content.hide
= link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full"
= clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
- if sast_artifact
.js-sast-summary
- failed_builds = @pipeline.statuses.latest.failed
- sast_artifact = @pipeline.sast_artifact
- sast_artifact_url = raw_project_build_artifacts_url(@project, sast_artifact, path: Ci::Build::SAST_FILE) if sast_artifact
- blob_path = project_blob_path(@project, @pipeline.sha)
.tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator
%li.js-pipeline-tab-link
= link_to project_pipeline_path(@project, @pipeline), data: { target: 'div#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
Pipeline
= link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
= _("Pipeline")
%li.js-builds-tab-link
= link_to builds_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
Jobs
= link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
= _("Jobs")
%span.badge.js-builds-counter= pipeline.total_size
- if failed_builds.present?
%li.js-failures-tab-link
= link_to failures_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
Failed Jobs
= link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
= _("Failed Jobs")
%span.badge.js-failures-counter= failed_builds.count
- if sast_artifact
%li.js-security-tab-link
= link_to security_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-security', action: 'security', toggle: 'tab' }, class: 'security-tab' do
= _("Security report")
%span.badge.js-sast-counter.hidden
.tab-content
#js-tab-pipeline.tab-pane
......@@ -53,3 +61,6 @@
%span.build-name
= link_to build.name, pipeline_job_url(pipeline, build)
%pre.build-log= build_summary(build, skip: index >= 10)
- if sast_artifact
#js-tab-security.build-security.tab-pane
#js-security-report-app{ data: { endpoint: sast_artifact_url, blob_path: blob_path } }
---
title: Render SAST report in Pipeline page
merge_request:
author:
type: added
......@@ -212,6 +212,7 @@ constraints(ProjectUrlConstrainer.new) do
get :builds
get :failures
get :status
get :security
end
end
......
import { n__, s__, __, sprintf } from '~/locale';
import { n__, s__, __ } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options';
import WidgetApprovals from './components/approvals/mr_widget_approvals';
import GeoSecondaryNode from './components/states/mr_widget_secondary_geo_node';
import collapsibleSection from './components/mr_widget_report_collapsible_section.vue';
import ReportSection from '../vue_shared/security_reports/components/report_section.vue';
import securityMixin from '../vue_shared/security_reports/mixins/security_report_mixin';
export default {
extends: CEWidgetOptions,
components: {
'mr-widget-approvals': WidgetApprovals,
'mr-widget-geo-secondary-node': GeoSecondaryNode,
collapsibleSection,
ReportSection,
},
mixins: [
securityMixin,
],
data() {
return {
isLoadingCodequality: false,
......@@ -114,81 +118,16 @@ export default {
securityText() {
const { newIssues, resolvedIssues, allIssues } = this.mr.securityReport;
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('');
return this.sastText(newIssues, resolvedIssues, allIssues);
},
dockerText() {
const { vulnerabilities, approved, unapproved } = this.mr.dockerReport;
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,
)}`;
return this.sastContainerText(vulnerabilities, approved, unapproved);
},
dastText() {
if (this.mr.dastReport.length) {
return n__(
'DAST detected %d alert by analyzing the review app',
'DAST detected %d alerts by analyzing the review app',
this.mr.dastReport.length,
);
}
return s__('ciReport|DAST detected no alerts by analyzing the review app');
getDastText() {
return this.dastText(this.mr.dastReport);
},
codequalityStatus() {
......@@ -210,29 +149,8 @@ export default {
dastStatus() {
return this.checkReportStatus(this.isLoadingDast, this.loadingDastFailed);
},
dockerInformationText() {
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,
);
},
},
methods: {
checkReportStatus(loading, error) {
if (loading) {
return 'loading';
} else if (error) {
return 'error';
}
return 'success';
},
fetchCodeQuality() {
const { head_path, base_path } = this.mr.codeclimate;
......@@ -349,13 +267,6 @@ export default {
this.loadingDastFailed = true;
});
},
translateText(type) {
return {
error: s__(`ciReport|Failed to load ${type} report`),
loading: s__(`ciReport|Loading ${type} report`),
};
},
},
created() {
if (this.shouldRenderCodeQuality) {
......@@ -397,7 +308,7 @@ export default {
:mr="mr"
:service="service"
/>
<collapsible-section
<report-section
class="js-codequality-widget"
v-if="shouldRenderCodeQuality"
type="codequality"
......@@ -408,7 +319,7 @@ export default {
:unresolved-issues="mr.codeclimateMetrics.newIssues"
:resolved-issues="mr.codeclimateMetrics.resolvedIssues"
/>
<collapsible-section
<report-section
class="js-performance-widget"
v-if="shouldRenderPerformance"
type="performance"
......@@ -420,7 +331,7 @@ export default {
:resolved-issues="mr.performanceMetrics.improved"
:neutral-issues="mr.performanceMetrics.neutral"
/>
<collapsible-section
<report-section
class="js-sast-widget"
v-if="shouldRenderSecurityReport"
type="security"
......@@ -433,7 +344,7 @@ export default {
:all-issues="mr.securityReport.allIssues"
:has-priority="true"
/>
<collapsible-section
<report-section
class="js-docker-widget"
v-if="shouldRenderDockerReport"
type="docker"
......@@ -443,17 +354,17 @@ export default {
:success-text="dockerText"
:unresolved-issues="mr.dockerReport.unapproved"
:neutral-issues="mr.dockerReport.approved"
:info-text="dockerInformationText"
:info-text="sastContainerInformationText()"
:has-priority="true"
/>
<collapsible-section
<report-section
class="js-dast-widget"
v-if="shouldRenderDastReport"
type="dast"
:status="dastStatus"
:loading-text="translateText('DAST').loading"
:error-text="translateText('DAST').error"
:success-text="dastText"
:success-text="getDastText"
:unresolved-issues="mr.dastReport"
:has-priority="true"
/>
......
import CEMergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
import { stripHtml } from '~/lib/utils/text_utility';
import {
parseIssues,
filterByKey,
setSastContainerReport,
setSastReport,
setDastReport,
} from '../../vue_shared/security_reports/helpers/utils';
export default class MergeRequestStore extends CEMergeRequestStore {
constructor(data) {
......@@ -88,101 +94,35 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.dast = data.dast;
this.dastReport = [];
}
/**
* 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
*/
setSecurityReport(data) {
if (data.base) {
const filterKey = 'cve';
const parsedHead = MergeRequestStore.parseIssues(data.head, data.headBlobPath);
const parsedBase = MergeRequestStore.parseIssues(data.base, data.baseBlobPath);
this.securityReport.newIssues = MergeRequestStore.filterByKey(
parsedHead,
parsedBase,
filterKey,
);
this.securityReport.resolvedIssues = MergeRequestStore.filterByKey(
parsedBase,
parsedHead,
filterKey,
);
// Remove the new Issues and the added issues
this.securityReport.allIssues = MergeRequestStore.filterByKey(
parsedHead,
this.securityReport.newIssues.concat(this.securityReport.resolvedIssues),
filterKey,
);
} else {
this.securityReport.newIssues = MergeRequestStore.parseIssues(data.head, data.headBlobPath);
}
const report = setSastReport(data);
this.securityReport.newIssues = report.newIssues;
this.securityReport.resolvedIssues = report.resolvedIssues;
this.securityReport.allIssues = report.allIssues;
}
setDockerReport(data = {}) {
const parsedVulnerabilities = MergeRequestStore
.parseDockerVulnerabilities(data.vulnerabilities);
this.dockerReport.vulnerabilities = parsedVulnerabilities || [];
const unapproved = data.unapproved || [];
// Approved can be calculated by subtracting unapproved from vulnerabilities.
this.dockerReport.approved = parsedVulnerabilities
.filter(item => !unapproved.find(el => el === item.vulnerability)) || [];
this.dockerReport.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}
*/
setDastReport(data) {
this.dastReport = data.site.alerts.map(alert => ({
name: alert.name,
parsedDescription: stripHtml(alert.desc, ' '),
priority: alert.riskdesc,
...alert,
}));
const report = setSastContainerReport(data);
this.dockerReport.approved = report.approved;
this.dockerReport.unapproved = report.unapproved;
this.dockerReport.vulnerabilities = report.vulnerabilities;
}
static parseDockerVulnerabilities(data) {
return 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,
}));
setDastReport(data) {
this.dastReport = setDastReport(data);
}
compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) {
const parsedHeadIssues = MergeRequestStore.parseIssues(headIssues, headBlobPath);
const parsedBaseIssues = MergeRequestStore.parseIssues(baseIssues, baseBlobPath);
const parsedHeadIssues = parseIssues(headIssues, headBlobPath);
const parsedBaseIssues = parseIssues(baseIssues, baseBlobPath);
this.codeclimateMetrics.newIssues = MergeRequestStore.filterByKey(
this.codeclimateMetrics.newIssues = filterByKey(
parsedHeadIssues,
parsedBaseIssues,
'fingerprint',
);
this.codeclimateMetrics.resolvedIssues = MergeRequestStore.filterByKey(
this.codeclimateMetrics.resolvedIssues = filterByKey(
parsedBaseIssues,
parsedHeadIssues,
'fingerprint',
......@@ -232,64 +172,6 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.performanceMetrics = { improved, degraded, neutral };
}
/**
* In order to reuse the same component we need
* to set both codequality and security issues to have the same data structure:
* [
* {
* name: String,
* priority: String,
* fingerprint: String,
* path: String,
* line: Number,
* urlPath: String
* }
* ]
* @param {array} issues
* @return {array}
*/
static parseIssues(issues, path = '') {
return issues.map((issue) => {
const parsedIssue = {
name: issue.check_name || issue.message,
...issue,
};
// code quality
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;
// security
} else if (issue.file) {
let parsedSecurityUrl = `${path}/${issue.file}`;
parsedIssue.path = issue.file;
if (issue.line) {
parsedSecurityUrl += `#L${issue.line}`;
}
parsedIssue.urlPath = parsedSecurityUrl;
}
return parsedIssue;
});
}
static filterByKey(firstArray, secondArray, key) {
return firstArray.filter(item => !secondArray.find(el => el[key] === item[key]));
}
// normalize performance metrics by indexing on performance subject and metric name
static normalizePerformanceMetrics(performanceData) {
const indexedSubjects = {};
......
......@@ -76,15 +76,15 @@
<h5 class="prepend-top-20">{{ instancesLabel }}</h5>
<ul
v-if="instances"
class="mr-widget-code-quality-list"
class="report-block-list"
>
<li
v-for="(instance, i) in instances"
:key="i"
class="mr-widget-code-quality-list-item-modal failed"
class="report-block-list-item-modal failed"
>
<icon
class="mr-widget-code-quality-icon"
class="report-block-icon"
name="status_failed_borderless"
:size="32"
/>
......@@ -102,7 +102,7 @@
<expand-button v-if="instance.evidence">
<pre
slot="expanded"
class="block mr-widget-dast-code prepend-top-10">{{ instance.evidence }}</pre>
class="block report-block-dast-code prepend-top-10">{{ instance.evidence }}</pre>
</expand-button>
</li>
</ul>
......
<script>
import { s__ } from '~/locale';
import icon from '~/vue_shared/components/icon.vue';
import modal from './mr_widget_dast_modal.vue';
import Icon from '~/vue_shared/components/icon.vue';
import Modal from './dast_modal.vue';
const modalDefaultData = {
modalId: 'modal-mrwidget-issue',
......@@ -12,10 +12,10 @@
};
export default {
name: 'MrWidgetReportIssues',
name: 'ReportIssues',
components: {
modal,
icon,
Modal,
Icon,
},
props: {
issues: {
......@@ -117,19 +117,19 @@
};
</script>
<template>
<ul class="mr-widget-code-quality-list">
<ul class="report-block-list">
<li
:class="{
failed: isStatusFailed,
success: isStatusSuccess,
neutral: isStatusNeutral
}"
class="mr-widget-code-quality-list-item"
class="report-block-list-item"
v-for="(issue, index) in issues"
:key="index"
>
<icon
class="mr-widget-code-quality-icon"
class="report-block-icon"
:name="iconName"
:size="32"
/>
......
<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 './mr_widget_report_issues.vue';
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: 'MRWidgetCodeQualityCollapsible',
name: 'ReportSection',
components: {
issuesBlock,
loadingIcon,
statusIcon,
IssuesBlock,
LoadingIcon,
StatusIcon,
},
props: {
isCollapsible: {
type: Boolean,
required: false,
default: true,
},
// security | codequality | performance | docker
type: {
type: String,
......@@ -37,22 +42,22 @@
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
default: () => ([]),
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
default: () => ([]),
},
neutralIssues: {
type: Array,
required: false,
default: () => [],
default: () => ([]),
},
allIssues: {
type: Array,
required: false,
default: () => [],
default: () => ([]),
},
infoText: {
type: [String, Boolean],
......@@ -67,10 +72,16 @@
},
data() {
if (this.isCollapsible) {
return {
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
};
}
return {
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
isFullReportVisible: true,
};
},
......@@ -111,7 +122,7 @@
};
</script>
<template>
<section class="mr-widget-code-quality mr-widget-section">
<section class="report-block mr-widget-section">
<div
v-if="isLoading"
......@@ -148,8 +159,8 @@
<button
type="button"
class="btn pull-right btn-sm"
v-if="hasIssues"
class="js-collapse-btn btn bt-default pull-right btn-sm"
v-if="isCollapsible && hasIssues"
@click="toggleCollapsed"
>
{{ collapseText }}
......@@ -158,15 +169,15 @@
</div>
<div
class="code-quality-container"
class="report-block-container"
v-if="hasIssues"
v-show="!isCollapsed"
v-show="!isCollapsible || (isCollapsible && !isCollapsed)"
>
<p
v-if="type === 'docker' && infoText"
v-html="infoText"
class="js-mr-code-quality-info mr-widget-code-quality-info"
class="js-mr-code-quality-info prepend-left-10 report-block-info"
>
</p>
......
export default {
sast: {
isLoading: false,
hasError: false,
newIssues: [],
resolvedIssues: [],
allIssues: [],
},
sastContainer: {
approved: [],
unapproved: [],
vulnerabilities: [],
},
dast: [],
codeclimate: {
newIssues: [],
resolvedIssues: [],
},
};
import { stripHtml } from '~/lib/utils/text_utility';
/**
* Parses SAST and Codeclimate Issues into a common and reusable format
* to reuse the same vue component.
* [
* {
* name: String,
* priority: String,
* fingerprint: String,
* path: String,
* line: Number,
* urlPath: String
* }
* ]
* @param {array} issues
* @return {array}
*/
export const parseIssues = (issues = [], path = '') => issues.map((issue) => {
const parsedIssue = {
name: issue.check_name || issue.message,
...issue,
};
// code quality
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;
// security
} else if (issue.file) {
let parsedSecurityUrl = `${path}/${issue.file}`;
parsedIssue.path = issue.file;
if (issue.line) {
parsedSecurityUrl += `#L${issue.line}`;
}
parsedIssue.urlPath = parsedSecurityUrl;
}
return parsedIssue;
});
/**
* 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 = parseIssues(data.head, data.headBlobPath);
const parsedBase = parseIssues(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 = parseIssues(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 { s__, n__, __, sprintf } 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('');
},
translateText(type) {
return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), { reportName: type }),
loading: sprintf(s__('ciReport|Loading %{reportName} report'), { reportName: type }),
};
},
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,
)}`;
},
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');
},
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,
);
},
},
};
.report-block-container {
border-top: 1px solid $gray-darker;
padding: $gl-padding-top;
background-color: $gray-light;
margin: $gl-padding #{-$gl-padding} #{-$gl-padding};
}
.report-block-dast-code {
margin-left: 26px;
}
.report-block-list {
list-style: none;
padding: 0 1px;
margin: 0;
line-height: $code_line_height;
.btn-open-modal {
padding: 0 5px 4px;
}
.report-block-list-item {
display: flex;
}
.report-block-list-item-modal {
display: flex;
flex-wrap: wrap;
}
.failed .report-block-icon {
color: $red-500;
}
.success .report-block-icon {
color: $green-500;
}
.neutral .report-block-icon {
color: $theme-gray-700;
}
.report-block-icon {
margin: -5px 4px 0 0;
fill: currentColor;
}
}
.pipeline-tab-content {
.space-children,
.space-children > * {
display: flex;
}
.media {
align-items: center;
}
}
\ No newline at end of file
module EE
module Projects
module PipelinesController
extend ActiveSupport::Concern
def security
commit
if pipeline.sast_artifact
render_show
else
redirect_to pipeline_path(pipeline)
end
end
end
end
end
require 'spec_helper'
describe Projects::PipelinesController do
set(:user) { create(:user) }
set(:project) { create(:project, :repository) }
before do
project.add_developer(user)
sign_in(user)
end
describe 'GET security' do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
context 'with a sast artifact' do
before do
create(
:ci_build,
:artifacts,
name: 'sast',
pipeline: pipeline,
options: {
artifacts: {
paths: [Ci::Build::SAST_FILE]
}
}
)
get :security, namespace_id: project.namespace, project_id: project, id: pipeline
end
it do
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template :show
end
end
context 'without sast artifact' do
before do
get :security, namespace_id: project.namespace, project_id: project, id: pipeline
end
it do
expect(response).to have_gitlab_http_status(:redirect)
expect(response).to redirect_to(pipeline_path(pipeline))
end
end
end
end
require 'spec_helper'
describe 'Pipeline', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
before do
sign_in(user)
project.add_developer(user)
end
describe 'GET /:project/pipelines/:id/security' do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
context 'with a sast artifact' do
before do
create(
:ci_build,
:artifacts,
name: 'sast',
pipeline: pipeline,
options: {
artifacts: {
paths: [Ci::Build::SAST_FILE]
}
}
)
visit security_project_pipeline_path(project, pipeline)
end
it 'shows jobs tab pane as active' do
expect(page).to have_content('Security report')
expect(page).to have_css('#js-tab-security')
end
it 'shows security report' do
expect(page).to have_content('SAST detected no security vulnerabilities')
end
end
context 'without sast artifact' do
before do
visit security_project_pipeline_path(project, pipeline)
end
it 'displays the pipeline graph' do
expect(current_path).to eq(pipeline_path(pipeline))
expect(page).not_to have_content('Security report')
expect(page).to have_selector('.pipeline-visualization')
end
end
end
end
import _ from 'underscore';
import Vue from 'vue';
import PipelineMediator from '~/pipelines/pipeline_details_mediatior';
import PipelineMediator from '~/pipelines/pipeline_details_mediator';
import { sastIssues, parsedSastIssuesStore } from '../vue_shared/security_reports/mock_data';
describe('PipelineMdediator', () => {
let mediator;
......@@ -39,4 +40,29 @@ 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;
......@@ -8,7 +9,6 @@ describe('Pipeline Store', () => {
});
it('should set defaults', () => {
expect(store.state).toEqual({ pipeline: {} });
expect(store.state.pipeline).toEqual({});
});
......@@ -24,4 +24,11 @@ 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 reportSummary from '~/pipelines/components/security_reports/sast_report_summary_widget.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { parsedSastIssuesHead } from '../../vue_shared/security_reports/mock_data';
describe('SAST report summary widget', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(reportSummary);
});
afterEach(() => {
vm.$destroy();
});
describe('with vulnerabilities', () => {
beforeEach(() => {
vm = mountComponent(Component, {
unresolvedIssues: parsedSastIssuesHead,
});
});
it('renders summary text with warning icon', () => {
expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('SAST degraded on 2 security vulnerabilities');
expect(vm.$el.querySelector('span').classList).toContain('ci-status-icon-warning');
});
});
describe('without vulnerabilities', () => {
beforeEach(() => {
vm = mountComponent(Component, {
});
});
it('render summary text with success icon', () => {
expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('SAST detected no security vulnerabilities');
expect(vm.$el.querySelector('span').classList).toContain('ci-status-icon-success');
});
});
});
import Vue from 'vue';
import securityReportApp from '~/pipelines/components/security_reports/security_report_app.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { parsedSastIssuesHead } from '../../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: [],
},
},
});
});
it('renders the sast report', () => {
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual('SAST degraded on 2 security vulnerabilities');
expect(vm.$el.querySelectorAll('.js-mr-code-new-issues li').length).toEqual(parsedSastIssuesHead.length);
const issue = vm.$el.querySelector('.js-mr-code-new-issues li').textContent;
expect(issue).toContain(parsedSastIssuesHead[0].message);
expect(issue).toContain(parsedSastIssuesHead[0].path);
});
});
});
......@@ -9,15 +9,18 @@ import mockData, {
headIssues,
basePerformance,
headPerformance,
securityIssuesBase,
securityIssues,
} from './mock_data';
import {
sastIssues,
sastIssuesBase,
dockerReport,
dockerReportParsed,
dast,
parsedDast,
sastBaseAllIssues,
sastHeadAllIssues,
} from './mock_data';
} from '../vue_shared/security_reports/mock_data';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('ee merge request widget options', () => {
......@@ -62,8 +65,8 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock = mock = new MockAdapter(axios);
mock.onGet('path.json').reply(200, securityIssuesBase);
mock.onGet('head_path.json').reply(200, securityIssues);
mock.onGet('path.json').reply(200, sastIssuesBase);
mock.onGet('head_path.json').reply(200, sastIssues);
vm = mountComponent(Component);
});
......@@ -445,13 +448,13 @@ describe('ee merge request widget options', () => {
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-docker-widget .mr-widget-code-quality-info').textContent.trim(),
vm.$el.querySelector('.js-docker-widget .report-block-info').textContent.trim(),
).toContain('Unapproved vulnerabilities (red) can be marked as approved.');
expect(
vm.$el.querySelector('.js-docker-widget .mr-widget-code-quality-info a').textContent.trim(),
vm.$el.querySelector('.js-docker-widget .report-block-info a').textContent.trim(),
).toContain('Learn more about whitelisting');
const firstVulnerability = vm.$el.querySelector('.js-docker-widget .mr-widget-code-quality-list').textContent.trim();
const firstVulnerability = vm.$el.querySelector('.js-docker-widget .report-block-list').textContent.trim();
expect(firstVulnerability).toContain(dockerReportParsed.unapproved[0].name);
expect(firstVulnerability).toContain(dockerReportParsed.unapproved[0].path);
......@@ -530,7 +533,7 @@ describe('ee merge request widget options', () => {
vm.$el.querySelector('.js-dast-widget button').click();
Vue.nextTick(() => {
const firstVulnerability = vm.$el.querySelector('.js-dast-widget .mr-widget-code-quality-list').textContent.trim();
const firstVulnerability = vm.$el.querySelector('.js-dast-widget .report-block-list').textContent.trim();
expect(firstVulnerability).toContain(parsedDast[0].name);
expect(firstVulnerability).toContain(parsedDast[0].priority);
done();
......
This diff is collapsed.
......@@ -3,19 +3,21 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import mockData, {
headIssues,
baseIssues,
securityIssues,
securityIssuesBase,
parsedBaseIssues,
parsedHeadIssues,
parsedSecurityIssuesStore,
parsedSecurityIssuesBaseStore,
} from '../mock_data';
import {
sastIssues,
sastIssuesBase,
parsedSastBaseStore,
parsedSastIssuesHead,
parsedSastIssuesStore,
allIssuesParsed,
parsedSecurityIssuesHead,
dockerReport,
dockerReportParsed,
dast,
parsedDast,
} from '../mock_data';
} from '../../vue_shared/security_reports/mock_data';
describe('MergeRequestStore', () => {
let store;
......@@ -98,37 +100,24 @@ describe('MergeRequestStore', () => {
describe('setSecurityReport', () => {
it('should set security issues with head', () => {
store.setSecurityReport({ head: securityIssues, headBlobPath: 'path' });
expect(store.securityReport.newIssues).toEqual(parsedSecurityIssuesStore);
store.setSecurityReport({ head: sastIssues, headBlobPath: 'path' });
expect(store.securityReport.newIssues).toEqual(parsedSastIssuesStore);
});
it('should set security issues with head and base', () => {
store.setSecurityReport({
head: securityIssues,
head: sastIssues,
headBlobPath: 'path',
base: securityIssuesBase,
base: sastIssuesBase,
baseBlobPath: 'path',
});
expect(store.securityReport.newIssues).toEqual(parsedSecurityIssuesHead);
expect(store.securityReport.resolvedIssues).toEqual(parsedSecurityIssuesBaseStore);
expect(store.securityReport.newIssues).toEqual(parsedSastIssuesHead);
expect(store.securityReport.resolvedIssues).toEqual(parsedSastBaseStore);
expect(store.securityReport.allIssues).toEqual(allIssuesParsed);
});
});
describe('parseIssues', () => {
it('should parse the received issues', () => {
const codequality = MergeRequestStore.parseIssues(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);
const security = MergeRequestStore.parseIssues(securityIssues, 'path')[0];
expect(security.name).toEqual(securityIssues[0].message);
expect(security.path).toEqual(securityIssues[0].file);
});
});
describe('isNothingToMergeState', () => {
it('returns true when nothingToMerge', () => {
store.state = stateKey.nothingToMerge;
......@@ -163,16 +152,6 @@ describe('MergeRequestStore', () => {
});
});
describe('parseDockerVulnerabilities', () => {
it('parses docker report', () => {
expect(
MergeRequestStore.parseDockerVulnerabilities(dockerReport.vulnerabilities),
).toEqual(
dockerReportParsed.vulnerabilities,
);
});
});
describe('initDastReport', () => {
it('sets the defaults', () => {
store.initDastReport({ dast: { path: 'dast.json' } });
......
import Vue from 'vue';
import modal from 'ee/vue_merge_request_widget/components/mr_widget_dast_modal.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import modal from 'ee/vue_shared/security_reports/components/dast_modal.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('mr widget modal', () => {
let vm;
......
import Vue from 'vue';
import mrWidgetCodeQualityIssues from 'ee/vue_merge_request_widget/components/mr_widget_report_issues.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import reportIssues from 'ee/vue_shared/security_reports/components/report_issues.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import {
securityParsedIssues,
codequalityParsedIssues,
} from '../../../vue_mr_widget/mock_data';
import {
sastParsedIssues,
dockerReportParsed,
parsedDast,
} from '../mock_data';
describe('merge request report issues', () => {
describe('Report issues', () => {
let vm;
let MRWidgetCodeQualityIssues;
let ReportIssues;
beforeEach(() => {
MRWidgetCodeQualityIssues = Vue.extend(mrWidgetCodeQualityIssues);
ReportIssues = Vue.extend(reportIssues);
});
afterEach(() => {
......@@ -23,7 +25,7 @@ describe('merge request report issues', () => {
describe('for codequality issues', () => {
describe('resolved issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
vm = mountComponent(ReportIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'success',
......@@ -31,20 +33,20 @@ describe('merge request report issues', () => {
});
it('should render a list of resolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(codequalityParsedIssues.length);
expect(vm.$el.querySelectorAll('.report-block-list li').length).toEqual(codequalityParsedIssues.length);
});
it('should render "Fixed" keyword', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain('Fixed');
expect(vm.$el.querySelector('.report-block-list li').textContent).toContain('Fixed');
expect(
vm.$el.querySelector('.mr-widget-code-quality-list li').textContent.replace(/\s+/g, ' ').trim(),
vm.$el.querySelector('.report-block-list li').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('Fixed: Insecure Dependency in Gemfile.lock:12');
});
});
describe('unresolved issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
vm = mountComponent(ReportIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'failed',
......@@ -52,19 +54,19 @@ describe('merge request report issues', () => {
});
it('should render a list of unresolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(codequalityParsedIssues.length);
expect(vm.$el.querySelectorAll('.report-block-list li').length).toEqual(codequalityParsedIssues.length);
});
it('should not render "Fixed" keyword', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).not.toContain('Fixed');
expect(vm.$el.querySelector('.report-block-list li').textContent).not.toContain('Fixed');
});
});
});
describe('for security issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: securityParsedIssues,
vm = mountComponent(ReportIssues, {
issues: sastParsedIssues,
type: 'security',
status: 'failed',
hasPriority: true,
......@@ -72,30 +74,30 @@ describe('merge request report issues', () => {
});
it('should render a list of unresolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(securityParsedIssues.length);
expect(vm.$el.querySelectorAll('.report-block-list li').length).toEqual(sastParsedIssues.length);
});
it('should render priority', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain(securityParsedIssues[0].priority);
expect(vm.$el.querySelector('.report-block-list li').textContent).toContain(sastParsedIssues[0].priority);
});
});
describe('with location', () => {
it('should render location', () => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: securityParsedIssues,
vm = mountComponent(ReportIssues, {
issues: sastParsedIssues,
type: 'security',
status: 'failed',
});
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain('in');
expect(vm.$el.querySelector('.mr-widget-code-quality-list li a').getAttribute('href')).toEqual(securityParsedIssues[0].urlPath);
expect(vm.$el.querySelector('.report-block-list li').textContent).toContain('in');
expect(vm.$el.querySelector('.report-block-list li a').getAttribute('href')).toEqual(sastParsedIssues[0].urlPath);
});
});
describe('without location', () => {
it('should not render location', () => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
vm = mountComponent(ReportIssues, {
issues: [{
name: 'foo',
}],
......@@ -103,14 +105,14 @@ describe('merge request report issues', () => {
status: 'failed',
});
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).not.toContain('in');
expect(vm.$el.querySelector('.mr-widget-code-quality-list li a')).toEqual(null);
expect(vm.$el.querySelector('.report-block-list li').textContent).not.toContain('in');
expect(vm.$el.querySelector('.report-block-list li a')).toEqual(null);
});
});
describe('for docker issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
vm = mountComponent(ReportIssues, {
issues: dockerReportParsed.unapproved,
type: 'docker',
status: 'failed',
......@@ -120,32 +122,32 @@ describe('merge request report issues', () => {
it('renders priority', () => {
expect(
vm.$el.querySelector('.mr-widget-code-quality-list li').textContent.trim(),
vm.$el.querySelector('.report-block-list li').textContent.trim(),
).toContain(dockerReportParsed.unapproved[0].priority);
});
it('renders CVE link', () => {
expect(
vm.$el.querySelector('.mr-widget-code-quality-list a').getAttribute('href'),
vm.$el.querySelector('.report-block-list a').getAttribute('href'),
).toEqual(dockerReportParsed.unapproved[0].nameLink);
expect(
vm.$el.querySelector('.mr-widget-code-quality-list a').textContent.trim(),
vm.$el.querySelector('.report-block-list a').textContent.trim(),
).toEqual(dockerReportParsed.unapproved[0].name);
});
it('renders namespace', () => {
expect(
vm.$el.querySelector('.mr-widget-code-quality-list li').textContent.trim(),
vm.$el.querySelector('.report-block-list li').textContent.trim(),
).toContain(dockerReportParsed.unapproved[0].path);
expect(
vm.$el.querySelector('.mr-widget-code-quality-list li').textContent.trim(),
vm.$el.querySelector('.report-block-list li').textContent.trim(),
).toContain('in');
});
});
describe('for dast issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
vm = mountComponent(ReportIssues, {
issues: parsedDast,
type: 'dast',
status: 'failed',
......
import Vue from 'vue';
import mrWidgetCodeQuality from 'ee/vue_merge_request_widget/components/mr_widget_report_collapsible_section.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { codequalityParsedIssues } from '../mock_data';
import reportSection from 'ee/vue_shared/security_reports/components/report_section.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import { codequalityParsedIssues } from '../../../vue_mr_widget/mock_data';
describe('Merge Request collapsible section', () => {
describe('Report section', () => {
let vm;
let MRWidgetCodeQuality;
let ReportSection;
beforeEach(() => {
MRWidgetCodeQuality = Vue.extend(mrWidgetCodeQuality);
ReportSection = Vue.extend(reportSection);
});
afterEach(() => {
......@@ -17,7 +17,7 @@ describe('Merge Request collapsible section', () => {
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(MRWidgetCodeQuality, {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'loading',
loadingText: 'Loading codeclimate report',
......@@ -30,7 +30,7 @@ describe('Merge Request collapsible section', () => {
describe('with success status', () => {
it('should render provided data', () => {
vm = mountComponent(MRWidgetCodeQuality, {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
......@@ -50,7 +50,7 @@ describe('Merge Request collapsible section', () => {
describe('toggleCollapsed', () => {
it('toggles issues', (done) => {
vm = mountComponent(MRWidgetCodeQuality, {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
......@@ -63,7 +63,7 @@ describe('Merge Request collapsible section', () => {
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').getAttribute('style'),
vm.$el.querySelector('.report-block-container').getAttribute('style'),
).toEqual('');
expect(
vm.$el.querySelector('button').textContent.trim(),
......@@ -73,7 +73,7 @@ describe('Merge Request collapsible section', () => {
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').getAttribute('style'),
vm.$el.querySelector('.report-block-container').getAttribute('style'),
).toEqual('display: none;');
expect(
vm.$el.querySelector('button').textContent.trim(),
......@@ -88,7 +88,7 @@ describe('Merge Request collapsible section', () => {
describe('with failed request', () => {
it('should render error indicator', () => {
vm = mountComponent(MRWidgetCodeQuality, {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'error',
loadingText: 'Loading codeclimate report',
......@@ -101,7 +101,7 @@ describe('Merge Request collapsible section', () => {
describe('With full report', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQuality, {
vm = mountComponent(ReportSection, {
status: 'success',
successText: 'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
type: 'security',
......@@ -172,4 +172,28 @@ describe('Merge Request collapsible 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-item').length,
).toEqual(codequalityParsedIssues.length);
});
});
});
import mixin from 'ee/vue_shared/security_reports/mixins/security_report_mixin';
import {
parsedSastBaseStore,
parsedSastIssuesHead,
dockerReportParsed,
parsedDast,
} from '../mock_data';
describe('security report mixin', () => {
describe('sastText', () => {
it('returns text for new and fixed issues', () => {
expect(mixin.methods.sastText(
parsedSastIssuesHead,
parsedSastBaseStore,
)).toEqual(
'SAST improved on 1 security vulnerability and degraded on 2 security vulnerabilities',
);
});
it('returns text for added issues', () => {
expect(mixin.methods.sastText(parsedSastIssuesHead, [])).toEqual(
'SAST degraded on 2 security vulnerabilities',
);
});
it('returns text for fixed issues', () => {
expect(mixin.methods.sastText([], parsedSastIssuesHead)).toEqual(
'SAST improved on 2 security vulnerabilities',
);
});
it('returns text for full report and no added or fixed issues', () => {
expect(mixin.methods.sastText([], [], parsedSastIssuesHead)).toEqual(
'SAST detected no new security vulnerabilities',
);
});
});
describe('translateText', () => {
it('returns loading and error text for the given value', () => {
expect(mixin.methods.translateText('sast')).toEqual({
error: 'Failed to load sast report',
loading: 'Loading sast report',
});
});
});
describe('checkReportStatus', () => {
it('returns loading when loading is true', () => {
expect(mixin.methods.checkReportStatus(true, false)).toEqual('loading');
});
it('returns error when error is true', () => {
expect(mixin.methods.checkReportStatus(false, true)).toEqual('error');
});
it('returns success when loading and error are false', () => {
expect(mixin.methods.checkReportStatus(false, false)).toEqual('success');
});
});
describe('sastContainerText', () => {
it('returns no vulnerabitilties text', () => {
expect(mixin.methods.sastContainerText()).toEqual(
'SAST:container no vulnerabilities were found',
);
});
it('returns approved vulnerabilities text', () => {
expect(
mixin.methods.sastContainerText(
dockerReportParsed.vulnerabilities,
dockerReportParsed.approved,
),
).toEqual(
'SAST:container found 1 approved vulnerability',
);
});
it('returns unnapproved vulnerabilities text', () => {
expect(
mixin.methods.sastContainerText(
dockerReportParsed.vulnerabilities,
[],
dockerReportParsed.unapproved,
),
).toEqual(
'SAST:container found 2 vulnerabilities',
);
});
it('returns approved & unapproved text', () => {
expect(mixin.methods.sastContainerText(
dockerReportParsed.vulnerabilities,
dockerReportParsed.approved,
dockerReportParsed.unapproved,
)).toEqual(
'SAST:container found 3 vulnerabilities, of which 1 is approved',
);
});
});
describe('dastText', () => {
it('returns dast text', () => {
expect(mixin.methods.dastText(parsedDast)).toEqual(
'DAST detected 2 alerts by analyzing the review app',
);
});
it('returns no alert text', () => {
expect(mixin.methods.dastText()).toEqual('DAST detected no alerts by analyzing the review app');
});
});
});
import {
parseIssues,
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('parseIssues', () => {
it('should parse the received issues', () => {
const codequality = parseIssues(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);
const security = parseIssues(sastIssues, 'path')[0];
expect(security.name).toEqual(sastIssues[0].message);
expect(security.path).toEqual(sastIssues[0].file);
});
});
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);
});
});
});
This diff is collapsed.
......@@ -640,4 +640,10 @@ describe 'project routing' do
end
end
end
describe Projects::PipelinesController, 'routing' do
it 'to #security' do
expect(get('/gitlab/gitlabhq/pipelines/12/security')).to route_to('projects/pipelines#security', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '12')
end
end
end
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