From 0756dd3ae71b582e151dd283a9d470847a3e541f Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 29 Jul 2020 17:51:09 -0400 Subject: [PATCH] [Security Solution][Exceptions] - Updates exception hooks and viewer (#73588) ## Summary This PR focuses on addressing issues around the pagination and functionality of rules with numerous (2+) exception lists. - Updated the `use_exception_list.ts` hook to make use of the new multi list find API - Updated the viewer to make use of the new multi list find API - Previously was doing a lot of the filtering and paging manually (and badly) in the UI, now the _find takes care of all that - Added logic for showing `No results` text if user filter/search returns no items - Previously would show the `This rule has not exceptions` text --- .../lists/public/exceptions/api.test.ts | 102 ++--- x-pack/plugins/lists/public/exceptions/api.ts | 48 +-- .../public/exceptions/hooks/use_api.test.ts | 115 +++++- .../lists/public/exceptions/hooks/use_api.ts | 57 ++- .../hooks/use_exception_list.test.ts | 352 ++++++++++++++---- .../exceptions/hooks/use_exception_list.ts | 190 +++++----- .../plugins/lists/public/exceptions/types.ts | 22 +- .../lists/public/exceptions/utils.test.ts | 105 ++++++ .../plugins/lists/public/exceptions/utils.ts | 36 ++ .../new/exception_list_item_auto_id.json | 2 +- .../components/exceptions/translations.ts | 14 + .../common/components/exceptions/types.ts | 4 +- .../viewer/exceptions_pagination.test.tsx | 7 +- .../viewer/exceptions_pagination.tsx | 8 +- .../viewer/exceptions_utility.test.tsx | 48 +-- .../exceptions/viewer/exceptions_utility.tsx | 12 +- .../viewer/exceptions_viewer_header.test.tsx | 16 +- .../viewer/exceptions_viewer_header.tsx | 28 +- ...t.tsx => exceptions_viewer_items.test.tsx} | 39 +- .../viewer/exceptions_viewer_items.tsx | 20 +- .../components/exceptions/viewer/index.tsx | 148 +++++--- .../components/exceptions/viewer/reducer.ts | 104 +++--- .../rules/patches/simplest_updated_name.json | 60 ++- 23 files changed, 1114 insertions(+), 423 deletions(-) create mode 100644 x-pack/plugins/lists/public/exceptions/utils.test.ts create mode 100644 x-pack/plugins/lists/public/exceptions/utils.ts rename x-pack/plugins/security_solution/public/common/components/exceptions/viewer/{exceptions_viewer_item.test.tsx => exceptions_viewer_items.test.tsx} (78%) diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index 455670098307fd..9add15c533d145 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -26,7 +26,7 @@ import { deleteExceptionListItemById, fetchExceptionListById, fetchExceptionListItemById, - fetchExceptionListItemsByListId, + fetchExceptionListsItemsByListIds, updateExceptionList, updateExceptionListItem, } from './api'; @@ -358,17 +358,18 @@ describe('Exceptions Lists API', () => { }); }); - describe('#fetchExceptionListItemsByListId', () => { + describe('#fetchExceptionListsItemsByListIds', () => { beforeEach(() => { fetchMock.mockClear(); fetchMock.mockResolvedValue(getFoundExceptionListItemSchemaMock()); }); - test('it invokes "fetchExceptionListItemsByListId" with expected url and body values', async () => { - await fetchExceptionListItemsByListId({ + test('it invokes "fetchExceptionListsItemsByListIds" with expected url and body values', async () => { + await fetchExceptionListsItemsByListIds({ + filterOptions: [], http: mockKibanaHttpService(), - listId: 'myList', - namespaceType: 'single', + listIds: ['myList', 'myOtherListId'], + namespaceTypes: ['single', 'single'], pagination: { page: 1, perPage: 20, @@ -379,8 +380,8 @@ describe('Exceptions Lists API', () => { expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', { method: 'GET', query: { - list_id: 'myList', - namespace_type: 'single', + list_id: 'myList,myOtherListId', + namespace_type: 'single,single', page: '1', per_page: '20', }, @@ -389,14 +390,16 @@ describe('Exceptions Lists API', () => { }); test('it invokes with expected url and body values when a filter exists and "namespaceType" of "single"', async () => { - await fetchExceptionListItemsByListId({ - filterOptions: { - filter: 'hello world', - tags: [], - }, + await fetchExceptionListsItemsByListIds({ + filterOptions: [ + { + filter: 'hello world', + tags: [], + }, + ], http: mockKibanaHttpService(), - listId: 'myList', - namespaceType: 'single', + listIds: ['myList'], + namespaceTypes: ['single'], pagination: { page: 1, perPage: 20, @@ -418,14 +421,16 @@ describe('Exceptions Lists API', () => { }); test('it invokes with expected url and body values when a filter exists and "namespaceType" of "agnostic"', async () => { - await fetchExceptionListItemsByListId({ - filterOptions: { - filter: 'hello world', - tags: [], - }, + await fetchExceptionListsItemsByListIds({ + filterOptions: [ + { + filter: 'hello world', + tags: [], + }, + ], http: mockKibanaHttpService(), - listId: 'myList', - namespaceType: 'agnostic', + listIds: ['myList'], + namespaceTypes: ['agnostic'], pagination: { page: 1, perPage: 20, @@ -447,14 +452,16 @@ describe('Exceptions Lists API', () => { }); test('it invokes with expected url and body values when tags exists', async () => { - await fetchExceptionListItemsByListId({ - filterOptions: { - filter: '', - tags: ['malware'], - }, + await fetchExceptionListsItemsByListIds({ + filterOptions: [ + { + filter: '', + tags: ['malware'], + }, + ], http: mockKibanaHttpService(), - listId: 'myList', - namespaceType: 'agnostic', + listIds: ['myList'], + namespaceTypes: ['agnostic'], pagination: { page: 1, perPage: 20, @@ -476,14 +483,16 @@ describe('Exceptions Lists API', () => { }); test('it invokes with expected url and body values when filter and tags exists', async () => { - await fetchExceptionListItemsByListId({ - filterOptions: { - filter: 'host.name', - tags: ['malware'], - }, + await fetchExceptionListsItemsByListIds({ + filterOptions: [ + { + filter: 'host.name', + tags: ['malware'], + }, + ], http: mockKibanaHttpService(), - listId: 'myList', - namespaceType: 'agnostic', + listIds: ['myList'], + namespaceTypes: ['agnostic'], pagination: { page: 1, perPage: 20, @@ -506,10 +515,11 @@ describe('Exceptions Lists API', () => { }); test('it returns expected format when call succeeds', async () => { - const exceptionResponse = await fetchExceptionListItemsByListId({ + const exceptionResponse = await fetchExceptionListsItemsByListIds({ + filterOptions: [], http: mockKibanaHttpService(), - listId: 'endpoint_list_id', - namespaceType: 'single', + listIds: ['endpoint_list_id'], + namespaceTypes: ['single'], pagination: { page: 1, perPage: 20, @@ -521,16 +531,17 @@ describe('Exceptions Lists API', () => { test('it returns error and does not make request if request payload fails decode', async () => { const payload = ({ + filterOptions: [], http: mockKibanaHttpService(), - listId: '1', - namespaceType: 'not a namespace type', + listIds: ['myList'], + namespaceTypes: ['not a namespace type'], pagination: { page: 1, perPage: 20, }, signal: abortCtrl.signal, } as unknown) as ApiCallByListIdProps & { listId: number }; - await expect(fetchExceptionListItemsByListId(payload)).rejects.toEqual( + await expect(fetchExceptionListsItemsByListIds(payload)).rejects.toEqual( 'Invalid value "not a namespace type" supplied to "namespace_type"' ); }); @@ -541,10 +552,11 @@ describe('Exceptions Lists API', () => { fetchMock.mockResolvedValue(badPayload); await expect( - fetchExceptionListItemsByListId({ + fetchExceptionListsItemsByListIds({ + filterOptions: [], http: mockKibanaHttpService(), - listId: 'myList', - namespaceType: 'single', + listIds: ['myList'], + namespaceTypes: ['single'], pagination: { page: 1, perPage: 20, diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 4d9397ec0adc6c..d661cb103fad80 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -249,42 +249,46 @@ export const fetchExceptionListById = async ({ * Fetch an ExceptionList's ExceptionItems by providing a ExceptionList list_id * * @param http Kibana http service - * @param listId ExceptionList list_id (not ID) - * @param namespaceType ExceptionList namespace_type + * @param listIds ExceptionList list_ids (not ID) + * @param namespaceTypes ExceptionList namespace_types * @param filterOptions optional - filter by field or tags * @param pagination optional * @param signal to cancel request * * @throws An error if response is not OK */ -export const fetchExceptionListItemsByListId = async ({ +export const fetchExceptionListsItemsByListIds = async ({ http, - listId, - namespaceType, - filterOptions = { - filter: '', - tags: [], - }, + listIds, + namespaceTypes, + filterOptions, pagination, signal, }: ApiCallByListIdProps): Promise => { - const namespace = - namespaceType === 'agnostic' ? EXCEPTION_LIST_NAMESPACE_AGNOSTIC : EXCEPTION_LIST_NAMESPACE; - const filters = [ - ...(filterOptions.filter.length - ? [`${namespace}.attributes.entries.field:${filterOptions.filter}*`] - : []), - ...(filterOptions.tags.length - ? filterOptions.tags.map((t) => `${namespace}.attributes.tags:${t}`) - : []), - ]; + const filters: string = filterOptions + .map((filter, index) => { + const namespace = namespaceTypes[index]; + const filterNamespace = + namespace === 'agnostic' ? EXCEPTION_LIST_NAMESPACE_AGNOSTIC : EXCEPTION_LIST_NAMESPACE; + const formattedFilters = [ + ...(filter.filter.length + ? [`${filterNamespace}.attributes.entries.field:${filter.filter}*`] + : []), + ...(filter.tags.length + ? filter.tags.map((t) => `${filterNamespace}.attributes.tags:${t}`) + : []), + ]; + + return formattedFilters.join(' AND '); + }) + .join(','); const query = { - list_id: listId, - namespace_type: namespaceType, + list_id: listIds.join(','), + namespace_type: namespaceTypes.join(','), page: pagination.page ? `${pagination.page}` : '1', per_page: pagination.perPage ? `${pagination.perPage}` : '20', - ...(filters.length ? { filter: filters.join(' AND ') } : {}), + ...(filters.trim() !== '' ? { filter: filters } : {}), }; const [validatedRequest, errorsRequest] = validate(query, findExceptionListItemSchema); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts index 1e0f7e58a0f4cf..c93155274937e1 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts @@ -9,9 +9,10 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as api from '../api'; import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; +import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; import { HttpStart } from '../../../../../../src/core/public'; -import { ApiCallByIdProps } from '../types'; +import { ApiCallByIdProps, ApiCallByListIdProps } from '../types'; import { ExceptionsApi, useApi } from './use_api'; @@ -252,4 +253,116 @@ describe('useApi', () => { expect(onErrorMock).toHaveBeenCalledWith(mockError); }); }); + + test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionItem" used', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + const expected: ApiCallByListIdProps = { + filterOptions: [], + http: mockKibanaHttpService, + listIds: ['list_id'], + namespaceTypes: ['single'], + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); + }); + }); + + test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + }); + + expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [], + pagination: { + page: 0, + perPage: 20, + total: 0, + }, + }); + }); + }); + + test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], + onError: onErrorMock, + onSuccess: jest.fn(), + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); + }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts index 45e180d9d617c6..def2f2626b8ec3 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts @@ -9,7 +9,8 @@ import { useMemo } from 'react'; import * as Api from '../api'; import { HttpStart } from '../../../../../../src/core/public'; import { ExceptionListItemSchema, ExceptionListSchema } from '../../../common/schemas'; -import { ApiCallMemoProps } from '../types'; +import { ApiCallFindListsItemsMemoProps, ApiCallMemoProps } from '../types'; +import { getIdsAndNamespaces } from '../utils'; export interface ExceptionsApi { deleteExceptionItem: (arg: ApiCallMemoProps) => Promise; @@ -20,6 +21,7 @@ export interface ExceptionsApi { getExceptionList: ( arg: ApiCallMemoProps & { onSuccess: (arg: ExceptionListSchema) => void } ) => Promise; + getExceptionListsItems: (arg: ApiCallFindListsItemsMemoProps) => Promise; } export const useApi = (http: HttpStart): ExceptionsApi => { @@ -105,6 +107,59 @@ export const useApi = (http: HttpStart): ExceptionsApi => { onError(error); } }, + async getExceptionListsItems({ + lists, + filterOptions, + pagination, + showDetectionsListsOnly, + showEndpointListsOnly, + onSuccess, + onError, + }: ApiCallFindListsItemsMemoProps): Promise { + const abortCtrl = new AbortController(); + const { ids, namespaces } = getIdsAndNamespaces({ + lists, + showDetection: showDetectionsListsOnly, + showEndpoint: showEndpointListsOnly, + }); + + try { + if (ids.length > 0 && namespaces.length > 0) { + const { + data, + page, + per_page: perPage, + total, + } = await Api.fetchExceptionListsItemsByListIds({ + filterOptions, + http, + listIds: ids, + namespaceTypes: namespaces, + pagination, + signal: abortCtrl.signal, + }); + onSuccess({ + exceptions: data, + pagination: { + page, + perPage, + total, + }, + }); + } else { + onSuccess({ + exceptions: [], + pagination: { + page: 0, + perPage: pagination.perPage ?? 0, + total: 0, + }, + }); + } + } catch (error) { + onError(error); + } + }, }), [http] ); 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.test.ts index f678ed4faeeda0..3a8b1713b901be 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.test.ts @@ -8,10 +8,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as api from '../api'; import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; -import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; -import { ExceptionList, UseExceptionListProps, UseExceptionListSuccess } from '../types'; +import { UseExceptionListProps, UseExceptionListSuccess } from '../types'; import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list'; @@ -21,9 +20,8 @@ describe('useExceptionList', () => { const onErrorMock = jest.fn(); beforeEach(() => { - jest.spyOn(api, 'fetchExceptionListById').mockResolvedValue(getExceptionListSchemaMock()); jest - .spyOn(api, 'fetchExceptionListItemsByListId') + .spyOn(api, 'fetchExceptionListsItemsByListIds') .mockResolvedValue(getFoundExceptionListItemSchemaMock()); }); @@ -39,17 +37,20 @@ describe('useExceptionList', () => { ReturnExceptionListAndItems >(() => useExceptionList({ - filterOptions: { filter: '', tags: [] }, + filterOptions: [], http: mockKibanaHttpService, lists: [ { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, ], + matchFilters: false, onError: onErrorMock, pagination: { page: 1, perPage: 20, total: 0, }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, }) ); await waitForNextUpdate(); @@ -57,7 +58,6 @@ describe('useExceptionList', () => { expect(result.current).toEqual([ true, [], - [], { page: 1, perPage: 20, @@ -68,7 +68,7 @@ describe('useExceptionList', () => { }); }); - test('fetch exception list and items', async () => { + test('fetches exception items', async () => { await act(async () => { const onSuccessMock = jest.fn(); const { result, waitForNextUpdate } = renderHook< @@ -76,11 +76,12 @@ describe('useExceptionList', () => { ReturnExceptionListAndItems >(() => useExceptionList({ - filterOptions: { filter: '', tags: [] }, + filterOptions: [], http: mockKibanaHttpService, lists: [ { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, ], + matchFilters: false, onError: onErrorMock, onSuccess: onSuccessMock, pagination: { @@ -88,56 +89,279 @@ describe('useExceptionList', () => { perPage: 20, total: 0, }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, }) ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params await waitForNextUpdate(); await waitForNextUpdate(); - const expectedListResult: ExceptionList[] = [ - { ...getExceptionListSchemaMock(), totalItems: 1 }, - ]; - const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock() .data; const expectedResult: UseExceptionListSuccess = { exceptions: expectedListItemsResult, - lists: expectedListResult, pagination: { page: 1, perPage: 1, total: 1 }, }; expect(result.current).toEqual([ false, - expectedListResult, expectedListItemsResult, { page: 1, perPage: 1, total: 1, }, - result.current[4], + result.current[3], ]); expect(onSuccessMock).toHaveBeenCalledWith(expectedResult); }); }); - test('fetch a new exception list and its items', async () => { - const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById'); - const spyOnfetchExceptionListItemsByListId = jest.spyOn(api, 'fetchExceptionListItemsByListId'); + test('fetches only detection list items if "showDetectionsListsOnly" is true', async () => { + const spyOnfetchExceptionListsItemsByListIds = jest.spyOn( + api, + 'fetchExceptionListsItemsByListIds' + ); + + await act(async () => { + const onSuccessMock = jest.fn(); + const { waitForNextUpdate } = renderHook( + () => + useExceptionList({ + filterOptions: [], + http: mockKibanaHttpService, + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + matchFilters: false, + onError: onErrorMock, + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: true, + showEndpointListsOnly: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionListsItemsByListIds).toHaveBeenCalledWith({ + filterOptions: [], + http: mockKibanaHttpService, + listIds: ['list_id'], + namespaceTypes: ['single'], + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('fetches only detection list items if "showEndpointListsOnly" is true', async () => { + const spyOnfetchExceptionListsItemsByListIds = jest.spyOn( + api, + 'fetchExceptionListsItemsByListIds' + ); + + await act(async () => { + const onSuccessMock = jest.fn(); + const { waitForNextUpdate } = renderHook( + () => + useExceptionList({ + filterOptions: [], + http: mockKibanaHttpService, + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + matchFilters: false, + onError: onErrorMock, + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionListsItemsByListIds).toHaveBeenCalledWith({ + filterOptions: [], + http: mockKibanaHttpService, + listIds: ['list_id_endpoint'], + namespaceTypes: ['agnostic'], + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('does not fetch items if no lists to fetch', async () => { + const spyOnfetchExceptionListsItemsByListIds = jest.spyOn( + api, + 'fetchExceptionListsItemsByListIds' + ); + + await act(async () => { + const onSuccessMock = jest.fn(); + const { result, waitForNextUpdate } = renderHook< + UseExceptionListProps, + ReturnExceptionListAndItems + >(() => + useExceptionList({ + filterOptions: [], + http: mockKibanaHttpService, + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + matchFilters: false, + onError: onErrorMock, + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); + expect(result.current).toEqual([ + false, + [], + { + page: 0, + perPage: 20, + total: 0, + }, + result.current[3], + ]); + }); + }); + + test('applies first filterOptions filter to all lists if "matchFilters" is true', async () => { + const spyOnfetchExceptionListsItemsByListIds = jest.spyOn( + api, + 'fetchExceptionListsItemsByListIds' + ); + + await act(async () => { + const onSuccessMock = jest.fn(); + const { waitForNextUpdate } = renderHook( + () => + useExceptionList({ + filterOptions: [{ filter: 'host.name', tags: [] }], + http: mockKibanaHttpService, + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + matchFilters: true, + onError: onErrorMock, + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }) + ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(spyOnfetchExceptionListsItemsByListIds).toHaveBeenCalledWith({ + filterOptions: [ + { filter: 'host.name', tags: [] }, + { filter: 'host.name', tags: [] }, + ], + http: mockKibanaHttpService, + listIds: ['list_id', 'list_id_endpoint'], + namespaceTypes: ['single', 'agnostic'], + pagination: { page: 1, perPage: 20 }, + signal: new AbortController().signal, + }); + }); + }); + + test('fetches a new exception list and its items', async () => { + const spyOnfetchExceptionListsItemsByListIds = jest.spyOn( + api, + 'fetchExceptionListsItemsByListIds' + ); const onSuccessMock = jest.fn(); await act(async () => { const { rerender, waitForNextUpdate } = renderHook< UseExceptionListProps, ReturnExceptionListAndItems >( - ({ filterOptions, http, lists, pagination, onError, onSuccess }) => - useExceptionList({ filterOptions, http, lists, onError, onSuccess, pagination }), + ({ + filterOptions, + http, + lists, + matchFilters, + pagination, + onError, + onSuccess, + showDetectionsListsOnly, + showEndpointListsOnly, + }) => + useExceptionList({ + filterOptions, + http, + lists, + matchFilters, + onError, + onSuccess, + pagination, + showDetectionsListsOnly, + showEndpointListsOnly, + }), { initialProps: { - filterOptions: { filter: '', tags: [] }, + filterOptions: [], http: mockKibanaHttpService, lists: [ { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, ], + matchFilters: false, onError: onErrorMock, onSuccess: onSuccessMock, pagination: { @@ -145,16 +369,23 @@ describe('useExceptionList', () => { perPage: 20, total: 0, }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, }, } ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params await waitForNextUpdate(); + await waitForNextUpdate(); + rerender({ - filterOptions: { filter: '', tags: [] }, + filterOptions: [], http: mockKibanaHttpService, lists: [ { id: 'newListId', listId: 'new_list_id', namespaceType: 'single', type: 'detection' }, ], + matchFilters: false, onError: onErrorMock, onSuccess: onSuccessMock, pagination: { @@ -162,109 +393,92 @@ describe('useExceptionList', () => { perPage: 20, total: 0, }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, }); + // NOTE: Only need one call here because hook already initilaized await waitForNextUpdate(); - expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(2); - expect(spyOnfetchExceptionListItemsByListId).toHaveBeenCalledTimes(2); + expect(spyOnfetchExceptionListsItemsByListIds).toHaveBeenCalledTimes(2); }); }); test('fetches list and items when refreshExceptionList callback invoked', async () => { - const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById'); - const spyOnfetchExceptionListItemsByListId = jest.spyOn(api, 'fetchExceptionListItemsByListId'); + const spyOnfetchExceptionListsItemsByListIds = jest.spyOn( + api, + 'fetchExceptionListsItemsByListIds' + ); await act(async () => { const { result, waitForNextUpdate } = renderHook< UseExceptionListProps, ReturnExceptionListAndItems >(() => useExceptionList({ - filterOptions: { filter: '', tags: [] }, + filterOptions: [], http: mockKibanaHttpService, lists: [ { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, ], + matchFilters: false, onError: onErrorMock, pagination: { page: 1, perPage: 20, total: 0, }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, }) ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params await waitForNextUpdate(); await waitForNextUpdate(); - expect(typeof result.current[4]).toEqual('function'); + expect(typeof result.current[3]).toEqual('function'); - if (result.current[4] != null) { - result.current[4](); + if (result.current[3] != null) { + result.current[3](); } - + // NOTE: Only need one call here because hook already initilaized await waitForNextUpdate(); - expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(2); - expect(spyOnfetchExceptionListItemsByListId).toHaveBeenCalledTimes(2); + expect(spyOnfetchExceptionListsItemsByListIds).toHaveBeenCalledTimes(2); }); }); - test('invokes "onError" callback if "fetchExceptionListItemsByListId" fails', async () => { - const mockError = new Error('failed to fetch list items'); - const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById'); - const spyOnfetchExceptionListItemsByListId = jest - .spyOn(api, 'fetchExceptionListItemsByListId') + test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { + const mockError = new Error('failed to fetches list items'); + const spyOnfetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') .mockRejectedValue(mockError); await act(async () => { const { waitForNextUpdate } = renderHook( () => useExceptionList({ - filterOptions: { filter: '', tags: [] }, - http: mockKibanaHttpService, - lists: [ - { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, - ], - onError: onErrorMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - }) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(1); - expect(onErrorMock).toHaveBeenCalledWith(mockError); - expect(spyOnfetchExceptionListItemsByListId).toHaveBeenCalledTimes(1); - }); - }); - - test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { - const mockError = new Error('failed to fetch list'); - jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - - await act(async () => { - const { waitForNextUpdate } = renderHook( - () => - useExceptionList({ - filterOptions: { filter: '', tags: [] }, + filterOptions: [], http: mockKibanaHttpService, lists: [ { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, ], + matchFilters: false, onError: onErrorMock, pagination: { page: 1, perPage: 20, total: 0, }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, }) ); + // NOTE: First `waitForNextUpdate` is initialization + // Second call applies the params await waitForNextUpdate(); await waitForNextUpdate(); expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(spyOnfetchExceptionListsItemsByListIds).toHaveBeenCalledTimes(1); }); }); }); 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.ts index c639dcff8b5372..8097a7b8c5898e 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import { fetchExceptionListById, fetchExceptionListItemsByListId } from '../api'; -import { ExceptionIdentifiers, ExceptionList, Pagination, UseExceptionListProps } from '../types'; -import { ExceptionListItemSchema, NamespaceType } from '../../../common/schemas'; +import { fetchExceptionListsItemsByListIds } from '../api'; +import { FilterExceptionsOptions, Pagination, UseExceptionListProps } from '../types'; +import { ExceptionListItemSchema } from '../../../common/schemas'; +import { getIdsAndNamespaces } from '../utils'; type Func = () => void; export type ReturnExceptionListAndItems = [ boolean, - ExceptionList[], ExceptionListItemSchema[], Pagination, Func | null @@ -27,6 +27,10 @@ export type ReturnExceptionListAndItems = [ * @param onError error callback * @param onSuccess callback when all lists fetched successfully * @param filterOptions optional - filter by fields or tags + * @param showDetectionsListsOnly boolean, if true, only detection lists are searched + * @param showEndpointListsOnly boolean, if true, only endpoint lists are searched + * @param matchFilters boolean, if true, applies first filter in filterOptions to + * all lists * @param pagination optional * */ @@ -38,134 +42,112 @@ export const useExceptionList = ({ perPage: 20, total: 0, }, - filterOptions = { - filter: '', - tags: [], - }, + filterOptions, + showDetectionsListsOnly, + showEndpointListsOnly, + matchFilters, onError, onSuccess, }: UseExceptionListProps): ReturnExceptionListAndItems => { - const [exceptionLists, setExceptionLists] = useState([]); const [exceptionItems, setExceptionListItems] = useState([]); const [paginationInfo, setPagination] = useState(pagination); - const fetchExceptionList = useRef(null); + const fetchExceptionListsItems = useRef(null); const [loading, setLoading] = useState(true); - const tags = useMemo(() => filterOptions.tags.sort().join(), [filterOptions.tags]); - const listIds = useMemo( - () => - lists - .map((t) => t.id) - .sort() - .join(), - [lists] - ); + const { ids, namespaces } = getIdsAndNamespaces({ + lists, + showDetection: showDetectionsListsOnly, + showEndpoint: showEndpointListsOnly, + }); + const filters: FilterExceptionsOptions[] = + matchFilters && filterOptions.length > 0 ? ids.map(() => filterOptions[0]) : filterOptions; + const idsAsString: string = ids.join(','); + const namespacesAsString: string = namespaces.join(','); + const filterAsString: string = filterOptions.map(({ filter }) => filter).join(','); + const filterTagsAsString: string = filterOptions.map(({ tags }) => tags.join(',')).join(','); useEffect( () => { - let isSubscribed = false; - let abortCtrl: AbortController; - - const fetchLists = async (): Promise => { - isSubscribed = true; - abortCtrl = new AbortController(); - - // TODO: workaround until api updated, will be cleaned up - let exceptions: ExceptionListItemSchema[] = []; - let exceptionListsReturned: ExceptionList[] = []; - - const fetchData = async ({ - id, - namespaceType, - }: { - id: string; - namespaceType: NamespaceType; - }): Promise => { - try { - setLoading(true); - - const { - list_id, - namespace_type, - ...restOfExceptionList - } = await fetchExceptionListById({ - http, - id, - namespaceType, - signal: abortCtrl.signal, + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchData = async (): Promise => { + try { + setLoading(true); + + if (ids.length === 0 && isSubscribed) { + setPagination({ + page: 0, + perPage: pagination.perPage, + total: 0, }); - const fetchListItemsResult = await fetchExceptionListItemsByListId({ - filterOptions, + setExceptionListItems([]); + + if (onSuccess != null) { + onSuccess({ + exceptions: [], + pagination: { + page: 0, + perPage: pagination.perPage, + total: 0, + }, + }); + } + setLoading(false); + } else { + const { page, per_page, total, data } = await fetchExceptionListsItemsByListIds({ + filterOptions: filters, http, - listId: list_id, - namespaceType: namespace_type, - pagination, + listIds: ids, + namespaceTypes: namespaces, + pagination: { + page: pagination.page, + perPage: pagination.perPage, + }, signal: abortCtrl.signal, }); if (isSubscribed) { - exceptionListsReturned = [ - ...exceptionListsReturned, - { - list_id, - namespace_type, - ...restOfExceptionList, - totalItems: fetchListItemsResult.total, - }, - ]; - setExceptionLists(exceptionListsReturned); setPagination({ - page: fetchListItemsResult.page, - perPage: fetchListItemsResult.per_page, - total: fetchListItemsResult.total, + page, + perPage: per_page, + total, }); - - exceptions = [...exceptions, ...fetchListItemsResult.data]; - setExceptionListItems(exceptions); + setExceptionListItems(data); if (onSuccess != null) { onSuccess({ - exceptions, - lists: exceptionListsReturned, + exceptions: data, pagination: { - page: fetchListItemsResult.page, - perPage: fetchListItemsResult.per_page, - total: fetchListItemsResult.total, + page, + perPage: per_page, + total, }, }); } } - } catch (error) { - if (isSubscribed) { - setExceptionLists([]); - setExceptionListItems([]); - setPagination({ - page: 1, - perPage: 20, - total: 0, - }); - if (onError != null) { - onError(error); - } + } + } catch (error) { + if (isSubscribed) { + setExceptionListItems([]); + setPagination({ + page: 1, + perPage: 20, + total: 0, + }); + if (onError != null) { + onError(error); } } - }; - - // TODO: Workaround for now. Once api updated, we can pass in array of lists to fetch - await Promise.all( - lists.map( - ({ id, namespaceType }: ExceptionIdentifiers): Promise => - fetchData({ id, namespaceType }) - ) - ); + } if (isSubscribed) { setLoading(false); } }; - fetchLists(); + fetchData(); - fetchExceptionList.current = fetchLists; + fetchExceptionListsItems.current = fetchData; return (): void => { isSubscribed = false; abortCtrl.abort(); @@ -173,15 +155,15 @@ export const useExceptionList = ({ }, // eslint-disable-next-line react-hooks/exhaustive-deps [ http, - listIds, - setExceptionLists, + idsAsString, + namespacesAsString, setExceptionListItems, pagination.page, pagination.perPage, - filterOptions.filter, - tags, + filterAsString, + filterTagsAsString, ] ); - return [loading, exceptionLists, exceptionItems, paginationInfo, fetchExceptionList.current]; + return [loading, exceptionItems, paginationInfo, fetchExceptionListsItems.current]; }; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index c0ec72e1c19eb2..ac21288848154c 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -44,7 +44,6 @@ export interface ExceptionList extends ExceptionListSchema { } export interface UseExceptionListSuccess { - lists: ExceptionList[]; exceptions: ExceptionListItemSchema[]; pagination: Pagination; } @@ -53,8 +52,11 @@ export interface UseExceptionListProps { http: HttpStart; lists: ExceptionIdentifiers[]; onError?: (arg: string[]) => void; - filterOptions?: FilterExceptionsOptions; + filterOptions: FilterExceptionsOptions[]; pagination?: Pagination; + showDetectionsListsOnly: boolean; + showEndpointListsOnly: boolean; + matchFilters: boolean; onSuccess?: (arg: UseExceptionListSuccess) => void; } @@ -67,9 +69,9 @@ export interface ExceptionIdentifiers { export interface ApiCallByListIdProps { http: HttpStart; - listId: string; - namespaceType: NamespaceType; - filterOptions?: FilterExceptionsOptions; + listIds: string[]; + namespaceTypes: NamespaceType[]; + filterOptions: FilterExceptionsOptions[]; pagination: Partial; signal: AbortSignal; } @@ -88,6 +90,16 @@ export interface ApiCallMemoProps { onSuccess: () => void; } +export interface ApiCallFindListsItemsMemoProps { + lists: ExceptionIdentifiers[]; + filterOptions: FilterExceptionsOptions[]; + pagination: Partial; + showDetectionsListsOnly: boolean; + showEndpointListsOnly: boolean; + onError: (arg: string[]) => void; + onSuccess: (arg: UseExceptionListSuccess) => void; +} + export interface AddExceptionListProps { http: HttpStart; list: CreateExceptionListSchema; diff --git a/x-pack/plugins/lists/public/exceptions/utils.test.ts b/x-pack/plugins/lists/public/exceptions/utils.test.ts new file mode 100644 index 00000000000000..cc1a96132b045f --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/utils.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { getIdsAndNamespaces } from './utils'; + +describe('Exceptions utils', () => { + describe('#getIdsAndNamespaces', () => { + test('it returns empty arrays if no lists found', async () => { + const output = getIdsAndNamespaces({ + lists: [], + showDetection: false, + showEndpoint: false, + }); + + expect(output).toEqual({ ids: [], namespaces: [] }); + }); + + test('it returns all lists if "showDetection" and "showEndpoint" are "false"', async () => { + const output = getIdsAndNamespaces({ + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + showDetection: false, + showEndpoint: false, + }); + + expect(output).toEqual({ + ids: ['list_id', 'list_id_endpoint'], + namespaces: ['single', 'agnostic'], + }); + }); + + test('it returns only detections lists if "showDetection" is "true"', async () => { + const output = getIdsAndNamespaces({ + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + showDetection: true, + showEndpoint: false, + }); + + expect(output).toEqual({ + ids: ['list_id'], + namespaces: ['single'], + }); + }); + + test('it returns only endpoint lists if "showEndpoint" is "true"', async () => { + const output = getIdsAndNamespaces({ + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + showDetection: false, + showEndpoint: true, + }); + + expect(output).toEqual({ + ids: ['list_id_endpoint'], + namespaces: ['agnostic'], + }); + }); + + test('it returns only detection lists if both "showEndpoint" and "showDetection" are "true"', async () => { + const output = getIdsAndNamespaces({ + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + { + id: 'myListIdEndpoint', + listId: 'list_id_endpoint', + namespaceType: 'agnostic', + type: 'endpoint', + }, + ], + showDetection: true, + showEndpoint: true, + }); + + expect(output).toEqual({ + ids: ['list_id'], + namespaces: ['single'], + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/utils.ts b/x-pack/plugins/lists/public/exceptions/utils.ts new file mode 100644 index 00000000000000..2acb690d3822c2 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/utils.ts @@ -0,0 +1,36 @@ +/* + * 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 { NamespaceType } from '../../common/schemas'; + +import { ExceptionIdentifiers } from './types'; + +export const getIdsAndNamespaces = ({ + lists, + showDetection, + showEndpoint, +}: { + lists: ExceptionIdentifiers[]; + showDetection: boolean; + showEndpoint: boolean; +}): { ids: string[]; namespaces: NamespaceType[] } => + lists + .filter((list) => { + if (showDetection) { + return list.type === 'detection'; + } else if (showEndpoint) { + return list.type === 'endpoint'; + } else { + return true; + } + }) + .reduce<{ ids: string[]; namespaces: NamespaceType[] }>( + (acc, { listId, namespaceType }) => ({ + ids: [...acc.ids, listId], + namespaces: [...acc.namespaces, namespaceType], + }), + { ids: [], namespaces: [] } + ); diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json index d63adc84a361db..f1281e2ea0560c 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json @@ -1,5 +1,5 @@ { - "list_id": "endpoint_list", + "list_id": "simple_list", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 87d2f9dcda9351..b826c1e49f2749 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -95,6 +95,13 @@ export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( } ); +export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_BODY = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.noSearchResultsPromptBody', + { + defaultMessage: 'No search results found.', + } +); + export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( 'xpack.securitySolution.exceptions.viewer.emptyPromptBody', { @@ -176,3 +183,10 @@ export const ADD_TO_CLIPBOARD = i18n.translate( export const DESCRIPTION = i18n.translate('xpack.securitySolution.exceptions.descriptionLabel', { defaultMessage: 'Description', }); + +export const TOTAL_ITEMS_FETCH_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.fetchTotalsError', + { + defaultMessage: 'Error getting exception item totals', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 54caab03e615a2..83367e5b9e7399 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -38,14 +38,14 @@ export interface ExceptionListItemIdentifiers { export interface FilterOptions { filter: string; - showDetectionsList: boolean; - showEndpointList: boolean; tags: string[]; } export interface Filter { filter: Partial; pagination: Partial; + showDetectionsListsOnly: boolean; + showEndpointListsOnly: boolean; } export interface ExceptionsPagination { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx index dcc8611cd7298b..768af7b837d9b5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx @@ -80,7 +80,6 @@ describe('ExceptionsViewerPagination', () => { wrapper.find('button[data-test-subj="exceptionsPerPageItem"]').at(0).simulate('click'); expect(mockOnPaginationChange).toHaveBeenCalledWith({ - filter: {}, pagination: { pageIndex: 0, pageSize: 20, totalItemCount: 1 }, }); }); @@ -127,8 +126,7 @@ describe('ExceptionsViewerPagination', () => { wrapper.find('[data-test-subj="pagination-button-next"]').at(1).simulate('click'); expect(mockOnPaginationChange).toHaveBeenCalledWith({ - filter: {}, - pagination: { pageIndex: 2, pageSize: 50, totalItemCount: 160 }, + pagination: { pageIndex: 1, pageSize: 50, totalItemCount: 160 }, }); }); @@ -151,8 +149,7 @@ describe('ExceptionsViewerPagination', () => { wrapper.find('button[data-test-subj="pagination-button-3"]').simulate('click'); expect(mockOnPaginationChange).toHaveBeenCalledWith({ - filter: {}, - pagination: { pageIndex: 4, pageSize: 50, totalItemCount: 160 }, + pagination: { pageIndex: 3, pageSize: 50, totalItemCount: 160 }, }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx index afc6d55de364d7..ae1a7771164410 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx @@ -20,7 +20,7 @@ import { ExceptionsPagination, Filter } from '../types'; interface ExceptionsViewerPaginationProps { pagination: ExceptionsPagination; - onPaginationChange: (arg: Filter) => void; + onPaginationChange: (arg: Partial) => void; } const ExceptionsViewerPaginationComponent = ({ @@ -39,9 +39,8 @@ const ExceptionsViewerPaginationComponent = ({ const handlePageClick = useCallback( (pageIndex: number): void => { onPaginationChange({ - filter: {}, pagination: { - pageIndex: pageIndex + 1, + pageIndex, pageSize: pagination.pageSize, totalItemCount: pagination.totalItemCount, }, @@ -57,9 +56,8 @@ const ExceptionsViewerPaginationComponent = ({ icon="empty" onClick={() => { onPaginationChange({ - filter: {}, pagination: { - pageIndex: pagination.pageIndex, + pageIndex: 0, pageSize: rows, totalItemCount: pagination.totalItemCount, }, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx index d697023b2ced4e..6927ecec788fb2 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx @@ -22,12 +22,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 2, pageSizeOptions: [5, 10, 20, 50, 100], }} - filterOptions={{ - filter: '', - showEndpointList: false, - showDetectionsList: false, - tags: [], - }} + showEndpointListsOnly={false} + showDetectionsListsOnly={false} ruleSettingsUrl={'some/url'} onRefreshClick={jest.fn()} /> @@ -49,12 +45,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 1, pageSizeOptions: [5, 10, 20, 50, 100], }} - filterOptions={{ - filter: '', - showEndpointList: false, - showDetectionsList: false, - tags: [], - }} + showEndpointListsOnly={false} + showDetectionsListsOnly={false} ruleSettingsUrl={'some/url'} onRefreshClick={jest.fn()} /> @@ -77,12 +69,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 1, pageSizeOptions: [5, 10, 20, 50, 100], }} - filterOptions={{ - filter: '', - showEndpointList: false, - showDetectionsList: false, - tags: [], - }} + showEndpointListsOnly={false} + showDetectionsListsOnly={false} ruleSettingsUrl={'some/url'} onRefreshClick={mockOnRefreshClick} /> @@ -104,12 +92,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 1, pageSizeOptions: [5, 10, 20, 50, 100], }} - filterOptions={{ - filter: '', - showEndpointList: false, - showDetectionsList: false, - tags: [], - }} + showEndpointListsOnly={false} + showDetectionsListsOnly={false} ruleSettingsUrl={'some/url'} onRefreshClick={jest.fn()} /> @@ -130,12 +114,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 1, pageSizeOptions: [5, 10, 20, 50, 100], }} - filterOptions={{ - filter: '', - showEndpointList: false, - showDetectionsList: true, - tags: [], - }} + showEndpointListsOnly={false} + showDetectionsListsOnly ruleSettingsUrl={'some/url'} onRefreshClick={jest.fn()} /> @@ -156,12 +136,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 1, pageSizeOptions: [5, 10, 20, 50, 100], }} - filterOptions={{ - filter: '', - showEndpointList: true, - showDetectionsList: false, - tags: [], - }} + showEndpointListsOnly + showDetectionsListsOnly={false} ruleSettingsUrl={'some/url'} onRefreshClick={jest.fn()} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx index 9ab4e170f4090f..206983f3d82d97 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from 'react-intl'; import styled from 'styled-components'; import * as i18n from '../translations'; -import { ExceptionsPagination, FilterOptions } from '../types'; +import { ExceptionsPagination } from '../types'; import { UtilityBar, UtilityBarSection, @@ -29,14 +29,16 @@ const MyUtilities = styled(EuiFlexGroup)` interface ExceptionsViewerUtilityProps { pagination: ExceptionsPagination; - filterOptions: FilterOptions; + showEndpointListsOnly: boolean; + showDetectionsListsOnly: boolean; ruleSettingsUrl: string; onRefreshClick: () => void; } const ExceptionsViewerUtilityComponent: React.FC = ({ pagination, - filterOptions, + showEndpointListsOnly, + showDetectionsListsOnly, ruleSettingsUrl, onRefreshClick, }): JSX.Element => ( @@ -65,7 +67,7 @@ const ExceptionsViewerUtilityComponent: React.FC = - {filterOptions.showEndpointList && ( + {showEndpointListsOnly && ( = }} /> )} - {filterOptions.showDetectionsList && ( + {showDetectionsListsOnly && ( { expect(mockOnFilterChange).toHaveBeenCalledWith({ filter: { filter: '', - showDetectionsList: true, - showEndpointList: false, tags: [], }, - pagination: {}, + pagination: { + pageIndex: 0, + }, + showDetectionsListsOnly: true, + showEndpointListsOnly: false, }); }); @@ -175,11 +177,13 @@ describe('ExceptionsViewerHeader', () => { expect(mockOnFilterChange).toHaveBeenCalledWith({ filter: { filter: '', - showDetectionsList: false, - showEndpointList: true, tags: [], }, - pagination: {}, + pagination: { + pageIndex: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx index c207f91f651ed3..9cbe5f0f36891a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx @@ -26,7 +26,7 @@ interface ExceptionsViewerHeaderProps { supportedListTypes: ExceptionListTypeEnum[]; detectionsListItems: number; endpointListItems: number; - onFilterChange: (arg: Filter) => void; + onFilterChange: (arg: Partial) => void; onAddExceptionClick: (type: ExceptionListTypeEnum) => void; } @@ -43,16 +43,20 @@ const ExceptionsViewerHeaderComponent = ({ }: ExceptionsViewerHeaderProps): JSX.Element => { const [filter, setFilter] = useState(''); const [tags, setTags] = useState([]); - const [showDetectionsList, setShowDetectionsList] = useState(false); - const [showEndpointList, setShowEndpointList] = useState(false); + const [showDetectionsListsOnly, setShowDetectionsList] = useState(false); + const [showEndpointListsOnly, setShowEndpointList] = useState(false); const [isAddExceptionMenuOpen, setAddExceptionMenuOpen] = useState(false); useEffect((): void => { onFilterChange({ - filter: { filter, showDetectionsList, showEndpointList, tags }, - pagination: {}, + filter: { filter, tags }, + pagination: { + pageIndex: 0, + }, + showDetectionsListsOnly, + showEndpointListsOnly, }); - }, [filter, tags, showDetectionsList, showEndpointList, onFilterChange]); + }, [filter, tags, showDetectionsListsOnly, showEndpointListsOnly, onFilterChange]); const onAddExceptionDropdownClick = useCallback( (): void => setAddExceptionMenuOpen(!isAddExceptionMenuOpen), @@ -60,14 +64,14 @@ const ExceptionsViewerHeaderComponent = ({ ); const handleDetectionsListClick = useCallback((): void => { - setShowDetectionsList(!showDetectionsList); + setShowDetectionsList(!showDetectionsListsOnly); setShowEndpointList(false); - }, [showDetectionsList, setShowDetectionsList, setShowEndpointList]); + }, [showDetectionsListsOnly, setShowDetectionsList, setShowEndpointList]); const handleEndpointListClick = useCallback((): void => { - setShowEndpointList(!showEndpointList); + setShowEndpointList(!showEndpointListsOnly); setShowDetectionsList(false); - }, [showEndpointList, setShowEndpointList, setShowDetectionsList]); + }, [showEndpointListsOnly, setShowEndpointList, setShowDetectionsList]); const handleOnSearch = useCallback( (searchValue: string): void => { @@ -148,7 +152,7 @@ const ExceptionsViewerHeaderComponent = ({ @@ -157,7 +161,7 @@ const ExceptionsViewerHeaderComponent = ({ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx similarity index 78% rename from x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx index 7ccb8d251eae10..3024ae0f141448 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx @@ -9,6 +9,7 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import * as i18n from '../translations'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ExceptionsViewerItems } from './exceptions_viewer_items'; @@ -17,7 +18,8 @@ describe('ExceptionsViewerItems', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPromptTitle"]').text()).toEqual( + i18n.EXCEPTION_EMPTY_PROMPT_TITLE + ); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPromptBody"]').text()).toEqual( + i18n.EXCEPTION_EMPTY_PROMPT_BODY + ); + }); + + it('it renders no search results found prompt if "showNoResults" is "true"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPromptTitle"]').text()).toEqual(''); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPromptBody"]').text()).toEqual( + i18n.EXCEPTION_NO_SEARCH_RESULTS_PROMPT_BODY + ); }); it('it renders exceptions if "showEmpty" and "isInitLoading" is "false", and exceptions exist', () => { @@ -37,6 +69,7 @@ describe('ExceptionsViewerItems', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> = ({ showEmpty, + showNoResults, isInitLoading, exceptions, loadingItemIds, @@ -51,12 +53,22 @@ const ExceptionsViewerItemsComponent: React.FC = ({ onEditExceptionItem, }): JSX.Element => ( - {showEmpty || isInitLoading ? ( + {showEmpty || showNoResults || isInitLoading ? ( {i18n.EXCEPTION_EMPTY_PROMPT_TITLE}} - body={

{i18n.EXCEPTION_EMPTY_PROMPT_BODY}

} + iconType={showNoResults ? 'searchProfilerApp' : 'list'} + title={ +

+ {showNoResults ? '' : i18n.EXCEPTION_EMPTY_PROMPT_TITLE} +

+ } + body={ +

+ {showNoResults + ? i18n.EXCEPTION_NO_SEARCH_RESULTS_PROMPT_BODY + : i18n.EXCEPTION_EMPTY_PROMPT_BODY} +

+ } data-test-subj="exceptionsEmptyPrompt" />
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 7f4d8252764060..7482068454a97c 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useEffect, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { EuiSpacer } from '@elastic/eui'; import uuid from 'uuid'; @@ -31,23 +31,23 @@ import { EditExceptionModal } from '../edit_exception_modal'; import { AddExceptionModal } from '../add_exception_modal'; const initialState: State = { - filterOptions: { filter: '', showEndpointList: false, showDetectionsList: false, tags: [] }, + filterOptions: { filter: '', tags: [] }, pagination: { pageIndex: 0, pageSize: 20, totalItemCount: 0, pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], }, - endpointList: null, - detectionsList: null, - allExceptions: [], exceptions: [], exceptionToEdit: null, - loadingLists: [], loadingItemIds: [], isInitLoading: true, currentModal: null, exceptionListTypeToEdit: null, + totalEndpointItems: 0, + totalDetectionsItems: 0, + showEndpointListsOnly: false, + showDetectionsListsOnly: false, }; interface ExceptionsViewerProps { @@ -87,46 +87,47 @@ const ExceptionsViewerComponent = ({ ); const [ { - endpointList, - detectionsList, exceptions, filterOptions, pagination, - loadingLists, loadingItemIds, isInitLoading, currentModal, exceptionToEdit, exceptionListTypeToEdit, + totalEndpointItems, + totalDetectionsItems, + showDetectionsListsOnly, + showEndpointListsOnly, }, dispatch, - ] = useReducer(allExceptionItemsReducer(), { ...initialState, loadingLists: exceptionListsMeta }); - const { deleteExceptionItem } = useApi(services.http); + ] = useReducer(allExceptionItemsReducer(), { ...initialState }); + const { deleteExceptionItem, getExceptionListsItems } = useApi(services.http); const setExceptions = useCallback( - ({ - lists: newLists, - exceptions: newExceptions, - pagination: newPagination, - }: UseExceptionListSuccess): void => { + ({ exceptions: newExceptions, pagination: newPagination }: UseExceptionListSuccess): void => { dispatch({ type: 'setExceptions', - lists: newLists, + lists: exceptionListsMeta, exceptions: newExceptions, pagination: newPagination, }); }, - [dispatch] + [dispatch, exceptionListsMeta] ); - const [loadingList, , , , fetchList] = useExceptionList({ + const [loadingList, , , fetchListItems] = useExceptionList({ http: services.http, - lists: loadingLists, - filterOptions, + lists: exceptionListsMeta, + filterOptions: + filterOptions.filter !== '' || filterOptions.tags.length > 0 ? [filterOptions] : [], pagination: { page: pagination.pageIndex + 1, perPage: pagination.pageSize, total: pagination.totalItemCount, }, + showDetectionsListsOnly, + showEndpointListsOnly, + matchFilters: true, onSuccess: setExceptions, onError: onDispatchToaster({ color: 'danger', @@ -145,22 +146,81 @@ const ExceptionsViewerComponent = ({ [dispatch] ); + const setExceptionItemTotals = useCallback( + (endpointItemTotals: number | null, detectionItemTotals: number | null): void => { + dispatch({ + type: 'setExceptionItemTotals', + totalEndpointItems: endpointItemTotals, + totalDetectionsItems: detectionItemTotals, + }); + }, + [dispatch] + ); + + const handleGetTotals = useCallback(async (): Promise => { + await getExceptionListsItems({ + lists: exceptionListsMeta, + filterOptions: [], + pagination: { + page: 0, + perPage: 1, + total: 0, + }, + showDetectionsListsOnly: true, + showEndpointListsOnly: false, + onSuccess: ({ pagination: detectionPagination }) => { + setExceptionItemTotals(null, detectionPagination.total ?? 0); + }, + onError: () => { + const dispatchToasterError = onDispatchToaster({ + color: 'danger', + title: i18n.TOTAL_ITEMS_FETCH_ERROR, + iconType: 'alert', + }); + + dispatchToasterError(); + }, + }); + await getExceptionListsItems({ + lists: exceptionListsMeta, + filterOptions: [], + pagination: { + page: 0, + perPage: 1, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + onSuccess: ({ pagination: endpointPagination }) => { + setExceptionItemTotals(endpointPagination.total ?? 0, null); + }, + onError: () => { + const dispatchToasterError = onDispatchToaster({ + color: 'danger', + title: i18n.TOTAL_ITEMS_FETCH_ERROR, + iconType: 'alert', + }); + + dispatchToasterError(); + }, + }); + }, [setExceptionItemTotals, exceptionListsMeta, getExceptionListsItems, onDispatchToaster]); + const handleFetchList = useCallback((): void => { - if (fetchList != null) { - fetchList(); + if (fetchListItems != null) { + fetchListItems(); + handleGetTotals(); } - }, [fetchList]); + }, [fetchListItems, handleGetTotals]); const handleFilterChange = useCallback( - ({ filter, pagination: pag }: Filter): void => { + (filters: Partial): void => { dispatch({ type: 'updateFilterOptions', - filterOptions: filter, - pagination: pag, - allLists: exceptionListsMeta, + filters, }); }, - [dispatch, exceptionListsMeta] + [dispatch] ); const handleAddException = useCallback( @@ -178,12 +238,13 @@ const ExceptionsViewerComponent = ({ (exception: ExceptionListItemSchema): void => { dispatch({ type: 'updateExceptionToEdit', + lists: exceptionListsMeta, exception, }); setCurrentModal('editModal'); }, - [setCurrentModal] + [setCurrentModal, exceptionListsMeta] ); const handleOnCancelExceptionModal = useCallback((): void => { @@ -235,23 +296,24 @@ const ExceptionsViewerComponent = ({ // Logic for initial render useEffect((): void => { if (isInitLoading && !loadingList && (exceptions.length === 0 || exceptions != null)) { + handleGetTotals(); dispatch({ type: 'updateIsInitLoading', loading: false, }); } - }, [isInitLoading, exceptions, loadingList, dispatch]); + }, [handleGetTotals, isInitLoading, exceptions, loadingList, dispatch]); // Used in utility bar info text - const ruleSettingsUrl = useMemo((): string => { - return services.application.getUrlForApp( - `security/detections/rules/id/${encodeURI(ruleId)}/edit` - ); - }, [ruleId, services.application]); + const ruleSettingsUrl = services.application.getUrlForApp( + `security/detections/rules/id/${encodeURI(ruleId)}/edit` + ); + + const showEmpty: boolean = + !isInitLoading && !loadingList && totalEndpointItems === 0 && totalDetectionsItems === 0; - const showEmpty = useMemo((): boolean => { - return !isInitLoading && !loadingList && exceptions.length === 0; - }, [isInitLoading, exceptions.length, loadingList]); + const showNoResults: boolean = + exceptions.length === 0 && (totalEndpointItems > 0 || totalDetectionsItems > 0); return ( <> @@ -288,8 +350,8 @@ const ExceptionsViewerComponent = ({ @@ -298,13 +360,15 @@ const ExceptionsViewerComponent = ({ ; - pagination: Partial; - allLists: ExceptionIdentifiers[]; + filters: Partial; } | { type: 'updateIsInitLoading'; loading: boolean } | { type: 'updateModalOpen'; modalName: ViewerModalName } - | { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema } + | { + type: 'updateExceptionToEdit'; + lists: ExceptionIdentifiers[]; + exception: ExceptionListItemSchema; + } | { type: 'updateLoadingItemIds'; items: ExceptionListItemIdentifiers[] } - | { type: 'updateExceptionListTypeToEdit'; exceptionListType: ExceptionListType | null }; + | { type: 'updateExceptionListTypeToEdit'; exceptionListType: ExceptionListType | null } + | { + type: 'setExceptionItemTotals'; + totalEndpointItems: number | null; + totalDetectionsItems: number | null; + }; export const allExceptionItemsReducer = () => (state: State, action: Action): State => { switch (action.type) { case 'setExceptions': { - const endpointList = action.lists.filter((t) => t.type === 'endpoint'); - const detectionsList = action.lists.filter((t) => t.type === 'detection'); + const { exceptions, pagination } = action; return { ...state, - endpointList: state.filterOptions.showDetectionsList - ? state.endpointList - : endpointList[0] ?? null, - detectionsList: state.filterOptions.showEndpointList - ? state.detectionsList - : detectionsList[0] ?? null, pagination: { ...state.pagination, - pageIndex: action.pagination.page - 1, - pageSize: action.pagination.perPage, - totalItemCount: action.pagination.total ?? 0, + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: pagination.total ?? 0, }, - allExceptions: action.exceptions, - exceptions: action.exceptions, + exceptions, }; } case 'updateFilterOptions': { - const returnState = { + const { filter, pagination, showEndpointListsOnly, showDetectionsListsOnly } = action.filters; + return { ...state, filterOptions: { ...state.filterOptions, - ...action.filterOptions, + ...filter, }, pagination: { ...state.pagination, - ...action.pagination, + ...pagination, }, + showEndpointListsOnly: showEndpointListsOnly ?? state.showEndpointListsOnly, + showDetectionsListsOnly: showDetectionsListsOnly ?? state.showDetectionsListsOnly, + }; + } + case 'setExceptionItemTotals': { + return { + ...state, + totalEndpointItems: + action.totalEndpointItems == null ? state.totalEndpointItems : action.totalEndpointItems, + totalDetectionsItems: + action.totalDetectionsItems == null + ? state.totalDetectionsItems + : action.totalDetectionsItems, }; - - if (action.filterOptions.showEndpointList) { - const list = action.allLists.filter((t) => t.type === 'endpoint'); - - return { - ...returnState, - loadingLists: list, - exceptions: list.length === 0 ? [] : [...state.exceptions], - }; - } else if (action.filterOptions.showDetectionsList) { - const list = action.allLists.filter((t) => t.type === 'detection'); - - return { - ...returnState, - loadingLists: list, - exceptions: list.length === 0 ? [] : [...state.exceptions], - }; - } else { - return { - ...returnState, - loadingLists: action.allLists, - }; - } } case 'updateIsInitLoading': { return { @@ -121,13 +115,13 @@ export const allExceptionItemsReducer = () => (state: State, action: Action): St }; } case 'updateExceptionToEdit': { - const exception = action.exception; - const exceptionListToEdit = [state.endpointList, state.detectionsList].find((list) => { - return list !== null && exception.list_id === list.list_id; + const { exception, lists } = action; + const exceptionListToEdit = lists.find((list) => { + return list !== null && exception.list_id === list.listId; }); return { ...state, - exceptionToEdit: action.exception, + exceptionToEdit: exception, exceptionListTypeToEdit: exceptionListToEdit ? exceptionListToEdit.type : null, }; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json index 56c9f151dc7129..bec88bcb0e30e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json @@ -1,4 +1,60 @@ { - "rule_id": "query-rule-id", - "name": "Changes only the name to this new value" + "author": [], + "actions": [], + "description": "endpoint list only", + "enabled": true, + "false_positives": [], + "filters": [], + "from": "now-360s", + "index": [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "interval": "5m", + "rule_id": "bf8ee47a-3f7f-4561-b2e6-92c9d618a0b2", + "language": "kuery", + "license": "", + "output_index": ".siem-signals-ytercero-default", + "max_signals": 100, + "risk_score": 50, + "risk_score_mapping": [], + "name": "endpoint list only", + "query": "host.name: * ", + "references": [], + "meta": { + "from": "1m", + "kibana_siem_app_url": "http://localhost:5601/app/security" + }, + "severity": "low", + "severity_mapping": [], + "tags": [], + "to": "now", + "type": "query", + "threat": [], + "throttle": "no_actions", + "exceptions_list": [ + { + "list_id": "endpoint_list", + "namespace_type": "agnostic", + "id": "endpoint_list", + "type": "endpoint" + }, + { + "list_id": "b27b7e13-4105-49cf-8142-cee0c61de321", + "namespace_type": "single", + "id": "8da260a0-d1bb-11ea-b248-4ba44bc54af7", + "type": "detection" + }, + { + "list_id": "b27b7e13-4105-49cf-8142-cee0c61de321", + "namespace_type": "single", + "id": "8da260a0-d1bb-11ea-b248-4ba44bc54af7", + "type": "detection" + } + ] }