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);
},
);
});
});
import { mount } from '@vue/test-utils';
import { mount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
sastDiffSuccessMock,
secretScanningDiffSuccessMock,
} from 'jest/vue_shared/security_reports/mock_data';
import Api from '~/api';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
const SAST_COMPARISON_PATH = '/sast.json';
const SECRET_SCANNING_COMPARISON_PATH = '/secret_detection.json';
describe('Security reports app', () => {
let wrapper;
let mrTabsMock;
const props = {
pipelineId: 123,
......@@ -15,148 +34,290 @@ describe('Security reports app', () => {
securityReportsDocsPath: '/docs',
};
const createComponent = () => {
wrapper = mount(SecurityReportsApp, {
propsData: { ...props },
});
const createComponent = options => {
wrapper = mount(
SecurityReportsApp,
merge(
{
localVue,
propsData: { ...props },
},
options,
),
);
};
const anyParams = expect.any(Object);
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMrTabsMock = () => {
mrTabsMock = { tabShown: jest.fn() };
window.mrTabs = mrTabsMock;
};
const setupMockJobArtifact = reportType => {
jest
.spyOn(Api, 'pipelineJobs')
.mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] });
};
const expectPipelinesTabAnchor = () => {
const mrTabsMock = { tabShown: jest.fn() };
window.mrTabs = mrTabsMock;
findPipelinesTabAnchor().trigger('click');
expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
};
afterEach(() => {
wrapper.destroy();
delete window.mrTabs;
});
describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
beforeEach(() => {
window.mrTabs = { tabShown: jest.fn() };
setupMockJobArtifact(reportType);
createComponent();
return wrapper.vm.$nextTick();
});
describe.each([false, true])(
'given the coreSecurityMrWidgetCounts feature flag is %p',
coreSecurityMrWidgetCounts => {
const createComponentWithFlag = options =>
createComponent(
merge(
{
provide: {
glFeatures: {
coreSecurityMrWidgetCounts,
},
},
},
options,
),
);
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
beforeEach(() => {
window.mrTabs = { tabShown: jest.fn() };
setupMockJobArtifact(reportType);
createComponentWithFlag();
return wrapper.vm.$nextTick();
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(
props.projectId,
props.pipelineId,
anyParams,
);
});
describe('clicking the anchor to the pipelines tab', () => {
beforeEach(() => {
setupMrTabsMock();
findPipelinesTabAnchor().trigger('click');
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
describe('clicking the anchor to the pipelines tab', () => {
it('calls the mrTabs.tabShown global', () => {
expectPipelinesTabAnchor();
});
});
it('renders a help link', () => {
expect(findHelpLink().attributes()).toMatchObject({
href: props.securityReportsDocsPath,
});
});
});
it('calls the mrTabs.tabShown global', () => {
expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
describe('given a report type "foo"', () => {
beforeEach(() => {
setupMockJobArtifact('foo');
createComponentWithFlag();
return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(
props.projectId,
props.pipelineId,
anyParams,
);
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
});
});
it('renders a help link', () => {
expect(findHelpLink().attributes()).toMatchObject({
href: props.securityReportsDocsPath,
describe('security artifacts on last page of multi-page response', () => {
const numPages = 3;
beforeEach(() => {
jest
.spyOn(Api, 'pipelineJobs')
.mockImplementation(async (projectId, pipelineId, { page }) => {
const requestedPage = parseInt(page, 10);
if (requestedPage < numPages) {
return {
// Some jobs with no relevant artifacts
data: [{}, {}],
headers: { 'x-next-page': String(requestedPage + 1) },
};
} else if (requestedPage === numPages) {
return {
data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }],
};
}
throw new Error('Test failed due to request of non-existent jobs page');
});
createComponentWithFlag();
return wrapper.vm.$nextTick();
});
it('fetches all pages', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages);
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
});
});
});
describe('given a report type "foo"', () => {
beforeEach(() => {
setupMockJobArtifact('foo');
createComponent();
return wrapper.vm.$nextTick();
});
describe('given an error from the API', () => {
let error;
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
beforeEach(() => {
error = new Error('an error');
jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
createComponentWithFlag();
return wrapper.vm.$nextTick();
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(
props.projectId,
props.pipelineId,
anyParams,
);
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
it('calls Flash correctly', () => {
expect(Flash.mock.calls).toEqual([
[
{
message: SecurityReportsApp.i18n.apiError,
captureError: true,
error,
},
],
]);
});
});
},
);
describe('security artifacts on last page of multi-page response', () => {
const numPages = 3;
describe('given the coreSecurityMrWidgetCounts feature flag is enabled', () => {
let mock;
const createComponentWithFlagEnabled = options =>
createComponent(
merge(options, {
provide: {
glFeatures: {
coreSecurityMrWidgetCounts: true,
},
},
}),
);
beforeEach(() => {
jest
.spyOn(Api, 'pipelineJobs')
.mockImplementation(async (projectId, pipelineId, { page }) => {
const requestedPage = parseInt(page, 10);
if (requestedPage < numPages) {
return {
// Some jobs with no relevant artifacts
data: [{}, {}],
headers: { 'x-next-page': String(requestedPage + 1) },
};
} else if (requestedPage === numPages) {
return {
data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }],
};
}
throw new Error('Test failed due to request of non-existent jobs page');
});
createComponent();
return wrapper.vm.$nextTick();
mock = new MockAdapter(axios);
});
it('fetches all pages', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages);
afterEach(() => {
mock.restore();
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
});
const SAST_SUCCESS_MESSAGE =
'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others';
const SECRET_SCANNING_SUCCESS_MESSAGE =
'Security scanning detected 2 potential vulnerabilities 1 Critical 1 High and 0 Others';
describe.each`
reportType | pathProp | path | successResponse | successMessage
${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE}
${REPORT_TYPE_SECRET_DETECTION} | ${'secretScanningComparisonPath'} | ${SECRET_SCANNING_COMPARISON_PATH} | ${secretScanningDiffSuccessMock} | ${SECRET_SCANNING_SUCCESS_MESSAGE}
`(
'given a $pathProp and $reportType artifact',
({ reportType, pathProp, path, successResponse, successMessage }) => {
beforeEach(() => {
setupMockJobArtifact(reportType);
});
describe('given an error from the API', () => {
let error;
describe('when loading', () => {
beforeEach(() => {
mock = new MockAdapter(axios, { delayResponse: 1 });
mock.onGet(path).replyOnce(200, successResponse);
beforeEach(() => {
error = new Error('an error');
jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
createComponent();
return wrapper.vm.$nextTick();
});
createComponentWithFlagEnabled({
propsData: {
[pathProp]: path,
},
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
return waitForPromises();
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
it('should have loading message', () => {
expect(wrapper.text()).toBe('Security scanning is loading');
});
it('calls Flash correctly', () => {
expect(Flash.mock.calls).toEqual([
[
{
message: SecurityReportsApp.i18n.apiError,
captureError: true,
error,
},
],
]);
});
it('should not render the pipeline tab anchor', () => {
expect(findPipelinesTabAnchor().exists()).toBe(false);
});
});
describe('when successfully loaded', () => {
beforeEach(() => {
mock.onGet(path).replyOnce(200, successResponse);
createComponentWithFlagEnabled({
propsData: {
[pathProp]: path,
},
});
return waitForPromises();
});
it('should show counts', () => {
expect(trimText(wrapper.text())).toContain(successMessage);
});
it('should render the pipeline tab anchor', () => {
expectPipelinesTabAnchor();
});
});
describe('when an error occurs', () => {
beforeEach(() => {
mock.onGet(path).replyOnce(500);
createComponentWithFlagEnabled({
propsData: {
[pathProp]: path,
},
});
return waitForPromises();
});
it('should show error message', () => {
expect(trimText(wrapper.text())).toContain('Loading resulted in an error');
});
it('should render the pipeline tab anchor', () => {
expectPipelinesTabAnchor();
});
});
},
);
});
});
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