Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/security_reports')
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue59
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue150
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/getters.js66
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/index.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/messages.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/state.js5
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js78
10 files changed, 381 insertions, 18 deletions
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
new file mode 100644
index 00000000000..9b1cbfe218b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
@@ -0,0 +1,8 @@
+export const SEVERITY_CLASS_NAME_MAP = {
+ critical: 'text-danger-800',
+ high: 'text-danger-600',
+ medium: 'text-warning-400',
+ low: 'text-warning-200',
+ info: 'text-primary-400',
+ unknown: 'text-secondary-400',
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue
new file mode 100644
index 00000000000..babb9fddcf6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { SEVERITY_CLASS_NAME_MAP } from './constants';
+
+export default {
+ components: {
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ shouldShowCountMessage() {
+ return !this.message.status && Boolean(this.message.countMessage);
+ },
+ },
+ methods: {
+ getSeverityClass(severity) {
+ return SEVERITY_CLASS_NAME_MAP[severity];
+ },
+ },
+ slotNames: ['critical', 'high', 'other'],
+ spacingClasses: {
+ critical: 'gl-pl-4',
+ high: 'gl-px-2',
+ other: 'gl-px-2',
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message.message">
+ <template #total="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <span v-if="shouldShowCountMessage" class="gl-font-sm">
+ <gl-sprintf :message="message.countMessage">
+ <template v-for="slotName in $options.slotNames" #[slotName]="{content}">
+ <span :key="slotName">
+ <strong
+ v-if="message[slotName] > 0"
+ :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]"
+ >
+ {{ content }}
+ </strong>
+ <span v-else :class="$options.spacingClasses[slotName]">
+ {{ content }}
+ </span>
+ </span>
+ </template>
+ </gl-sprintf>
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index 2f87c4e7878..413b4a70b40 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -1,3 +1,9 @@
export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
export const FEEDBACK_TYPE_ISSUE = 'issue';
export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
+
+/**
+ * Security scan report types, as provided by the backend.
+ */
+export const REPORT_TYPE_SAST = 'sast';
+export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index 89253cc7116..b61783ed7b0 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -1,19 +1,28 @@
<script>
+import { mapActions, mapGetters } from 'vuex';
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ReportSection from '~/reports/components/report_section.vue';
-import { status } from '~/reports/constants';
+import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants';
import { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
-import Flash from '~/flash';
+import createFlash from '~/flash';
import Api from '~/api';
+import SecuritySummary from './components/security_summary.vue';
+import store from './store';
+import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
+import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION } from './constants';
export default {
+ store,
components: {
GlIcon,
GlLink,
GlSprintf,
ReportSection,
+ SecuritySummary,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
pipelineId: {
type: Number,
@@ -27,25 +36,53 @@ export default {
type: String,
required: true,
},
+ sastComparisonPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ secretScanningComparisonPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- hasSecurityReports: false,
+ availableSecurityReports: [],
+ canShowCounts: false,
- // Error state is shown even when successfully loaded, since success
+ // When core_security_mr_widget_counts is not enabled, the
+ // error state is shown even when successfully loaded, since success
// state suggests that the security scans detected no security problems,
// which is not necessarily the case. A future iteration will actually
// check whether problems were found and display the appropriate status.
- status: status.ERROR,
+ status: ERROR,
};
},
+ computed: {
+ ...mapGetters(['groupedSummaryText', 'summaryStatus']),
+ hasSecurityReports() {
+ return this.availableSecurityReports.length > 0;
+ },
+ hasSastReports() {
+ return this.availableSecurityReports.includes(REPORT_TYPE_SAST);
+ },
+ hasSecretDetectionReports() {
+ return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION);
+ },
+ isLoaded() {
+ return this.summaryStatus !== LOADING;
+ },
+ },
created() {
- this.checkHasSecurityReports(this.$options.reportTypes)
- .then(hasSecurityReports => {
- this.hasSecurityReports = hasSecurityReports;
+ this.checkAvailableSecurityReports(this.$options.reportTypes)
+ .then(availableSecurityReports => {
+ this.availableSecurityReports = Array.from(availableSecurityReports);
+ this.fetchCounts();
})
.catch(error => {
- Flash({
+ createFlash({
message: this.$options.i18n.apiError,
captureError: true,
error,
@@ -53,7 +90,18 @@ export default {
});
},
methods: {
- async checkHasSecurityReports(reportTypes) {
+ ...mapActions(MODULE_SAST, {
+ setSastDiffEndpoint: 'setDiffEndpoint',
+ fetchSastDiff: 'fetchDiff',
+ }),
+ ...mapActions(MODULE_SECRET_DETECTION, {
+ setSecretDetectionDiffEndpoint: 'setDiffEndpoint',
+ fetchSecretDetectionDiff: 'fetchDiff',
+ }),
+ async checkAvailableSecurityReports(reportTypes) {
+ const reportTypesSet = new Set(reportTypes);
+ const availableReportTypes = new Set();
+
let page = 1;
while (page) {
// eslint-disable-next-line no-await-in-loop
@@ -62,18 +110,40 @@ export default {
page,
});
- const hasSecurityReports = jobs.some(({ artifacts = [] }) =>
- artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
- );
+ jobs.forEach(({ artifacts = [] }) => {
+ artifacts.forEach(({ file_type }) => {
+ if (reportTypesSet.has(file_type)) {
+ availableReportTypes.add(file_type);
+ }
+ });
+ });
- if (hasSecurityReports) {
- return true;
+ // If we've found artifacts for all the report types, stop looking!
+ if (availableReportTypes.size === reportTypesSet.size) {
+ return availableReportTypes;
}
page = parseIntPagination(normalizeHeaders(headers)).nextPage;
}
- return false;
+ return availableReportTypes;
+ },
+ fetchCounts() {
+ if (!this.glFeatures.coreSecurityMrWidgetCounts) {
+ return;
+ }
+
+ if (this.sastComparisonPath && this.hasSastReports) {
+ this.setSastDiffEndpoint(this.sastComparisonPath);
+ this.fetchSastDiff();
+ this.canShowCounts = true;
+ }
+
+ if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) {
+ this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath);
+ this.fetchSecretDetectionDiff();
+ this.canShowCounts = true;
+ }
},
activatePipelinesTab() {
if (window.mrTabs) {
@@ -81,7 +151,7 @@ export default {
}
},
},
- reportTypes: ['sast', 'secret_detection'],
+ reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
@@ -89,13 +159,57 @@ export default {
scansHaveRun: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
),
+ downloadFromPipelineTab: s__(
+ 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
+ ),
securityReportsHelp: s__('SecurityReports|Security reports help page link'),
},
+ summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR],
};
</script>
<template>
<report-section
- v-if="hasSecurityReports"
+ v-if="canShowCounts"
+ :status="summaryStatus"
+ :has-issues="false"
+ class="mr-widget-border-top mr-report"
+ data-testid="security-mr-widget"
+ >
+ <template v-for="slot in $options.summarySlots" #[slot]>
+ <span :key="slot">
+ <security-summary :message="groupedSummaryText" />
+
+ <gl-link
+ target="_blank"
+ data-testid="help"
+ :href="securityReportsDocsPath"
+ :aria-label="$options.i18n.securityReportsHelp"
+ >
+ <gl-icon name="question" />
+ </gl-link>
+ </span>
+ </template>
+
+ <template v-if="isLoaded" #sub-heading>
+ <span class="gl-font-sm">
+ <gl-sprintf :message="$options.i18n.downloadFromPipelineTab">
+ <template #link="{ content }">
+ <gl-link
+ class="gl-font-sm"
+ data-testid="show-pipelines"
+ @click="activatePipelinesTab"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </report-section>
+
+ <!-- TODO: Remove this section when removing core_security_mr_widget_counts
+ feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 -->
+ <report-section
+ v-else-if="hasSecurityReports"
:status="status"
:has-issues="false"
class="mr-widget-border-top mr-report"
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js
new file mode 100644
index 00000000000..6aeab56eea2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/constants.js
@@ -0,0 +1,7 @@
+/**
+ * Vuex module names corresponding to security scan types. These are similar to
+ * the snake_case report types from the backend, but should not be considered
+ * to be equivalent.
+ */
+export const MODULE_SAST = 'sast';
+export const MODULE_SECRET_DETECTION = 'secretDetection';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js
new file mode 100644
index 00000000000..1e5a60c32fd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js
@@ -0,0 +1,66 @@
+import { s__, sprintf } from '~/locale';
+import { countVulnerabilities, groupedTextBuilder } from './utils';
+import { LOADING, ERROR, SUCCESS } from '~/reports/constants';
+import { TRANSLATION_IS_LOADING } from './messages';
+
+export const summaryCounts = state =>
+ countVulnerabilities(
+ state.reportTypes.reduce((acc, reportType) => {
+ acc.push(...state[reportType].newIssues);
+ return acc;
+ }, []),
+ );
+
+export const groupedSummaryText = (state, getters) => {
+ const reportType = s__('ciReport|Security scanning');
+ let status = '';
+
+ // All reports are loading
+ if (getters.areAllReportsLoading) {
+ return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) };
+ }
+
+ // All reports returned error
+ if (getters.allReportsHaveError) {
+ return { message: s__('ciReport|Security scanning failed loading any results') };
+ }
+
+ if (getters.areReportsLoading && getters.anyReportHasError) {
+ status = s__('ciReport|is loading, errors when loading results');
+ } else if (getters.areReportsLoading && !getters.anyReportHasError) {
+ status = s__('ciReport|is loading');
+ } else if (!getters.areReportsLoading && getters.anyReportHasError) {
+ status = s__('ciReport|: Loading resulted in an error');
+ }
+
+ const { critical, high, other } = getters.summaryCounts;
+
+ return groupedTextBuilder({ reportType, status, critical, high, other });
+};
+
+export const summaryStatus = (state, getters) => {
+ if (getters.areReportsLoading) {
+ return LOADING;
+ }
+
+ if (getters.anyReportHasError || getters.anyReportHasIssues) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const areReportsLoading = state =>
+ state.reportTypes.some(reportType => state[reportType].isLoading);
+
+export const areAllReportsLoading = state =>
+ state.reportTypes.every(reportType => state[reportType].isLoading);
+
+export const allReportsHaveError = state =>
+ state.reportTypes.every(reportType => state[reportType].hasError);
+
+export const anyReportHasError = state =>
+ state.reportTypes.some(reportType => state[reportType].hasError);
+
+export const anyReportHasIssues = state =>
+ state.reportTypes.some(reportType => state[reportType].newIssues.length > 0);
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js
new file mode 100644
index 00000000000..10705e04a21
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/index.js
@@ -0,0 +1,16 @@
+import Vuex from 'vuex';
+import * as getters from './getters';
+import state from './state';
+import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants';
+import sast from './modules/sast';
+import secretDetection from './modules/secret_detection';
+
+export default () =>
+ new Vuex.Store({
+ modules: {
+ [MODULE_SAST]: sast,
+ [MODULE_SECRET_DETECTION]: secretDetection,
+ },
+ getters,
+ state,
+ });
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js
new file mode 100644
index 00000000000..c25e252a768
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/messages.js
@@ -0,0 +1,4 @@
+import { s__ } from '~/locale';
+
+export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading');
+export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error');
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js
new file mode 100644
index 00000000000..5dc4d1ad2fb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/state.js
@@ -0,0 +1,5 @@
+import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants';
+
+export default () => ({
+ reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION],
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
index 6e50efae741..c5e786c92b1 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
@@ -1,5 +1,7 @@
import pollUntilComplete from '~/lib/utils/poll_until_complete';
import axios from '~/lib/utils/axios_utils';
+import { __, n__, sprintf } from '~/locale';
+import { CRITICAL, HIGH } from '~/vulnerabilities/constants';
import {
FEEDBACK_TYPE_DISMISSAL,
FEEDBACK_TYPE_ISSUE,
@@ -73,3 +75,79 @@ export const parseDiff = (diff, enrichData) => {
existing: diff.existing ? diff.existing.map(enrichVulnerability) : [],
};
};
+
+const createCountMessage = ({ critical, high, other, total }) => {
+ const otherMessage = n__('%d Other', '%d Others', other);
+ const countMessage = __(
+ '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}',
+ );
+ return total ? sprintf(countMessage, { critical, high, otherMessage }) : '';
+};
+
+const createStatusMessage = ({ reportType, status, total }) => {
+ const vulnMessage = n__('vulnerability', 'vulnerabilities', total);
+ let message;
+ if (status) {
+ message = __('%{reportType} %{status}');
+ } else if (!total) {
+ message = __('%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities.');
+ } else {
+ message = __(
+ '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}',
+ );
+ }
+ return sprintf(message, { reportType, status, total, vulnMessage });
+};
+
+/**
+ * Counts vulnerabilities.
+ * Returns the amount of critical, high, and other vulnerabilities.
+ *
+ * @param {Array} vulnerabilities The raw vulnerabilities to parse
+ * @returns {{critical: number, high: number, other: number}}
+ */
+export const countVulnerabilities = (vulnerabilities = []) =>
+ vulnerabilities.reduce(
+ (acc, { severity }) => {
+ if (severity === CRITICAL) {
+ acc.critical += 1;
+ } else if (severity === HIGH) {
+ acc.high += 1;
+ } else {
+ acc.other += 1;
+ }
+
+ return acc;
+ },
+ { critical: 0, high: 0, other: 0 },
+ );
+
+/**
+ * Takes an object of options and returns the object with an externalized string representing
+ * the critical, high, and other severity vulnerabilities for a given report.
+ *
+ * The resulting string _may_ still contain sprintf-style placeholders. These
+ * are left in place so they can be replaced with markup, via the
+ * SecuritySummary component.
+ * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options
+ * @returns {Object} the parameters with an externalized string
+ */
+export const groupedTextBuilder = ({
+ reportType = '',
+ status = '',
+ critical = 0,
+ high = 0,
+ other = 0,
+} = {}) => {
+ const total = critical + high + other;
+
+ return {
+ countMessage: createCountMessage({ critical, high, other, total }),
+ message: createStatusMessage({ reportType, status, total }),
+ critical,
+ high,
+ other,
+ status,
+ total,
+ };
+};