Commit 1cc83bb3 authored by Olivier Gonzalez's avatar Olivier Gonzalez Committed by Grzegorz Bizon

Allow to dismiss vulnerabilities in security reports

parent e91f45bd
......@@ -108,7 +108,7 @@ export default () => {
const securityTab = document.getElementById('js-security-report-app');
const sastSummary = document.querySelector('.js-sast-summary');
const updateBadgeCount = (count) => {
const updateBadgeCount = count => {
const badge = document.querySelector('.js-sast-counter');
if (badge.textContent !== '') {
badge.textContent = parseInt(badge.textContent, 10) + count;
......@@ -124,8 +124,12 @@ export default () => {
const datasetOptions = securityTab.dataset;
const endpoint = datasetOptions.endpoint;
const blobPath = datasetOptions.blobPath;
const sastHelpPath = datasetOptions.sastHelpPath;
const dependencyScanningEndpoint = datasetOptions.dependencyScanningEndpoint;
const dependencyScanningHelpPath = datasetOptions.dependencyScanningHelpPath;
const vulnerabilityFeedbackPath = datasetOptions.vulnerabilityFeedbackPath;
const vulnerabilityFeedbackHelpPath = datasetOptions.vulnerabilityFeedbackHelpPath;
const pipelineId = parseInt(datasetOptions.pipelineId, 10);
// Widget summary
// eslint-disable-next-line no-new
new Vue({
......@@ -166,7 +170,12 @@ export default () => {
props: {
headBlobPath: blobPath,
sastHeadPath: endpoint,
sastHelpPath,
dependencyScanningHeadPath: dependencyScanningEndpoint,
dependencyScanningHelpPath,
vulnerabilityFeedbackPath,
vulnerabilityFeedbackHelpPath,
pipelineId,
},
on: {
updateBadgeCount: this.updateBadge,
......
......@@ -22,6 +22,9 @@ export default {
return __('Click to expand text');
},
},
destroyed() {
this.isCollapsed = true;
},
methods: {
onClick() {
this.isCollapsed = !this.isCollapsed;
......
......@@ -50,7 +50,7 @@ module Users
migrate_merge_requests
migrate_notes
migrate_abuse_reports
migrate_award_emojis
migrate_award_emoji
end
def migrate_issues
......@@ -71,7 +71,7 @@ module Users
user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
end
def migrate_award_emojis
def migrate_award_emoji
user.award_emoji.update_all(user_id: ghost_user.id)
end
end
......
......@@ -31,6 +31,7 @@
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")}';
window.gl.mrWidgetData.vulnerability_feedback_help_path = '#{help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports")}';
#js-vue-mr-widget.mr-widget
......
......@@ -66,5 +66,8 @@
#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,
pipeline_id: @pipeline.id,
vulnerability_feedback_path: project_vulnerability_feedback_index_path(@project),
vulnerability_feedback_help_path: help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports"),
sast_help_path: help_page_path('user/project/merge_requests/sast'),
dependency_scanning_help_path: help_page_path('user/project/merge_requests/dependency_scanning')} }
......@@ -19,6 +19,7 @@ ActiveSupport::Inflector.inflections do |inflect|
project_registry
file_registry
job_artifact_registry
vulnerability_feedback
)
inflect.acronym 'EE'
end
......@@ -385,6 +385,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
## EE-specific
resources :vulnerability_feedback, only: [:index, :create, :destroy], constraints: { id: /\d+/ }
resources :issues, concerns: :awardable, constraints: { id: /\d+/ } do
member do
post :toggle_subscription
......
......@@ -2678,6 +2678,23 @@ ActiveRecord::Schema.define(version: 20180503193953) do
add_index "users_star_projects", ["project_id"], name: "index_users_star_projects_on_project_id", using: :btree
add_index "users_star_projects", ["user_id", "project_id"], name: "index_users_star_projects_on_user_id_and_project_id", unique: true, using: :btree
create_table "vulnerability_feedback", force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.integer "feedback_type", limit: 2, null: false
t.integer "category", limit: 2, null: false
t.integer "project_id", null: false
t.integer "author_id", null: false
t.integer "pipeline_id"
t.integer "issue_id"
t.string "project_fingerprint", limit: 40, null: false
end
add_index "vulnerability_feedback", ["author_id"], name: "index_vulnerability_feedback_on_author_id", using: :btree
add_index "vulnerability_feedback", ["issue_id"], name: "index_vulnerability_feedback_on_issue_id", using: :btree
add_index "vulnerability_feedback", ["pipeline_id"], name: "index_vulnerability_feedback_on_pipeline_id", using: :btree
add_index "vulnerability_feedback", ["project_id", "category", "feedback_type", "project_fingerprint"], name: "vulnerability_feedback_unique_idx", unique: true, using: :btree
create_table "web_hook_logs", force: :cascade do |t|
t.integer "web_hook_id", null: false
t.string "trigger"
......@@ -2930,6 +2947,10 @@ ActiveRecord::Schema.define(version: 20180503193953) do
add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade
add_foreign_key "users", "application_setting_terms", column: "accepted_term_id", name: "fk_789cd90b35", on_delete: :cascade
add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade
add_foreign_key "vulnerability_feedback", "ci_pipelines", column: "pipeline_id", on_delete: :nullify
add_foreign_key "vulnerability_feedback", "issues", on_delete: :nullify
add_foreign_key "vulnerability_feedback", "projects", on_delete: :cascade
add_foreign_key "vulnerability_feedback", "users", column: "author_id", on_delete: :cascade
add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade
add_foreign_key "web_hooks", "projects", name: "fk_0c8ca6d9d1", on_delete: :cascade
end
......@@ -236,6 +236,9 @@ export default {
:dependency-scanning-head-path="mr.dependencyScanning.head_path"
:dependency-scanning-base-path="mr.dependencyScanning.base_path"
:dependency-scanning-help-path="mr.dependencyScanningHelp"
:vulnerability-feedback-path="mr.vulnerabilityFeedbackPath"
:vulnerability-feedback-help-path="mr.vulnerabilityFeedbackHelpPath"
:pipeline-id="mr.securityReportsPipelineId"
/>
<div class="mr-widget-section">
<component
......
......@@ -16,6 +16,9 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.sastContainerHelp = data.sast_container_help_path;
this.dastHelp = data.dast_help_path;
this.dependencyScanningHelp = data.dependency_scanning_help_path;
this.vulnerabilityFeedbackPath = data.vulnerability_feedback_path;
this.vulnerabilityFeedbackHelpPath = data.vulnerability_feedback_help_path;
this.securityReportsPipelineId = data.pipeline_id;
this.initCodeclimate(data);
this.initPerformanceReport(data);
......@@ -30,10 +33,9 @@ export default class MergeRequestStore extends CEMergeRequestStore {
}
initSquashBeforeMerge(data) {
this.squashBeforeMergeHelpPath = this.squashBeforeMergeHelpPath
|| data.squash_before_merge_help_path;
this.enableSquashBeforeMerge = this.enableSquashBeforeMerge
|| data.enable_squash_before_merge;
this.squashBeforeMergeHelpPath =
this.squashBeforeMergeHelpPath || data.squash_before_merge_help_path;
this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || data.enable_squash_before_merge;
}
initGeo(data) {
......@@ -96,14 +98,15 @@ export default class MergeRequestStore extends CEMergeRequestStore {
const degraded = [];
const neutral = [];
Object.keys(headMetricsIndexed).forEach((subject) => {
Object.keys(headMetricsIndexed).forEach(subject => {
const subjectMetrics = headMetricsIndexed[subject];
Object.keys(subjectMetrics).forEach((metric) => {
Object.keys(subjectMetrics).forEach(metric => {
const headMetricData = subjectMetrics[metric];
if (baseMetricsIndexed[subject] && baseMetricsIndexed[subject][metric]) {
const baseMetricData = baseMetricsIndexed[subject][metric];
const metricDirection = 'desiredSize' in headMetricData && headMetricData.desiredSize === 'smaller' ? -1 : 1;
const metricDirection =
'desiredSize' in headMetricData && headMetricData.desiredSize === 'smaller' ? -1 : 1;
const metricData = {
name: metric,
path: subject,
......
......@@ -4,8 +4,13 @@
* [priority]: [name]
*/
import ModalOpenName from './modal_open_name.vue';
export default {
name: 'SastIssueBody',
components: {
ModalOpenName,
},
props: {
issue: {
type: Object,
......@@ -16,17 +21,6 @@ export default {
type: Number,
required: true,
},
modalTargetId: {
type: String,
required: true,
},
},
methods: {
openDastModal() {
this.$emit('openDastModal', this.issue, this.issueIndex);
},
},
};
</script>
......@@ -35,15 +29,10 @@ export default {
<div class="report-block-list-issue-description-text append-right-5">
<template v-if="issue.priority">{{ issue.priority }}:</template>
<button
type="button"
@click="openDastModal()"
data-toggle="modal"
class="js-modal-dast btn-link btn-blank text-left break-link"
:data-target="modalTargetId"
>
{{ issue.name }}
</button>
<modal-open-name
:issue="issue"
class="js-modal-dast"
/>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Modal from '~/vue_shared/components/gl_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import ExpandButton from '~/vue_shared/components/expand_button.vue';
export default {
components: {
Modal,
LoadingButton,
ExpandButton,
Icon,
},
computed: {
...mapState(['modal', 'vulnerabilityFeedbackHelpPath']),
revertTitle() {
return this.modal.vulnerability.isDismissed
? s__('ciReport|Revert dismissal')
: s__('ciReport|Dismiss vulnerability');
},
},
methods: {
...mapActions(['dismissIssue', 'revertDismissIssue', 'createNewIssue']),
handleDismissClick() {
if (this.modal.vulnerability.isDismissed) {
this.revertDismissIssue();
} else {
this.dismissIssue();
}
},
hasInstances(field, key) {
return key === 'instances' && field.value && field.value.length > 0;
},
},
};
</script>
<template>
<modal
id="modal-mrwidget-security-issue"
:header-title-text="modal.title"
class="modal-security-report-dast"
>
<slot>
<div
v-for="(field, key, index) in modal.data"
v-if="field.value || hasInstances(field, key)"
class="row prepend-top-10 append-bottom-10"
:key="index"
>
<label class="col-sm-2 text-right">
{{ field.text }}:
</label>
<div class="col-sm-10 text-secondary">
<div
v-if="hasInstances(field, key)"
class="well"
>
<ul class="report-block-list">
<li
v-for="(instance, i) in field.value"
:key="i"
class="report-block-list-issue"
>
<div class="report-block-list-icon append-right-5 failed">
<icon
name="status_failed_borderless"
:size="32"
/>
</div>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
<div class="report-block-list-issue-description-text append-right-5">
{{ instance.method }}
</div>
<div class="report-block-list-issue-description-link">
<a
:href="instance.uri"
target="_blank"
rel="noopener noreferrer nofollow"
class="break-link"
>
{{ instance.uri }}
</a>
</div>
<expand-button v-if="instance.evidence">
<pre
slot="expanded"
class="block report-block-dast-code prepend-top-10 report-block-issue-code"
>{{ instance.evidence }}</pre>
</expand-button>
</div>
</li>
</ul>
</div>
<template v-else>
<a
:class="`js-link-${key}`"
v-if="field.isLink"
target="_blank"
:href="field.url"
>
{{ field.value }}
</a>
<span v-else>
{{ field.value }}
</span>
</template>
</div>
</div>
<div class="row prepend-top-20 append-bottom-10">
<div class="col-sm-10 col-sm-offset-2 text-secondary">
<a
class="js-link-vulnerabilityFeedbackHelpPath"
:href="vulnerabilityFeedbackHelpPath"
>
Learn more about interacting with security reports (experimental).
</a>
</div>
</div>
<div
v-if="modal.error"
class="alert alert-danger"
>
{{ modal.error }}
</div>
</slot>
<div slot="footer">
<button
type="button"
class="btn btn-default"
data-dismiss="modal"
>
{{ __('Cancel' ) }}
</button>
<loading-button
container-class="js-dismiss-btn btn btn-close"
:loading="modal.isDismissingIssue"
:disabled="modal.isDismissingIssue"
@click="handleDismissClick"
:label="revertTitle"
/>
<a
v-if="modal.vulnerability.hasIssue"
:href="modal.vulnerability.issueFeedback && modal.vulnerability.issueFeedback.issue_url"
rel="noopener noreferrer nofollow"
class="btn btn-success btn-inverted"
>
{{ __('View issue' ) }}
</a>
<loading-button
v-else
container-class="btn btn-success btn-inverted"
:loading="modal.isCreatingNewIssue"
:disabled="modal.isCreatingNewIssue"
@click="createNewIssue"
:label="__('Create issue')"
/>
</div>
</modal>
</template>
<script>
import { mapActions } from 'vuex';
export default {
props: {
issue: {
type: Object,
required: true,
},
},
methods: {
...mapActions(['openModal']),
handleIssueClick() {
this.openModal(this.issue);
},
},
};
</script>
<template>
<button
type="button"
@click="handleIssueClick()"
class="btn-link btn-blank text-left break-link"
>
{{ issue.name }}
</button>
</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';
......@@ -11,20 +8,10 @@ import DastIssue from './dast_issue_body.vue';
import { SAST, DAST, SAST_CONTAINER } from '../store/constants';
const modalDefaultData = {
modalId: 'modal-mrwidget-issue',
modalDesc: '',
modalTitle: '',
modalInstances: [],
modalTargetId: '#modal-mrwidget-issue',
};
export default {
name: 'ReportIssues',
components: {
Modal,
Icon,
ExpandButton,
SastIssue,
SastContainerIssue,
DastIssue,
......@@ -47,9 +34,6 @@ export default {
required: true,
},
},
data() {
return modalDefaultData;
},
computed: {
iconName() {
if (this.isStatusFailed) {
......@@ -85,37 +69,6 @@ export default {
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>
......@@ -123,6 +76,7 @@ export default {
<ul class="report-block-list">
<li
class="report-block-list-issue"
:class="{ 'is-dismissed': issue.isDismissed }"
v-for="(issue, index) in issues"
:key="index"
>
......@@ -149,8 +103,6 @@ export default {
v-else-if="isTypeDast"
:issue="issue"
:issue-index="index"
:modal-target-id="modalTargetId"
@openDastModal="openDastModal"
/>
<sast-container-issue
......@@ -170,63 +122,5 @@ export default {
/>
</li>
</ul>
<modal
v-if="isTypeDast"
:id="modalId"
:header-title-text="modalTitle"
ref="modal"
class="modal-security-report-dast"
>
<slot>
{{ modalDesc }}
<h5 class="prepend-top-20">
{{ s__('ciReport|Instances') }}
</h5>
<ul
v-if="modalInstances"
class="report-block-list"
>
<li
v-for="(instance, i) in modalInstances"
:key="i"
class="report-block-list-issue"
>
<div class="report-block-list-icon append-right-5 failed">
<icon
name="status_failed_borderless"
:size="32"
/>
</div>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
<div class="report-block-list-issue-description-text append-right-5">
{{ instance.method }}
</div>
<div class="report-block-list-issue-description-link">
<a
:href="instance.uri"
target="_blank"
rel="noopener noreferrer nofollow"
class="break-link"
>
{{ instance.uri }}
</a>
</div>
<expand-button v-if="instance.evidence">
<pre
slot="expanded"
class="block report-block-dast-code prepend-top-10 report-block-issue-code"
>{{ instance.evidence }}</pre>
</expand-button>
</div>
</li>
</ul>
</slot>
<div slot="footer">
</div>
</modal>
</div>
</template>
<script>
/**
* Renders SAST CONTAINER body text
* [priority]: [name|link] in [link]:[line]
* [priority]: [name] in [link]:[line]
*/
import ReportLink from './report_link.vue';
import ModalOpenName from './modal_open_name.vue';
export default {
name: 'SastContainerIssueBody',
components: {
ReportLink,
ModalOpenName,
},
props: {
issue: {
type: Object,
......@@ -25,17 +25,7 @@ export default {
<div class="report-block-list-issue-description-text append-right-5">
<template v-if="issue.priority">{{ issue.priority }}:</template>
<a
v-if="issue.nameLink"
:href="issue.nameLink"
target="_blank"
rel="noopener noreferrer nofollow"
>
{{ issue.name }}
</a>
<template v-else>
{{ issue.name }}
</template>
<modal-open-name :issue="issue" />
</div>
<report-link
......
......@@ -4,12 +4,14 @@
* [priority]: [name] in [link] : [line]
*/
import ReportLink from './report_link.vue';
import ModalOpenName from './modal_open_name.vue';
export default {
name: 'SastIssueBody',
components: {
ReportLink,
ModalOpenName,
},
props: {
......@@ -25,7 +27,7 @@ export default {
<div class="report-block-list-issue-description-text append-right-5">
<template v-if="issue.priority">{{ issue.priority }}:</template>
{{ issue.name }}
<modal-open-name :issue="issue" />
</div>
<report-link
......
<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 {
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 IssueModal from './components/modal.vue';
import securityReportsMixin from './mixins/security_report_mixin';
export default {
store,
components: {
ReportSection,
SummaryRow,
IssuesList,
IssueModal,
},
mixins: [securityReportsMixin],
props: {
......@@ -85,6 +87,21 @@
required: false,
default: '',
},
vulnerabilityFeedbackPath: {
type: String,
required: false,
default: '',
},
vulnerabilityFeedbackHelpPath: {
type: String,
required: false,
default: '',
},
pipelineId: {
type: Number,
required: false,
default: null,
},
},
sast: SAST,
dast: DAST,
......@@ -109,6 +126,10 @@
this.setHeadBlobPath(this.headBlobPath);
this.setBaseBlobPath(this.baseBlobPath);
this.setVulnerabilityFeedbackPath(this.vulnerabilityFeedbackPath);
this.setVulnerabilityFeedbackHelpPath(this.vulnerabilityFeedbackHelpPath);
this.setPipelineId(this.pipelineId);
if (this.sastHeadPath) {
this.setSastHeadPath(this.sastHeadPath);
......@@ -162,9 +183,12 @@
'fetchSastContainerReports',
'fetchDastReports',
'fetchDependencyScanningReports',
'setVulnerabilityFeedbackPath',
'setVulnerabilityFeedbackHelpPath',
'setPipelineId',
]),
},
};
};
</script>
<template>
<report-section
......@@ -179,7 +203,6 @@
slot="body"
class="mr-widget-grouped-section report-block"
>
<template v-if="sastHeadPath">
<summary-row
class="js-sast-widget"
......@@ -250,6 +273,8 @@
:type="$options.dast"
/>
</template>
<issue-modal />
</div>
</report-section>
</template>
......@@ -5,6 +5,7 @@ import createFlash from '~/flash';
import { SAST } from './store/constants';
import store from './store';
import ReportSection from './components/report_section.vue';
import IssueModal from './components/modal.vue';
import mixin from './mixins/security_report_mixin';
import reportsMixin from './mixins/reports_mixin';
......@@ -12,6 +13,7 @@ export default {
store,
components: {
ReportSection,
IssueModal,
},
mixins: [mixin, reportsMixin],
props: {
......@@ -39,6 +41,21 @@ export default {
required: false,
default: null,
},
vulnerabilityFeedbackPath: {
type: String,
required: false,
default: '',
},
vulnerabilityFeedbackHelpPath: {
type: String,
required: false,
default: '',
},
pipelineId: {
type: Number,
required: false,
default: null,
},
},
sast: SAST,
computed: {
......@@ -58,6 +75,9 @@ export default {
created() {
// update the store with the received props
this.setHeadBlobPath(this.headBlobPath);
this.setVulnerabilityFeedbackPath(this.vulnerabilityFeedbackPath);
this.setVulnerabilityFeedbackHelpPath(this.vulnerabilityFeedbackHelpPath);
this.setPipelineId(this.pipelineId);
if (this.sastHeadPath) {
this.setSastHeadPath(this.sastHeadPath);
......@@ -89,6 +109,9 @@ export default {
'setDependencyScanningHeadPath',
'fetchSastReports',
'fetchDependencyScanningReports',
'setVulnerabilityFeedbackPath',
'setVulnerabilityFeedbackHelpPath',
'setPipelineId',
]),
summaryTextBuilder(type, issuesCount = 0) {
......@@ -142,5 +165,7 @@ export default {
:has-issues="dependencyScanning.newIssues.length > 0"
:popover-options="dependencyScanningPopover"
/>
<issue-modal />
</div>
</template>
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
export const setHeadBlobPath = ({ commit }, blobPath) => commit(types.SET_HEAD_BLOB_PATH, blobPath);
export const setBaseBlobPath = ({ commit }, blobPath) => commit(types.SET_BASE_BLOB_PATH, blobPath);
export const setVulnerabilityFeedbackPath = ({ commit }, path) =>
commit(types.SET_VULNERABILITY_FEEDBACK_PATH, path);
export const setVulnerabilityFeedbackHelpPath = ({ commit }, path) =>
commit(types.SET_VULNERABILITY_FEEDBACK_HELP_PATH, path);
export const setPipelineId = ({ commit }, id) => commit(types.SET_PIPELINE_ID, id);
/**
* SAST
*/
......@@ -29,11 +40,17 @@ export const fetchSastReports = ({ state, dispatch }) => {
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
axios.get(state.vulnerabilityFeedbackPath, {
params: {
category: 'sast',
},
}),
])
.then(values => {
dispatch('receiveSastReports', {
head: values && values[0] ? values[0].data : null,
base: values && values[1] ? values[1].data : null,
enrichData: values && values[2] ? values[2].data : [],
});
})
.catch(() => {
......@@ -41,6 +58,8 @@ export const fetchSastReports = ({ state, dispatch }) => {
});
};
export const updateSastIssue = ({ commit }, issue) => commit(types.UPDATE_SAST_ISSUE, issue);
/**
* SAST CONTAINER
*/
......@@ -68,11 +87,17 @@ export const fetchSastContainerReports = ({ state, dispatch }) => {
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
axios.get(state.vulnerabilityFeedbackPath, {
params: {
category: 'container_scanning',
},
}),
])
.then(values => {
dispatch('receiveSastContainerReports', {
head: values[0] ? values[0].data : null,
base: values[1] ? values[1].data : null,
enrichData: values && values[2] ? values[2].data : [],
});
})
.catch(() => {
......@@ -80,6 +105,9 @@ export const fetchSastContainerReports = ({ state, dispatch }) => {
});
};
export const updateContainerScanningIssue = ({ commit }, issue) =>
commit(types.UPDATE_CONTAINER_SCANNING_ISSUE, issue);
/**
* DAST
*/
......@@ -103,11 +131,17 @@ export const fetchDastReports = ({ state, dispatch }) => {
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
axios.get(state.vulnerabilityFeedbackPath, {
params: {
category: 'dast',
},
}),
])
.then(values => {
dispatch('receiveDastReports', {
head: values && values[0] ? values[0].data : null,
base: values && values[1] ? values[1].data : null,
enrichData: values && values[2] ? values[2].data : [],
});
})
.catch(() => {
......@@ -115,6 +149,8 @@ export const fetchDastReports = ({ state, dispatch }) => {
});
};
export const updateDastIssue = ({ commit }, issue) => commit(types.UPDATE_DAST_ISSUE, issue);
/**
* DEPENDENCY SCANNING
*/
......@@ -142,11 +178,17 @@ export const fetchDependencyScanningReports = ({ state, dispatch }) => {
return Promise.all([
head ? axios.get(head) : Promise.resolve(),
base ? axios.get(base) : Promise.resolve(),
axios.get(state.vulnerabilityFeedbackPath, {
params: {
category: 'dependency_scanning',
},
}),
])
.then(values => {
dispatch('receiveDependencyScanningReports', {
head: values[0] ? values[0].data : null,
base: values[1] ? values[1].data : null,
enrichData: values && values[2] ? values[2].data : [],
});
})
.catch(() => {
......@@ -154,5 +196,135 @@ export const fetchDependencyScanningReports = ({ state, dispatch }) => {
});
};
export const updateDependencyScanningIssue = ({ commit }, issue) =>
commit(types.UPDATE_DEPENDENCY_SCANNING_ISSUE, issue);
export const openModal = ({ dispatch }, issue) => {
dispatch('setModalData', issue);
$('#modal-mrwidget-security-issue').modal('show');
};
export const setModalData = ({ commit }, issue) => commit(types.SET_ISSUE_MODAL_DATA, issue);
export const requestDismissIssue = ({ commit }) => commit(types.REQUEST_DISMISS_ISSUE);
export const receiveDismissIssue = ({ commit }) => commit(types.RECEIVE_DISMISS_ISSUE_SUCCESS);
export const receiveDismissIssueError = ({ commit }, error) =>
commit(types.RECEIVE_DISMISS_ISSUE_ERROR, error);
export const dismissIssue = ({ state, dispatch }) => {
dispatch('requestDismissIssue');
return axios
.post(state.vulnerabilityFeedbackPath, { vulnerability_feedback: {
feedback_type: 'dismissal',
category: state.modal.vulnerability.category,
project_fingerprint: state.modal.vulnerability.project_fingerprint,
pipeline_id: state.pipelineId,
vulnerability_data: state.modal.vulnerability,
} })
.then(({ data }) => {
dispatch('receiveDismissIssue');
// Update the issue with the created dismissal feedback applied
const updatedIssue = {
...state.modal.vulnerability,
isDismissed: true,
dismissalFeedback: data,
};
switch (updatedIssue.category) {
case 'sast':
dispatch('updateSastIssue', updatedIssue);
break;
case 'dependency_scanning':
dispatch('updateDependencyScanningIssue', updatedIssue);
break;
case 'container_scanning':
dispatch('updateContainerScanningIssue', updatedIssue);
break;
case 'dast':
dispatch('updateDastIssue', updatedIssue);
break;
default:
}
$('#modal-mrwidget-security-issue').modal('hide');
})
.catch(() => {
dispatch(
'receiveDismissIssueError',
s__('ciReport|There was an error dismissing the vulnerability. Please try again.'),
);
});
};
export const revertDismissIssue = ({ state, dispatch }) => {
dispatch('requestDismissIssue');
return axios
.delete(`${state.vulnerabilityFeedbackPath}/${state.modal.vulnerability.dismissalFeedback.id}`)
.then(() => {
dispatch('receiveDismissIssue');
// Update the issue with the reverted dismissal feedback applied
const updatedIssue = {
...state.modal.vulnerability,
isDismissed: false,
dismissalFeedback: null,
};
switch (updatedIssue.category) {
case 'sast':
dispatch('updateSastIssue', updatedIssue);
break;
case 'dependency_scanning':
dispatch('updateDependencyScanningIssue', updatedIssue);
break;
case 'container_scanning':
dispatch('updateContainerScanningIssue', updatedIssue);
break;
case 'dast':
dispatch('updateDastIssue', updatedIssue);
break;
default:
}
$('#modal-mrwidget-security-issue').modal('hide');
})
.catch(() =>
dispatch(
'receiveDismissIssueError',
s__('ciReport|There was an error reverting the dismissal. Please try again.'),
),
);
};
export const requestCreateIssue = ({ commit }) => commit(types.REQUEST_CREATE_ISSUE);
export const receiveCreateIssue = ({ commit }) => commit(types.RECEIVE_CREATE_ISSUE_SUCCESS);
export const receiveCreateIssueError = ({ commit }, error) =>
commit(types.RECEIVE_CREATE_ISSUE_ERROR, error);
export const createNewIssue = ({ state, dispatch }) => {
dispatch('requestCreateIssue');
return axios
.post(state.vulnerabilityFeedbackPath, { vulnerability_feedback: {
feedback_type: 'issue',
category: state.modal.vulnerability.category,
project_fingerprint: state.modal.vulnerability.project_fingerprint,
pipeline_id: state.pipelineId,
vulnerability_data: state.modal.vulnerability,
} })
.then(response => {
dispatch('receiveCreateIssue');
// redirect the user to the created issue
visitUrl(response.data.issue_url);
})
.catch(() =>
dispatch(
'receiveCreateIssueError',
s__('ciReport|There was an error creating the issue. Please try again.'),
),
);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const SET_HEAD_BLOB_PATH = 'SET_HEAD_BLOB_PATH';
export const SET_BASE_BLOB_PATH = 'SET_BASE_BLOB_PATH';
export const SET_VULNERABILITY_FEEDBACK_PATH = 'SET_VULNERABILITY_FEEDBACK_PATH';
export const SET_VULNERABILITY_FEEDBACK_HELP_PATH = 'SET_VULNERABILITY_FEEDBACK_HELP_PATH';
export const SET_PIPELINE_ID = 'SET_PIPELINE_ID';
// SAST
export const SET_SAST_HEAD_PATH = 'SET_SAST_HEAD_PATH';
......@@ -28,3 +31,18 @@ export const SET_DEPENDENCY_SCANNING_BASE_PATH = 'SET_DEPENDENCY_SCANNING_BASE_P
export const REQUEST_DEPENDENCY_SCANNING_REPORTS = 'REQUEST_DEPENDENCY_SCANNING_REPORTS';
export const RECEIVE_DEPENDENCY_SCANNING_REPORTS = 'RECEIVE_DEPENDENCY_SCANNING_REPORTS';
export const RECEIVE_DEPENDENCY_SCANNING_ERROR = 'RECEIVE_DEPENDENCY_SCANNING_ERROR';
// Dismiss security issue
export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA';
export const CLEAR_ISSUE_MODAL_DATA = 'CLEAR_ISSUE_MODAL_DATA';
export const REQUEST_DISMISS_ISSUE = 'REQUEST_DISMISS_ISSUE';
export const RECEIVE_DISMISS_ISSUE_SUCCESS = 'RECEIVE_DISMISS_ISSUE_SUCCESS';
export const RECEIVE_DISMISS_ISSUE_ERROR = 'RECEIVE_DISMISS_ISSUE_ERROR';
export const REQUEST_CREATE_ISSUE = 'CREATE_DISMISS_ISSUE';
export const RECEIVE_CREATE_ISSUE_SUCCESS = 'CREATE_DISMISS_ISSUE_SUCCESS';
export const RECEIVE_CREATE_ISSUE_ERROR = 'CREATE_DISMISS_ISSUE_ERROR';
export const UPDATE_SAST_ISSUE = 'UPDATE_SAST_ISSUE';
export const UPDATE_DEPENDENCY_SCANNING_ISSUE = 'UPDATE_DEPENDENCY_SCANNING_ISSUE';
export const UPDATE_CONTAINER_SCANNING_ISSUE = 'UPDATE_CONTAINER_SCANNING_ISSUE';
export const UPDATE_DAST_ISSUE = 'UPDATE_DAST_ISSUE';
......@@ -3,10 +3,12 @@
import * as types from './mutation_types';
import {
parseSastIssues,
parseDependencyScanningIssues,
filterByKey,
parseSastContainer,
parseDastIssues,
getUnapprovedVulnerabilities,
findIssueIndex,
} from './utils';
export default {
......@@ -18,6 +20,18 @@ export default {
state.blobPath.base = path;
},
[types.SET_VULNERABILITY_FEEDBACK_PATH](state, path) {
state.vulnerabilityFeedbackPath = path;
},
[types.SET_VULNERABILITY_FEEDBACK_HELP_PATH](state, path) {
state.vulnerabilityFeedbackHelpPath = path;
},
[types.SET_PIPELINE_ID](state, id) {
state.pipelineId = id;
},
// SAST
[types.SET_SAST_HEAD_PATH](state, path) {
state.sast.paths.head = path;
......@@ -49,8 +63,8 @@ export default {
[types.RECEIVE_SAST_REPORTS](state, reports) {
if (reports.base && reports.head) {
const filterKey = 'cve';
const parsedHead = parseSastIssues(reports.head, state.blobPath.head);
const parsedBase = parseSastIssues(reports.base, state.blobPath.base);
const parsedHead = parseSastIssues(reports.head, reports.enrichData, state.blobPath.head);
const parsedBase = parseSastIssues(reports.base, reports.enrichData, state.blobPath.base);
const newIssues = filterByKey(parsedHead, parsedBase, filterKey);
const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey);
......@@ -63,7 +77,7 @@ export default {
state.summaryCounts.added += newIssues.length;
state.summaryCounts.fixed += resolvedIssues.length;
} else if (reports.head && !reports.base) {
const newIssues = parseSastIssues(reports.head, state.blobPath.head);
const newIssues = parseSastIssues(reports.head, reports.enrichData, state.blobPath.head);
state.sast.newIssues = newIssues;
state.sast.isLoading = false;
......@@ -95,11 +109,11 @@ export default {
[types.RECEIVE_SAST_CONTAINER_REPORTS](state, reports) {
if (reports.base && reports.head) {
const headIssues = getUnapprovedVulnerabilities(
parseSastContainer(reports.head.vulnerabilities),
parseSastContainer(reports.head.vulnerabilities, reports.enrichData),
reports.head.unapproved,
);
const baseIssues = getUnapprovedVulnerabilities(
parseSastContainer(reports.base.vulnerabilities),
parseSastContainer(reports.base.vulnerabilities, reports.enrichData),
reports.base.unapproved,
);
const filterKey = 'vulnerability';
......@@ -114,7 +128,7 @@ export default {
state.summaryCounts.fixed += resolvedIssues.length;
} else if (reports.head && !reports.base) {
const newIssues = getUnapprovedVulnerabilities(
parseSastContainer(reports.head.vulnerabilities),
parseSastContainer(reports.head.vulnerabilities, reports.enrichData),
reports.head.unapproved,
);
......@@ -145,8 +159,8 @@ export default {
[types.RECEIVE_DAST_REPORTS](state, reports) {
if (reports.head && reports.base) {
const headIssues = parseDastIssues(reports.head.site.alerts);
const baseIssues = parseDastIssues(reports.base.site.alerts);
const headIssues = parseDastIssues(reports.head.site.alerts, reports.enrichData);
const baseIssues = parseDastIssues(reports.base.site.alerts, reports.enrichData);
const filterKey = 'pluginid';
const newIssues = filterByKey(headIssues, baseIssues, filterKey);
const resolvedIssues = filterByKey(baseIssues, headIssues, filterKey);
......@@ -157,7 +171,7 @@ export default {
state.summaryCounts.added += newIssues.length;
state.summaryCounts.fixed += resolvedIssues.length;
} else if (reports.head && !reports.base) {
const newIssues = parseDastIssues(reports.head.site.alerts);
const newIssues = parseDastIssues(reports.head.site.alerts, reports.enrichData);
state.dast.newIssues = newIssues;
state.dast.isLoading = false;
......@@ -202,8 +216,10 @@ export default {
[types.RECEIVE_DEPENDENCY_SCANNING_REPORTS](state, reports) {
if (reports.base && reports.head) {
const filterKey = 'cve';
const parsedHead = parseSastIssues(reports.head, state.blobPath.head);
const parsedBase = parseSastIssues(reports.base, state.blobPath.base);
const parsedHead = parseDependencyScanningIssues(reports.head, reports.enrichData,
state.blobPath.head);
const parsedBase = parseDependencyScanningIssues(reports.base, reports.enrichData,
state.blobPath.base);
const newIssues = filterByKey(parsedHead, parsedBase, filterKey);
const resolvedIssues = filterByKey(parsedBase, parsedHead, filterKey);
......@@ -218,7 +234,8 @@ export default {
}
if (reports.head && !reports.base) {
const newIssues = parseSastIssues(reports.head, state.blobPath.head);
const newIssues = parseDependencyScanningIssues(reports.head, reports.enrichData,
state.blobPath.head);
state.dependencyScanning.newIssues = newIssues;
state.dependencyScanning.isLoading = false;
state.summaryCounts.added += newIssues.length;
......@@ -229,4 +246,134 @@ export default {
state.dependencyScanning.isLoading = false;
state.dependencyScanning.hasError = true;
},
[types.SET_ISSUE_MODAL_DATA](state, issue) {
state.modal.title = issue.name;
state.modal.data.description.value = issue.description;
state.modal.data.file.value = issue.file;
state.modal.data.file.url = issue.urlPath;
state.modal.data.namespace.value = issue.namespace;
state.modal.data.severity.value = issue.severity;
state.modal.data.solution.value = issue.solution;
state.modal.data.confidenceLevel.value = issue.confidence;
state.modal.data.source.value = issue.source;
state.modal.data.instances.value = issue.instances;
state.modal.vulnerability = issue;
// Link to CVE-ID for Container Scanning
if (issue.nameLink) {
state.modal.data.identifier.value = issue.name;
state.modal.data.identifier.isLink = true;
state.modal.data.identifier.url = issue.nameLink;
} else {
state.modal.data.identifier.value = issue.identifier;
state.modal.data.identifier.isLink = false;
state.modal.data.identifier.url = null;
}
// clear previous state
state.modal.error = null;
},
[types.REQUEST_DISMISS_ISSUE](state) {
state.modal.isDismissingIssue = true;
// reset error in case previous state was error
state.modal.error = null;
},
[types.RECEIVE_DISMISS_ISSUE_SUCCESS](state) {
state.modal.isDismissingIssue = false;
},
[types.UPDATE_SAST_ISSUE](state, issue) {
// Find issue in the correct list and update it
const newIssuesIndex = findIssueIndex(state.sast.newIssues, issue);
if (newIssuesIndex !== -1) {
state.sast.newIssues.splice(newIssuesIndex, 1, issue);
return;
}
const resolvedIssuesIndex = findIssueIndex(state.sast.resolvedIssues, issue);
if (resolvedIssuesIndex !== -1) {
state.sast.resolvedIssues.splice(resolvedIssuesIndex, 1, issue);
return;
}
const allIssuesIndex = findIssueIndex(state.sast.allIssues, issue);
if (allIssuesIndex !== -1) {
state.sast.allIssues.splice(allIssuesIndex, 1, issue);
}
},
[types.UPDATE_DEPENDENCY_SCANNING_ISSUE](state, issue) {
// Find issue in the correct list and update it
const newIssuesIndex = findIssueIndex(state.dependencyScanning.newIssues, issue);
if (newIssuesIndex !== -1) {
state.dependencyScanning.newIssues.splice(newIssuesIndex, 1, issue);
return;
}
const resolvedIssuesIndex = findIssueIndex(state.dependencyScanning.resolvedIssues, issue);
if (resolvedIssuesIndex !== -1) {
state.dependencyScanning.resolvedIssues.splice(resolvedIssuesIndex, 1, issue);
return;
}
const allIssuesIndex = findIssueIndex(state.dependencyScanning.allIssues, issue);
if (allIssuesIndex !== -1) {
state.dependencyScanning.allIssues.splice(allIssuesIndex, 1, issue);
}
},
[types.UPDATE_CONTAINER_SCANNING_ISSUE](state, issue) {
// Find issue in the correct list and update it
const newIssuesIndex = findIssueIndex(state.sastContainer.newIssues, issue);
if (newIssuesIndex !== -1) {
state.sastContainer.newIssues.splice(newIssuesIndex, 1, issue);
return;
}
const resolvedIssuesIndex = findIssueIndex(state.sastContainer.resolvedIssues, issue);
if (resolvedIssuesIndex !== -1) {
state.sastContainer.resolvedIssues.splice(resolvedIssuesIndex, 1, issue);
}
},
[types.UPDATE_DAST_ISSUE](state, issue) {
// Find issue in the correct list and update it
const newIssuesIndex = findIssueIndex(state.dast.newIssues, issue);
if (newIssuesIndex !== -1) {
state.dast.newIssues.splice(newIssuesIndex, 1, issue);
return;
}
const resolvedIssuesIndex = findIssueIndex(state.dast.resolvedIssues, issue);
if (resolvedIssuesIndex !== -1) {
state.dast.resolvedIssues.splice(resolvedIssuesIndex, 1, issue);
}
},
[types.RECEIVE_DISMISS_ISSUE_ERROR](state, error) {
state.modal.error = error;
state.modal.isDismissingIssue = false;
},
[types.REQUEST_CREATE_ISSUE](state) {
state.modal.isCreatingNewIssue = true;
// reset error in case previous state was error
state.modal.error = null;
},
[types.RECEIVE_CREATE_ISSUE_SUCCESS](state) {
state.modal.isCreatingNewIssue = false;
},
[types.RECEIVE_CREATE_ISSUE_ERROR](state, error) {
state.modal.error = error;
state.modal.isCreatingNewIssue = false;
},
};
import { s__ } from '~/locale';
export default () => ({
summaryCounts: {
added: 0,
......@@ -9,6 +11,10 @@ export default () => ({
base: null,
},
vulnerabilityFeedbackPath: null,
vulnerabilityFeedbackHelpPath: null,
pipelineId: null,
sast: {
paths: {
head: null,
......@@ -60,4 +66,70 @@ export default () => ({
resolvedIssues: [],
allIssues: [],
},
modal: {
title: null,
// Dynamic data rendered for each issue
data: {
description: {
value: null,
text: s__('ciReport|Description'),
isLink: false,
},
file: {
value: null,
url: null,
text: s__('ciReport|File'),
isLink: true,
},
namespace: {
value: null,
text: s__('ciReport|Namespace'),
isLink: false,
},
identifier: {
value: null,
url: null,
text: s__('ciReport|Identifier'),
isLink: false,
},
severity: {
value: null,
text: s__('ciReport|Severity'),
isLink: false,
},
solution: {
value: null,
text: s__('ciReport|Solution'),
isLink: false,
},
confidenceLevel: {
value: null,
text: s__('ciReport|Confidence Level'),
isLink: false,
},
source: {
value: null,
text: s__('ciReport|Source'),
isLink: true,
},
instances: {
value: [],
text: s__('ciReport|Instances'),
isLink: false,
},
},
learnMoreUrl: null,
vulnerability: {
isDimissed: false,
hasIssue: false,
},
isCreatingNewIssue: false,
isDismissingIssue: false,
error: null,
},
});
import sha1 from 'sha1';
import { stripHtml } from '~/lib/utils/text_utility';
import { n__, s__, sprintf } from '~/locale';
/**
* Maps SAST & Dependency scanning issues:
* Returns the index of an issue in given list
* @param {Array} issues
* @param {Object} issue
*/
export const findIssueIndex = (issues, issue) =>
issues.findIndex(el => el.project_fingerprint === issue.project_fingerprint);
/**
* Returns given vulnerability enriched with the corresponding
* feedbacks (`dismissal` or `issue` type)
* @param {Object} vulnerability
* @param {Array} feedbacks
*/
function enrichVulnerabilityWithfeedbacks(vulnerability, feedbacks = []) {
return feedbacks.filter(
feedback => feedback.project_fingerprint === vulnerability.project_fingerprint,
).reduce((vuln, feedback) => {
if (feedback.feedback_type === 'dismissal') {
return {
...vuln,
isDismissed: true,
dismissalFeedback: feedback,
};
} else if (feedback.feedback_type === 'issue') {
return {
...vuln,
hasIssue: true,
issueFeedback: feedback,
};
}
return vuln;
}, vulnerability);
}
/**
* Maps SAST issues:
* { tool: String, message: String, url: String , cve: String ,
* file: String , solution: String, priority: String }
* to contain:
......@@ -10,14 +46,50 @@ import { n__, s__, sprintf } from '~/locale';
* @param {Array} issues
* @param {String} path
*/
export const parseSastIssues = (issues = [], path = '') =>
issues.map(issue => ({
export const parseSastIssues = (issues = [], feedbacks = [], path = '') =>
issues.map(issue => {
const parsed = {
...issue,
category: 'sast',
// TODO: replace with issue.project_fingerprint
project_fingerprint: sha1(issue.cve),
name: issue.message,
path: issue.file,
urlPath: issue.line ? `${path}/${issue.file}#L${issue.line}` : `${path}/${issue.file}`,
}),
);
};
return {
...parsed,
...enrichVulnerabilityWithfeedbacks(parsed, feedbacks),
};
});
/**
* Maps 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 parseDependencyScanningIssues = (issues = [], feedbacks = [], path = '') =>
issues.map(issue => {
const parsed = {
...issue,
category: 'dependency_scanning',
// TODO: replace with issue.project_fingerprint
project_fingerprint: sha1(issue.cve),
name: issue.message,
path: issue.file,
urlPath: issue.line ? `${path}/${issue.file}#L${issue.line}` : `${path}/${issue.file}`,
};
return {
...parsed,
...enrichVulnerabilityWithfeedbacks(parsed, feedbacks),
};
});
/**
* Parses Sast Container results into a common format to allow to use the same Vue component
......@@ -26,22 +98,59 @@ export const parseSastIssues = (issues = [], path = '') =>
* @param {Array} data
* @returns {Array}
*/
export const parseSastContainer = (data = []) =>
data.map(element => ({
...element,
name: element.vulnerability,
priority: element.severity,
path: element.namespace,
export const parseSastContainer = (issues = [], feedbacks = []) =>
issues.map(issue => {
const parsed = {
...issue,
category: 'container_scanning',
// TODO: replace with issue.project_fingerprint
project_fingerprint: sha1(`${issue.namespace}:${issue.vulnerability}:${issue.featurename}:${issue.featureversion}`),
name: issue.vulnerability,
priority: issue.severity,
path: issue.namespace,
// external link to provide better description
nameLink: `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${element.vulnerability}`,
}));
nameLink: `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${issue.vulnerability}`,
};
return {
...parsed,
...enrichVulnerabilityWithfeedbacks(parsed, feedbacks),
};
});
export const parseDastIssues = (issues = []) =>
issues.map(issue => ({
export const parseDastIssues = (issues = [], feedbacks = []) =>
issues.map(issue => {
const parsed = {
...issue,
category: 'dast',
// TODO: replace with issue.project_fingerprint
project_fingerprint: sha1(issue.pluginid),
parsedDescription: stripHtml(issue.desc, ' '),
priority: issue.riskdesc,
...issue,
}));
solution: stripHtml(issue.solution, ' '),
description: stripHtml(issue.desc, ' '),
};
if (issue.cweid && issue.cweid !== '') {
Object.assign(parsed, {
identifier: `CWE-${issue.cweid}`,
});
}
if (issue.riskdesc && issue.riskdesc !== '') {
// Split 'severity (confidence)'
const [, severity, confidence] = issue.riskdesc.match(/(.*) \((.*)\)/);
Object.assign(parsed, {
severity,
confidence,
});
}
return {
...parsed,
...enrichVulnerabilityWithfeedbacks(parsed, feedbacks),
};
});
/**
* Compares two arrays by the given key and returns the difference
......@@ -81,10 +190,7 @@ export const textBuilder = (
);
}
return sprintf(
'%{type} detected no vulnerabilities for the source branch only',
{ type },
);
return sprintf('%{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) {
......
......@@ -89,6 +89,10 @@
align-content: flex-start;
}
.is-dismissed .report-block-list-issue-description {
text-decoration: line-through;
}
.report-block-list-issue-description {
align-content: space-around;
align-items: flex-start;
......@@ -101,6 +105,7 @@
.break-link {
word-wrap: break-word;
word-break: break-all;
text-decoration: inherit;
}
.btn-help svg {
......@@ -109,7 +114,7 @@
}
.report-block-issue-code {
width: $modal-lg - 70px;
width: 600px;
}
.modal-security-report-dast {
......@@ -118,6 +123,6 @@
}
// TODO remove this when gl_modal support not rendering the footer
.modal-footer {
display: none;
display: block;
}
}
class Projects::VulnerabilityFeedbackController < Projects::ApplicationController
before_action :vulnerability_feedback, only: [:destroy]
before_action :authorize_read_vulnerability_feedback!, only: [:index]
before_action :authorize_admin_vulnerability_feedback!, only: [:create, :destroy]
skip_before_action :authenticate_user!, only: [:index]
respond_to :json
def index
# TODO: Move to finder or list service
@vulnerability_feedback = @project.vulnerability_feedback.with_associations
if params[:category].present?
@vulnerability_feedback = @vulnerability_feedback
.where(category: VulnerabilityFeedback.categories[params[:category]])
end
if params[:feedback_type].present?
@vulnerability_feedback = @vulnerability_feedback
.where(feedback_type: VulnerabilityFeedback.feedback_types[params[:feedback_type]])
end
render json: serializer.represent(@vulnerability_feedback)
end
def create
service = VulnerabilityFeedbackModule::CreateService.new(project, current_user, vulnerability_feedback_params)
result = service.execute
if result[:status] == :success
render json: serializer.represent(result[:vulnerability_feedback])
else
render json: result[:message], status: :unprocessable_entity
end
end
def destroy
service = VulnerabilityFeedbackModule::DestroyService.new(@vulnerability_feedback)
service.execute
head :no_content
end
private
def authorize_admin_vulnerability_feedback!
render_403 unless can?(current_user, :admin_vulnerability_feedback, project)
end
def serializer
VulnerabilityFeedbackSerializer.new(current_user: current_user, project: project)
end
def vulnerability_feedback
@vulnerability_feedback ||= @project.vulnerability_feedback.find(params[:id])
end
def vulnerability_feedback_params
params.require(:vulnerability_feedback).permit(*vulnerability_feedback_params_attributes)
end
def vulnerability_feedback_params_attributes
%i[
category
feedback_type
pipeline_id
project_fingerprint
] + [
vulnerability_data: vulnerability_data_params_attributes
]
end
def vulnerability_data_params_attributes
%i[
category
confidence
count
cve
cweid
desc
description
featurename
featureversion
file
fingerprint
fixedby
line
link
message
name
namespace
otherinfo
pluginid
priority
project_fingerprint
reference
riskcode
riskdesc
severity
solution
sourceid
title
tool
tools
url
wascid
] + [
instances: %i[
param
method
uri
],
identifiers: %i[
name
value
]
]
end
end
......@@ -32,6 +32,7 @@ module EE
has_many :approver_groups, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :audit_events, as: :entity
has_many :path_locks
has_many :vulnerability_feedback
has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id
......
......@@ -24,6 +24,7 @@ module EE
has_many :epics, foreign_key: :author_id
has_many :assigned_epics, foreign_key: :assignee_id, class_name: "Epic"
has_many :path_locks, dependent: :destroy # rubocop: disable Cop/ActiveRecordDependent
has_many :vulnerability_feedback, foreign_key: :author_id
has_many :approvals, dependent: :destroy # rubocop: disable Cop/ActiveRecordDependent
has_many :approvers, dependent: :destroy # rubocop: disable Cop/ActiveRecordDependent
......
class VulnerabilityFeedback < ActiveRecord::Base
belongs_to :project
belongs_to :author, class_name: "User"
belongs_to :issue
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
attr_accessor :vulnerability_data
enum feedback_type: { dismissal: 0, issue: 1 }
enum category: { sast: 0, dependency_scanning: 1, container_scanning: 2, dast: 3 }
validates :project, presence: true
validates :author, presence: true
validates :issue, presence: true, if: :issue?
validates :feedback_type, presence: true
validates :category, presence: true
validates :project_fingerprint, presence: true, uniqueness: { scope: [:project_id, :category, :feedback_type] }
scope :with_associations, -> { includes(:pipeline, :issue) }
end
......@@ -6,6 +6,7 @@ module EE
board
issue_link
approvers
vulnerability_feedback
].freeze
prepended do
......@@ -72,7 +73,12 @@ module EE
enable :admin_epic_issue
end
rule { can?(:developer_access) }.enable :admin_board
rule { can?(:developer_access) }.policy do
enable :admin_board
enable :admin_vulnerability_feedback
end
rule { can?(:read_project) }.enable :read_vulnerability_feedback
rule { repository_mirrors_enabled & ((mirror_available & can?(:admin_project)) | admin) }.enable :admin_mirror
......
......@@ -110,6 +110,14 @@ module EE
path: Ci::Build::DAST_FILE)
end
end
expose :pipeline_id, if: -> (mr, _) { mr.head_pipeline } do |merge_request|
merge_request.head_pipeline.id
end
expose :vulnerability_feedback_path do |merge_request|
project_vulnerability_feedback_index_path(merge_request.project)
end
end
end
end
class VulnerabilityFeedbackEntity < Grape::Entity
include Gitlab::Routing
include GitlabRoutingHelper
expose :id
expose :project_id
expose :author_id
expose :issue_id
expose :pipeline_id
expose :issue_url, if: -> (feedback, _) { feedback.issue? } do |feedback|
project_issue_url(feedback.project, feedback.issue)
end
expose :category
expose :feedback_type
expose :branch do |feedback|
feedback&.pipeline&.ref
end
expose :project_fingerprint
end
class VulnerabilityFeedbackSerializer < BaseSerializer
entity VulnerabilityFeedbackEntity
end
......@@ -5,6 +5,7 @@ module EE
def migrate_records
migrate_epics
migrate_vulnerability_feedback
super
end
......@@ -12,6 +13,10 @@ module EE
user.epics.update_all(author_id: ghost_user.id)
::Epic.where(last_edited_by_id: user.id).update_all(last_edited_by_id: ghost_user.id)
end
def migrate_vulnerability_feedback
user.vulnerability_feedback.update_all(author_id: ghost_user.id)
end
end
end
end
module Issues
class CreateFromVulnerabilityDataService < ::BaseService
def execute
issue_params = {
title: issue_title(@params),
description: issue_content(@params)
}
issue = Issues::CreateService.new(@project, @current_user, issue_params).execute
if issue.valid?
success(issue)
else
error(issue.errors)
end
end
private
def success(issue)
super().merge(issue: issue)
end
def issue_title(params)
title = case params[:category]
when 'sast', 'dependency_scanning', 'dast'
params[:name]
when 'container_scanning'
"#{params[:name]} in #{params[:namespace]}"
end
"Investigate vulnerability: #{title}"
end
def issue_content(params)
data = case params[:category]
when 'sast', 'dependency_scanning'
sast_data(params)
when 'container_scanning'
container_scanning_data(params)
when 'dast'
dast_data(params)
end
render_content data
end
def sast_data(params)
data = { identifiers: [] }
data[:severity] = params[:severity]
data[:confidence] = params[:confidence]
data[:description] = params[:description].presence ||
params[:name]
data[:solution] = params[:solution]
if params[:identifiers].present?
params[:identifiers].each do |identifier|
# Only show known identifiers
case identifier[:name]
when 'CVE'
data[:identifiers] << {
value: identifier[:value],
link: cve_link(identifier[:value])
}
when 'CWE'
data[:identifiers] << {
value: "CWE-#{identifier[:value]}",
link: cwe_link(identifier[:value])
}
end
end
end
data
end
def container_scanning_data(params)
data = { identifiers: [] }
data[:severity] = params[:severity]
data[:description] = params[:description].presence ||
"**#{params[:namespace]}** is affected by #{params[:name]}"
if params[:fixedby].present? &&
params[:featurename].present? &&
params[:featureversion].present?
data[:solution] = "Upgrade **#{params[:featurename]}** from `#{params[:featureversion]}` to `#{params[:fixedby]}`"
end
if params[:name].present?
data[:identifiers] << {
value: params[:name],
link: cve_link(params[:name])
}
end
data
end
def dast_data(params)
data = { identifiers: [] }
data[:severity] = params[:severity]
data[:confidence] = params[:confidence]
data[:description] = params[:desc]
data[:solution] = params[:solution]
if params[:cweid].present?
data[:identifiers] << {
value: "CWE-#{params[:cweid]}",
link: cwe_link(params[:cweid])
}
end
if params[:wascid].present?
data[:identifiers] << {
value: "WASC-#{params[:wascid]}"
}
end
data
end
def render_content(data)
content = "### Description:\n#{data[:description]}\n\n"
content << "* Severity: #{data[:severity]}\n" if data[:severity].present?
content << "* Confidence: #{data[:confidence]}\n" if data[:confidence].present?
content << "\n### Solution:\n#{data[:solution]}\n" if data[:solution].present?
if data[:identifiers].present?
content << "\n### Identifiers:\n\n"
data[:identifiers].each do |identifier|
content << if identifier[:link].present?
"* [#{identifier[:value]}](#{identifier[:link]})\n"
else
"* #{identifier[:value]}\n"
end
end
end
content
end
# cve_id must be 'CVE-YYYY-XXXX' (prefix + year + digits)
def cve_link(cve_id)
"https://cve.mitre.org/cgi-bin/cvename.cgi?name=#{cve_id}"
end
# cve_id must be a number only (no 'CWE-' prefix)
def cwe_link(cwe_id)
"https://cwe.mitre.org/data/definitions/#{cwe_id}.html"
end
end
end
module VulnerabilityFeedbackModule
class CreateService < ::BaseService
def execute
vulnerability_feedback = @project.vulnerability_feedback.new(@params)
vulnerability_feedback.author = @current_user
if vulnerability_feedback.issue? # (feedback_type == 'issue')
return error('vulnerability_data is missing or empty') if vulnerability_feedback.vulnerability_data.blank?
result = Issues::CreateFromVulnerabilityDataService
.new(@project, @current_user, vulnerability_feedback.vulnerability_data)
.execute
return result if result[:status] == :error
issue = result[:issue]
vulnerability_feedback.issue = issue
end
if vulnerability_feedback.save
success(vulnerability_feedback)
else
# Rollback created issue
issue.destroy if issue
error(vulnerability_feedback.errors)
end
rescue ArgumentError => e
# VulnerabilityFeedback relies on #enum attributes which raise this exception
error(e.message)
end
private
def success(vulnerability_feedback)
super().merge(vulnerability_feedback: vulnerability_feedback)
end
end
end
module VulnerabilityFeedbackModule
class DestroyService < ::BaseService
def initialize(vulnerability_feedback)
@vulnerability_feedback = vulnerability_feedback
end
def execute
# TODO: Add system note when destroying a dismissal feedback
@vulnerability_feedback.destroy
end
end
end
---
title: Allow user to dismiss a vulnerability or create an issue out of it
merge_request: 5452
author:
type: added
class CreateVulnerabilityFeedback < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :vulnerability_feedback do |t|
t.timestamps_with_timezone null: false
t.integer :feedback_type, limit: 2, null: false
t.integer :category, limit: 2, null: false
t.references :project, null: false, foreign_key: { on_delete: :cascade }
t.integer :author_id, null: false
t.foreign_key :users, column: :author_id, on_delete: :cascade
t.integer :pipeline_id
t.foreign_key :ci_pipelines, column: :pipeline_id, on_delete: :nullify
t.references :issue, null: true, index: true, foreign_key: { on_delete: :nullify }
t.string :project_fingerprint, limit: 40, null: false
t.index :author_id
t.index :pipeline_id
t.index [:project_id, :category, :feedback_type, :project_fingerprint], unique: true, name: 'vulnerability_feedback_unique_idx'
end
end
end
require 'spec_helper'
describe Projects::VulnerabilityFeedbackController do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:user) { create(:user) }
let(:guest) { create(:user) }
before do
group.add_developer(user)
end
describe 'GET #index' do
let(:pipeline_1) { create(:ci_pipeline, project: project) }
let(:pipeline_2) { create(:ci_pipeline, project: project) }
let(:issue) { create(:issue, project: project) }
let!(:vuln_feedback_1) { create(:vulnerability_feedback, project: project, author: user, pipeline: pipeline_1, feedback_type: 'dismissal', category: 'sast', project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa1') }
let!(:vuln_feedback_2) { create(:vulnerability_feedback, project: project, author: user, pipeline: pipeline_1, feedback_type: 'issue', category: 'sast', project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa2', issue: issue) }
let!(:vuln_feedback_3) { create(:vulnerability_feedback, project: project, author: user, pipeline: pipeline_2, feedback_type: 'dismissal', category: 'sast', project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa3') }
let!(:vuln_feedback_4) { create(:vulnerability_feedback, project: project, author: user, pipeline: pipeline_2, feedback_type: 'dismissal', category: 'dependency_scanning', project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa4') }
context '@vulnerability_feedback' do
it 'returns a successful 200 response' do
list_feedbacks
expect(response).to have_gitlab_http_status(200)
end
it 'returns project feedbacks list' do
list_feedbacks
expect(response).to match_response_schema('vulnerability_feedback_list', dir: 'ee')
expect(json_response.length).to eq 4
end
context 'with filter params' do
it 'returns project feedbacks list filtered on category' do
list_feedbacks({ category: 'sast' })
expect(response).to match_response_schema('vulnerability_feedback_list', dir: 'ee')
expect(json_response.length).to eq 3
end
it 'returns project feedbacks list filtered on feedback_type' do
list_feedbacks({ feedback_type: 'issue' })
expect(response).to match_response_schema('vulnerability_feedback_list', dir: 'ee')
expect(json_response.length).to eq 1
end
it 'returns project feedbacks list filtered on category and feedback_type' do
list_feedbacks({ category: 'sast', feedback_type: 'dismissal' })
expect(response).to match_response_schema('vulnerability_feedback_list', dir: 'ee')
expect(json_response.length).to eq 2
end
end
context 'with unauthorized user for given project' do
let(:unauthorized_user) { create(:user) }
let(:project) { create(:project, :private, namespace: group) }
before do
sign_in(unauthorized_user)
end
it 'returns a 404 response' do
list_feedbacks
expect(response).to have_gitlab_http_status(404)
end
end
end
def list_feedbacks(params = {})
get :index, { namespace_id: project.namespace.to_param, project_id: project }.merge(params)
end
end
describe 'POST #create' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:create_params) do
{
feedback_type: 'dismissal', pipeline_id: pipeline.id, category: 'sast',
project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8',
vulnerability_data: {
priority: 'Low', line: '41',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
name: 'Predictable pseudorandom number generator',
description: 'Description of Predictable pseudorandom number generator',
tool: 'find_sec_bugs'
}
}
end
context 'with valid params' do
it 'returns the created list' do
create_feedback user: user, project: project, params: create_params
expect(response).to match_response_schema('vulnerability_feedback', dir: 'ee')
end
end
context 'with invalid params' do
it 'returns an unprocessable entity 422 response when feedbback_type is nil' do
create_feedback user: user, project: project, params: create_params.except(:feedback_type)
expect(response).to have_gitlab_http_status(422)
end
it 'returns an unprocessable entity 422 response when feedbback_type is invalid' do
create_feedback user: user, project: project, params: create_params.merge(feedback_type: 'foo')
expect(response).to have_gitlab_http_status(422)
end
end
context 'with unauthorized user for feedback creation' do
it 'returns a forbidden 403 response' do
group.add_guest(guest)
create_feedback user: guest, project: project, params: create_params
expect(response).to have_gitlab_http_status(403)
end
end
context 'with unauthorized user for given project' do
let(:unauthorized_user) { create(:user) }
let(:project) { create(:project, :private, namespace: group) }
it 'returns a 404 response' do
create_feedback user: unauthorized_user, project: project, params: create_params
expect(response).to have_gitlab_http_status(404)
end
end
def create_feedback(user:, project:, params:)
sign_in(user)
post :create, namespace_id: project.namespace.to_param, project_id: project, vulnerability_feedback: params
end
end
describe 'DELETE #destroy' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let!(:vuln_feedback) { create(:vulnerability_feedback, project: project, author: user, pipeline: pipeline, feedback_type: 'dismissal', category: 'sast', project_fingerprint: 'abc123') }
context 'with valid params' do
it 'returns a successful 204 response' do
destroy_feedback user: user, project: project, id: vuln_feedback.id
expect(response).to have_gitlab_http_status(204)
end
end
context 'with invalid params' do
it 'returns a not found 404 response for invalid vulnerability feedback id' do
destroy_feedback user: user, project: project, id: 123
expect(response).to have_gitlab_http_status(404)
end
end
context 'with unauthorized user for feedback deletion' do
it 'returns a forbidden 403 response' do
group.add_guest(guest)
destroy_feedback user: guest, project: project, id: vuln_feedback.id
expect(response).to have_gitlab_http_status(403)
end
end
context 'with unauthorized user for given project' do
let(:unauthorized_user) { create(:user) }
let(:project) { create(:project, :private, namespace: group) }
it 'returns a 404 response' do
destroy_feedback user: unauthorized_user, project: project, id: vuln_feedback.id
expect(response).to have_gitlab_http_status(404)
end
end
def destroy_feedback(user:, project:, id:)
sign_in(user)
delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: id
end
end
end
FactoryBot.define do
factory :vulnerability_feedback do
project
author
issue nil
association :pipeline, factory: :ci_pipeline
feedback_type 'dismissal'
category 'sast'
project_fingerprint '418291a26024a1445b23fe64de9380cdcdfd1fa8'
end
end
{
"type": "object",
"required" : [
"id",
"project_id",
"author_id",
"feedback_type",
"category",
"project_fingerprint"
],
"properties" : {
"id": { "type": "integer" },
"project_id": { "type": "integer" },
"author_id": { "type": "integer" },
"pipeline_id": { "type": ["integer", "null"] },
"issue_id": { "type": ["integer", "null"] },
"issue_url": { "type": ["string", "null"] },
"feedback_type": {
"type": "string",
"enum": ["dismissal", "issue"]
},
"category": {
"type": "string",
"enum": ["sast", "dependency_scanning", "container_scanning", "dast"]
},
"project_fingerprint": { "type": "string" },
"branch": { "type": "string" }
},
"additionalProperties": false
}
{
"type": "array",
"items": { "$ref": "vulnerability_feedback.json" }
}
require 'spec_helper'
describe EE::User do
describe 'associations' do
subject { build(:user) }
it { is_expected.to have_many(:vulnerability_feedback) }
end
describe '#access_level=' do
let(:user) { build(:user) }
......
......@@ -17,6 +17,7 @@ describe Project do
it { is_expected.to have_one(:repository_state).class_name('ProjectRepositoryState').inverse_of(:project) }
it { is_expected.to have_many(:path_locks) }
it { is_expected.to have_many(:vulnerability_feedback) }
it { is_expected.to have_many(:sourced_pipelines) }
it { is_expected.to have_many(:source_pipelines) }
it { is_expected.to have_many(:audit_events).dependent(false) }
......
......@@ -5,11 +5,17 @@ describe ProjectPolicy do
set(:owner) { create(:user) }
set(:admin) { create(:admin) }
set(:master) { create(:user) }
set(:developer) { create(:user) }
set(:reporter) { create(:user) }
set(:guest) { create(:user) }
let(:project) { create(:project, :public, namespace: owner.namespace) }
before do
project.add_master(master)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
end
context 'admin_mirror' do
......@@ -182,4 +188,119 @@ describe ProjectPolicy do
end
end
end
describe 'read_vulnerability_feedback' do
subject { described_class.new(current_user, project) }
context 'with public project' do
let(:current_user) { nil }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with private project' do
let(:current_user) { admin }
let(:project) { create(:project, :private, namespace: owner.namespace) }
context 'with admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with master' do
let(:current_user) { master }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with developer' do
let(:current_user) { developer }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_allowed(:read_vulnerability_feedback) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_vulnerability_feedback) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_vulnerability_feedback) }
end
end
end
describe 'admin_vulnerability_feedback' do
subject { described_class.new(current_user, project) }
context 'with admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:admin_vulnerability_feedback) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:admin_vulnerability_feedback) }
end
context 'with master' do
let(:current_user) { master }
it { is_expected.to be_allowed(:admin_vulnerability_feedback) }
end
context 'with developer' do
let(:current_user) { developer }
it { is_expected.to be_allowed(:admin_vulnerability_feedback) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:admin_vulnerability_feedback) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:admin_vulnerability_feedback) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:admin_vulnerability_feedback) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:admin_vulnerability_feedback) }
end
end
end
require "spec_helper"
describe 'EE-specific project routing' do
# project_vulnerability_feedback GET /:project_id/vulnerability_feedback(.:format) projects/vulnerability_feedback#index
# POST /:project_id/vulnerability_feedback(.:format) projects/vulnerability_feedback#create
# project_vulnerability_feedback DELETE /:project_id/vulnerability_feedback/:id(.:format) projects/vulnerability_feedback#destroy
describe Projects::VulnerabilityFeedbackController, 'routing', type: :routing do
before do
allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq', any_args).and_return(true)
end
it "to #index" do
expect(get("/gitlab/gitlabhq/vulnerability_feedback")).to route_to('projects/vulnerability_feedback#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
it "to #create" do
expect(post("/gitlab/gitlabhq/vulnerability_feedback")).to route_to('projects/vulnerability_feedback#create', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
it "to #destroy" do
expect(delete("/gitlab/gitlabhq/vulnerability_feedback/1")).to route_to('projects/vulnerability_feedback#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1')
end
end
end
......@@ -98,4 +98,14 @@ describe MergeRequestWidgetEntity do
expect(subject.as_json[:dast]).to include(:head_path)
expect(subject.as_json[:dast]).to include(:base_path)
end
it 'has vulnerability feedbacks path' do
expect(subject.as_json).to include(:vulnerability_feedback_path)
end
it 'has pipeline id' do
allow(merge_request).to receive(:head_pipeline).and_return(pipeline)
expect(subject.as_json).to include(:pipeline_id)
end
end
require 'spec_helper'
describe Issues::CreateFromVulnerabilityDataService, '#execute' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:user) { create(:user) }
before do
group.add_developer(user)
end
shared_examples 'a created issue' do
let(:result) { described_class.new(project, user, params).execute }
it 'creates the issue with the given params' do
expect(result[:status]).to eq(:success)
issue = result[:issue]
expect(issue).to be_persisted
expect(issue.project).to eq(project)
expect(issue.author).to eq(user)
expect(issue.title).to eq(expected_title)
expect(issue.description).to eq(expected_description)
end
end
context 'when params are valid' do
context 'when category is SAST' do
context 'when a description is present' do
let(:params) do
{
category: 'sast',
priority: 'Low', line: '41',
severity: 'Low', confidence: 'High',
solution: 'Please do something!',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
name: 'Predictable pseudorandom number generator',
description: 'Description of Predictable pseudorandom number generator',
tool: 'find_sec_bugs',
identifiers: [
{ name: 'CVE', value: 'CVE-2017-15650' },
{ name: 'CWE', value: '16' },
{ name: 'GAS_RULE_ID', value: 'G105' }
]
}
end
let(:expected_title) { 'Investigate vulnerability: Predictable pseudorandom number generator' }
let(:expected_description) do
<<~DESC.chomp
### Description:
Description of Predictable pseudorandom number generator
* Severity: Low
* Confidence: High
### Solution:
Please do something!
### Identifiers:
* [CVE-2017-15650](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15650)
* [CWE-16](https://cwe.mitre.org/data/definitions/16.html)
DESC
end
it_behaves_like 'a created issue'
end
context 'when a description is NOT present' do
let(:params) do
{
category: 'sast',
priority: 'Low', line: '41',
severity: 'Low', confidence: 'High',
solution: 'Please do something!',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
name: 'Predictable pseudorandom number generator',
tool: 'find_sec_bugs',
identifiers: [
{ name: 'CVE', value: 'CVE-2017-15650' }
]
}
end
let(:expected_title) { 'Investigate vulnerability: Predictable pseudorandom number generator' }
let(:expected_description) do
<<~DESC.chomp
### Description:
Predictable pseudorandom number generator
* Severity: Low
* Confidence: High
### Solution:
Please do something!
### Identifiers:
* [CVE-2017-15650](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15650)
DESC
end
it_behaves_like 'a created issue'
end
end
context 'when category is dependency scanning' do
context 'when a description is present' do
let(:params) do
{
category: 'dependency_scanning',
priority: 'Low', line: '41',
severity: 'Low', confidence: 'High',
solution: 'Please do something!',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
name: 'Predictable pseudorandom number generator',
description: 'Description of Predictable pseudorandom number generator',
tool: 'find_sec_bugs',
identifiers: [
{ name: 'CVE', value: 'CVE-2017-15650' },
{ name: 'CWE', value: '16' },
{ name: 'GAS_RULE_ID', value: 'G105' }
]
}
end
let(:expected_title) { 'Investigate vulnerability: Predictable pseudorandom number generator' }
let(:expected_description) do
<<~DESC.chomp
### Description:
Description of Predictable pseudorandom number generator
* Severity: Low
* Confidence: High
### Solution:
Please do something!
### Identifiers:
* [CVE-2017-15650](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15650)
* [CWE-16](https://cwe.mitre.org/data/definitions/16.html)
DESC
end
it_behaves_like 'a created issue'
end
context 'when a description is NOT present' do
let(:params) do
{
category: 'dependency_scanning',
priority: 'Low', line: '41',
severity: 'Low', confidence: 'High',
solution: 'Please do something!',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
name: 'Predictable pseudorandom number generator',
tool: 'find_sec_bugs',
identifiers: [
{ name: 'CVE', value: 'CVE-2017-15650' }
]
}
end
let(:expected_title) { 'Investigate vulnerability: Predictable pseudorandom number generator' }
let(:expected_description) do
<<~DESC.chomp
### Description:
Predictable pseudorandom number generator
* Severity: Low
* Confidence: High
### Solution:
Please do something!
### Identifiers:
* [CVE-2017-15650](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15650)
DESC
end
it_behaves_like 'a created issue'
end
end
context 'when category is container scanning' do
context 'when a description is present' do
let(:params) do
{
category: 'container_scanning',
priority: 'Low',
severity: 'Low',
namespace: 'alpine:v3.4',
featurename: 'musl',
featureversion: '1.1.14-r15',
fixedby: '1.1.14-r16',
name: 'CVE-2017-15650',
vulnerability: 'CVE-2017-15650',
description: 'This is a description for CVE-2017-15650.',
tool: 'find_sec_bugs'
}
end
let(:expected_title) { 'Investigate vulnerability: CVE-2017-15650 in alpine:v3.4' }
let(:expected_description) do
<<~DESC.chomp
### Description:
This is a description for CVE-2017-15650.
* Severity: Low
### Solution:
Upgrade **musl** from `1.1.14-r15` to `1.1.14-r16`
### Identifiers:
* [CVE-2017-15650](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15650)
DESC
end
it_behaves_like 'a created issue'
end
context 'when a description is NOT present' do
let(:params) do
{
category: 'container_scanning',
priority: 'Low',
severity: 'Low',
namespace: 'alpine:v3.4',
featurename: 'musl',
featureversion: '1.1.14-r15',
fixedby: '1.1.14-r16',
name: 'CVE-2017-15650',
vulnerability: 'CVE-2017-15650',
description: '',
tool: 'find_sec_bugs'
}
end
let(:expected_title) { 'Investigate vulnerability: CVE-2017-15650 in alpine:v3.4' }
let(:expected_description) do
<<~DESC.chomp
### Description:
**alpine:v3.4** is affected by CVE-2017-15650
* Severity: Low
### Solution:
Upgrade **musl** from `1.1.14-r15` to `1.1.14-r16`
### Identifiers:
* [CVE-2017-15650](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15650)
DESC
end
it_behaves_like 'a created issue'
end
end
context 'when category is DAST' do
let(:params) do
{
category: 'dast',
priority: 'Low',
severity: 'Low',
name: 'X-Content-Type-Options Header Missing',
desc: 'The Anti-MIME-Sniffing header X-Content-Type-Options was not set to nosniff.',
cweid: '123',
wascid: '456',
solution: 'Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to nosniff for all web pages.'
}
end
let(:expected_title) { 'Investigate vulnerability: X-Content-Type-Options Header Missing' }
let(:expected_description) do
<<~DESC.chomp
### Description:
The Anti-MIME-Sniffing header X-Content-Type-Options was not set to nosniff.
* Severity: Low
### Solution:
Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to nosniff for all web pages.
### Identifiers:
* [CWE-123](https://cwe.mitre.org/data/definitions/123.html)
* WASC-456
DESC
end
it_behaves_like 'a created issue'
end
end
end
require 'spec_helper'
describe VulnerabilityFeedbackModule::CreateService, '#execute' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:user) { create(:user) }
let(:pipeline) { create(:ci_pipeline, project: project) }
before do
group.add_developer(user)
end
context 'when params are valid' do
let(:feedback_params) do
{
feedback_type: 'dismissal', pipeline_id: pipeline.id, category: 'sast',
project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8',
vulnerability_data: {
category: 'sast',
priority: 'Low', line: '41',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
name: 'Predictable pseudorandom number generator',
description: 'Description of Predictable pseudorandom number generator',
tool: 'find_sec_bugs'
}
}
end
context 'when feedback_type is dismissal' do
let(:result) { described_class.new(project, user, feedback_params).execute }
it 'creates the feedback with the given params' do
expect(result[:status]).to eq(:success)
feedback = result[:vulnerability_feedback]
expect(feedback).to be_persisted
expect(feedback.project).to eq(project)
expect(feedback.author).to eq(user)
expect(feedback.feedback_type).to eq('dismissal')
expect(feedback.pipeline_id).to eq(pipeline.id)
expect(feedback.category).to eq('sast')
expect(feedback.project_fingerprint).to eq('418291a26024a1445b23fe64de9380cdcdfd1fa8')
expect(feedback.dismissal?).to eq(true)
expect(feedback.issue?).to eq(false)
expect(feedback.issue).to be_nil
end
end
context 'when feedback_type is issue' do
let(:result) do
described_class.new(
project,
user,
feedback_params.merge(feedback_type: 'issue')
).execute
end
it 'creates the feedback with the given params' do
expect(result[:status]).to eq(:success)
feedback = result[:vulnerability_feedback]
expect(feedback).to be_persisted
expect(feedback.project).to eq(project)
expect(feedback.author).to eq(user)
expect(feedback.feedback_type).to eq('issue')
expect(feedback.pipeline_id).to eq(pipeline.id)
expect(feedback.category).to eq('sast')
expect(feedback.project_fingerprint).to eq('418291a26024a1445b23fe64de9380cdcdfd1fa8')
expect(feedback.dismissal?).to eq(false)
expect(feedback.issue?).to eq(true)
expect(feedback.issue).to be_an(Issue)
end
it 'delegates the Issue creation to CreateFromVulnerabilityDataService' do
expect_any_instance_of(Issues::CreateFromVulnerabilityDataService)
.to receive(:execute).once.and_call_original
expect(result[:status]).to eq(:success)
end
end
end
context 'when params are invalid' do
context 'when vulnerability_data params is missing and feedback_type is issue' do
let(:feedback_params) do
{
feedback_type: 'issue', pipeline_id: pipeline.id, category: 'sast',
project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8'
}
end
let(:result) { described_class.new(project, user, feedback_params).execute }
it 'returns error with correct message' do
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('vulnerability_data is missing or empty')
end
end
context 'when feedback_type is invalid' do
let(:feedback_params) do
{
feedback_type: 'foo', pipeline_id: pipeline.id, category: 'sast',
project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8'
}
end
let(:result) { described_class.new(project, user, feedback_params).execute }
it 'returns error with correct message' do
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq("'foo' is not a valid feedback_type")
end
end
end
end
......@@ -19,4 +19,13 @@ describe Users::MigrateToGhostUserService do
end
end
end
context 'vulnerability_feedback' do
let!(:user) { create(:user) }
let(:service) { described_class.new(user) }
include_examples "migrating a deleted user's associated records to the ghost user", VulnerabilityFeedback, [:author] do
let(:created_record) { create(:vulnerability_feedback, author: user) }
end
end
end
......@@ -125,7 +125,8 @@
"blob_path": {
"head_path": { "type": "string" },
"base_path": { "type": "string" }
}
},
"vulnerability_feedback_path": { "type": "string" }
},
"additionalProperties": false
}
......@@ -57,6 +57,7 @@ describe('ee merge request widget options', () => {
base_path: 'path.json',
head_path: 'head_path.json',
},
vulnerability_feedback_path: 'vulnerability_feedback_path',
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
......@@ -67,6 +68,7 @@ describe('ee merge request widget options', () => {
it('should render loading indicator', () => {
mock.onGet('path.json').reply(200, sastBaseAllIssues);
mock.onGet('head_path.json').reply(200, sastHeadAllIssues);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
expect(vm.$el.querySelector('.js-sast-widget').textContent.trim()).toContain(
......@@ -79,6 +81,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, sastIssuesBase);
mock.onGet('head_path.json').reply(200, sastIssues);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -98,7 +101,8 @@ describe('ee merge request widget options', () => {
describe('with full report and no added or fixed issues', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, sastBaseAllIssues);
mock.onGet('head_path.json').reply(200, sastHeadAllIssues);
mock.onGet('head_path.json').reply(200, sastBaseAllIssues);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -120,6 +124,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, []);
mock.onGet('head_path.json').reply(200, []);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -141,6 +146,8 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('path.json').reply(500, []);
mock.onGet('head_path.json').reply(500, []);
mock.onGet('vulnerability_feedback_path').reply(500, []);
vm = mountComponent(Component);
});
......@@ -163,6 +170,7 @@ describe('ee merge request widget options', () => {
base_path: 'path.json',
head_path: 'head_path.json',
},
vulnerability_feedback_path: 'vulnerability_feedback_path',
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
......@@ -173,6 +181,8 @@ describe('ee merge request widget options', () => {
it('should render loading indicator', () => {
mock.onGet('path.json').reply(200, sastIssuesBase);
mock.onGet('head_path.json').reply(200, sastIssues);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
expect(
......@@ -185,6 +195,8 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, sastIssuesBase);
mock.onGet('head_path.json').reply(200, sastIssues);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -196,9 +208,7 @@ describe('ee merge request widget options', () => {
'.js-dependency-scanning-widget .report-block-list-issue-description',
).textContent,
),
).toEqual(
'Dependency scanning detected 2 new vulnerabilities and 1 fixed vulnerability',
);
).toEqual('Dependency scanning detected 2 new vulnerabilities and 1 fixed vulnerability');
done();
}, 0);
});
......@@ -207,7 +217,8 @@ describe('ee merge request widget options', () => {
describe('with full report and no added or fixed issues', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, sastBaseAllIssues);
mock.onGet('head_path.json').reply(200, sastHeadAllIssues);
mock.onGet('head_path.json').reply(200, sastBaseAllIssues);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -230,6 +241,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, []);
mock.onGet('head_path.json').reply(200, []);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -252,6 +264,8 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('path.json').reply(500, []);
mock.onGet('head_path.json').reply(500, []);
mock.onGet('vulnerability_feedback_path').reply(500, []);
vm = mountComponent(Component);
});
......@@ -420,8 +434,7 @@ describe('ee merge request widget options', () => {
setTimeout(() => {
expect(
removeBreakLine(
vm.$el.querySelector('.js-performance-widget .js-code-text')
.textContent,
vm.$el.querySelector('.js-performance-widget .js-code-text').textContent,
),
).toEqual('Performance metrics improved on 2 points and degraded on 1 point');
done();
......@@ -436,9 +449,7 @@ describe('ee merge request widget options', () => {
Vue.nextTick(() => {
expect(
removeBreakLine(
vm.$el.querySelector(
'.js-performance-widget .js-code-text',
).textContent,
vm.$el.querySelector('.js-performance-widget .js-code-text').textContent,
),
).toEqual('Performance metrics improved on 2 points');
done();
......@@ -453,9 +464,7 @@ describe('ee merge request widget options', () => {
Vue.nextTick(() => {
expect(
removeBreakLine(
vm.$el.querySelector(
'.js-performance-widget .js-code-text',
).textContent,
vm.$el.querySelector('.js-performance-widget .js-code-text').textContent,
),
).toEqual('Performance metrics degraded on 1 point');
done();
......@@ -476,8 +485,7 @@ describe('ee merge request widget options', () => {
setTimeout(() => {
expect(
removeBreakLine(
vm.$el.querySelector('.js-performance-widget .js-code-text')
.textContent,
vm.$el.querySelector('.js-performance-widget .js-code-text').textContent,
),
).toEqual('No changes to performance metrics');
done();
......@@ -495,7 +503,9 @@ describe('ee merge request widget options', () => {
it('should render error indicator', done => {
setTimeout(() => {
expect(
removeBreakLine(vm.$el.querySelector('.js-performance-widget .js-code-text').textContent),
removeBreakLine(
vm.$el.querySelector('.js-performance-widget .js-code-text').textContent,
),
).toContain('Failed to load performance report');
done();
}, 0);
......@@ -511,6 +521,7 @@ describe('ee merge request widget options', () => {
head_path: 'gl-sast-container.json',
base_path: 'sast-container-base.json',
},
vulnerability_feedback_path: 'vulnerability_feedback_path',
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
......@@ -521,6 +532,8 @@ describe('ee merge request widget options', () => {
it('should render loading indicator', () => {
mock.onGet('gl-sast-container.json').reply(200, dockerReport);
mock.onGet('sast-container-base.json').reply(200, dockerBaseReport);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
expect(removeBreakLine(vm.$el.querySelector('.js-sast-container').textContent)).toContain(
......@@ -533,6 +546,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('gl-sast-container.json').reply(200, dockerReport);
mock.onGet('sast-container-base.json').reply(200, dockerBaseReport);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -554,6 +568,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('gl-sast-container.json').reply(500, {});
mock.onGet('sast-container-base.json').reply(500, {});
mock.onGet('vulnerability_feedback_path').reply(500, []);
vm = mountComponent(Component);
});
......@@ -577,6 +592,7 @@ describe('ee merge request widget options', () => {
head_path: 'dast.json',
base_path: 'dast_base.json',
},
vulnerability_feedback_path: 'vulnerability_feedback_path',
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
......@@ -587,6 +603,8 @@ describe('ee merge request widget options', () => {
it('should render loading indicator', () => {
mock.onGet('dast.json').reply(200, dast);
mock.onGet('dast_base.json').reply(200, dastBase);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
expect(vm.$el.querySelector('.js-dast-widget').textContent.trim()).toContain(
......@@ -599,6 +617,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('dast.json').reply(200, dast);
mock.onGet('dast_base.json').reply(200, dastBase);
mock.onGet('vulnerability_feedback_path').reply(200, []);
vm = mountComponent(Component);
});
......@@ -619,6 +638,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => {
mock.onGet('dast.json').reply(500, {});
mock.onGet('dast_base.json').reply(500, {});
mock.onGet('vulnerability_feedback_path').reply(500, []);
vm = mountComponent(Component);
});
......
......@@ -224,6 +224,7 @@ export default {
base_path: 'blob_path',
head_path: 'blob_path',
},
vulnerability_feedback_help_path: '/help/user/project/merge_requests/index#interacting-with-security-reports',
merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775',
};
// Codeclimate
......
......@@ -63,22 +63,8 @@ describe('dast issue body', () => {
});
});
it('renders issue name', () => {
it('renders button with issue name', () => {
expect(vm.$el.textContent.trim()).toContain(dastIssue.name);
});
it('renders button to open modal box', () => {
const button = vm.$el.querySelector('.js-modal-dast');
expect(button.getAttribute('data-toggle')).toEqual('modal');
expect(button.getAttribute('data-target')).toEqual('#modal-mrwidget-issue');
});
it('emits event when button is clicked', () => {
spyOn(vm, '$emit');
vm.$el.querySelector('.js-modal-dast').click();
expect(vm.$emit).toHaveBeenCalledWith('openDastModal', dastIssue, 1);
});
});
});
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/modal_open_name.vue';
import store from 'ee/vue_shared/security_reports/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { parsedDast } from '../mock_data';
describe('Modal open name', () => {
const Component = Vue.extend(component);
let vm;
beforeEach(() => {
vm = mountComponentWithStore(Component, {
store,
props: {
issue: parsedDast[0],
},
});
});
afterEach(() => {
vm.$destroy();
});
it('renders the issue name', () => {
expect(vm.$el.textContent.trim()).toEqual(parsedDast[0].name);
});
it('calls openModal actions when button is clicked', () => {
spyOn(vm, 'openModal');
vm.$el.click();
expect(vm.openModal).toHaveBeenCalled();
});
});
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/modal.vue';
import store from 'ee/vue_shared/security_reports/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Security Reports modal', () => {
const Component = Vue.extend(component);
let vm;
beforeEach(() => {
store.dispatch('setVulnerabilityFeedbackPath', 'path');
store.dispatch('setVulnerabilityFeedbackHelpPath', 'feedbacksHelpPath');
store.dispatch('setPipelineId', 123);
});
afterEach(() => {
vm.$destroy();
});
describe('with dismissed issue', () => {
beforeEach(() => {
store.dispatch('setModalData', {
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2014-9999',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
name: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
urlPath: 'path/Gemfile.lock',
isDismissed: true,
vulnerability_feedback: {
vulnerability_data: {
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2016-9999',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
},
},
});
vm = mountComponentWithStore(Component, {
store,
});
});
it('renders button to revert dismissal', () => {
expect(vm.$el.querySelector('.js-dismiss-btn').textContent.trim()).toEqual(
'Revert dismissal',
);
});
it('calls revertDismissed when revert dismissal button is clicked', () => {
spyOn(vm, 'revertDismissIssue');
const button = vm.$el.querySelector('.js-dismiss-btn');
button.click();
expect(vm.revertDismissIssue).toHaveBeenCalled();
});
});
describe('with not dismissed isssue', () => {
beforeEach(() => {
store.dispatch('setModalData', {
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2014-9999',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
name: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
urlPath: 'path/Gemfile.lock',
});
vm = mountComponentWithStore(Component, {
store,
});
});
it('renders button to dismiss issue', () => {
expect(vm.$el.querySelector('.js-dismiss-btn').textContent.trim()).toEqual(
'Dismiss vulnerability',
);
});
it('calls dismissIssue when dismiss issue button is clicked', () => {
spyOn(vm, 'dismissIssue');
const button = vm.$el.querySelector('.js-dismiss-btn');
button.click();
expect(vm.dismissIssue).toHaveBeenCalled();
});
});
describe('with instances', () => {
beforeEach(() => {
store.dispatch('setModalData', {
name: 'Absence of Anti-CSRF Tokens',
riskcode: '1',
riskdesc: 'Low (Medium)',
priority: 'Low (Medium)',
desc: '<p>No Anti-CSRF tokens were found in a HTML submission form.</p>',
parsedDescription: ' No Anti-CSRF tokens were found in a HTML submission form. ',
pluginid: '123',
instances: [
{
uri: 'http://192.168.32.236:3001/explore?sort=latest_activity_desc',
method: 'GET',
evidence:
"<form class='navbar-form' action='/search' accept-charset='UTF-8' method='get'>",
},
{
uri: 'http://192.168.32.236:3001/help/user/group/subgroups/index.md',
method: 'GET',
evidence:
"<form class='navbar-form' action='/search' accept-charset='UTF-8' method='get'>",
},
],
description: ' No Anti-CSRF tokens were found in a HTML submission form. ',
solution: '',
});
vm = mountComponentWithStore(Component, {
store,
});
});
it('renders instances list', () => {
const instances = vm.$el.querySelectorAll('.report-block-list li');
expect(instances[0].textContent).toContain(
'http://192.168.32.236:3001/explore?sort=latest_activity_desc',
);
expect(instances[1].textContent).toContain(
'http://192.168.32.236:3001/help/user/group/subgroups/index.md',
);
});
});
describe('data & create issue button', () => {
beforeEach(() => {
store.dispatch('setModalData', {
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2014-9999',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
name: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
urlPath: 'path/Gemfile.lock',
});
vm = mountComponentWithStore(Component, {
store,
});
});
it('renders keys in `data`', () => {
expect(vm.$el.textContent).toContain('Arbitrary file existence disclosure in Action Pack');
expect(vm.$el.textContent).toContain(
'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
);
});
it('renders link fields with link', () => {
expect(vm.$el.querySelector('.js-link-file').getAttribute('href')).toEqual('path/Gemfile.lock');
});
it('renders help link', () => {
expect(vm.$el.querySelector('.js-link-vulnerabilityFeedbackHelpPath').getAttribute('href')).toEqual('feedbacksHelpPath');
});
});
});
import Vue from 'vue';
import reportIssues from 'ee/vue_shared/security_reports/components/report_issues.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import store from 'ee/vue_shared/security_reports/store';
import mountComponent, { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import {
codequalityParsedIssues,
} from 'spec/vue_mr_widget/mock_data';
......@@ -120,12 +121,9 @@ describe('Report issues', () => {
).toContain(dockerReportParsed.unapproved[0].priority);
});
it('renders CVE link', () => {
it('renders CVE name', () => {
expect(
vm.$el.querySelector('.report-block-list a').getAttribute('href'),
).toEqual(dockerReportParsed.unapproved[0].nameLink);
expect(
vm.$el.querySelector('.report-block-list a').textContent.trim(),
vm.$el.querySelector('.report-block-list button').textContent.trim(),
).toEqual(dockerReportParsed.unapproved[0].name);
});
......@@ -141,10 +139,12 @@ describe('Report issues', () => {
describe('for dast issues', () => {
beforeEach(() => {
vm = mountComponent(ReportIssues, {
vm = mountComponentWithStore(ReportIssues, { store,
props: {
issues: parsedDast,
type: 'DAST',
status: 'failed',
},
});
});
......@@ -152,20 +152,5 @@ describe('Report issues', () => {
expect(vm.$el.textContent).toContain(parsedDast[0].name);
expect(vm.$el.textContent).toContain(parsedDast[0].priority);
});
it('opens modal with more information and list of instances', (done) => {
vm.$el.querySelector('.js-modal-dast').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.modal-title').textContent.trim()).toEqual('Low (Medium): Absence of Anti-CSRF Tokens');
expect(vm.$el.querySelector('.modal-body').textContent).toContain('No Anti-CSRF tokens were found in a HTML submission form.');
const instance = vm.$el.querySelector('.modal-body li').textContent;
expect(instance).toContain('http://192.168.32.236:3001/explore?sort=latest_activity_desc');
expect(instance).toContain('GET');
done();
});
});
});
});
......@@ -44,29 +44,12 @@ describe('sast container issue body', () => {
});
});
describe('with name link', () => {
it('renders name link', () => {
it('renders name', () => {
vm = mountComponent(Component, {
issue: sastContainerIssue,
});
expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(sastContainerIssue.nameLink);
expect(vm.$el.querySelector('a').textContent.trim()).toEqual(sastContainerIssue.name);
});
});
describe('without name link', () => {
it('does not render name link', () => {
const issueCopy = Object.assign({}, sastContainerIssue);
delete issueCopy.nameLink;
vm = mountComponent(Component, {
issue: issueCopy,
});
expect(vm.$el.querySelector('a')).toBeNull();
expect(vm.$el.textContent.trim()).toContain(sastContainerIssue.name);
});
expect(vm.$el.querySelector('button').textContent.trim()).toEqual(sastContainerIssue.name);
});
describe('path', () => {
......
......@@ -47,6 +47,7 @@ describe('Grouped security reports app', () => {
mock.onGet('sast_container_base.json').reply(500);
mock.onGet('dss_head.json').reply(500);
mock.onGet('dss_base.json').reply(500);
mock.onGet('vulnerability_feedback_path.json').reply(500, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -63,6 +64,9 @@ describe('Grouped security reports app', () => {
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......@@ -99,6 +103,7 @@ describe('Grouped security reports app', () => {
mock.onGet('sast_container_base.json').reply(200, dockerBaseReport);
mock.onGet('dss_head.json').reply(200, sastIssues);
mock.onGet('dss_base.json').reply(200, sastIssuesBase);
mock.onGet('vulnerability_feedback_path.json').reply(200, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -115,6 +120,9 @@ describe('Grouped security reports app', () => {
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......@@ -142,6 +150,7 @@ describe('Grouped security reports app', () => {
mock.onGet('sast_container_base.json').reply(200, dockerBaseReport);
mock.onGet('dss_head.json').reply(200, sastIssues);
mock.onGet('dss_base.json').reply(200, sastIssuesBase);
mock.onGet('vulnerability_feedback_path.json').reply(200, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -158,6 +167,9 @@ describe('Grouped security reports app', () => {
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......@@ -180,6 +192,19 @@ describe('Grouped security reports app', () => {
done();
}, 0);
});
it('opens modal with more information', (done) => {
setTimeout(() => {
vm.$el.querySelector('.break-link').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.modal-title').textContent.trim()).toEqual(sastIssues[0].message);
expect(vm.$el.querySelector('.modal-body').textContent).toContain(sastIssues[0].solution);
done();
});
}, 0);
});
});
describe('with all issues for sast and dependency scanning', () => {
......@@ -192,6 +217,7 @@ describe('Grouped security reports app', () => {
mock.onGet('sast_container_base.json').reply(200, dockerBaseReport);
mock.onGet('dss_head.json').reply(200, sastHeadAllIssues);
mock.onGet('dss_base.json').reply(200, sastBaseAllIssues);
mock.onGet('vulnerability_feedback_path.json').reply(200, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -208,6 +234,9 @@ describe('Grouped security reports app', () => {
sastContainerHelpPath: 'path',
dastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......
......@@ -34,6 +34,7 @@ describe('Slipt security reports app', () => {
beforeEach(() => {
mock.onGet('sast_head.json').reply(200, sastIssues);
mock.onGet('dss_head.json').reply(200, sastIssues);
mock.onGet('vulnerability_feedback_path.json').reply(200, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -42,6 +43,9 @@ describe('Slipt security reports app', () => {
dependencyScanningHeadPath: 'dss_head.json',
sastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......@@ -57,6 +61,7 @@ describe('Slipt security reports app', () => {
beforeEach(() => {
mock.onGet('sast_head.json').reply(200, sastIssues);
mock.onGet('dss_head.json').reply(200, sastIssues);
mock.onGet('vulnerability_feedback_path.json').reply(200, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -65,6 +70,9 @@ describe('Slipt security reports app', () => {
dependencyScanningHeadPath: 'dss_head.json',
sastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......@@ -86,6 +94,7 @@ describe('Slipt security reports app', () => {
beforeEach(() => {
mock.onGet('sast_head.json').reply(500);
mock.onGet('dss_head.json').reply(500);
mock.onGet('vulnerability_feedback_path.json').reply(500, []);
vm = mountComponent(Component, {
headBlobPath: 'path',
......@@ -94,6 +103,9 @@ describe('Slipt security reports app', () => {
dependencyScanningHeadPath: 'dss_head.json',
sastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
});
});
......
import sha1 from 'sha1';
import {
findIssueIndex,
parseSastIssues,
parseDependencyScanningIssues,
parseSastContainer,
parseDastIssues,
filterByKey,
......@@ -7,20 +10,93 @@ import {
textBuilder,
statusIcon,
} from 'ee/vue_shared/security_reports/store/utils';
import { sastIssues, dockerReport, dast, parsedDast } from '../mock_data';
import {
sastIssues,
sastFeedbacks,
dependencyScanningIssues,
dependencyScanningFeedbacks,
dockerReport,
containerScanningFeedbacks,
dast,
dastFeedbacks,
parsedDast,
} from '../mock_data';
describe('security reports utils', () => {
describe('findIssueIndex', () => {
let issuesList;
beforeEach(() => {
issuesList = [
{ project_fingerprint: 'abc123' },
{ project_fingerprint: 'abc456' },
{ project_fingerprint: 'abc789' },
];
});
it('returns index of found issue', () => {
const issue = {
project_fingerprint: 'abc456',
};
expect(findIssueIndex(issuesList, issue)).toEqual(1);
});
it('returns -1 when issue is not found', () => {
const issue = {
project_fingerprint: 'foo',
};
expect(findIssueIndex(issuesList, issue)).toEqual(-1);
});
});
describe('parseSastIssues', () => {
it('should parse the received issues', () => {
const security = parseSastIssues(sastIssues, 'path')[0];
expect(security.name).toEqual(sastIssues[0].message);
expect(security.path).toEqual(sastIssues[0].file);
const parsed = parseSastIssues(sastIssues, [], 'path')[0];
expect(parsed.name).toEqual(sastIssues[0].message);
expect(parsed.path).toEqual(sastIssues[0].file);
expect(parsed.project_fingerprint).toEqual(sha1(sastIssues[0].cve));
});
it('includes vulnerability feedbacks', () => {
const parsed = parseSastIssues(
sastIssues,
sastFeedbacks,
'path',
)[0];
expect(parsed.hasIssue).toEqual(true);
expect(parsed.isDismissed).toEqual(true);
expect(parsed.dismissalFeedback).toEqual(sastFeedbacks[0]);
expect(parsed.issueFeedback).toEqual(sastFeedbacks[1]);
});
});
describe('parseDependencyScanningIssues', () => {
it('should parse the received issues', () => {
const parsed = parseDependencyScanningIssues(dependencyScanningIssues, [], 'path')[0];
expect(parsed.name).toEqual(dependencyScanningIssues[0].message);
expect(parsed.path).toEqual(dependencyScanningIssues[0].file);
expect(parsed.project_fingerprint).toEqual(sha1(dependencyScanningIssues[0].cve));
});
it('includes vulnerability feedbacks', () => {
const parsed = parseDependencyScanningIssues(
dependencyScanningIssues,
dependencyScanningFeedbacks,
'path',
)[0];
expect(parsed.hasIssue).toEqual(true);
expect(parsed.isDismissed).toEqual(true);
expect(parsed.dismissalFeedback).toEqual(dependencyScanningFeedbacks[0]);
expect(parsed.issueFeedback).toEqual(dependencyScanningFeedbacks[1]);
});
});
describe('parseSastContainer', () => {
it('parses sast container issues', () => {
const parsed = parseSastContainer(dockerReport.vulnerabilities)[0];
const issue = dockerReport.vulnerabilities[0];
expect(parsed.name).toEqual(dockerReport.vulnerabilities[0].vulnerability);
expect(parsed.priority).toEqual(dockerReport.vulnerabilities[0].severity);
......@@ -30,13 +106,37 @@ describe('security reports utils', () => {
dockerReport.vulnerabilities[0].vulnerability
}`,
);
expect(parsed.project_fingerprint).toEqual(
sha1(`${issue.namespace}:${issue.vulnerability}:${issue.featurename}:${issue.featureversion}`));
});
it('includes vulnerability feedbacks', () => {
const parsed = parseSastContainer(
dockerReport.vulnerabilities,
containerScanningFeedbacks,
)[0];
expect(parsed.hasIssue).toEqual(true);
expect(parsed.isDismissed).toEqual(true);
expect(parsed.dismissalFeedback).toEqual(containerScanningFeedbacks[0]);
expect(parsed.issueFeedback).toEqual(containerScanningFeedbacks[1]);
});
});
describe('parseDastIssues', () => {
it('parsed dast report', () => {
it('parses dast report', () => {
expect(parseDastIssues(dast.site.alerts)).toEqual(parsedDast);
});
it('includes vulnerability feedbacks', () => {
const parsed = parseDastIssues(
dast.site.alerts,
dastFeedbacks,
)[0];
expect(parsed.hasIssue).toEqual(true);
expect(parsed.isDismissed).toEqual(true);
expect(parsed.dismissalFeedback).toEqual(dastFeedbacks[0]);
expect(parsed.issueFeedback).toEqual(dastFeedbacks[1]);
});
});
describe('filterByKey', () => {
......@@ -64,7 +164,9 @@ describe('security reports utils', () => {
describe('textBuilder', () => {
describe('with no issues', () => {
it('should return no vulnerabiltities text', () => {
expect(textBuilder('', { head: 'foo', base: 'bar' }, 0, 0, 0)).toEqual(' detected no security vulnerabilities');
expect(textBuilder('', { head: 'foo', base: 'bar' }, 0, 0, 0)).toEqual(
' detected no security vulnerabilities',
);
});
});
......
......@@ -327,6 +327,7 @@ project:
- deploy_tokens
- settings
- ci_cd_settings
- vulnerability_feedback
award_emoji:
- awardable
- user
......
......@@ -86,7 +86,7 @@ shared_examples "migrating a deleted user's associated records to the ghost user
end
it "blocks the user before #{record_class_name} migration begins" do
expect(service).to receive("migrate_#{record_class_name.parameterize('_')}s".to_sym) do
expect(service).to receive("migrate_#{record_class_name.parameterize('_').pluralize}".to_sym) do
expect(user.reload).to be_blocked
end
......
......@@ -1586,6 +1586,10 @@ chardet@^0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
"charenc@>= 0.0.1":
version "0.0.2"
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
chart.js@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-1.0.2.tgz#ad57d2229cfd8ccf5955147e8121b4911e69dfe7"
......@@ -2086,6 +2090,10 @@ cross-spawn@^6.0.5:
shebang-command "^1.2.0"
which "^1.2.9"
"crypt@>= 0.0.1":
version "0.0.2"
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
cryptiles@2.x.x:
version "2.0.5"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8"
......@@ -7859,6 +7867,13 @@ sha.js@^2.4.0, sha.js@^2.4.8:
inherits "^2.0.1"
safe-buffer "^5.0.1"
sha1@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/sha1/-/sha1-1.1.1.tgz#addaa7a93168f393f19eb2b15091618e2700f848"
dependencies:
charenc ">= 0.0.1"
crypt ">= 0.0.1"
shebang-command@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
......
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