diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/security_reports')
14 files changed, 660 insertions, 38 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/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue new file mode 100644 index 00000000000..3c606283c7d --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue @@ -0,0 +1,58 @@ +<script> +import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlIcon, + GlLink, + GlPopover, + }, + props: { + helpPath: { + type: String, + required: true, + }, + discoverProjectSecurityPath: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + securityReportsHelp: s__('SecurityReports|Security reports help page link'), + upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'), + upgradeToInteract: s__( + 'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.', + ), + }, +}; +</script> + +<template> + <span v-if="discoverProjectSecurityPath"> + <gl-button + ref="discoverProjectSecurity" + icon="information-o" + category="tertiary" + :aria-label="$options.i18n.upgradeToManageVulnerabilities" + /> + + <gl-popover + :target="() => $refs.discoverProjectSecurity.$el" + :title="$options.i18n.upgradeToManageVulnerabilities" + placement="top" + triggers="click blur" + > + {{ $options.i18n.upgradeToInteract }} + <gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{ + __('Learn more') + }}</gl-link> + </gl-popover> + </span> + + <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp"> + <gl-icon name="question" /> + </gl-link> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue new file mode 100644 index 00000000000..d7c1e27ff3e --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -0,0 +1,48 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'SecurityReportDownloadDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + artifacts: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + artifactText({ name }) { + return sprintf(s__('SecurityReports|Download %{artifactName}'), { + artifactName: name, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="s__('SecurityReports|Download results')" + :loading="loading" + icon="download" + right + > + <gl-dropdown-item + v-for="artifact in artifacts" + :key="artifact.path" + :href="artifact.path" + download + > + {{ artifactText(artifact) }} + </gl-dropdown-item> + </gl-dropdown> +</template> 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..68241a8c5be 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -1,3 +1,32 @@ +import { invert } from 'lodash'; + 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'; + +/** + * SecurityReportTypeEnum values for use with GraphQL. + * + * These should correspond to the lowercase security scan report types. + */ +export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST'; +export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION'; + +/** + * A mapping from security scan report types to SecurityReportTypeEnum values. + */ +export const reportTypeToSecurityReportTypeEnum = { + [REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST, + [REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION, +}; + +/** + * A mapping from SecurityReportTypeEnum values to security scan report types. + */ +export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum); diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql new file mode 100644 index 00000000000..310d8d88904 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql @@ -0,0 +1,23 @@ +query securityReportDownloadPaths( + $projectPath: ID! + $iid: String! + $reportTypes: [SecurityReportTypeEnum!] +) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + headPipeline { + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } + } + } + } +} 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..bdbf9957ad4 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,37 @@ <script> -import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; +import { 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 HelpIcon from './components/help_icon.vue'; +import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; +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, + reportTypeToSecurityReportTypeEnum, +} from './constants'; +import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql'; +import { extractSecurityReportArtifacts } from './utils'; export default { + store, components: { - GlIcon, GlLink, GlSprintf, ReportSection, + HelpIcon, + SecurityReportDownloadDropdown, + SecuritySummary, }, + mixins: [glFeatureFlagsMixin()], props: { pipelineId: { type: Number, @@ -27,33 +45,131 @@ export default { type: String, required: true, }, + discoverProjectSecurityPath: { + type: String, + required: false, + default: '', + }, + sastComparisonPath: { + type: String, + required: false, + default: '', + }, + secretScanningComparisonPath: { + type: String, + required: false, + default: '', + }, + targetProjectFullPath: { + type: String, + required: false, + default: '', + }, + mrIid: { + type: Number, + required: false, + default: 0, + }, + canDiscoverProjectSecurity: { + type: Boolean, + required: false, + default: false, + }, }, 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, }; }, + apollo: { + reportArtifacts: { + query: securityReportDownloadPathsQuery, + variables() { + return { + projectPath: this.targetProjectFullPath, + iid: String(this.mrIid), + reportTypes: this.$options.reportTypes.map( + reportType => reportTypeToSecurityReportTypeEnum[reportType], + ), + }; + }, + skip() { + return !this.canShowDownloads; + }, + update(data) { + return extractSecurityReportArtifacts(this.$options.reportTypes, data); + }, + error(error) { + this.showError(error); + }, + result({ loading }) { + if (loading) { + return; + } + + // Query has completed, so populate the availableSecurityReports. + this.onCheckingAvailableSecurityReports( + this.reportArtifacts.map(({ reportType }) => reportType), + ); + }, + }, + }, + computed: { + ...mapGetters(['groupedSummaryText', 'summaryStatus']), + canShowDownloads() { + return this.glFeatures.coreSecurityMrWidgetDownloads; + }, + hasSecurityReports() { + return this.availableSecurityReports.length > 0; + }, + hasSastReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SAST); + }, + hasSecretDetectionReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); + }, + isLoadingReportArtifacts() { + return this.$apollo.queries.reportArtifacts.loading; + }, + shouldShowDownloadGuidance() { + return !this.canShowDownloads && this.summaryStatus !== LOADING; + }, + scansHaveRunMessage() { + return this.canShowDownloads + ? this.$options.i18n.scansHaveRun + : this.$options.i18n.scansHaveRunWithDownloadGuidance; + }, + }, created() { - this.checkHasSecurityReports(this.$options.reportTypes) - .then(hasSecurityReports => { - this.hasSecurityReports = hasSecurityReports; - }) - .catch(error => { - Flash({ - message: this.$options.i18n.apiError, - captureError: true, - error, - }); - }); + if (!this.canShowDownloads) { + this.checkAvailableSecurityReports(this.$options.reportTypes) + .then(availableSecurityReports => { + this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports)); + }) + .catch(this.showError); + } }, 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,47 +178,127 @@ 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) { window.mrTabs.tabShown('pipelines'); } }, + onCheckingAvailableSecurityReports(availableSecurityReports) { + this.availableSecurityReports = availableSecurityReports; + this.fetchCounts(); + }, + showError(error) { + createFlash({ + message: this.$options.i18n.apiError, + captureError: true, + error, + }); + }, }, - 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.', ), - scansHaveRun: s__( + scansHaveRun: s__('SecurityReports|Security scans have run'), + scansHaveRunWithDownloadGuidance: s__( 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', ), - securityReportsHelp: s__('SecurityReports|Security reports help page link'), + downloadFromPipelineTab: s__( + 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', + ), }, + 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" /> + + <help-icon + :help-path="securityReportsDocsPath" + :discover-project-security-path="discoverProjectSecurityPath" + /> + </span> + </template> + + <template v-if="shouldShowDownloadGuidance" #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> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> + </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" data-testid="security-mr-widget" > <template #error> - <gl-sprintf :message="$options.i18n.scansHaveRun"> + <gl-sprintf :message="scansHaveRunMessage"> <template #link="{ content }"> <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{ content @@ -110,14 +306,17 @@ export default { </template> </gl-sprintf> - <gl-link - target="_blank" - data-testid="help" - :href="securityReportsDocsPath" - :aria-label="$options.i18n.securityReportsHelp" - > - <gl-icon name="question" /> - </gl-link> + <help-icon + :help-path="securityReportsDocsPath" + :discover-project-security-path="discoverProjectSecurityPath" + /> + </template> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> </template> </report-section> </template> 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, + }; +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js new file mode 100644 index 00000000000..827a87f9aaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -0,0 +1,22 @@ +import { securityReportTypeEnumToReportType } from './constants'; + +export const extractSecurityReportArtifacts = (reportTypes, data) => { + const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; + + return jobs.reduce((acc, job) => { + const artifacts = job.artifacts?.nodes ?? []; + + artifacts.forEach(({ downloadPath, fileType }) => { + const reportType = securityReportTypeEnumToReportType[fileType]; + if (reportType && reportTypes.includes(reportType)) { + acc.push({ + name: job.name, + reportType, + path: downloadPath, + }); + } + }); + + return acc; + }, []); +}; |