Commit 5174e854 authored by Mark Florian's avatar Mark Florian

Implement vulnerability counts

This implements vulnerability counts on the `SecurityReportsApp`
component, implemented behind a disabled-by-default feature flag
`core_security_mr_widget_counts`, as part of
https://gitlab.com/gitlab-org/gitlab/-/issues/273423.

This cannot be enabled until the backend endpoints are modified to be
usable in non-Ultimate plans. See
https://gitlab.com/gitlab-org/gitlab/-/issues/284689 for more details.
parent b16cc1f6
...@@ -240,6 +240,10 @@ export default class MergeRequestStore { ...@@ -240,6 +240,10 @@ export default class MergeRequestStore {
this.baseBlobPath = blobPath.base_path || ''; this.baseBlobPath = blobPath.base_path || '';
this.codequalityHelpPath = data.codequality_help_path; this.codequalityHelpPath = data.codequality_help_path;
this.codeclimate = data.codeclimate; this.codeclimate = data.codeclimate;
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
} }
get isNothingToMergeState() { get isNothingToMergeState() {
......
export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
export const FEEDBACK_TYPE_ISSUE = 'issue'; export const FEEDBACK_TYPE_ISSUE = 'issue';
export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; 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';
<script> <script>
import { mapActions, mapGetters } from 'vuex';
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; 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 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 { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import Flash from '~/flash'; import Flash from '~/flash';
import Api from '~/api'; 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 { export default {
store,
components: { components: {
GlIcon, GlIcon,
GlLink, GlLink,
GlSprintf, GlSprintf,
ReportSection, ReportSection,
SecuritySummary,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
pipelineId: { pipelineId: {
type: Number, type: Number,
...@@ -27,22 +36,50 @@ export default { ...@@ -27,22 +36,50 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
sastComparisonPath: {
type: String,
required: false,
default: '',
},
secretScanningComparisonPath: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { 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, // state suggests that the security scans detected no security problems,
// which is not necessarily the case. A future iteration will actually // which is not necessarily the case. A future iteration will actually
// check whether problems were found and display the appropriate status. // 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() { created() {
this.checkHasSecurityReports(this.$options.reportTypes) this.checkAvailableSecurityReports(this.$options.reportTypes)
.then(hasSecurityReports => { .then(availableSecurityReports => {
this.hasSecurityReports = hasSecurityReports; this.availableSecurityReports = Array.from(availableSecurityReports);
this.fetchCounts();
}) })
.catch(error => { .catch(error => {
Flash({ Flash({
...@@ -53,7 +90,18 @@ export default { ...@@ -53,7 +90,18 @@ export default {
}); });
}, },
methods: { 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; let page = 1;
while (page) { while (page) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
...@@ -62,18 +110,40 @@ export default { ...@@ -62,18 +110,40 @@ export default {
page, page,
}); });
const hasSecurityReports = jobs.some(({ artifacts = [] }) => jobs.forEach(({ artifacts = [] }) => {
artifacts.some(({ file_type }) => reportTypes.includes(file_type)), artifacts.forEach(({ file_type }) => {
); if (reportTypesSet.has(file_type)) {
availableReportTypes.add(file_type);
}
});
});
if (hasSecurityReports) { // If we've found artifacts for all the report types, stop looking!
return true; if (availableReportTypes.size === reportTypesSet.size) {
return availableReportTypes;
} }
page = parseIntPagination(normalizeHeaders(headers)).nextPage; 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() { activatePipelinesTab() {
if (window.mrTabs) { if (window.mrTabs) {
...@@ -81,7 +151,7 @@ export default { ...@@ -81,7 +151,7 @@ export default {
} }
}, },
}, },
reportTypes: ['sast', 'secret_detection'], reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
i18n: { i18n: {
apiError: s__( apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.', 'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
...@@ -89,13 +159,57 @@ export default { ...@@ -89,13 +159,57 @@ export default {
scansHaveRun: s__( scansHaveRun: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', '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'), securityReportsHelp: s__('SecurityReports|Security reports help page link'),
}, },
summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR],
}; };
</script> </script>
<template> <template>
<report-section <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" :status="status"
:has-issues="false" :has-issues="false"
class="mr-widget-border-top mr-report" class="mr-widget-border-top mr-report"
......
...@@ -41,6 +41,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -41,6 +41,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:highlight_current_diff_row, @project) push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true) push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true) push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:test_failure_history, @project) push_frontend_feature_flag(:test_failure_history, @project)
......
---
name: core_security_mr_widget_counts
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47656
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/284097
milestone: '13.7'
type: development
group: group::static analysis
default_enabled: false
...@@ -318,6 +318,8 @@ export default { ...@@ -318,6 +318,8 @@ export default {
:pipeline-id="mr.pipeline.id" :pipeline-id="mr.pipeline.id"
:project-id="mr.targetProjectId" :project-id="mr.targetProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath" :security-reports-docs-path="mr.securityReportsDocsPath"
:sast-comparison-path="mr.sastComparisonPath"
:secret-scanning-comparison-path="mr.secretScanningComparisonPath"
/> />
<grouped-security-reports-app <grouped-security-reports-app
v-else-if="shouldRenderExtendedSecurityReport" v-else-if="shouldRenderExtendedSecurityReport"
......
...@@ -59,8 +59,6 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -59,8 +59,6 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.coverageFuzzingComparisonPath = data.coverage_fuzzing_comparison_path; this.coverageFuzzingComparisonPath = data.coverage_fuzzing_comparison_path;
this.dastComparisonPath = data.dast_comparison_path; this.dastComparisonPath = data.dast_comparison_path;
this.dependencyScanningComparisonPath = data.dependency_scanning_comparison_path; this.dependencyScanningComparisonPath = data.dependency_scanning_comparison_path;
this.sastComparisonPath = data.sast_comparison_path;
this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
} }
initGeo(data) { initGeo(data) {
......
...@@ -12,6 +12,10 @@ export default { ...@@ -12,6 +12,10 @@ export default {
license_management: false, license_management: false,
secret_detection: false, secret_detection: false,
}, },
container_scanning_comparison_path: '/container_scanning_comparison_path',
dependency_scanning_comparison_path: '/dependency_scanning_comparison_path',
dast_comparison_path: '/dast_comparison_path',
coverage_fuzzing_comparison_path: '/coverage_fuzzing_comparison_path',
}; };
// Browser Performance Testing // Browser Performance Testing
......
import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store'; import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mockData from 'ee_jest/vue_mr_widget/mock_data'; import mockData from 'ee_jest/vue_mr_widget/mock_data';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import { convertToCamelCase } from '~/lib/utils/text_utility';
describe('MergeRequestStore', () => { describe('MergeRequestStore', () => {
let store; let store;
...@@ -66,4 +67,23 @@ describe('MergeRequestStore', () => { ...@@ -66,4 +67,23 @@ describe('MergeRequestStore', () => {
}); });
}); });
}); });
describe('setPaths', () => {
it.each([
'container_scanning_comparison_path',
'dependency_scanning_comparison_path',
'sast_comparison_path',
'dast_comparison_path',
'secret_scanning_comparison_path',
'coverage_fuzzing_comparison_path',
])('should set %s path', property => {
// Ensure something is set in the mock data
expect(property in mockData).toBe(true);
const expectedValue = mockData[property];
store.setPaths({ ...mockData });
expect(store[convertToCamelCase(property)]).toBe(expectedValue);
});
});
}); });
...@@ -24057,6 +24057,9 @@ msgstr "" ...@@ -24057,6 +24057,9 @@ msgstr ""
msgid "SecurityReports|Fuzzing artifacts" msgid "SecurityReports|Fuzzing artifacts"
msgstr "" msgstr ""
msgid "SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports"
msgstr ""
msgid "SecurityReports|Hide dismissed" msgid "SecurityReports|Hide dismissed"
msgstr "" msgstr ""
......
...@@ -263,6 +263,8 @@ export default { ...@@ -263,6 +263,8 @@ export default {
merge_trains_count: 3, merge_trains_count: 3,
merge_train_index: 1, merge_train_index: 1,
security_reports_docs_path: 'security-reports-docs-path', security_reports_docs_path: 'security-reports-docs-path',
sast_comparison_path: '/sast_comparison_path',
secret_scanning_comparison_path: '/secret_scanning_comparison_path',
}; };
export const mockStore = { export const mockStore = {
......
import { convertToCamelCase } from '~/lib/utils/text_utility';
import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import mockData from '../mock_data'; import mockData from '../mock_data';
...@@ -146,5 +147,18 @@ describe('MergeRequestStore', () => { ...@@ -146,5 +147,18 @@ describe('MergeRequestStore', () => {
expect(store.securityReportsDocsPath).toBe('security-reports-docs-path'); expect(store.securityReportsDocsPath).toBe('security-reports-docs-path');
}); });
it.each(['sast_comparison_path', 'secret_scanning_comparison_path'])(
'should set %s path',
property => {
// Ensure something is set in the mock data
expect(property in mockData).toBe(true);
const expectedValue = mockData[property];
store.setPaths({ ...mockData });
expect(store[convertToCamelCase(property)]).toBe(expectedValue);
},
);
}); });
}); });
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