Commit f0daa643 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '46540-add-custom-security-scanner' into 'master'

Add custom scanners to vulnerability list scanner filter [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!49710
parents 16bea7ed 77d82b63
<script>
import { GlDropdownDivider, GlDropdownItem, GlTruncate } from '@gitlab/ui';
import { union, uniq, without, get, set, keyBy } from 'lodash';
import { DEFAULT_SCANNER } from 'ee/security_dashboard/constants';
import { createScannerOption } from '../../helpers';
import FilterBody from './filter_body.vue';
import FilterItem from './filter_item.vue';
import StandardFilter from './standard_filter.vue';
export default {
components: {
GlDropdownDivider,
GlDropdownItem,
GlTruncate,
FilterBody,
FilterItem,
},
extends: StandardFilter,
inject: ['scanners'],
computed: {
options() {
return Object.values(this.groups).flatMap((x) => Object.values(x));
},
/**
* For this computed property, we create an object with the following hierarchy:
* {
* $vendor: {
* $category: {
* id: 'used for querystring',
* reportType: 'used for GraphQL',
* name: 'used for Vue template',
* externalIds: ['used', 'for', 'GraphQL'],
* },
* $category: { ... }
* },
* $vendor: {
* $category: { ... },
* $category: { ... }
* }
* }
* The category object is added/removed from selectedOptions when an option is clicked on in the
* dropdown. It contains the data needed for the GraphQL query and to upate the querystring. The
* parent keys are used for O(1) lookups so we can assign the entries in the scanners array to
* the correct category object:
*
* const scanners = [{ vendor: 'GitLab', report_type: 'SAST', external_id: 'eslint'}]
* this.groups.GitLab.SAST.externalIds.push(scanner[0].external_id)
*
* In the template, we use Object.entries() and Object.values() on this computed property to
* render the hierarchical options.
*/
groups() {
const options = keyBy(this.filter.options, 'reportType');
const groups = { GitLab: options };
this.scanners.forEach((scanner) => {
const vendor = scanner.vendor || DEFAULT_SCANNER; // Default to GitLab if there's no vendor.
const reportType = scanner.report_type;
const id = `${vendor}.${reportType}`;
// Create the vendor and report type key if they don't exist.
if (!get(groups, id)) {
set(groups, id, createScannerOption(vendor, reportType));
}
// Add the external ID to the group's report type.
groups[vendor][reportType].externalIds.push(scanner.external_id);
});
return groups;
},
filterObject() {
const reportType = uniq(this.selectedOptions.map((x) => x.reportType));
const scanner = uniq(this.selectedOptions.flatMap((x) => x.externalIds));
return { reportType, scanner };
},
},
methods: {
toggleGroup(groupName) {
const options = Object.values(this.groups[groupName]);
// If every option is selected, de-select all of them. Otherwise, select all of them.
this.selectedOptions = options.every((option) => this.selectedSet.has(option))
? without(this.selectedOptions, ...options)
: union(this.selectedOptions, options);
this.updateRouteQuery();
},
},
};
</script>
<template>
<filter-body
:name="filter.name"
:selected-options="selectedOptionsOrAll"
:show-search-box="false"
>
<filter-item
v-if="filter.allOption"
:is-checked="isNoOptionsSelected"
:text="filter.allOption.name"
data-testid="all"
@click="deselectAllOptions"
/>
<template v-for="[groupName, groupOptions] in Object.entries(groups)">
<gl-dropdown-divider :key="`${groupName}:divider`" />
<gl-dropdown-item
:key="`${groupName}:header`"
:data-testid="`${groupName}Header`"
@click.native.capture.stop="toggleGroup(groupName)"
>
<gl-truncate class="gl-font-weight-bold" :text="groupName" />
</gl-dropdown-item>
<filter-item
v-for="option in Object.values(groupOptions)"
:key="option.id"
:is-checked="isSelected(option)"
:text="option.name"
:data-testid="option.id"
@click="toggleOption(option)"
/>
</template>
</filter-body>
</template>
...@@ -23,6 +23,9 @@ export default { ...@@ -23,6 +23,9 @@ export default {
}; };
}, },
computed: { computed: {
options() {
return this.filter.options;
},
selectedSet() { selectedSet() {
return new Set(this.selectedOptions); return new Set(this.selectedOptions);
}, },
...@@ -41,7 +44,7 @@ export default { ...@@ -41,7 +44,7 @@ export default {
return { [this.filter.id]: this.selectedOptions.map((x) => x.id) }; return { [this.filter.id]: this.selectedOptions.map((x) => x.id) };
}, },
filteredOptions() { filteredOptions() {
return this.filter.options.filter((option) => return this.options.filter((option) =>
option.name.toLowerCase().includes(this.searchTerm.toLowerCase()), option.name.toLowerCase().includes(this.searchTerm.toLowerCase()),
); );
}, },
...@@ -50,7 +53,7 @@ export default { ...@@ -50,7 +53,7 @@ export default {
return Array.isArray(ids) ? ids : [ids]; return Array.isArray(ids) ? ids : [ids];
}, },
routeQueryOptions() { routeQueryOptions() {
const options = this.filter.options.filter((x) => this.routeQueryIds.includes(x.id)); const options = this.options.filter((x) => this.routeQueryIds.includes(x.id));
const hasAllId = this.routeQueryIds.includes(this.filter.allOption.id); const hasAllId = this.routeQueryIds.includes(this.filter.allOption.id);
if (options.length && !hasAllId) { if (options.length && !hasAllId) {
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
} from '../helpers'; } from '../helpers';
import StandardFilter from './filters/standard_filter.vue'; import StandardFilter from './filters/standard_filter.vue';
import ActivityFilter from './filters/activity_filter.vue'; import ActivityFilter from './filters/activity_filter.vue';
import ScannerFilter from './filters/scanner_filter.vue';
const searchBoxOptionCount = 20; // Number of options before the search box is shown. const searchBoxOptionCount = 20; // Number of options before the search box is shown.
...@@ -41,7 +42,13 @@ export default { ...@@ -41,7 +42,13 @@ export default {
this.$emit('filterChange', this.filterQuery); this.$emit('filterChange', this.filterQuery);
}), }),
getFilterComponent({ id }) { getFilterComponent({ id }) {
return id === activityFilter.id ? ActivityFilter : StandardFilter; if (id === activityFilter.id) {
return ActivityFilter;
} else if (gon.features?.customSecurityScanners && id === scannerFilter.id) {
return ScannerFilter;
}
return StandardFilter;
}, },
}, },
searchBoxOptionCount, searchBoxOptionCount,
......
...@@ -6,3 +6,5 @@ export const vulnerabilitiesSeverityCountScopes = { ...@@ -6,3 +6,5 @@ export const vulnerabilitiesSeverityCountScopes = {
group: 'group', group: 'group',
project: 'project', project: 'project',
}; };
export const DEFAULT_SCANNER = 'GitLab';
...@@ -2,8 +2,10 @@ import isPlainObject from 'lodash/isPlainObject'; ...@@ -2,8 +2,10 @@ import isPlainObject from 'lodash/isPlainObject';
import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants'; import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import convertReportType from 'ee/vue_shared/security_reports/store/utils/convert_report_type';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { DEFAULT_SCANNER } from './constants';
const parseOptions = (obj) => const parseOptions = (obj) =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name })); Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
...@@ -30,10 +32,21 @@ export const severityFilter = { ...@@ -30,10 +32,21 @@ export const severityFilter = {
defaultOptions: [], defaultOptions: [],
}; };
export const createScannerOption = (vendor, reportType) => {
const type = reportType.toUpperCase();
return {
id: gon.features?.customSecurityScanners ? `${vendor}.${type}` : type,
reportType: reportType.toUpperCase(),
name: convertReportType(reportType),
externalIds: [],
};
};
export const scannerFilter = { export const scannerFilter = {
name: s__('SecurityReports|Scanner'), name: s__('SecurityReports|Scanner'),
id: 'reportType', id: 'reportType',
options: parseOptions(REPORT_TYPES), options: Object.keys(REPORT_TYPES).map((x) => createScannerOption(DEFAULT_SCANNER, x)),
allOption: BASE_FILTERS.report_type, allOption: BASE_FILTERS.report_type,
defaultOptions: [], defaultOptions: [],
}; };
......
...@@ -5,6 +5,10 @@ module Groups ...@@ -5,6 +5,10 @@ module Groups
class VulnerabilitiesController < Groups::ApplicationController class VulnerabilitiesController < Groups::ApplicationController
layout 'group' layout 'group'
before_action do
push_frontend_feature_flag(:custom_security_scanners, current_user)
end
feature_category :vulnerability_management feature_category :vulnerability_management
def index def index
......
...@@ -5,6 +5,10 @@ module Projects ...@@ -5,6 +5,10 @@ module Projects
class VulnerabilityReportController < Projects::ApplicationController class VulnerabilityReportController < Projects::ApplicationController
include SecurityDashboardsPermissions include SecurityDashboardsPermissions
before_action do
push_frontend_feature_flag(:custom_security_scanners, current_user)
end
feature_category :vulnerability_management feature_category :vulnerability_management
alias_method :vulnerable, :project alias_method :vulnerable, :project
......
...@@ -3,5 +3,9 @@ ...@@ -3,5 +3,9 @@
module Security module Security
class VulnerabilitiesController < ::Security::ApplicationController class VulnerabilitiesController < ::Security::ApplicationController
layout 'instance_security' layout 'instance_security'
before_action do
push_frontend_feature_flag(:custom_security_scanners, current_user)
end
end end
end end
---
name: custom_security_scanners
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49710
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/299295
milestone: '13.9'
type: development
group: group::threat insights
default_enabled: false
import { shallowMount } from '@vue/test-utils';
import { sampleSize, cloneDeep } from 'lodash';
import { GlDropdownItem } from '@gitlab/ui';
import ScannerFilter from 'ee/security_dashboard/components/filters/scanner_filter.vue';
import FilterItem from 'ee/security_dashboard/components/filters/filter_item.vue';
import { scannerFilter } from 'ee/security_dashboard/helpers';
import { DEFAULT_SCANNER } from 'ee/security_dashboard/constants';
const filter = cloneDeep(scannerFilter);
filter.options = filter.options.map((option) => ({
...option,
id: `GitLab.${option.id}`,
}));
const createScannerConfig = (vendor, reportType, externalId) => ({
vendor,
report_type: reportType,
external_id: externalId,
});
const scanners = [
createScannerConfig(DEFAULT_SCANNER, 'DEPENDENCY_SCANNING', 'bundler_audit'),
createScannerConfig(DEFAULT_SCANNER, 'SAST', 'eslint'),
createScannerConfig(DEFAULT_SCANNER, 'SAST', 'find_sec_bugs'),
createScannerConfig(DEFAULT_SCANNER, 'DEPENDENCY_SCANNING', 'gemnasium'),
createScannerConfig(DEFAULT_SCANNER, 'SECRET_DETECTION', 'gitleaks'),
createScannerConfig(DEFAULT_SCANNER, 'CONTAINER_SCANNING', 'klar'),
createScannerConfig(DEFAULT_SCANNER, 'COVERAGE_FUZZING', 'libfuzzer'),
createScannerConfig(DEFAULT_SCANNER, 'SAST', 'pmd-apex'),
createScannerConfig(DEFAULT_SCANNER, 'SAST', 'sobelow'),
createScannerConfig(DEFAULT_SCANNER, 'SAST', 'tslint'),
createScannerConfig(DEFAULT_SCANNER, 'DAST', 'zaproxy'),
createScannerConfig('Custom', 'SAST', 'custom1'),
createScannerConfig('Custom', 'SAST', 'custom2'),
createScannerConfig('Custom', 'DAST', 'custom3'),
];
describe('Scanner Filter component', () => {
let wrapper;
const createWrapper = () => {
wrapper = shallowMount(ScannerFilter, {
propsData: { filter },
provide: { scanners },
});
};
beforeEach(() => {
gon.features = { customSecurityScanners: true };
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('shows the correct dropdown items', () => {
const getTestIds = (selector) =>
wrapper.findAll(selector).wrappers.map((x) => x.attributes('data-testid'));
const options = getTestIds(FilterItem);
const expectedOptions = [
'all',
...filter.options.map((x) => x.id),
'Custom.SAST',
'Custom.DAST',
];
const headers = getTestIds(GlDropdownItem);
const expectedHeaders = ['GitLabHeader', 'CustomHeader'];
expect(options).toEqual(expectedOptions);
expect(headers).toEqual(expectedHeaders);
});
it('toggles selection of all items in a group when the group header is clicked', async () => {
const expectSelectedItems = (items) => {
const checkedItems = wrapper
.findAll(FilterItem)
.wrappers.filter((x) => x.props('isChecked'))
.map((x) => x.attributes('data-testid'));
const expectedItems = items.map((x) => x.id);
expect(checkedItems.sort()).toEqual(expectedItems.sort());
};
const clickAndCheck = async (expectedOptions) => {
await wrapper.find('[data-testid="GitLabHeader"]').trigger('click');
expectSelectedItems(expectedOptions);
};
const selectedOptions = sampleSize(filter.options, 3); // Randomly select some options.
await wrapper.setData({ selectedOptions });
expectSelectedItems(selectedOptions);
await clickAndCheck(filter.options); // First click selects all.
await clickAndCheck([filter.allOption]); // Second check unselects all.
await clickAndCheck(filter.options); // Third click selects all again.
});
it('emits filter-changed event with expected data when selected options is changed', async () => {
const selectedIds = ['GitLab.SAST', 'Custom.SAST'];
const selectedOptions = wrapper.vm.options.filter((x) => selectedIds.includes(x.id));
await wrapper.setData({ selectedOptions });
expect(wrapper.emitted('filter-changed')[1][0]).toEqual({
reportType: ['SAST'],
scanner: scanners.filter((x) => x.report_type === 'SAST').map((x) => x.external_id),
});
});
});
import VueRouter from 'vue-router'; import { shallowMount } from '@vue/test-utils';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ScannerFilter from 'ee/security_dashboard/components/filters/scanner_filter.vue';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue'; import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
const router = new VueRouter();
const localVue = createLocalVue();
localVue.use(VueRouter);
describe('First class vulnerability filters component', () => { describe('First class vulnerability filters component', () => {
let wrapper; let wrapper;
...@@ -19,15 +15,34 @@ describe('First class vulnerability filters component', () => { ...@@ -19,15 +15,34 @@ describe('First class vulnerability filters component', () => {
const findStateFilter = () => wrapper.find('[data-testid="state"]'); const findStateFilter = () => wrapper.find('[data-testid="state"]');
const findProjectFilter = () => wrapper.find('[data-testid="projectId"]'); const findProjectFilter = () => wrapper.find('[data-testid="projectId"]');
const createComponent = ({ propsData, listeners } = {}) => { const createComponent = (propsData) => {
return shallowMount(Filters, { localVue, router, propsData, listeners }); return shallowMount(Filters, { propsData });
}; };
beforeEach(() => {
gon.features = {};
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
it.each`
flagValue | expectedComponent | expectedName
${true} | ${ScannerFilter} | ${'ScannerFilter'}
${false} | ${StandardFilter} | ${'StandardFilter'}
`(
`renders $expectedName component when customSecurityScanners feature flag is $flagValue`,
({ flagValue, expectedComponent }) => {
wrapper = createComponent();
const filter = { id: 'reportType' };
gon.features.customSecurityScanners = flagValue;
expect(wrapper.vm.getFilterComponent(filter)).toEqual(expectedComponent);
},
);
describe('on render without project filter', () => { describe('on render without project filter', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
......
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