Commit 77d82b63 authored by Daniel Tian's avatar Daniel Tian Committed by Kushal Pandya

Add custom scanners to scanner filter dropdown on vulnerability report

Add custom scanners to scanner filter dropdown on vulnerability report
parent 6c6eb96a
<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