From be055b85b82656e35784b678dfe22c2f025a265e Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 16 Dec 2020 00:45:18 -0500 Subject: [PATCH] [Security Solution][Detections] - Add skeleton exceptions list tab to all rules page (#85465) ## Summary This PR is the first of 2 to complete the addition of a table displaying all exception lists on the all rules page. This PR focuses on the following: - all exception lists displayed - 'number of rules assigned to' displayed - names and links of rules assigned to displayed - refresh action button working - no trusted apps list show - search by `name`, `created_by`, `list_id` - just searching a word will search by list name - to search by `created_by` type `created_by:ytercero` - to search by `list_id` type `list_id:some-list-id` #### TO DO (follow up PR) - [ ] add tests - [ ] wire up export of exception list - [ ] wire up deletion of exception list Screen Shot 2020-12-09 at 2 10 59 PM ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- packages/kbn-optimizer/limits.yml | 2 +- .../find_exception_list_schema.mock.ts | 2 +- .../find_exception_list_schema.test.ts | 2 +- .../request/find_exception_list_schema.ts | 8 +- x-pack/plugins/lists/common/types.ts | 4 + .../lists/public/exceptions/api.test.ts | 85 ++- x-pack/plugins/lists/public/exceptions/api.ts | 56 ++ ...st.ts => use_exception_list_items.test.ts} | 26 +- ...on_list.ts => use_exception_list_items.ts} | 2 +- .../hooks/use_exception_lists.test.ts | 348 ++++++++++++ .../exceptions/hooks/use_exception_lists.ts | 115 ++++ .../plugins/lists/public/exceptions/types.ts | 36 +- .../lists/public/exceptions/utils.test.ts | 167 +++++- .../plugins/lists/public/exceptions/utils.ts | 83 ++- x-pack/plugins/lists/public/shared_exports.ts | 6 +- .../lists/server/routes/delete_list_route.ts | 1 + .../server/saved_objects/exception_list.ts | 9 +- .../exception_list_client_types.ts | 2 +- .../find_exception_list.test.ts | 66 +++ .../exception_lists/find_exception_list.ts | 44 +- .../find_exception_list_items.ts | 10 +- .../server/services/exception_lists/utils.ts | 10 +- .../exceptions/viewer/index.test.tsx | 8 +- .../components/exceptions/viewer/index.tsx | 11 +- .../public/common/components/link_to/index.ts | 2 +- .../containers/detection_engine/rules/api.ts | 14 +- .../rules/all/exceptions/columns.tsx | 120 ++++ .../rules/all/exceptions/exceptions_table.tsx | 205 +++++++ .../rules/all/exceptions/translations.ts | 77 +++ .../exceptions/use_all_exception_lists.tsx | 113 ++++ .../detection_engine/rules/all/index.test.tsx | 33 +- .../detection_engine/rules/all/index.tsx | 510 ++--------------- .../rules/all/rules_tables.tsx | 530 ++++++++++++++++++ .../rules/all/utility_bar.test.tsx | 38 +- .../rules/all/utility_bar.tsx | 116 ++-- .../detection_engine/rules/translations.ts | 15 +- .../public/shared_imports.ts | 5 +- 37 files changed, 2268 insertions(+), 613 deletions(-) rename x-pack/plugins/lists/public/exceptions/hooks/{use_exception_list.test.ts => use_exception_list_items.test.ts} (96%) rename x-pack/plugins/lists/public/exceptions/hooks/{use_exception_list.ts => use_exception_list_items.ts} (99%) create mode 100644 x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts create mode 100644 x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 95c425a81c5c..c58d010a1f31 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -48,7 +48,7 @@ pageLoadAssetSize: lensOss: 19341 licenseManagement: 41817 licensing: 39008 - lists: 183665 + lists: 202261 logstash: 53548 management: 46112 maps: 183610 diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.mock.ts index 96f4b7e1cbd6..bb75a44b14d0 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.mock.ts @@ -22,7 +22,7 @@ export const getFindExceptionListSchemaMock = (): FindExceptionListSchema => ({ export const getFindExceptionListSchemaDecodedMock = (): FindExceptionListSchemaDecoded => ({ filter: FILTER, - namespace_type: NAMESPACE_TYPE, + namespace_type: [NAMESPACE_TYPE], page: 1, per_page: 25, sort_field: undefined, diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.test.ts index 6f5d34d6be73..77a700073ef0 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.test.ts @@ -37,7 +37,7 @@ describe('find_exception_list_schema', () => { expect(getPaths(left(message.errors))).toEqual([]); const expected: FindExceptionListSchemaDecoded = { filter: undefined, - namespace_type: 'single', + namespace_type: ['single'], page: undefined, per_page: undefined, sort_field: undefined, diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts index 7765bbfbb29b..57d0db4b98f8 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts @@ -6,15 +6,15 @@ import * as t from 'io-ts'; -import { filter, namespace_type, sort_field, sort_order } from '../common/schemas'; +import { filter, sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; -import { NamespaceType } from '../types'; +import { DefaultNamespaceArray, NamespaceTypeArray } from '../types/default_namespace_array'; export const findExceptionListSchema = t.exact( t.partial({ filter, // defaults to undefined if not set during decode - namespace_type, // defaults to 'single' if not set during decode + namespace_type: DefaultNamespaceArray, // defaults to 'single' if not set during decode page: StringToPositiveNumber, // defaults to undefined if not set during decode per_page: StringToPositiveNumber, // defaults to undefined if not set during decode sort_field, // defaults to undefined if not set during decode @@ -29,5 +29,5 @@ export type FindExceptionListSchemaDecoded = Omit< RequiredKeepUndefined>, 'namespace_type' > & { - namespace_type: NamespaceType; + namespace_type: NamespaceTypeArray; }; diff --git a/x-pack/plugins/lists/common/types.ts b/x-pack/plugins/lists/common/types.ts index cee5567a55a6..20d99c40422f 100644 --- a/x-pack/plugins/lists/common/types.ts +++ b/x-pack/plugins/lists/common/types.ts @@ -4,6 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +export const exceptionListSavedObjectType = 'exception-list'; +export const exceptionListAgnosticSavedObjectType = 'exception-list-agnostic'; +export type SavedObjectType = 'exception-list' | 'exception-list-agnostic'; + /** * This makes any optional property the same as Required would but also has the * added benefit of keeping your undefined. diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index 0c31015fc9f5..e45403e319c2 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -17,6 +17,7 @@ import { ExceptionListItemSchema, ExceptionListSchema, } from '../../common/schemas'; +import { getFoundExceptionListSchemaMock } from '../../common/schemas/response/found_exception_list_schema.mock'; import { addEndpointExceptionList, @@ -26,11 +27,12 @@ import { deleteExceptionListItemById, fetchExceptionListById, fetchExceptionListItemById, + fetchExceptionLists, fetchExceptionListsItemsByListIds, updateExceptionList, updateExceptionListItem, } from './api'; -import { ApiCallByIdProps, ApiCallByListIdProps } from './types'; +import { ApiCallByIdProps, ApiCallByListIdProps, ApiCallFetchExceptionListsProps } from './types'; const abortCtrl = new AbortController(); @@ -289,6 +291,87 @@ describe('Exceptions Lists API', () => { }); }); + describe('#fetchExceptionLists', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getFoundExceptionListSchemaMock()); + }); + + test('it invokes "fetchExceptionLists" with expected url and body values', async () => { + await fetchExceptionLists({ + filters: 'exception-list.attributes.name: Sample Endpoint', + http: httpMock, + namespaceTypes: 'single,agnostic', + pagination: { + page: 1, + perPage: 20, + }, + signal: abortCtrl.signal, + }); + expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/_find', { + method: 'GET', + query: { + filter: 'exception-list.attributes.name: Sample Endpoint', + namespace_type: 'single,agnostic', + page: '1', + per_page: '20', + sort_field: 'exception-list.created_at', + sort_order: 'desc', + }, + signal: abortCtrl.signal, + }); + }); + + test('it returns expected exception list on success', async () => { + const exceptionResponse = await fetchExceptionLists({ + filters: 'exception-list.attributes.name: Sample Endpoint', + http: httpMock, + namespaceTypes: 'single,agnostic', + pagination: { + page: 1, + perPage: 20, + }, + signal: abortCtrl.signal, + }); + expect(exceptionResponse.data).toEqual([getExceptionListSchemaMock()]); + }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = ({ + filters: 'exception-list.attributes.name: Sample Endpoint', + http: httpMock, + namespaceTypes: 'notANamespaceType', + pagination: { + page: 1, + perPage: 20, + }, + signal: abortCtrl.signal, + } as unknown) as ApiCallFetchExceptionListsProps & { namespaceTypes: string[] }; + await expect(fetchExceptionLists(payload)).rejects.toEqual( + 'Invalid value "notANamespaceType" supplied to "namespace_type"' + ); + }); + + test('it returns error if response payload fails decode', async () => { + const badPayload = getExceptionListSchemaMock(); + // @ts-expect-error + delete badPayload.id; + httpMock.fetch.mockResolvedValue({ data: [badPayload], page: 1, per_page: 20, total: 1 }); + + await expect( + fetchExceptionLists({ + filters: 'exception-list.attributes.name: Sample Endpoint', + http: httpMock, + namespaceTypes: 'single,agnostic', + pagination: { + page: 1, + perPage: 20, + }, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "data,id"'); + }); + }); + describe('#fetchExceptionListById', () => { beforeEach(() => { httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock()); diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 824a25296260..fc0c8934d639 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -15,6 +15,7 @@ import { ExceptionListItemSchema, ExceptionListSchema, FoundExceptionListItemSchema, + FoundExceptionListSchema, createEndpointListSchema, createExceptionListItemSchema, createExceptionListSchema, @@ -23,7 +24,9 @@ import { exceptionListItemSchema, exceptionListSchema, findExceptionListItemSchema, + findExceptionListSchema, foundExceptionListItemSchema, + foundExceptionListSchema, readExceptionListItemSchema, readExceptionListSchema, updateExceptionListItemSchema, @@ -37,6 +40,7 @@ import { AddExceptionListProps, ApiCallByIdProps, ApiCallByListIdProps, + ApiCallFetchExceptionListsProps, UpdateExceptionListItemProps, UpdateExceptionListProps, } from './types'; @@ -201,6 +205,58 @@ export const updateExceptionListItem = async ({ } }; +/** + * Fetch all ExceptionLists (optionally by namespaceType) + * + * @param http Kibana http service + * @param namespaceTypes ExceptionList namespace_types of lists to find + * @param filters search bar filters + * @param pagination optional + * @param signal to cancel request + * + * @throws An error if request params or response is not OK + */ +export const fetchExceptionLists = async ({ + http, + filters, + namespaceTypes, + pagination, + signal, +}: ApiCallFetchExceptionListsProps): Promise => { + const query = { + filter: filters, + namespace_type: namespaceTypes, + page: pagination.page ? `${pagination.page}` : '1', + per_page: pagination.perPage ? `${pagination.perPage}` : '20', + sort_field: 'exception-list.created_at', + sort_order: 'desc', + }; + + const [validatedRequest, errorsRequest] = validate(query, findExceptionListSchema); + + if (validatedRequest != null) { + try { + const response = await http.fetch(`${EXCEPTION_LIST_URL}/_find`, { + method: 'GET', + query, + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, foundExceptionListSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; + /** * Fetch an ExceptionList by providing a ExceptionList ID * diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts similarity index 96% rename from x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts rename to x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts index 5c544c7e96e3..6aaf0d3e49ca 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts @@ -10,13 +10,13 @@ import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; -import { UseExceptionListProps, UseExceptionListSuccess } from '../types'; +import { UseExceptionListItemsSuccess, UseExceptionListProps } from '../types'; -import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list'; +import { ReturnExceptionListAndItems, useExceptionListItems } from './use_exception_list_items'; const mockKibanaHttpService = coreMock.createStart().http; -describe('useExceptionList', () => { +describe('useExceptionListItems', () => { const onErrorMock = jest.fn(); beforeEach(() => { @@ -36,7 +36,7 @@ describe('useExceptionList', () => { UseExceptionListProps, ReturnExceptionListAndItems >(() => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ @@ -75,7 +75,7 @@ describe('useExceptionList', () => { UseExceptionListProps, ReturnExceptionListAndItems >(() => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ @@ -100,7 +100,7 @@ describe('useExceptionList', () => { const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock() .data; - const expectedResult: UseExceptionListSuccess = { + const expectedResult: UseExceptionListItemsSuccess = { exceptions: expectedListItemsResult, pagination: { page: 1, perPage: 1, total: 1 }, }; @@ -129,7 +129,7 @@ describe('useExceptionList', () => { const onSuccessMock = jest.fn(); const { waitForNextUpdate } = renderHook( () => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ @@ -179,7 +179,7 @@ describe('useExceptionList', () => { const onSuccessMock = jest.fn(); const { waitForNextUpdate } = renderHook( () => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ @@ -231,7 +231,7 @@ describe('useExceptionList', () => { UseExceptionListProps, ReturnExceptionListAndItems >(() => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ @@ -278,7 +278,7 @@ describe('useExceptionList', () => { const onSuccessMock = jest.fn(); const { waitForNextUpdate } = renderHook( () => - useExceptionList({ + useExceptionListItems({ filterOptions: [{ filter: 'host.name', tags: [] }], http: mockKibanaHttpService, lists: [ @@ -343,7 +343,7 @@ describe('useExceptionList', () => { showDetectionsListsOnly, showEndpointListsOnly, }) => - useExceptionList({ + useExceptionListItems({ filterOptions, http, lists, @@ -413,7 +413,7 @@ describe('useExceptionList', () => { UseExceptionListProps, ReturnExceptionListAndItems >(() => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ @@ -455,7 +455,7 @@ describe('useExceptionList', () => { await act(async () => { const { waitForNextUpdate } = renderHook( () => - useExceptionList({ + useExceptionListItems({ filterOptions: [], http: mockKibanaHttpService, lists: [ diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts similarity index 99% rename from x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts rename to x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts index 2fafe9de59fd..e1708b598f2d 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts @@ -34,7 +34,7 @@ export type ReturnExceptionListAndItems = [ * @param pagination optional * */ -export const useExceptionList = ({ +export const useExceptionListItems = ({ http, lists, pagination = { diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts new file mode 100644 index 000000000000..03e942a61225 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -0,0 +1,348 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import * as api from '../api'; +import { getFoundExceptionListSchemaMock } from '../../../common/schemas/response/found_exception_list_schema.mock'; +import { ExceptionListSchema } from '../../../common/schemas'; +import { UseExceptionListsProps } from '../types'; + +import { ReturnExceptionLists, useExceptionLists } from './use_exception_lists'; + +const mockKibanaHttpService = coreMock.createStart().http; +const mockKibanaNotificationsService = coreMock.createStart().notifications; + +describe('useExceptionLists', () => { + beforeEach(() => { + jest.spyOn(api, 'fetchExceptionLists').mockResolvedValue(getFoundExceptionListSchemaMock()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseExceptionListsProps, + ReturnExceptionLists + >(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }) + ); + await waitForNextUpdate(); + + expect(result.current).toEqual([ + true, + [], + { + page: 1, + perPage: 20, + total: 0, + }, + null, + ]); + }); + }); + + test('fetches exception lists', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseExceptionListsProps, + ReturnExceptionLists + >(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedListItemsResult: ExceptionListSchema[] = getFoundExceptionListSchemaMock().data; + + expect(result.current).toEqual([ + false, + expectedListItemsResult, + { + page: 1, + perPage: 1, + total: 1, + }, + result.current[3], + ]); + }); + }); + + test('fetches trusted apps lists if "showTrustedApps" is true', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: true, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ + filters: + '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + http: mockKibanaHttpService, + namespaceTypes: 'single,agnostic', + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('does not fetch trusted apps lists if "showTrustedApps" is false', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ + filters: + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + http: mockKibanaHttpService, + namespaceTypes: 'single,agnostic', + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('applies filters to query', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: { + created_by: 'Moi', + name: 'Sample Endpoint', + }, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ + filters: + '(exception-list.attributes.created_by:Moi* OR exception-list-agnostic.attributes.created_by:Moi*) AND (exception-list.attributes.name:Sample Endpoint* OR exception-list-agnostic.attributes.name:Sample Endpoint*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + http: mockKibanaHttpService, + namespaceTypes: 'single,agnostic', + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('fetches a new exception list and its items when props change', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook< + UseExceptionListsProps, + ReturnExceptionLists + >( + ({ + errorMessage, + filterOptions, + http, + namespaceTypes, + notifications, + pagination, + showTrustedApps, + }) => + useExceptionLists({ + errorMessage, + filterOptions, + http, + namespaceTypes, + notifications, + pagination, + showTrustedApps, + }), + { + initialProps: { + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }, + } + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + rerender({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }); + // NOTE: Only need one call here because hook already initilaized + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledTimes(2); + }); + }); + + test('fetches list when refreshExceptionList callback invoked', async () => { + const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseExceptionListsProps, + ReturnExceptionLists + >(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(typeof result.current[3]).toEqual('function'); + + if (result.current[3] != null) { + result.current[3](); + } + // NOTE: Only need one call here because hook already initilaized + await waitForNextUpdate(); + + expect(spyOnfetchExceptionLists).toHaveBeenCalledTimes(2); + }); + }); + + test('invokes notifications service if "fetchExceptionLists" fails', async () => { + const mockError = new Error('failed to fetches list items'); + const spyOnfetchExceptionLists = jest + .spyOn(api, 'fetchExceptionLists') + .mockRejectedValue(mockError); + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useExceptionLists({ + errorMessage: 'Uh oh', + filterOptions: {}, + http: mockKibanaHttpService, + namespaceTypes: ['single', 'agnostic'], + notifications: mockKibanaNotificationsService, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showTrustedApps: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(mockKibanaNotificationsService.toasts.addError).toHaveBeenCalledWith(mockError, { + title: 'Uh oh', + }); + expect(spyOnfetchExceptionLists).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.ts new file mode 100644 index 000000000000..03a24cc2b269 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { fetchExceptionLists } from '../api'; +import { Pagination, UseExceptionListsProps } from '../types'; +import { ExceptionListSchema } from '../../../common/schemas'; +import { getFilters } from '../utils'; + +export type Func = () => void; +export type ReturnExceptionLists = [boolean, ExceptionListSchema[], Pagination, Func | null]; + +/** + * Hook for fetching ExceptionLists + * + * @param http Kibana http service + * @param errorMessage message shown to user if error occurs + * @param filterOptions filter by certain fields + * @param namespaceTypes spaces to be searched + * @param notifications kibana service for displaying toasters + * @param showTrustedApps boolean - include/exclude trusted app lists + * @param pagination + * + */ +export const useExceptionLists = ({ + errorMessage, + http, + pagination = { + page: 1, + perPage: 20, + total: 0, + }, + filterOptions = {}, + namespaceTypes, + notifications, + showTrustedApps = false, +}: UseExceptionListsProps): ReturnExceptionLists => { + const [exceptionLists, setExceptionLists] = useState([]); + const [paginationInfo, setPagination] = useState(pagination); + const [loading, setLoading] = useState(true); + const fetchExceptionListsRef = useRef(null); + + const namespaceTypesAsString = useMemo(() => namespaceTypes.join(','), [namespaceTypes]); + const filters = useMemo( + (): string => getFilters(filterOptions, namespaceTypes, showTrustedApps), + [namespaceTypes, filterOptions, showTrustedApps] + ); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchData = async (): Promise => { + try { + setLoading(true); + + const { page, per_page: perPage, total, data } = await fetchExceptionLists({ + filters, + http, + namespaceTypes: namespaceTypesAsString, + pagination: { + page: pagination.page, + perPage: pagination.perPage, + }, + signal: abortCtrl.signal, + }); + + if (isSubscribed) { + setPagination({ + page, + perPage, + total, + }); + setExceptionLists(data); + setLoading(false); + } + } catch (error) { + if (isSubscribed) { + notifications.toasts.addError(error, { + title: errorMessage, + }); + setExceptionLists([]); + setPagination({ + page: 1, + perPage: 20, + total: 0, + }); + setLoading(false); + } + } + }; + + fetchData(); + + fetchExceptionListsRef.current = fetchData; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [ + errorMessage, + notifications, + pagination.page, + pagination.perPage, + filters, + namespaceTypesAsString, + http, + ]); + + return [loading, exceptionLists, paginationInfo, fetchExceptionListsRef.current]; +}; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index 64e4efb5daad..02b78bc1a5e5 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -17,7 +17,7 @@ import { UpdateExceptionListItemSchema, UpdateExceptionListSchema, } from '../../common/schemas'; -import { HttpStart } from '../../../../../src/core/public'; +import { HttpStart, NotificationsStart } from '../../../../../src/core/public'; export interface FilterExceptionsOptions { filter: string; @@ -43,7 +43,7 @@ export interface ExceptionList extends ExceptionListSchema { totalItems: number; } -export interface UseExceptionListSuccess { +export interface UseExceptionListItemsSuccess { exceptions: ExceptionListItemSchema[]; pagination: Pagination; } @@ -57,7 +57,7 @@ export interface UseExceptionListProps { showDetectionsListsOnly: boolean; showEndpointListsOnly: boolean; matchFilters: boolean; - onSuccess?: (arg: UseExceptionListSuccess) => void; + onSuccess?: (arg: UseExceptionListItemsSuccess) => void; } export interface ExceptionListIdentifiers { @@ -97,7 +97,35 @@ export interface ApiCallFindListsItemsMemoProps { showDetectionsListsOnly: boolean; showEndpointListsOnly: boolean; onError: (arg: string[]) => void; - onSuccess: (arg: UseExceptionListSuccess) => void; + onSuccess: (arg: UseExceptionListItemsSuccess) => void; +} +export interface ApiCallFetchExceptionListsProps { + http: HttpStart; + namespaceTypes: string; + pagination: Partial; + filters: string; + signal: AbortSignal; +} + +export interface UseExceptionListsSuccess { + exceptions: ExceptionListSchema[]; + pagination: Pagination; +} + +export interface ExceptionListFilter { + name?: string | null; + list_id?: string | null; + created_by?: string | null; +} + +export interface UseExceptionListsProps { + errorMessage: string; + filterOptions?: ExceptionListFilter; + http: HttpStart; + namespaceTypes: NamespaceType[]; + notifications: NotificationsStart; + pagination?: Pagination; + showTrustedApps: boolean; } export interface AddExceptionListProps { diff --git a/x-pack/plugins/lists/public/exceptions/utils.test.ts b/x-pack/plugins/lists/public/exceptions/utils.test.ts index cc1a96132b04..b428117ca71c 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.test.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getIdsAndNamespaces } from './utils'; +import { getFilters, getGeneralFilters, getIdsAndNamespaces, getTrustedAppsFilter } from './utils'; describe('Exceptions utils', () => { describe('#getIdsAndNamespaces', () => { @@ -102,4 +102,169 @@ describe('Exceptions utils', () => { }); }); }); + + describe('getGeneralFilters', () => { + test('it returns empty string if no filters', () => { + const filters = getGeneralFilters({}, ['exception-list']); + + expect(filters).toEqual(''); + }); + + test('it properly formats filters when one namespace type passed in', () => { + const filters = getGeneralFilters({ created_by: 'moi', name: 'Sample' }, ['exception-list']); + + expect(filters).toEqual( + '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*)' + ); + }); + + test('it properly formats filters when two namespace types passed in', () => { + const filters = getGeneralFilters({ created_by: 'moi', name: 'Sample' }, [ + 'exception-list', + 'exception-list-agnostic', + ]); + + expect(filters).toEqual( + '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*)' + ); + }); + }); + + describe('getTrustedAppsFilter', () => { + test('it returns filter to search for "exception-list" namespace trusted apps', () => { + const filter = getTrustedAppsFilter(true, ['exception-list']); + + expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); + }); + + test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { + const filter = getTrustedAppsFilter(true, ['exception-list', 'exception-list-agnostic']); + + expect(filter).toEqual( + '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it returns filter to exclude "exception-list" namespace trusted apps', () => { + const filter = getTrustedAppsFilter(false, ['exception-list']); + + expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); + }); + + test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { + const filter = getTrustedAppsFilter(false, ['exception-list', 'exception-list-agnostic']); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + }); + + describe('getFilters', () => { + describe('single', () => { + test('it properly formats when no filters passed and "showTrustedApps" is false', () => { + const filter = getFilters({}, ['single'], false); + + expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); + }); + + test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + const filter = getFilters({}, ['single'], true); + + expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); + }); + + test('it properly formats when filters passed and "showTrustedApps" is false', () => { + const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], false); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it if filters passed and "showTrustedApps" is true', () => { + const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], true); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + }); + + describe('agnostic', () => { + test('it properly formats when no filters passed and "showTrustedApps" is false', () => { + const filter = getFilters({}, ['agnostic'], false); + + expect(filter).toEqual( + '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + const filter = getFilters({}, ['agnostic'], true); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it properly formats when filters passed and "showTrustedApps" is false', () => { + const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], false); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it if filters passed and "showTrustedApps" is true', () => { + const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], true); + + expect(filter).toEqual( + '(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + }); + + describe('single, agnostic', () => { + test('it properly formats when no filters passed and "showTrustedApps" is false', () => { + const filter = getFilters({}, ['single', 'agnostic'], false); + + expect(filter).toEqual( + '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + const filter = getFilters({}, ['single', 'agnostic'], true); + + expect(filter).toEqual( + '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it properly formats when filters passed and "showTrustedApps" is false', () => { + const filter = getFilters( + { created_by: 'moi', name: 'Sample' }, + ['single', 'agnostic'], + false + ); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + + test('it properly formats when filters passed and "showTrustedApps" is true', () => { + const filter = getFilters( + { created_by: 'moi', name: 'Sample' }, + ['single', 'agnostic'], + true + ); + + expect(filter).toEqual( + '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + ); + }); + }); + }); }); diff --git a/x-pack/plugins/lists/public/exceptions/utils.ts b/x-pack/plugins/lists/public/exceptions/utils.ts index 3a5984956b0d..dc50a89e91a2 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.ts @@ -4,9 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NamespaceType } from '../../common/schemas'; +import { get } from 'lodash/fp'; -import { ExceptionListIdentifiers } from './types'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../common/constants'; +import { NamespaceType } from '../../common/schemas'; +import { NamespaceTypeArray } from '../../common/schemas/types/default_namespace_array'; +import { + SavedObjectType, + exceptionListAgnosticSavedObjectType, + exceptionListSavedObjectType, +} from '../../common/types'; + +import { ExceptionListFilter, ExceptionListIdentifiers } from './types'; + +export const getSavedObjectType = ({ + namespaceType, +}: { + namespaceType: NamespaceType; +}): SavedObjectType => { + if (namespaceType === 'agnostic') { + return exceptionListAgnosticSavedObjectType; + } else { + return exceptionListSavedObjectType; + } +}; + +export const getSavedObjectTypes = ({ + namespaceType, +}: { + namespaceType: NamespaceTypeArray; +}): SavedObjectType[] => { + return namespaceType.map((singleNamespaceType) => + getSavedObjectType({ namespaceType: singleNamespaceType }) + ); +}; export const getIdsAndNamespaces = ({ lists, @@ -34,3 +65,51 @@ export const getIdsAndNamespaces = ({ }), { ids: [], namespaces: [] } ); + +export const getGeneralFilters = ( + filters: ExceptionListFilter, + namespaceTypes: SavedObjectType[] +): string => { + return Object.keys(filters) + .map((filterKey) => { + const value = get(filterKey, filters); + if (value != null) { + const filtersByNamespace = namespaceTypes + .map((namespace) => { + return `${namespace}.attributes.${filterKey}:${value}*`; + }) + .join(' OR '); + return `(${filtersByNamespace})`; + } else return null; + }) + .filter((item) => item != null) + .join(' AND '); +}; + +export const getTrustedAppsFilter = ( + showTrustedApps: boolean, + namespaceTypes: SavedObjectType[] +): string => { + if (showTrustedApps) { + const filters = namespaceTypes.map((namespace) => { + return `${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; + }); + return `(${filters.join(' OR ')})`; + } else { + const filters = namespaceTypes.map((namespace) => { + return `not ${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; + }); + return `(${filters.join(' AND ')})`; + } +}; + +export const getFilters = ( + filters: ExceptionListFilter, + namespaceTypes: NamespaceType[], + showTrustedApps: boolean +): string => { + const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes }); + const generalFilters = getGeneralFilters(filters, namespaces); + const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces); + return [generalFilters, trustedAppsFilter].filter((filter) => filter.trim() !== '').join(' AND '); +}; diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index b8293971d14d..acda580d779c 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -11,7 +11,8 @@ export { useAsync } from './common/hooks/use_async'; export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; -export { useExceptionList } from './exceptions/hooks/use_exception_list'; +export { useExceptionListItems } from './exceptions/hooks/use_exception_list_items'; +export { useExceptionLists } from './exceptions/hooks/use_exception_lists'; export { useFindLists } from './lists/hooks/use_find_lists'; export { useImportList } from './lists/hooks/use_import_list'; export { useDeleteList } from './lists/hooks/use_delete_list'; @@ -32,5 +33,6 @@ export { ExceptionList, ExceptionListIdentifiers, Pagination, - UseExceptionListSuccess, + UseExceptionListItemsSuccess, + UseExceptionListsSuccess, } from './exceptions/types'; diff --git a/x-pack/plugins/lists/server/routes/delete_list_route.ts b/x-pack/plugins/lists/server/routes/delete_list_route.ts index 9562a83b7c31..bf8fac7f3dd8 100644 --- a/x-pack/plugins/lists/server/routes/delete_list_route.ts +++ b/x-pack/plugins/lists/server/routes/delete_list_route.ts @@ -147,6 +147,7 @@ const getReferencedExceptionLists = async ( .join(' OR '); return exceptionLists.findExceptionList({ filter: `(${filter})`, + namespaceType: ['agnostic', 'single'], page: 1, perPage: 10000, sortField: undefined, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index b3fd2c0eced9..729b27032eda 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -6,11 +6,12 @@ import { SavedObjectsType } from 'kibana/server'; -import { migrations } from './migrations'; +import { + exceptionListAgnosticSavedObjectType, + exceptionListSavedObjectType, +} from '../../common/types'; -export const exceptionListSavedObjectType = 'exception-list'; -export const exceptionListAgnosticSavedObjectType = 'exception-list-agnostic'; -export type SavedObjectType = 'exception-list' | 'exception-list-agnostic'; +import { migrations } from './migrations'; /** * This is a super set of exception list and exception list items. The switch diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 74dc1e215f47..619b9533bdbd 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -205,7 +205,7 @@ export interface FindValueListExceptionListsItems { } export interface FindExceptionListOptions { - namespaceType?: NamespaceType; + namespaceType: NamespaceTypeArray; filter: FilterOrUndefined; perPage: PerPageOrUndefined; page: PageOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts new file mode 100644 index 000000000000..e083f82879d4 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getExceptionListFilter } from './find_exception_list'; + +describe('find_exception_list', () => { + describe('getExceptionListFilter', () => { + test('it should create a filter for agnostic lists if only searching for agnostic lists', () => { + const filter = getExceptionListFilter({ + filter: undefined, + savedObjectTypes: ['exception-list-agnostic'], + }); + expect(filter).toEqual('(exception-list-agnostic.attributes.list_type: list)'); + }); + + test('it should create a filter for agnostic lists with additional filters if only searching for agnostic lists', () => { + const filter = getExceptionListFilter({ + filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"', + savedObjectTypes: ['exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"' + ); + }); + + test('it should create a filter for single lists if only searching for single lists', () => { + const filter = getExceptionListFilter({ + filter: undefined, + savedObjectTypes: ['exception-list'], + }); + expect(filter).toEqual('(exception-list.attributes.list_type: list)'); + }); + + test('it should create a filter for single lists with additional filters if only searching for single lists', () => { + const filter = getExceptionListFilter({ + filter: 'exception-list.attributes.name: "Sample Endpoint Exception List"', + savedObjectTypes: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: list) AND exception-list.attributes.name: "Sample Endpoint Exception List"' + ); + }); + + test('it should create a filter that searches for both agnostic and single lists', () => { + const filter = getExceptionListFilter({ + filter: undefined, + savedObjectTypes: ['exception-list-agnostic', 'exception-list'], + }); + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list)' + ); + }); + + test('it should create a filter that searches for both agnostic and single lists with additional filters if only searching for agnostic lists', () => { + const filter = getExceptionListFilter({ + filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"', + savedObjectTypes: ['exception-list-agnostic', 'exception-list'], + }); + expect(filter).toEqual( + '(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"' + ); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts index a30e843d853a..8f330f2e3f97 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts @@ -6,26 +6,22 @@ import { SavedObjectsClientContract } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { SavedObjectType } from '../../../common/types'; import { ExceptionListSoSchema, FilterOrUndefined, FoundExceptionListSchema, - NamespaceType, PageOrUndefined, PerPageOrUndefined, SortFieldOrUndefined, SortOrderOrUndefined, } from '../../../common/schemas'; -import { - SavedObjectType, - exceptionListAgnosticSavedObjectType, - exceptionListSavedObjectType, -} from '../../saved_objects'; -import { getSavedObjectType, transformSavedObjectsToFoundExceptionList } from './utils'; +import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionList } from './utils'; interface FindExceptionListOptions { - namespaceType?: NamespaceType; + namespaceType: NamespaceTypeArray; savedObjectsClient: SavedObjectsClientContract; filter: FilterOrUndefined; perPage: PerPageOrUndefined; @@ -43,37 +39,31 @@ export const findExceptionList = async ({ sortField, sortOrder, }: FindExceptionListOptions): Promise => { - const savedObjectType: SavedObjectType[] = namespaceType - ? [getSavedObjectType({ namespaceType })] - : [exceptionListSavedObjectType, exceptionListAgnosticSavedObjectType]; + const savedObjectTypes = getSavedObjectTypes({ namespaceType }); const savedObjectsFindResponse = await savedObjectsClient.find({ - filter: getExceptionListFilter({ filter, savedObjectType }), + filter: getExceptionListFilter({ filter, savedObjectTypes }), page, perPage, sortField, sortOrder, - type: savedObjectType, + type: savedObjectTypes, }); + return transformSavedObjectsToFoundExceptionList({ savedObjectsFindResponse }); }; export const getExceptionListFilter = ({ filter, - savedObjectType, + savedObjectTypes, }: { filter: FilterOrUndefined; - savedObjectType: SavedObjectType[]; + savedObjectTypes: SavedObjectType[]; }): string => { - const savedObjectTypeFilter = `(${savedObjectType - .map((sot) => `${sot}.attributes.list_type: list`) - .join(' OR ')})`; - if (filter == null) { - return savedObjectTypeFilter; - } else { - if (Array.isArray(savedObjectType)) { - return `${savedObjectTypeFilter} AND ${filter}`; - } else { - return `${savedObjectType}.attributes.list_type: list AND ${filter}`; - } - } + const listTypesFilter = savedObjectTypes + .map((type) => `${type}.attributes.list_type: list`) + .join(' OR '); + + if (filter != null) { + return `(${listTypesFilter}) AND ${filter}`; + } else return `(${listTypesFilter})`; }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts index 5a21d7a12b02..41437495ecc5 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts @@ -5,6 +5,11 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; +import { + SavedObjectType, + exceptionListAgnosticSavedObjectType, + exceptionListSavedObjectType, +} from '../../../common/types'; import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; @@ -17,11 +22,6 @@ import { SortFieldOrUndefined, SortOrderOrUndefined, } from '../../../common/schemas'; -import { - SavedObjectType, - exceptionListAgnosticSavedObjectType, - exceptionListSavedObjectType, -} from '../../saved_objects'; import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionListItem } from './utils'; import { getExceptionList } from './get_exception_list'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index 6a7bd249bf62..53ac48d1ea63 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -6,6 +6,11 @@ import uuid from 'uuid'; import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { + SavedObjectType, + exceptionListAgnosticSavedObjectType, + exceptionListSavedObjectType, +} from '../../../common/types'; import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; import { CommentsArray, @@ -21,11 +26,6 @@ import { exceptionListItemType, exceptionListType, } from '../../../common/schemas'; -import { - SavedObjectType, - exceptionListAgnosticSavedObjectType, - exceptionListSavedObjectType, -} from '../../saved_objects'; export const getSavedObjectType = ({ namespaceType, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx index 4c60f3ba5ccc..02205e815e35 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -13,7 +13,7 @@ import { ExceptionsViewer } from './'; import { useKibana } from '../../../../common/lib/kibana'; import { ExceptionListTypeEnum, - useExceptionList, + useExceptionListItems, useApi, } from '../../../../../public/lists_plugin_deps'; import { getExceptionListSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_schema.mock'; @@ -40,7 +40,7 @@ describe('ExceptionsViewer', () => { getExceptionListsItems: jest.fn().mockResolvedValue(getFoundExceptionListItemSchemaMock()), }); - (useExceptionList as jest.Mock).mockReturnValue([ + (useExceptionListItems as jest.Mock).mockReturnValue([ false, [], [], @@ -54,7 +54,7 @@ describe('ExceptionsViewer', () => { }); it('it renders loader if "loadingList" is true', () => { - (useExceptionList as jest.Mock).mockReturnValue([ + (useExceptionListItems as jest.Mock).mockReturnValue([ true, [], [], @@ -106,7 +106,7 @@ describe('ExceptionsViewer', () => { }); it('it renders empty prompt if no exception items exist', () => { - (useExceptionList as jest.Mock).mockReturnValue([ + (useExceptionListItems as jest.Mock).mockReturnValue([ false, [getExceptionListSchemaMock()], [], diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index f90c83bf953e..145378f96122 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -17,11 +17,11 @@ import { ExceptionsViewerHeader } from './exceptions_viewer_header'; import { ExceptionListItemIdentifiers, Filter } from '../types'; import { allExceptionItemsReducer, State, ViewerModalName } from './reducer'; import { - useExceptionList, + useExceptionListItems, ExceptionListIdentifiers, ExceptionListTypeEnum, ExceptionListItemSchema, - UseExceptionListSuccess, + UseExceptionListItemsSuccess, useApi, } from '../../../../../public/lists_plugin_deps'; import { ExceptionsViewerPagination } from './exceptions_pagination'; @@ -105,7 +105,10 @@ const ExceptionsViewerComponent = ({ const { deleteExceptionItem, getExceptionListsItems } = useApi(services.http); const setExceptions = useCallback( - ({ exceptions: newExceptions, pagination: newPagination }: UseExceptionListSuccess): void => { + ({ + exceptions: newExceptions, + pagination: newPagination, + }: UseExceptionListItemsSuccess): void => { dispatch({ type: 'setExceptions', lists: exceptionListsMeta, @@ -115,7 +118,7 @@ const ExceptionsViewerComponent = ({ }, [dispatch, exceptionListsMeta] ); - const [loadingList, , , fetchListItems] = useExceptionList({ + const [loadingList, , , fetchListItems] = useExceptionListItems({ http: services.http, lists: exceptionListsMeta, filterOptions: diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 06a7f83bcb6a..0e4ee3155bc8 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -30,7 +30,7 @@ interface FormatUrlOptions { skipSearch: boolean; } -type FormatUrl = (path: string, options?: Partial) => string; +export type FormatUrl = (path: string, options?: Partial) => string; export const useFormatUrl = (page: SecurityPageName) => { const { getUrlForApp } = useKibana().services.application; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 9512ae6f2d6e..a5dddd6d9afd 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -108,14 +108,16 @@ export const fetchRules = async ({ }, signal, }: FetchRulesProps): Promise => { + const showCustomRuleFilter = filterOptions.showCustomRules + ? [`alert.attributes.tags: "__internal_immutable:false"`] + : []; + const showElasticRuleFilter = filterOptions.showElasticRules + ? [`alert.attributes.tags: "__internal_immutable:true"`] + : []; const filtersWithoutTags = [ ...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []), - ...(filterOptions.showCustomRules - ? [`alert.attributes.tags: "__internal_immutable:false"`] - : []), - ...(filterOptions.showElasticRules - ? [`alert.attributes.tags: "__internal_immutable:true"`] - : []), + ...showCustomRuleFilter, + ...showElasticRuleFilter, ].join(' AND '); const tags = [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx new file mode 100644 index 000000000000..57b86119dc16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable react/display-name */ + +import React from 'react'; +import { EuiButtonIcon, EuiBasicTableColumn, EuiToolTip } from '@elastic/eui'; +import { History } from 'history'; + +import { FormatUrl } from '../../../../../../common/components/link_to'; +import { LinkAnchor } from '../../../../../../common/components/links'; +import * as i18n from './translations'; +import { ExceptionListInfo } from './use_all_exception_lists'; +import { getRuleDetailsUrl } from '../../../../../../common/components/link_to/redirect_to_detection_engine'; + +export type AllExceptionListsColumns = EuiBasicTableColumn; +export type Func = (listId: string) => () => void; + +export const getAllExceptionListsColumns = ( + onExport: Func, + onDelete: Func, + history: History, + formatUrl: FormatUrl +): AllExceptionListsColumns[] => [ + { + align: 'left', + field: 'list_id', + name: i18n.EXCEPTION_LIST_ID_TITLE, + truncateText: true, + dataType: 'string', + width: '15%', + render: (value: ExceptionListInfo['list_id']) => ( + + <>{value} + + ), + }, + { + align: 'center', + field: 'rules', + name: i18n.NUMBER_RULES_ASSIGNED_TO_TITLE, + truncateText: true, + dataType: 'number', + width: '10%', + render: (value: ExceptionListInfo['rules']) => { + return

{value.length}

; + }, + }, + { + align: 'left', + field: 'rules', + name: i18n.RULES_ASSIGNED_TO_TITLE, + truncateText: true, + dataType: 'string', + width: '20%', + render: (value: ExceptionListInfo['rules']) => { + return ( + <> + {value.map(({ id, name }, index) => ( + <> + void }) => { + ev.preventDefault(); + history.push(getRuleDetailsUrl(id)); + }} + href={formatUrl(getRuleDetailsUrl(id))} + > + {name} + + {index !== value.length - 1 ? ', ' : ''} + + ))} + + ); + }, + }, + { + align: 'left', + field: 'created_at', + name: i18n.LIST_DATE_CREATED_TITLE, + truncateText: true, + dataType: 'date', + width: '14%', + }, + { + align: 'left', + field: 'updated_at', + name: i18n.LIST_DATE_UPDATED_TITLE, + truncateText: true, + width: '14%', + }, + { + align: 'center', + isExpander: false, + width: '25px', + render: (list: ExceptionListInfo) => ( + + ), + }, + { + align: 'center', + width: '25px', + isExpander: false, + render: (list: ExceptionListInfo) => ( + + ), + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx new file mode 100644 index 000000000000..65aaaea06b40 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useEffect, useCallback, useState, ChangeEvent } from 'react'; +import { + EuiBasicTable, + EuiEmptyPrompt, + EuiLoadingContent, + EuiProgress, + EuiFieldSearch, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { History } from 'history'; +import { set } from 'lodash/fp'; + +import { useKibana } from '../../../../../../common/lib/kibana'; +import { useExceptionLists } from '../../../../../../shared_imports'; +import { FormatUrl } from '../../../../../../common/components/link_to'; +import { HeaderSection } from '../../../../../../common/components/header_section'; +import { Loader } from '../../../../../../common/components/loader'; +import { Panel } from '../../../../../../common/components/panel'; +import * as i18n from './translations'; +import { AllRulesUtilityBar } from '../utility_bar'; +import { LastUpdatedAt } from '../../../../../../common/components/last_updated'; +import { AllExceptionListsColumns, getAllExceptionListsColumns } from './columns'; +import { useAllExceptionLists } from './use_all_exception_lists'; + +// Known lost battle with Eui :( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any; + +export type Func = () => void; +export interface ExceptionListFilter { + name?: string | null; + list_id?: string | null; + created_by?: string | null; +} + +interface ExceptionListsTableProps { + history: History; + hasNoPermissions: boolean; + loading: boolean; + formatUrl: FormatUrl; +} + +export const ExceptionListsTable = React.memo( + ({ formatUrl, history, hasNoPermissions, loading }) => { + const { + services: { http, notifications }, + } = useKibana(); + const [filters, setFilters] = useState({ + name: null, + list_id: null, + created_by: null, + }); + const [loadingExceptions, exceptions, pagination, refreshExceptions] = useExceptionLists({ + errorMessage: i18n.ERROR_EXCEPTION_LISTS, + filterOptions: filters, + http, + namespaceTypes: ['single', 'agnostic'], + notifications, + showTrustedApps: false, + }); + const [loadingTableInfo, data] = useAllExceptionLists({ + exceptionLists: exceptions ?? [], + }); + const [initLoading, setInitLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(Date.now()); + + const handleDelete = useCallback((id: string) => () => {}, []); + + const handleExport = useCallback((id: string) => () => {}, []); + + const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => { + return getAllExceptionListsColumns(handleExport, handleDelete, history, formatUrl); + }, [handleExport, handleDelete, history, formatUrl]); + + const handleRefresh = useCallback((): void => { + if (refreshExceptions != null) { + setLastUpdated(Date.now()); + refreshExceptions(); + } + }, [refreshExceptions]); + + useEffect(() => { + if (initLoading && !loading && !loadingExceptions && !loadingTableInfo) { + setInitLoading(false); + } + }, [initLoading, loading, loadingExceptions, loadingTableInfo]); + + const emptyPrompt = useMemo((): JSX.Element => { + return ( + {i18n.NO_EXCEPTION_LISTS}} + titleSize="xs" + body={i18n.NO_LISTS_BODY} + /> + ); + }, []); + + const handleSearch = useCallback((search: string) => { + const regex = search.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/); + const formattedFilter = regex + .filter((c) => c != null) + .reduce( + (filter, term) => { + const [qualifier, value] = term.split(':'); + + if (qualifier == null) { + filter.name = search; + } else if (value != null && Object.keys(filter).includes(qualifier)) { + return set(qualifier, value, filter); + } + + return filter; + }, + { name: null, list_id: null, created_by: null } + ); + setFilters(formattedFilter); + }, []); + + const handleSearchChange = useCallback( + (event: ChangeEvent) => { + const val = event.target.value; + handleSearch(val); + }, + [handleSearch] + ); + + const paginationMemo = useMemo( + () => ({ + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: pagination.total, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }), + [pagination] + ); + + return ( + <> + + <> + {loadingTableInfo && ( + + )} + } + > + + + + {loadingTableInfo && !initLoading && ( + + )} + {initLoading ? ( + + ) : ( + <> + + {}} + pagination={paginationMemo} + /> + + )} + + + + ); + } +); + +ExceptionListsTable.displayName = 'ExceptionListsTable'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts new file mode 100644 index 000000000000..2eba8fb2e579 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const EXCEPTION_LIST_ID_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.idTitle', + { + defaultMessage: 'List ID', + } +); + +export const NUMBER_RULES_ASSIGNED_TO_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.numberRulesAssignedTitle', + { + defaultMessage: 'Number of rules assigned to', + } +); + +export const RULES_ASSIGNED_TO_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.rulesAssignedTitle', + { + defaultMessage: 'Rules assigned to', + } +); + +export const LIST_DATE_CREATED_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.dateCreatedTitle', + { + defaultMessage: 'Date created', + } +); + +export const LIST_DATE_UPDATED_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.dateUPdatedTitle', + { + defaultMessage: 'Last edited', + } +); + +export const ERROR_EXCEPTION_LISTS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.errorFetching', + { + defaultMessage: 'Error fetching exception lists', + } +); + +export const NO_EXCEPTION_LISTS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allExceptionLists.filters.noExceptionsTitle', + { + defaultMessage: 'No exception lists found', + } +); + +export const EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder', + { + defaultMessage: 'Search exception lists', + } +); + +export const ALL_EXCEPTIONS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle', + { + defaultMessage: 'Exception Lists', + } +); + +export const NO_LISTS_BODY = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody', + { + defaultMessage: "We weren't able to find any exception lists.", + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx new file mode 100644 index 000000000000..4b47080cc2da --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; +import { ExceptionListSchema } from '../../../../../../../../lists/common'; + +import { fetchRules } from '../../../../../containers/detection_engine/rules/api'; + +export interface ExceptionListInfo extends ExceptionListSchema { + rules: Array<{ name: string; id: string }>; +} + +export type UseAllExceptionListsReturn = [boolean, ExceptionListInfo[]]; + +/** + * Hook for preparing exception lists table info. For now, we need to do a table scan + * of all rules to figure out which exception lists are used in what rules. This is very + * slow, however, there is an issue open that would push all this work to Kiaban/ES and + * speed things up a ton - https://github.com/elastic/kibana/issues/85173 + * + * @param exceptionLists ExceptionListSchema(s) to evaluate + * + */ +export const useAllExceptionLists = ({ + exceptionLists, +}: { + exceptionLists: ExceptionListSchema[]; +}): UseAllExceptionListsReturn => { + const [loading, setLoading] = useState(true); + const [exceptionsListInfo, setExceptionsListInfo] = useState([]); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchData = async (): Promise => { + if (exceptionLists.length === 0 && isSubscribed) { + setLoading(false); + return; + } + + try { + setLoading(true); + + const listsSkeleton = exceptionLists.reduce>( + (acc, { id, ...rest }) => { + acc[id] = { + ...rest, + id, + rules: [], + }; + + return acc; + }, + {} + ); + + const { data: rules } = await fetchRules({ + pagination: { + page: 1, + perPage: 500, + total: 0, + }, + signal: abortCtrl.signal, + }); + + const updatedLists = rules.reduce>((acc, rule) => { + const exceptions = rule.exceptions_list; + + if (exceptions != null && exceptions.length > 0) { + exceptions.forEach((ex) => { + const list = acc[ex.id]; + if (list != null) { + acc[ex.id] = { + ...list, + rules: [...list.rules, { id: rule.id, name: rule.name }], + }; + } + }); + } + + return acc; + }, listsSkeleton); + + const lists = Object.keys(updatedLists).map( + (listKey) => updatedLists[listKey] + ); + + setExceptionsListInfo(lists); + + if (isSubscribed) { + setLoading(false); + } + } catch (error) { + if (isSubscribed) { + setLoading(false); + } + } + }; + + fetchData(); + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [exceptionLists.length, exceptionLists]); + + return [loading, exceptionsListInfo]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index be42d7b3212f..b3ad1988a43e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -158,7 +158,7 @@ describe('AllRules', () => { /> ); - expect(wrapper.find('[title="All rules"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="allRulesTableTab-rules"]')).toHaveLength(1); }); it('it pulls from uiSettings to determine default refresh values', async () => { @@ -230,7 +230,7 @@ describe('AllRules', () => { }); }); - describe('rules tab', () => { + describe('tabs', () => { it('renders all rules tab by default', async () => { const wrapper = mount( @@ -283,4 +283,33 @@ describe('AllRules', () => { expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy(); }); }); + + it('renders exceptions lists tab when tab clicked', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + const exceptionsTab = wrapper.find('[data-test-subj="allRulesTableTab-exceptions"] button'); + exceptionsTab.simulate('click'); + + wrapper.update(); + expect(wrapper.exists('[data-test-subj="allExceptionListsPanel"]')).toBeTruthy(); + expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy(); + expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index 6ec06d5115f0..4c4095ee6f74 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -4,79 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBasicTable, - EuiLoadingContent, - EuiSpacer, - EuiTab, - EuiTabs, - EuiProgress, - EuiOverlayMask, - EuiConfirmModal, - EuiWindowEvent, -} from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import uuid from 'uuid'; -import { debounce } from 'lodash/fp'; -import { - useRules, - useRulesStatuses, - CreatePreBuiltRules, - FilterOptions, - Rule, - PaginationOptions, - exportRules, - RulesSortingFields, -} from '../../../../containers/detection_engine/rules'; -import { HeaderSection } from '../../../../../common/components/header_section'; -import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; -import { useStateToaster } from '../../../../../common/components/toasters'; -import { Loader } from '../../../../../common/components/loader'; -import { Panel } from '../../../../../common/components/panel'; -import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt'; -import { GenericDownloader } from '../../../../../common/components/generic_downloader'; -import { AllRulesTables, SortingType } from '../../../../components/rules/all_rules_tables'; -import { getPrePackagedRuleStatus } from '../helpers'; -import * as i18n from '../translations'; -import { EuiBasicTableOnChange } from '../types'; -import { getBatchItems } from './batch_actions'; -import { getColumns, getMonitoringColumns } from './columns'; -import { showRulesTable } from './helpers'; -import { allRulesReducer, State } from './reducer'; -import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; -import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities'; -import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; -import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license'; import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; -import { isBoolean } from '../../../../../common/utils/privileges'; -import { AllRulesUtilityBar } from './utility_bar'; -import { LastUpdatedAt } from '../../../../../common/components/last_updated'; -import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; - -const INITIAL_SORT_FIELD = 'enabled'; -const initialState: State = { - exportRuleIds: [], - filterOptions: { - filter: '', - sortField: INITIAL_SORT_FIELD, - sortOrder: 'desc', - }, - loadingRuleIds: [], - loadingRulesAction: null, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - rules: [], - selectedRuleIds: [], - lastUpdated: 0, - showIdleModal: false, - isRefreshOn: true, -}; +import { CreatePreBuiltRules } from '../../../../containers/detection_engine/rules'; +import { RulesTables } from './rules_tables'; +import * as i18n from '../translations'; +import { ExceptionListsTable } from './exceptions/exceptions_table'; interface AllRulesProps { createPrePackagedRules: CreatePreBuiltRules | null; @@ -94,6 +31,7 @@ interface AllRulesProps { export enum AllRulesTabs { rules = 'rules', monitoring = 'monitoring', + exceptions = 'exceptions', } const allRulesTabs = [ @@ -107,6 +45,11 @@ const allRulesTabs = [ name: i18n.MONITORING_TAB, disabled: false, }, + { + id: AllRulesTabs.exceptions, + name: i18n.EXCEPTIONS_TAB, + disabled: false, + }, ]; /** @@ -130,312 +73,9 @@ export const AllRules = React.memo( rulesNotUpdated, setRefreshRulesData, }) => { - const [initLoading, setInitLoading] = useState(true); - const tableRef = useRef(); - const { - services: { - application: { - capabilities: { actions }, - }, - }, - } = useKibana(); - const [defaultAutoRefreshSetting] = useUiSetting$<{ - on: boolean; - value: number; - idleTimeout: number; - }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); - const [ - { - exportRuleIds, - filterOptions, - loadingRuleIds, - loadingRulesAction, - pagination, - rules, - selectedRuleIds, - lastUpdated, - showIdleModal, - isRefreshOn, - }, - dispatch, - ] = useReducer(allRulesReducer(tableRef), { - ...initialState, - lastUpdated: Date.now(), - isRefreshOn: defaultAutoRefreshSetting.on, - }); - const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); - const [, dispatchToaster] = useStateToaster(); - const mlCapabilities = useMlCapabilities(); - const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); const { formatUrl } = useFormatUrl(SecurityPageName.detections); - - // TODO: Refactor license check + hasMlAdminPermissions to common check - const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); - - const setRules = useCallback((newRules: Rule[], newPagination: Partial) => { - dispatch({ - type: 'setRules', - rules: newRules, - pagination: newPagination, - }); - }, []); - - const setShowIdleModal = useCallback((show: boolean) => { - dispatch({ - type: 'setShowIdleModal', - show, - }); - }, []); - - const setLastRefreshDate = useCallback(() => { - dispatch({ - type: 'setLastRefreshDate', - }); - }, []); - - const setAutoRefreshOn = useCallback((on: boolean) => { - dispatch({ - type: 'setAutoRefreshOn', - on, - }); - }, []); - - const [isLoadingRules, , reFetchRulesData] = useRules({ - pagination, - filterOptions, - refetchPrePackagedRulesStatus, - dispatchRulesInReducer: setRules, - }); - - const sorting = useMemo( - (): SortingType => ({ - sort: { - field: filterOptions.sortField, - direction: filterOptions.sortOrder, - }, - }), - [filterOptions] - ); - - const prePackagedRuleStatus = getPrePackagedRuleStatus( - rulesInstalled, - rulesNotInstalled, - rulesNotUpdated - ); - - const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [ - actions, - ]); - - const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void): JSX.Element[] => { - return getBatchItems({ - closePopover, - dispatch, - dispatchToaster, - hasMlPermissions, - hasActionsPrivileges, - loadingRuleIds, - selectedRuleIds, - reFetchRules: reFetchRulesData, - rules, - }); - }, - [ - dispatch, - dispatchToaster, - hasMlPermissions, - loadingRuleIds, - reFetchRulesData, - rules, - selectedRuleIds, - hasActionsPrivileges, - ] - ); - - const paginationMemo = useMemo( - () => ({ - pageIndex: pagination.page - 1, - pageSize: pagination.perPage, - totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], - }), - [pagination] - ); - - const tableOnChangeCallback = useCallback( - ({ page, sort }: EuiBasicTableOnChange) => { - dispatch({ - type: 'updateFilterOptions', - filterOptions: { - sortField: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types - sortOrder: sort?.direction ?? 'desc', - }, - pagination: { page: page.index + 1, perPage: page.size }, - }); - }, - [dispatch] - ); - - const rulesColumns = useMemo(() => { - return getColumns({ - dispatch, - dispatchToaster, - formatUrl, - history, - hasMlPermissions, - hasNoPermissions, - loadingRuleIds: - loadingRulesAction != null && - (loadingRulesAction === 'enable' || loadingRulesAction === 'disable') - ? loadingRuleIds - : [], - reFetchRules: reFetchRulesData, - hasReadActionsPrivileges: hasActionsPrivileges, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - dispatch, - dispatchToaster, - formatUrl, - hasMlPermissions, - history, - loadingRuleIds, - loadingRulesAction, - reFetchRulesData, - ]); - - const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [ - history, - formatUrl, - ]); - - useEffect(() => { - if (reFetchRulesData != null) { - setRefreshRulesData(reFetchRulesData); - } - }, [reFetchRulesData, setRefreshRulesData]); - - useEffect(() => { - if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) { - setInitLoading(false); - } - }, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]); - - const handleCreatePrePackagedRules = useCallback(async () => { - if (createPrePackagedRules != null && reFetchRulesData != null) { - await createPrePackagedRules(); - reFetchRulesData(true); - } - }, [createPrePackagedRules, reFetchRulesData]); - - const euiBasicTableSelectionProps = useMemo( - () => ({ - selectable: (item: Rule) => !loadingRuleIds.includes(item.id), - onSelectionChange: (selected: Rule[]) => - dispatch({ type: 'selectedRuleIds', ids: selected.map((r) => r.id) }), - }), - [loadingRuleIds] - ); - - const onFilterChangedCallback = useCallback((newFilterOptions: Partial) => { - dispatch({ - type: 'updateFilterOptions', - filterOptions: { - ...newFilterOptions, - }, - pagination: { page: 1 }, - }); - }, []); - - const isLoadingAnActionOnRule = useMemo(() => { - if ( - loadingRuleIds.length > 0 && - (loadingRulesAction === 'disable' || loadingRulesAction === 'enable') - ) { - return false; - } else if (loadingRuleIds.length > 0) { - return true; - } - return false; - }, [loadingRuleIds, loadingRulesAction]); - - const handleRefreshData = useCallback((): void => { - if (reFetchRulesData != null && !isLoadingAnActionOnRule) { - reFetchRulesData(true); - setLastRefreshDate(); - } - }, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]); - - const handleResetIdleTimer = useCallback((): void => { - if (isRefreshOn) { - setShowIdleModal(true); - setAutoRefreshOn(false); - } - }, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]); - - const debounceResetIdleTimer = useMemo(() => { - return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer); - }, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]); - - useEffect(() => { - const interval = setInterval(() => { - if (isRefreshOn) { - handleRefreshData(); - } - }, defaultAutoRefreshSetting.value); - - return () => { - clearInterval(interval); - }; - }, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]); - - const handleIdleModalContinue = useCallback((): void => { - setShowIdleModal(false); - handleRefreshData(); - setAutoRefreshOn(true); - }, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]); - - const handleAutoRefreshSwitch = useCallback( - (refreshOn: boolean) => { - if (refreshOn) { - handleRefreshData(); - } - setAutoRefreshOn(refreshOn); - }, - [setAutoRefreshOn, handleRefreshData] - ); - - const shouldShowRulesTable = useMemo( - (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, - [initLoading, rulesCustomInstalled, rulesInstalled] - ); - - const shouldShowPrepackagedRulesPrompt = useMemo( - (): boolean => - rulesCustomInstalled != null && - rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && - !initLoading, - [initLoading, prePackagedRuleStatus, rulesCustomInstalled] - ); - - const handleGenericDownloaderSuccess = useCallback( - (exportCount) => { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }, - [dispatchToaster] - ); + const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); const tabs = useMemo( () => ( @@ -459,109 +99,35 @@ export const AllRules = React.memo( return ( <> - - - - - - - {tabs} - - <> - {(isLoadingRules || isLoadingRulesStatuses) && ( - - )} - - } - > - - - - {isLoadingAnActionOnRule && !initLoading && ( - - )} - {shouldShowPrepackagedRulesPrompt && ( - - )} - {initLoading && ( - - )} - {showIdleModal && ( - - -

{i18n.REFRESH_PROMPT_BODY}

-
-
- )} - {shouldShowRulesTable && ( - <> - - - - )} - -
+ {(allRulesTab === AllRulesTabs.rules || allRulesTab === AllRulesTabs.monitoring) && ( + + )} + {allRulesTab === AllRulesTabs.exceptions && ( + + )} ); } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx new file mode 100644 index 000000000000..99d6c70ee409 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -0,0 +1,530 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBasicTable, + EuiLoadingContent, + EuiProgress, + EuiOverlayMask, + EuiConfirmModal, + EuiWindowEvent, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import uuid from 'uuid'; +import { debounce } from 'lodash/fp'; +import { History } from 'history'; + +import { + useRules, + useRulesStatuses, + CreatePreBuiltRules, + FilterOptions, + Rule, + PaginationOptions, + exportRules, + RulesSortingFields, +} from '../../../../containers/detection_engine/rules'; +import { FormatUrl } from '../../../../../common/components/link_to'; +import { HeaderSection } from '../../../../../common/components/header_section'; +import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; +import { useStateToaster } from '../../../../../common/components/toasters'; +import { Loader } from '../../../../../common/components/loader'; +import { Panel } from '../../../../../common/components/panel'; +import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt'; +import { GenericDownloader } from '../../../../../common/components/generic_downloader'; +import { AllRulesTables, SortingType } from '../../../../components/rules/all_rules_tables'; +import { getPrePackagedRuleStatus } from '../helpers'; +import * as i18n from '../translations'; +import { EuiBasicTableOnChange } from '../types'; +import { getBatchItems } from './batch_actions'; +import { getColumns, getMonitoringColumns } from './columns'; +import { showRulesTable } from './helpers'; +import { allRulesReducer, State } from './reducer'; +import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; +import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities'; +import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; +import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license'; +import { isBoolean } from '../../../../../common/utils/privileges'; +import { AllRulesUtilityBar } from './utility_bar'; +import { LastUpdatedAt } from '../../../../../common/components/last_updated'; +import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; +import { AllRulesTabs } from '.'; + +const INITIAL_SORT_FIELD = 'enabled'; +const initialState: State = { + exportRuleIds: [], + filterOptions: { + filter: '', + sortField: INITIAL_SORT_FIELD, + sortOrder: 'desc', + }, + loadingRuleIds: [], + loadingRulesAction: null, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + rules: [], + selectedRuleIds: [], + lastUpdated: 0, + showIdleModal: false, + isRefreshOn: true, +}; + +interface RulesTableProps { + history: History; + formatUrl: FormatUrl; + createPrePackagedRules: CreatePreBuiltRules | null; + hasNoPermissions: boolean; + loading: boolean; + loadingCreatePrePackagedRules: boolean; + refetchPrePackagedRulesStatus: () => void; + rulesCustomInstalled: number | null; + rulesInstalled: number | null; + rulesNotInstalled: number | null; + rulesNotUpdated: number | null; + setRefreshRulesData: (refreshRule: (refreshPrePackagedRule?: boolean) => void) => void; + selectedTab: AllRulesTabs; +} + +/** + * Table Component for displaying all Rules for a given cluster. Provides the ability to filter + * by name, sort by enabled, and perform the following actions: + * * Enable/Disable + * * Duplicate + * * Delete + * * Import/Export + */ +export const RulesTables = React.memo( + ({ + history, + formatUrl, + createPrePackagedRules, + hasNoPermissions, + loading, + loadingCreatePrePackagedRules, + refetchPrePackagedRulesStatus, + rulesCustomInstalled, + rulesInstalled, + rulesNotInstalled, + rulesNotUpdated, + setRefreshRulesData, + selectedTab, + }) => { + const [initLoading, setInitLoading] = useState(true); + const tableRef = useRef(); + const { + services: { + application: { + capabilities: { actions }, + }, + }, + } = useKibana(); + const [defaultAutoRefreshSetting] = useUiSetting$<{ + on: boolean; + value: number; + idleTimeout: number; + }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); + const [ + { + exportRuleIds, + filterOptions, + loadingRuleIds, + loadingRulesAction, + pagination, + rules, + selectedRuleIds, + lastUpdated, + showIdleModal, + isRefreshOn, + }, + dispatch, + ] = useReducer(allRulesReducer(tableRef), { + ...initialState, + lastUpdated: Date.now(), + isRefreshOn: defaultAutoRefreshSetting.on, + }); + const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); + const [, dispatchToaster] = useStateToaster(); + const mlCapabilities = useMlCapabilities(); + + // TODO: Refactor license check + hasMlAdminPermissions to common check + const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); + + const setRules = useCallback((newRules: Rule[], newPagination: Partial) => { + dispatch({ + type: 'setRules', + rules: newRules, + pagination: newPagination, + }); + }, []); + + const setShowIdleModal = useCallback((show: boolean) => { + dispatch({ + type: 'setShowIdleModal', + show, + }); + }, []); + + const setLastRefreshDate = useCallback(() => { + dispatch({ + type: 'setLastRefreshDate', + }); + }, []); + + const setAutoRefreshOn = useCallback((on: boolean) => { + dispatch({ + type: 'setAutoRefreshOn', + on, + }); + }, []); + + const [isLoadingRules, , reFetchRulesData] = useRules({ + pagination, + filterOptions, + refetchPrePackagedRulesStatus, + dispatchRulesInReducer: setRules, + }); + + const sorting = useMemo( + (): SortingType => ({ + sort: { + field: filterOptions.sortField, + direction: filterOptions.sortOrder, + }, + }), + [filterOptions] + ); + + const prePackagedRuleStatus = getPrePackagedRuleStatus( + rulesInstalled, + rulesNotInstalled, + rulesNotUpdated + ); + + const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [ + actions, + ]); + + const getBatchItemsPopoverContent = useCallback( + (closePopover: () => void): JSX.Element[] => { + return getBatchItems({ + closePopover, + dispatch, + dispatchToaster, + hasMlPermissions, + hasActionsPrivileges, + loadingRuleIds, + selectedRuleIds, + reFetchRules: reFetchRulesData, + rules, + }); + }, + [ + dispatch, + dispatchToaster, + hasMlPermissions, + loadingRuleIds, + reFetchRulesData, + rules, + selectedRuleIds, + hasActionsPrivileges, + ] + ); + + const paginationMemo = useMemo( + () => ({ + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: pagination.total, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }), + [pagination] + ); + + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + dispatch({ + type: 'updateFilterOptions', + filterOptions: { + sortField: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types + sortOrder: sort?.direction ?? 'desc', + }, + pagination: { page: page.index + 1, perPage: page.size }, + }); + }, + [dispatch] + ); + + const rulesColumns = useMemo(() => { + return getColumns({ + dispatch, + dispatchToaster, + formatUrl, + history, + hasMlPermissions, + hasNoPermissions, + loadingRuleIds: + loadingRulesAction != null && + (loadingRulesAction === 'enable' || loadingRulesAction === 'disable') + ? loadingRuleIds + : [], + reFetchRules: reFetchRulesData, + hasReadActionsPrivileges: hasActionsPrivileges, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + dispatch, + dispatchToaster, + formatUrl, + hasMlPermissions, + history, + loadingRuleIds, + loadingRulesAction, + reFetchRulesData, + ]); + + const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [ + history, + formatUrl, + ]); + + useEffect(() => { + if (reFetchRulesData != null) { + setRefreshRulesData(reFetchRulesData); + } + }, [reFetchRulesData, setRefreshRulesData]); + + useEffect(() => { + if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) { + setInitLoading(false); + } + }, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]); + + const handleCreatePrePackagedRules = useCallback(async () => { + if (createPrePackagedRules != null && reFetchRulesData != null) { + await createPrePackagedRules(); + reFetchRulesData(true); + } + }, [createPrePackagedRules, reFetchRulesData]); + + const euiBasicTableSelectionProps = useMemo( + () => ({ + selectable: (item: Rule) => !loadingRuleIds.includes(item.id), + onSelectionChange: (selected: Rule[]) => + dispatch({ type: 'selectedRuleIds', ids: selected.map((r) => r.id) }), + }), + [loadingRuleIds] + ); + + const onFilterChangedCallback = useCallback((newFilterOptions: Partial) => { + dispatch({ + type: 'updateFilterOptions', + filterOptions: { + ...newFilterOptions, + }, + pagination: { page: 1 }, + }); + }, []); + + const isLoadingAnActionOnRule = useMemo(() => { + if ( + loadingRuleIds.length > 0 && + (loadingRulesAction === 'disable' || loadingRulesAction === 'enable') + ) { + return false; + } else if (loadingRuleIds.length > 0) { + return true; + } + return false; + }, [loadingRuleIds, loadingRulesAction]); + + const handleRefreshData = useCallback((): void => { + if (reFetchRulesData != null && !isLoadingAnActionOnRule) { + reFetchRulesData(true); + setLastRefreshDate(); + } + }, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]); + + const handleResetIdleTimer = useCallback((): void => { + if (isRefreshOn) { + setShowIdleModal(true); + setAutoRefreshOn(false); + } + }, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]); + + const debounceResetIdleTimer = useMemo(() => { + return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer); + }, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]); + + useEffect(() => { + const interval = setInterval(() => { + if (isRefreshOn) { + handleRefreshData(); + } + }, defaultAutoRefreshSetting.value); + + return () => { + clearInterval(interval); + }; + }, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]); + + const handleIdleModalContinue = useCallback((): void => { + setShowIdleModal(false); + handleRefreshData(); + setAutoRefreshOn(true); + }, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]); + + const handleAutoRefreshSwitch = useCallback( + (refreshOn: boolean) => { + if (refreshOn) { + handleRefreshData(); + } + setAutoRefreshOn(refreshOn); + }, + [setAutoRefreshOn, handleRefreshData] + ); + + const shouldShowRulesTable = useMemo( + (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, + [initLoading, rulesCustomInstalled, rulesInstalled] + ); + + const shouldShowPrepackagedRulesPrompt = useMemo( + (): boolean => + rulesCustomInstalled != null && + rulesCustomInstalled === 0 && + prePackagedRuleStatus === 'ruleNotInstalled' && + !initLoading, + [initLoading, prePackagedRuleStatus, rulesCustomInstalled] + ); + + const handleGenericDownloaderSuccess = useCallback( + (exportCount) => { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }, + [dispatchToaster] + ); + + return ( + <> + + + + + + + + + + <> + {(isLoadingRules || isLoadingRulesStatuses) && ( + + )} + + } + > + + + + {isLoadingAnActionOnRule && !initLoading && ( + + )} + {shouldShowPrepackagedRulesPrompt && ( + + )} + {initLoading && ( + + )} + {showIdleModal && ( + + +

{i18n.REFRESH_PROMPT_BODY}

+
+
+ )} + {shouldShowRulesTable && ( + <> + + + + )} + +
+ + ); + } +); + +RulesTables.displayName = 'RulesTables'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx index f454f9b91400..b69f0d6edf8f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx @@ -22,10 +22,11 @@ describe('AllRules', () => { userHasNoPermissions={false} onRefresh={jest.fn()} paginationTotal={4} - numberSelectedRules={1} + numberSelectedItems={1} onGetBatchItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} + showBulkActions /> ); @@ -36,6 +37,29 @@ describe('AllRules', () => { ); }); + it('does not render total selected and bulk actions when "showBulkActions" is false', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="showingRules"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="tableBulkActions"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="showingExceptionLists"]').at(0).text()).toEqual( + 'Showing 4 lists' + ); + }); + it('renders utility actions if user has permissions', () => { const wrapper = mount( @@ -43,10 +67,11 @@ describe('AllRules', () => { userHasNoPermissions={false} onRefresh={jest.fn()} paginationTotal={4} - numberSelectedRules={1} + numberSelectedItems={1} onGetBatchItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} + showBulkActions /> ); @@ -61,10 +86,11 @@ describe('AllRules', () => { userHasNoPermissions onRefresh={jest.fn()} paginationTotal={4} - numberSelectedRules={1} + numberSelectedItems={1} onGetBatchItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} + showBulkActions /> ); @@ -80,10 +106,11 @@ describe('AllRules', () => { userHasNoPermissions={false} onRefresh={mockRefresh} paginationTotal={4} - numberSelectedRules={1} + numberSelectedItems={1} onGetBatchItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={jest.fn()} + showBulkActions /> ); @@ -101,10 +128,11 @@ describe('AllRules', () => { userHasNoPermissions={false} onRefresh={jest.fn()} paginationTotal={4} - numberSelectedRules={1} + numberSelectedItems={1} onGetBatchItemsPopoverContent={jest.fn()} isAutoRefreshOn={true} onRefreshSwitch={mockSwitch} + showBulkActions /> ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index 3553dcc9b7c1..517baa8bbee3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -18,12 +18,13 @@ import * as i18n from '../translations'; interface AllRulesUtilityBarProps { userHasNoPermissions: boolean; - numberSelectedRules: number; + numberSelectedItems: number; paginationTotal: number; - isAutoRefreshOn: boolean; - onRefresh: (refreshRule: boolean) => void; - onGetBatchItemsPopoverContent: (closePopover: () => void) => JSX.Element[]; - onRefreshSwitch: (checked: boolean) => void; + isAutoRefreshOn?: boolean; + showBulkActions: boolean; + onRefresh?: (refreshRule: boolean) => void; + onGetBatchItemsPopoverContent?: (closePopover: () => void) => JSX.Element[]; + onRefreshSwitch?: (checked: boolean) => void; } export const AllRulesUtilityBar = React.memo( @@ -31,22 +32,29 @@ export const AllRulesUtilityBar = React.memo( userHasNoPermissions, onRefresh, paginationTotal, - numberSelectedRules, + numberSelectedItems, onGetBatchItemsPopoverContent, isAutoRefreshOn, + showBulkActions = true, onRefreshSwitch, }) => { const handleGetBatchItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - ), + (closePopover: () => void): JSX.Element | null => { + if (onGetBatchItemsPopoverContent != null) { + return ; + } else { + return null; + } + }, [onGetBatchItemsPopoverContent] ); const handleAutoRefreshSwitch = useCallback( (closePopover: () => void) => (e: EuiSwitchEvent) => { - onRefreshSwitch(e.target.checked); - closePopover(); + if (onRefreshSwitch != null) { + onRefreshSwitch(e.target.checked); + closePopover(); + } }, [onRefreshSwitch] ); @@ -58,7 +66,7 @@ export const AllRulesUtilityBar = React.memo( ( - - {i18n.SHOWING_RULES(paginationTotal)} - + {showBulkActions ? ( + + {i18n.SHOWING_RULES(paginationTotal)} + + ) : ( + + {i18n.SHOWING_EXCEPTION_LISTS(paginationTotal)} + + )} - - - {i18n.SELECTED_RULES(numberSelectedRules)} - - {!userHasNoPermissions && ( + {showBulkActions ? ( + <> + + + {i18n.SELECTED_RULES(numberSelectedItems)} + + {!userHasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} + + + {i18n.REFRESH} + + + {i18n.REFRESH_RULE_POPOVER_LABEL} + + + + ) : ( + - {i18n.BATCH_ACTIONS} + {i18n.REFRESH} - )} - - {i18n.REFRESH} - - - {i18n.REFRESH_RULE_POPOVER_LABEL} - - + + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 38fb457185b6..2d993c7be08b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -374,7 +374,14 @@ export const RULES_TAB = i18n.translate( export const MONITORING_TAB = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring', { - defaultMessage: 'Monitoring', + defaultMessage: 'Rule Monitoring', + } +); + +export const EXCEPTIONS_TAB = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions', + { + defaultMessage: 'Exception Lists', } ); @@ -589,3 +596,9 @@ export const REFRESH_RULE_POPOVER_LABEL = i18n.translate( defaultMessage: 'Refresh settings', } ); + +export const SHOWING_EXCEPTION_LISTS = (totalLists: number) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists', { + values: { totalLists }, + defaultMessage: 'Showing {totalLists} {totalLists, plural, =1 {list} other {lists}}', + }); diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index a0484ca39c2b..eb1ebabf9b10 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -36,7 +36,8 @@ export { useCursor, useApi, useAsync, - useExceptionList, + useExceptionListItems, + useExceptionLists, usePersistExceptionItem, usePersistExceptionList, useFindLists, @@ -52,7 +53,7 @@ export { ExceptionListIdentifiers, ExceptionList, Pagination, - UseExceptionListSuccess, + UseExceptionListItemsSuccess, addEndpointExceptionList, withOptionalSignal, } from '../../lists/public';