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 {
this.baseBlobPath = blobPath.base_path || '';
this.codequalityHelpPath = data.codequality_help_path;
this.codeclimate = data.codeclimate;
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
}
get isNothingToMergeState() {
......
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';
<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 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,22 +36,50 @@ 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({
......@@ -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"
......
......@@ -41,6 +41,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:highlight_current_diff_row, @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_counts, @project)
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
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 {
:pipeline-id="mr.pipeline.id"
:project-id="mr.targetProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
:sast-comparison-path="mr.sastComparisonPath"
:secret-scanning-comparison-path="mr.secretScanningComparisonPath"
/>
<grouped-security-reports-app
v-else-if="shouldRenderExtendedSecurityReport"
......
......@@ -59,8 +59,6 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.coverageFuzzingComparisonPath = data.coverage_fuzzing_comparison_path;
this.dastComparisonPath = data.dast_comparison_path;
this.dependencyScanningComparisonPath = data.dependency_scanning_comparison_path;
this.sastComparisonPath = data.sast_comparison_path;
this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
}
initGeo(data) {
......
......@@ -12,6 +12,10 @@ export default {
license_management: 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
......
import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mockData from 'ee_jest/vue_mr_widget/mock_data';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import { convertToCamelCase } from '~/lib/utils/text_utility';
describe('MergeRequestStore', () => {
let store;
......@@ -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 ""
msgid "SecurityReports|Fuzzing artifacts"
msgstr ""
msgid "SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports"
msgstr ""
msgid "SecurityReports|Hide dismissed"
msgstr ""
......
......@@ -263,6 +263,8 @@ export default {
merge_trains_count: 3,
merge_train_index: 1,
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 = {
......
import { convertToCamelCase } from '~/lib/utils/text_utility';
import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import mockData from '../mock_data';
......@@ -146,5 +147,18 @@ describe('MergeRequestStore', () => {
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