diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts b/x-pack/solutions/security/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts index 4a172d2ec10a7..925acce320b13 100644 --- a/x-pack/solutions/security/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts +++ b/x-pack/solutions/security/packages/kbn-securitysolution-io-ts-list-types/src/response/import_exceptions_schema/index.ts @@ -20,6 +20,8 @@ export const bulkErrorErrorSchema = t.exact( }) ); +export type BulkErrorErrorSchema = t.TypeOf; + export const bulkErrorSchema = t.intersection([ t.exact( t.type({ diff --git a/x-pack/solutions/security/plugins/lists/server/exception_item_import_error.ts b/x-pack/solutions/security/plugins/lists/server/exception_item_import_error.ts new file mode 100644 index 0000000000000..354d678145d7b --- /dev/null +++ b/x-pack/solutions/security/plugins/lists/server/exception_item_import_error.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { BulkErrorErrorSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { ListsErrorWithStatusCode } from '.'; + +export class ExceptionItemImportError extends Error implements BulkErrorErrorSchema { + public readonly status_code: number; + + constructor(error: Error, public readonly listId: string, public readonly itemId: string) { + super(error.message); + this.status_code = error instanceof ListsErrorWithStatusCode ? error.getStatusCode() : 400; + } +} diff --git a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.ts index 2860e17d1f9e8..a6eab357c1678 100644 --- a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -1146,6 +1146,10 @@ export class ExceptionListClient { overwrite, generateNewListId, }: ImportExceptionListAndItemsOptions): Promise => { + console.log( + '🧀 exception_list_client.ts:1086 🥭 importExceptionListAndItems', + JSON.stringify({}, null, ' ') + ); const { savedObjectsClient, user } = this; // validation of import and sorting of lists and items @@ -1185,6 +1189,10 @@ export class ExceptionListClient { maxExceptionsImportSize, overwrite, }: ImportExceptionListAndItemsAsArrayOptions): Promise => { + console.log( + '🧀 exception_list_client.ts:1126 🥭 importExceptionListAndItemsAsArray', + JSON.stringify({}, null, ' ') + ); const { savedObjectsClient, user } = this; // validation of import and sorting of lists and items diff --git a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts index 99127bd7eaff9..5b7d6f391e07a 100644 --- a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts +++ b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/utils/import/dedupe_incoming_items.ts @@ -6,11 +6,13 @@ */ import { v4 as uuidv4 } from 'uuid'; -import type { - BulkErrorSchema, - ImportExceptionListItemSchemaDecoded, +import { + type BulkErrorSchema, + type ImportExceptionListItemSchemaDecoded, } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionItemImportError } from '../../../../exception_item_import_error'; + /** * Reports on duplicates and returns unique array of items * @param items - exception items to be checked for duplicate list_ids @@ -21,7 +23,14 @@ export const getTupleErrorsAndUniqueExceptionListItems = ( ): [BulkErrorSchema[], ImportExceptionListItemSchemaDecoded[]] => { const { errors, itemsAcc } = items.reduce( (acc, parsedExceptionItem) => { - if (parsedExceptionItem instanceof Error) { + if (parsedExceptionItem instanceof ExceptionItemImportError) { + acc.errors.set(uuidv4(), { + error: parsedExceptionItem, + + item_id: parsedExceptionItem.itemId, + list_id: parsedExceptionItem.listId, + }); + } else if (parsedExceptionItem instanceof Error) { acc.errors.set(uuidv4(), { error: { message: `Error found importing exception list item: ${parsedExceptionItem.message}`, diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index 0225df85b911d..4369d99582dc7 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -13,6 +13,7 @@ import { ListOperatorTypeEnum, type ListOperatorType, } from '@kbn/securitysolution-io-ts-list-types'; +import type { ENDPOINT_ARTIFACT_LIST_IDS } from '@kbn/securitysolution-list-constants'; import { ENDPOINT_ARTIFACT_LISTS, ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { ConditionEntryField } from '@kbn/securitysolution-utils'; import { LIST_ITEM_ENTRY_OPERATOR_TYPES } from './common/artifact_list_item_entry_values'; @@ -453,4 +454,32 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = {} + ) => { + switch (listId) { + case ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id: + return this.generateEndpointException(overrides); + + case ENDPOINT_ARTIFACT_LISTS.blocklists.id: + return this.generateBlocklist(overrides); + + case ENDPOINT_ARTIFACT_LISTS.eventFilters.id: + return this.generateEventFilter(overrides); + + case ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id: + return this.generateHostIsolationException(overrides); + + case ENDPOINT_ARTIFACT_LISTS.trustedApps.id: + return this.generateTrustedApp(overrides); + + case ENDPOINT_ARTIFACT_LISTS.trustedDevices.id: + return this.generateTrustedDevice(overrides); + + default: + throw new Error(`Unknown listId: ${listId}. Unable to generate exception list item.`); + } + }; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index 5b4705db42b4a..a6a04558015c2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -8,10 +8,13 @@ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout'; import { useLocation } from 'react-router-dom'; import { useIsMounted } from '@kbn/securitysolution-hook-utils'; +import { HeaderMenu } from '@kbn/securitysolution-exception-list-components'; +import { useApi } from '@kbn/securitysolution-list-hooks'; +import { AutoDownload } from '../../../common/components/auto_download/auto_download'; import type { ServerApiError } from '../../../common/types'; import { AdministrationListPage } from '../administration_list_page'; @@ -41,10 +44,13 @@ import { useUrlParams } from '../../hooks/use_url_params'; import type { ListPageRouteState, MaybeImmutable } from '../../../../common/endpoint/types'; import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; import { ArtifactDeleteModal } from './components/artifact_delete_modal'; -import { useToasts } from '../../../common/lib/kibana'; +import { useKibana, useToasts } from '../../../common/lib/kibana'; import { useMemoizedRouteState } from '../../common/hooks'; import { BackToExternalAppSecondaryButton } from '../back_to_external_app_secondary_button'; import { BackToExternalAppButton } from '../back_to_external_app_button'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { ArtifactImportFlyout } from './components/artifact_import_flyout'; +import { useIsImportFlyoutOpened } from './hooks/use_is_import_flyout_opened'; type ArtifactEntryCardType = typeof ArtifactEntryCard; @@ -92,15 +98,26 @@ export const ArtifactListPage = memo( allowCardDeleteAction = true, CardDecorator, }) => { + const areEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( + 'endpointExceptionsMovedUnderManagement' + ); + const { services } = useKibana(); + const { http } = services; const { state: routeState } = useLocation(); const getTestId = useTestIdGenerator(dataTestSubj); const toasts = useToasts(); const isMounted = useIsMounted(); + const isFlyoutOpened = useIsFlyoutOpened(allowCardEditAction, allowCardCreateAction); + const isImportFlyoutOpened = + useIsImportFlyoutOpened(allowCardCreateAction) && + areEndpointExceptionsMovedUnderManagementFFEnabled; + const setUrlParams = useSetUrlParams(); const { urlParams: { filter, includedPolicies }, } = useUrlParams(); + const { exportExceptionList } = useApi(http); const { isPageInitializing, @@ -130,6 +147,8 @@ export const ArtifactListPage = memo( undefined | ExceptionListItemSchema >(undefined); + const [exportedData, setExportedData] = useState(); + const labels = useMemo(() => { return { ...artifactListPageLabels, @@ -237,6 +256,42 @@ export const ArtifactListPage = memo( setSelectedItemForEdit(undefined); }, []); + const handleExport = useCallback( + () => + exportExceptionList({ + id: apiClient.listId, + listId: apiClient.listId, + includeExpiredExceptions: true, + namespaceType: 'agnostic', + + onError: (exportError: Error) => + toasts?.addError(exportError, { title: labels.pageExportErrorToastTitle }), + + onSuccess: (blob) => { + setExportedData(blob); + toasts?.addSuccess(labels.pageExportSuccessToastTitle); + }, + }), + [ + exportExceptionList, + apiClient.listId, + toasts, + labels.pageExportErrorToastTitle, + labels.pageExportSuccessToastTitle, + ] + ); + + const handleOnDownload = useCallback(() => setExportedData(undefined), []); + + const handleImport = useCallback(() => setUrlParams({ show: 'import' }), [setUrlParams]); + + const closeImportFlyout = useCallback(() => setUrlParams({ show: undefined }), [setUrlParams]); + + const handleImportFlyoutOnSuccess = useCallback(() => { + closeImportFlyout(); + refetchListData(); + }, [closeImportFlyout, refetchListData]); + const description = useMemo(() => { const subtitleText = labels.pageAboutInfo ? ( {labels.pageAboutInfo} @@ -266,20 +321,51 @@ export const ArtifactListPage = memo( title={labels.pageTitle} subtitle={description} actions={ - allowCardCreateAction && ( - - {labels.pageAddButtonTitle} - - ) + + {allowCardCreateAction && ( + + {labels.pageAddButtonTitle} + + )} + + {areEndpointExceptionsMovedUnderManagementFFEnabled && ( + + )} + } data-test-subj={getTestId('container')} > + + {isFlyoutOpened && ( ( /> )} + {isImportFlyoutOpened && ( + + )} + {selectedItemForDelete && ( ( {!doesDataExist ? ( { + let render: ( + props?: Partial + ) => Promise>; + let renderResult: ReturnType; + let coreStart: AppContextTestRender['coreStart']; + let mockedTrustedAppApi: ReturnType; + let props: ArtifactImportFlyoutProps; + let ui: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + ({ coreStart } = mockedContext); + + mockedTrustedAppApi = trustedAppsAllHttpMocks(coreStart.http); + + props = { + labels: artifactListPageLabels, + apiClient: new TrustedAppsApiClient(coreStart.http), + onCancel: jest.fn(), + onSuccess: jest.fn(), + }; + + render = async () => { + renderResult = mockedContext.render( + + ); + ui = getArtifactImportFlyoutUiMocks(renderResult, 'testFlyout'); + + await waitFor(async () => expect(renderResult.getByTestId('testFlyout')).toBeInTheDocument()); + + return renderResult; + }; + }); + + it('should display `Cancel` button enabled', async () => { + await render(); + + expect(ui.getCancelButton()).toBeEnabled(); + }); + + it('should call onCancel when `Cancel` button is clicked', async () => { + await render(); + + await userEvent.click(ui.getCancelButton()); + + expect(props.onCancel).toHaveBeenCalled(); + }); + + it('should display `Import` button disabled', async () => { + await render(); + + expect(ui.getImportButton()).toBeDisabled(); + }); + + it('should enable `Import` button when a file is selected', async () => { + await render(); + + await ui.uploadFile(); + + expect(ui.getImportButton()).toBeEnabled(); + }); + + it('should call the import API when `Import` button is clicked', async () => { + await render(); + + await ui.uploadFile(); + await userEvent.click(ui.getImportButton()); + + expect(mockedTrustedAppApi.responseProvider.trustedAppImportList).toHaveBeenCalledWith( + expect.objectContaining({ + version: '2023-10-31', + query: { overwrite: false } as HttpFetchOptionsWithPath['query'], + }) + ); + }); + + it('should disable `Import` button while the import is in progress', async () => { + const deferrable = getDeferred(); + mockedTrustedAppApi.responseProvider.trustedAppImportList.mockDelay.mockReturnValue( + deferrable.promise + ); + + await render(); + + await ui.uploadFile(); + await userEvent.click(ui.getImportButton()); + + expect(ui.getImportButton()).toBeDisabled(); + }); + + it('should show a success toast and call `onSuccess` after a successful import', async () => { + await render(); + + await ui.uploadFile(); + await userEvent.click(ui.getImportButton()); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Artifact list imported successfully' + ); + expect(props.onSuccess).toHaveBeenCalled(); + }); + + it('should show an error toast if the import API fails', async () => { + mockedTrustedAppApi.responseProvider.trustedAppImportList.mockImplementation(() => { + throw new Error('Import failed'); + }); + + await render(); + + await ui.uploadFile(); + await userEvent.click(ui.getImportButton()); + + expect(coreStart.notifications.toasts.addError).toHaveBeenCalledWith( + expect.objectContaining(new Error('Import failed')), + { title: 'Artifact list import failed' } + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.tsx new file mode 100644 index 0000000000000..ffd211a55c9d1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiFilePickerProps } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFilePicker, + EuiFlexGroup, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useToasts } from '../../../../common/lib/kibana'; +import type { ArtifactListPageLabels } from '../translations'; +import { useImportArtifactList } from '../../../hooks/artifacts/use_import_artifact_list'; +import type { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; + +export interface ArtifactImportFlyoutProps { + onCancel: () => void; + onSuccess: () => void; + apiClient: ExceptionsListApiClient; + labels: ArtifactListPageLabels; + 'data-test-subj'?: string; +} + +export const ArtifactImportFlyout: React.FC = ({ + onCancel, + onSuccess, + apiClient, + labels, + 'data-test-subj': dataTestSubj = 'artifactImportFlyout', +}) => { + const toasts = useToasts(); + const getTestId = useTestIdGenerator(dataTestSubj); + + const [file, setFile] = React.useState(null); + + const { isLoading, mutate } = useImportArtifactList(apiClient); + + const handleOnCancel = useCallback(() => { + onCancel(); + }, [onCancel]); + + const handleOnSubmit = useCallback(() => { + if (file !== null) { + mutate( + { file }, + { + onError: (error) => { + toasts.addError(error, { title: labels.pageImportErrorToastTitle }); + }, + onSuccess: (response) => { + // todo: response contains lot of useful information, show somewhere? + toasts.addSuccess(labels.pageImportSuccessToastTitle); + onSuccess(); + }, + } + ); + } + }, [ + file, + labels.pageImportErrorToastTitle, + labels.pageImportSuccessToastTitle, + mutate, + onSuccess, + toasts, + ]); + + const handleOnFileChange: EuiFilePickerProps['onChange'] = useCallback( + (files: FileList | null) => { + if (files && files.length > 0) { + setFile(files[0]); + } else { + setFile(null); + } + }, + [] + ); + + const importEndpointArtifactListFlyoutTitleId = useGeneratedHtmlId({ + prefix: 'importEndpointArtifactListFlyoutTitleId', + }); + + return ( + + + +

{labels.pageImportButtonTitle}

+
+
+ + + + + + + + + {labels.flyoutCancelButtonLabel} + + + + {labels.importFlyoutImportSubmitButtonLabel} + + + +
+ ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts index 72077bf7841d1..294287b41262b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts @@ -20,6 +20,7 @@ describe('When showing the Empty State in ArtifactListPage', () => { ) => ReturnType; let renderResult: ReturnType; let history: AppContextTestRender['history']; + let setExperimentalFlag: AppContextTestRender['setExperimentalFlag']; let mockedApi: ReturnType; let getLastFormComponentProps: ReturnType< typeof getFormComponentMock @@ -29,7 +30,7 @@ describe('When showing the Empty State in ArtifactListPage', () => { beforeEach(() => { const renderSetup = getArtifactListPageRenderingSetup(); - ({ history, mockedApi, getLastFormComponentProps } = renderSetup); + ({ history, mockedApi, getLastFormComponentProps, setExperimentalFlag } = renderSetup); originalListApiResponseProvider = mockedApi.responseProvider.trustedAppsList.getMockImplementation()!; @@ -62,7 +63,9 @@ describe('When showing the Empty State in ArtifactListPage', () => { }); describe('and user is allowed to Create entries', () => { - it('should show title, about info and add button', async () => { + it('should show title, about info, add and import buttons', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + const { getByTestId, queryByTestId } = render(); await waitFor(async () => { @@ -79,9 +82,26 @@ describe('When showing the Empty State in ArtifactListPage', () => { artifactListPageLabels.emptyStatePrimaryButtonLabel ); + expect(getByTestId('testPage-emptyState-importButton').textContent).toEqual( + artifactListPageLabels.emptyStateImportButtonLabel + ); + expect(queryByTestId('testPage-emptyState-title-no-entries')).toBeNull(); }); + it('should not show import button when experimental flag is disabled', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: false }); + + const { getByTestId, queryByTestId } = render(); + + await waitFor(async () => { + expect(getByTestId('testPage-emptyState')); + }); + + expect(getByTestId('testPage-emptyState-addButton')).toBeInTheDocument(); + expect(queryByTestId('testPage-emptyState-importButton')).not.toBeInTheDocument(); + }); + it('should open create flyout when primary button is clicked', async () => { render(); const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); @@ -92,6 +112,18 @@ describe('When showing the Empty State in ArtifactListPage', () => { expect(history.location.search).toMatch(/show=create/); }); + it('should open import flyout when import button is clicked', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + + render(); + const importButton = await renderResult.findByTestId('testPage-emptyState-importButton'); + + await userEvent.click(importButton); + + expect(renderResult.getByTestId('artifactImportFlyout')).toBeTruthy(); + expect(history.location.search).toMatch(/show=import/); + }); + describe('and the first item is created', () => { it('should show the list after creating first item and remove empty state', async () => { render(); @@ -125,16 +157,21 @@ describe('When showing the Empty State in ArtifactListPage', () => { }); describe('and user is not allowed to Create entries', () => { - it('should hide title, about info and add button promoting entry creation', async () => { + it('should hide title, about info and add/import buttons promoting entry creation', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + render({ allowCardCreateAction: false }); await waitFor(async () => { expect(renderResult.getByTestId('testPage-emptyState')); }); - expect(renderResult.queryByTestId('testPage-emptyState-title')).toBeNull(); - expect(renderResult.queryByTestId('testPage-emptyState-aboutInfo')).toBeNull(); - expect(renderResult.queryByTestId('testPage-emptyState-addButton')).toBeNull(); + expect(renderResult.queryByTestId('testPage-emptyState-title')).not.toBeInTheDocument(); + expect(renderResult.queryByTestId('testPage-emptyState-aboutInfo')).not.toBeInTheDocument(); + expect(renderResult.queryByTestId('testPage-emptyState-addButton')).not.toBeInTheDocument(); + expect( + renderResult.queryByTestId('testPage-emptyState-importButton') + ).not.toBeInTheDocument(); }); it('should show title indicating there are no entries', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.tsx index 1939578a2995a..f9c6ddd27591f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.tsx @@ -7,7 +7,8 @@ import React, { memo } from 'react'; import styled, { css } from 'styled-components'; -import { EuiButton, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { ManagementEmptyStateWrapper } from '../../management_empty_state_wrapper'; import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; @@ -19,10 +20,12 @@ const EmptyPrompt = styled(EuiEmptyPrompt)` export const NoDataEmptyState = memo<{ onAdd: () => void; + onImport: () => void; titleLabel: string; titleNoEntriesLabel: string; aboutInfo: string; primaryButtonLabel: string; + importButtonLabel: string; canCreateItems?: boolean; /** Should the Add button be disabled */ isAddDisabled?: boolean; @@ -32,6 +35,7 @@ export const NoDataEmptyState = memo<{ }>( ({ onAdd, + onImport, isAddDisabled = false, backComponent, 'data-test-subj': dataTestSubj, @@ -39,9 +43,13 @@ export const NoDataEmptyState = memo<{ titleNoEntriesLabel, aboutInfo, primaryButtonLabel, + importButtonLabel, secondaryAboutInfo, canCreateItems = true, }) => { + const isEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( + 'endpointExceptionsMovedUnderManagement' + ); const getTestId = useTestIdGenerator(dataTestSubj); return ( @@ -63,14 +71,25 @@ export const NoDataEmptyState = memo<{ } actions={[ - - {primaryButtonLabel} - , + + + {primaryButtonLabel} + + {isEndpointExceptionsMovedUnderManagementFFEnabled && ( + + {importButtonLabel} + + )} + , ...(backComponent ? [backComponent] : []), ]} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_import_flyout_opened.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_import_flyout_opened.ts new file mode 100644 index 0000000000000..cd43a8b58ea55 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_import_flyout_opened.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useUrlParams } from '../../../hooks/use_url_params'; +import type { ArtifactListPageUrlParams } from '../types'; + +export const useIsImportFlyoutOpened = (allowCreate: boolean = true): boolean => { + const showUrlParamValue = useUrlParams().urlParams.show ?? ''; + return useMemo( + () => showUrlParamValue === 'import' && allowCreate, + + [allowCreate, showUrlParamValue] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx index b424596d436f8..740cf9e422fd4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/integration_tests/artifact_list_page.test.tsx @@ -12,7 +12,11 @@ import type { ArtifactListPageProps } from '../artifact_list_page'; import { act, fireEvent, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ArtifactListPageRenderingSetup } from '../mocks'; -import { getArtifactListPageRenderingSetup } from '../mocks'; +import { + getArtifactImportFlyoutUiMocks, + getArtifactImportExportUiMocks, + getArtifactListPageRenderingSetup, +} from '../mocks'; import { getDeferred } from '../../../mocks/utils'; import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; import type { ArtifactEntryCardDecoratorProps } from '../../artifact_entry_card'; @@ -32,17 +36,26 @@ describe('When using the ArtifactListPage component', () => { let history: AppContextTestRender['history']; let mockedApi: ReturnType; let getFirstCard: ArtifactListPageRenderingSetup['getFirstCard']; + let importExportUi: ReturnType; + let importFlyoutUi: ReturnType; + let setExperimentalFlag: ArtifactListPageRenderingSetup['setExperimentalFlag']; beforeEach(() => { const renderSetup = getArtifactListPageRenderingSetup(); - ({ history, mockedApi, getFirstCard } = renderSetup); + ({ history, mockedApi, getFirstCard, setExperimentalFlag } = renderSetup); mockUseGetEndpointSpecificPolicies.mockReturnValue({ data: mockedApi.responseProvider.endpointPackagePolicyList(), }); - render = (props = {}) => (renderResult = renderSetup.renderArtifactListPage(props)); + render = (props = {}) => { + renderResult = renderSetup.renderArtifactListPage(props); + importExportUi = getArtifactImportExportUiMocks(renderResult, 'testPage'); + importFlyoutUi = getArtifactImportFlyoutUiMocks(renderResult); + + return renderResult; + }; }); it('should display a loader while determining which view to show', async () => { @@ -149,6 +162,88 @@ describe('When using the ArtifactListPage component', () => { expect(getAllByText('mock decorator')).toHaveLength(10); }); + describe('Import and export', () => { + beforeEach(() => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + }); + + it('should not show import and export actions with feature flag disabled', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: false }); + + await renderWithListData(); + + expect(importExportUi.queryMenuButton()).not.toBeInTheDocument(); + }); + + it('should show import and export actions', async () => { + await renderWithListData(); + + expect(importExportUi.getMenuButton()).toBeInTheDocument(); + + await userEvent.click(importExportUi.getMenuButton()); + expect(importExportUi.getImportButton()).toBeInTheDocument(); + expect(importExportUi.getExportButton()).toBeInTheDocument(); + }); + + it('should enable import and export buttons when user can create artifacts', async () => { + await renderWithListData({ allowCardCreateAction: true }); + + await userEvent.click(importExportUi.getMenuButton()); + expect(importExportUi.getImportButton()).toBeEnabled(); + expect(importExportUi.getExportButton()).toBeEnabled(); + }); + + it('should disable import button when user cannot create artifacts', async () => { + await renderWithListData({ allowCardCreateAction: false }); + + await userEvent.click(importExportUi.getMenuButton()); + expect(importExportUi.getImportButton()).toBeDisabled(); + expect(importExportUi.getExportButton()).toBeEnabled(); + }); + + it('should display the import flyout when import is clicked', async () => { + await renderWithListData(); + + await userEvent.click(importExportUi.getMenuButton()); + await userEvent.click(importExportUi.getImportButton()); + + expect(importFlyoutUi.queryImportFlyout()).toBeInTheDocument(); + }); + + it('should display the import flyout if it is requested via URL param', async () => { + history.push('somepage?show=import'); + await renderWithListData(); + + expect(importFlyoutUi.queryImportFlyout()).toBeInTheDocument(); + }); + + it('should not display the import flyout if it is requested via URL param without FF enabled', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: false }); + history.push('somepage?show=import'); + await renderWithListData(); + + expect(importFlyoutUi.queryImportFlyout()).not.toBeInTheDocument(); + }); + + it('should refetch list data after a successful import', async () => { + await renderWithListData(); + + await userEvent.click(importExportUi.getMenuButton()); + await userEvent.click(importExportUi.getImportButton()); + + await importFlyoutUi.uploadFile(); + const currentApiCallCount = mockedApi.responseProvider.trustedAppsList.mock.calls.length; + + await userEvent.click(importFlyoutUi.getImportButton()); + + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppsList).toHaveBeenCalledTimes( + currentApiCallCount + 1 + ); + }); + }); + }); + describe('and interacting with card actions', () => { const clickCardAction = async (action: 'edit' | 'delete') => { await getFirstCard({ showActions: true }); @@ -198,16 +293,19 @@ describe('When using the ArtifactListPage component', () => { it.each([ ['create', 'show=create'], ['edit', 'show=edit&itemId=123'], + ['import', 'show=import'], ])( 'should NOT show flyout if url has a show param of %s but the action is not allowed', async (_, urlParam) => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); history.push(`somepage?${urlParam}`); const { queryByTestId } = await renderWithListData({ allowCardCreateAction: false, allowCardEditAction: false, }); - expect(queryByTestId('testPage-flyout')).toBeNull(); + expect(queryByTestId('testPage-flyout')).not.toBeInTheDocument(); + expect(importFlyoutUi.queryImportFlyout()).not.toBeInTheDocument(); } ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx index 5deacae50e265..f7d9000f92bf2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx @@ -145,3 +145,36 @@ export const getArtifactListPageRenderingSetup = (): ArtifactListPageRenderingSe setExperimentalFlag: mockedContext.setExperimentalFlag, }; }; + +export const getArtifactImportExportUiMocks = ( + renderResult: ReturnType, + dataTestSubj: string = 'testPage' +) => { + const getMenuButton = () => + renderResult.getByTestId(`${dataTestSubj}-exportImportMenuButtonIcon`); + const queryMenuButton = () => + renderResult.queryByTestId(`${dataTestSubj}-exportImportMenuButtonIcon`); + const getExportButton = () => + renderResult.getByTestId(`${dataTestSubj}-exportImportMenuActionItemExportButton`); + const getImportButton = () => + renderResult.getByTestId(`${dataTestSubj}-exportImportMenuActionItemImportButton`); + + return { getExportButton, getImportButton, getMenuButton, queryMenuButton }; +}; + +export const getArtifactImportFlyoutUiMocks = ( + renderResult: ReturnType, + dataTestSubj: string = 'artifactImportFlyout' +) => { + const queryImportFlyout = () => renderResult.queryByTestId(dataTestSubj); + const getCancelButton = () => renderResult.getByTestId(`${dataTestSubj}-cancelButton`); + const getImportButton = () => renderResult.getByTestId(`${dataTestSubj}-importButton`); + + const uploadFile = () => + userEvent.upload( + renderResult.getByTestId(`${dataTestSubj}-filePicker`), + new File(['random file content'], 'trusted_apps.ndjson') + ); + + return { queryImportFlyout, getCancelButton, getImportButton, uploadFile }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/translations.ts index cbe600ae3a53f..dd29a8caab713 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/translations.ts @@ -23,6 +23,48 @@ export const artifactListPageLabels = Object.freeze({ pageAddButtonTitle: i18n.translate('xpack.securitySolution.artifactListPage.addButtonTitle', { defaultMessage: 'Add artifact', }), + pageImportButtonTitle: i18n.translate( + 'xpack.securitySolution.artifactListPage.importButtonTitle', + { + defaultMessage: 'Import artifact list', + } + ), + pageExportButtonTitle: i18n.translate( + 'xpack.securitySolution.artifactListPage.exportButtonTitle', + { + defaultMessage: 'Export artifact list', + } + ), + pageExportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.artifactListPage.exportSuccessToastTitle', + { + defaultMessage: 'Artifact list exported successfully', + } + ), + pageExportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.artifactListPage.exportErrorToastTitle', + { + defaultMessage: 'Artifact list export failed', + } + ), + pageImportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.artifactListPage.importSuccessToastTitle', + { + defaultMessage: 'Artifact list imported successfully', + } + ), + pageImportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.artifactListPage.importErrorToastTitle', + { + defaultMessage: 'Artifact list import failed', + } + ), + importFlyoutImportSubmitButtonLabel: i18n.translate( + 'xpack.securitySolution.artifactListPage.importFlyoutImportSubmitButtonLabel', + { + defaultMessage: 'Import list', + } + ), // ------------------------------ // EMPTY state labels @@ -43,6 +85,10 @@ export const artifactListPageLabels = Object.freeze({ 'xpack.securitySolution.artifactListPage.emptyStatePrimaryButtonLabel', { defaultMessage: 'Add' } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.artifactListPage.emptyStateImportButtonLabel', + { defaultMessage: 'Import list' } + ), // ------------------------------ // SEARCH BAR labels @@ -108,6 +154,12 @@ export type ArtifactListPageRequiredLabels = Pick< | 'pageTitle' | 'pageAboutInfo' | 'pageAddButtonTitle' + | 'pageImportButtonTitle' + | 'pageExportButtonTitle' + | 'pageExportSuccessToastTitle' + | 'pageExportErrorToastTitle' + | 'pageImportSuccessToastTitle' + | 'pageImportErrorToastTitle' | 'getShowingCountLabel' | 'cardActionEditLabel' | 'cardActionDeleteLabel' @@ -122,6 +174,7 @@ export type ArtifactListPageRequiredLabels = Pick< | 'emptyStateTitle' | 'emptyStateInfo' | 'emptyStatePrimaryButtonLabel' + | 'emptyStateImportButtonLabel' >; export type ArtifactListPageOptionalLabels = Omit; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/types.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/types.ts index 84f4a9283b0be..d7d0de31fda98 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/types.ts @@ -17,7 +17,7 @@ export interface ArtifactListPageUrlParams { pageSize?: number; filter?: string; includedPolicies?: string; - show?: 'create' | 'edit'; + show?: 'create' | 'edit' | 'import'; itemId?: string; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts index 6fae846737bf4..74ada49577d68 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts @@ -132,6 +132,7 @@ describe( cy.getByTestSubj('policy-artifacts-empty-unexisting').should('exist'); cy.getByTestSubj('unexisting-manage-artifacts-button').should('not.exist'); + cy.getByTestSubj('unexisting-manage-artifacts-import-button').should('not.exist'); } ); @@ -140,6 +141,7 @@ describe( visitArtifactTab(testData.tabId); cy.getByTestSubj('policy-artifacts-empty-unexisting').should('exist'); + cy.getByTestSubj('unexisting-manage-artifacts-import-button').should('exist'); cy.getByTestSubj('unexisting-manage-artifacts-button').should('exist').click(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_import_artifact_list.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_import_artifact_list.test.tsx new file mode 100644 index 0000000000000..232175aca64e0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_import_artifact_list.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useImportArtifactList } from './use_import_artifact_list'; +import type { HttpSetup } from '@kbn/core/public'; +import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; +import { + getFakeListId, + getFakeListDefinition, + getFakeHttpService, + renderMutation, +} from '../test_utils'; +import type { ImportExceptionsResponseSchema } from '@kbn/securitysolution-io-ts-list-types'; + +describe('Import artifact list hook', () => { + let result: ReturnType; + + let fakeHttpServices: jest.Mocked; + let instance: ExceptionsListApiClient; + + beforeEach(() => { + fakeHttpServices = getFakeHttpService(); + instance = new ExceptionsListApiClient( + fakeHttpServices, + getFakeListId(), + getFakeListDefinition() + ); + }); + + it('import an artifact list', async () => { + const apiResponse: ImportExceptionsResponseSchema = { + success: true, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 0, + errors: [], + }; + + fakeHttpServices.post.mockClear(); + fakeHttpServices.post.mockResolvedValueOnce(apiResponse); + const onSuccessMock: jest.Mock = jest.fn(); + + result = await renderMutation(() => + useImportArtifactList(instance, { + retry: false, + onSuccess: onSuccessMock, + }) + ); + + expect(fakeHttpServices.post).toHaveBeenCalledTimes(0); + + const mockFile = new File(['test content'], 'test.ndjson', { type: 'application/ndjson' }); + + const res = await result.mutateAsync({ file: mockFile }); + + expect(res).toBe(apiResponse); + expect(onSuccessMock).toHaveBeenCalledTimes(1); + expect(fakeHttpServices.post).toHaveBeenCalledTimes(1); + expect(fakeHttpServices.post).toHaveBeenCalledWith( + '/api/exception_lists/_import', + expect.objectContaining({ + version: '2023-10-31', + headers: { 'Content-Type': undefined }, + body: expect.any(FormData), + query: { + overwrite: false, + }, + }) + ); + }); + + it('throw when importing an artifact list', async () => { + const expectedError = { + response: { + status: 500, + }, + }; + fakeHttpServices.post.mockClear(); + fakeHttpServices.post.mockRejectedValue(expectedError); + + const onErrorMock: jest.Mock = jest.fn(); + + result = await renderMutation(() => + useImportArtifactList(instance, { + onError: onErrorMock, + retry: false, + }) + ); + + const mockFile = new File(['test content'], 'test.ndjson', { type: 'application/ndjson' }); + + try { + await result.mutateAsync({ file: mockFile }); + } catch (error) { + expect(error).toBe(expectedError); + expect(fakeHttpServices.post).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_import_artifact_list.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_import_artifact_list.tsx new file mode 100644 index 0000000000000..63e1f6b3af7c4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_import_artifact_list.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { ImportExceptionsResponseSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; +import type { UseMutationOptions, UseMutationResult } from '@kbn/react-query'; +import { useMutation } from '@kbn/react-query'; +import type { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; + +const DEFAULT_OPTIONS = Object.freeze({}); + +export function useImportArtifactList( + exceptionListApiClient: ExceptionsListApiClient, + customOptions: UseMutationOptions< + ImportExceptionsResponseSchema, + IHttpFetchError, + { file: File }, + () => void + > = DEFAULT_OPTIONS +): UseMutationResult< + ImportExceptionsResponseSchema, + IHttpFetchError, + { file: File }, + () => void +> { + return useMutation< + ImportExceptionsResponseSchema, + IHttpFetchError, + { file: File }, + () => void + >(({ file }) => { + return exceptionListApiClient.import(file); + }, customOptions); +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/mocks/trusted_apps_http_mocks.ts b/x-pack/solutions/security/plugins/security_solution/public/management/mocks/trusted_apps_http_mocks.ts index f092bd3dcbd4d..5f57674a5c435 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/mocks/trusted_apps_http_mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/mocks/trusted_apps_http_mocks.ts @@ -20,6 +20,7 @@ import type { CreateExceptionListItemSchema, DeleteExceptionListItemSchema, ExceptionListSchema, + ImportExceptionsResponseSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { getTrustedAppsListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; import type { ResponseProvidersInterface } from '../../common/mock/endpoint/http_handler_mock_factory'; @@ -184,6 +185,33 @@ export const trustedAppsDeleteOneHttpMocks = }, ]); +export type TrustedAppsImportListHttpMocksInterface = ResponseProvidersInterface<{ + trustedAppImportList: (options: HttpFetchOptionsWithPath) => ImportExceptionsResponseSchema; +}>; + +/** + * HTTP mocks that support importing Trusted Apps list + */ +export const trustedAppsImportListHttpMocks = + httpHandlerMockFactory([ + { + id: 'trustedAppImportList', + path: `${EXCEPTION_LIST_URL}/_import`, + method: 'post', + handler: (): ImportExceptionsResponseSchema => { + return { + errors: [], + success: true, + success_count: 3, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 2, + }; + }, + }, + ]); + export type TrustedAppPostHttpMocksInterface = ResponseProvidersInterface<{ trustedAppCreate: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; }>; @@ -237,6 +265,7 @@ export type TrustedAppsAllHttpMocksInterface = FleetGetEndpointPackagePolicyList TrustedAppsGetOneHttpMocksInterface & TrustedAppPutHttpMocksInterface & TrustedAppsDeleteOneHttpMocksInterface & + TrustedAppsImportListHttpMocksInterface & TrustedAppPostHttpMocksInterface & TrustedAppsPostCreateListHttpMockInterface; /** Use this HTTP mock when wanting to mock the API calls done by the Trusted Apps Http service */ @@ -247,6 +276,7 @@ export const trustedAppsAllHttpMocks = composeHttpHandlerMocks i18n.translate('xpack.securitySolution.blocklist.showingTotal', { defaultMessage: @@ -101,6 +131,10 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { 'xpack.securitySolution.blocklist.emptyStatePrimaryButtonLabel', { defaultMessage: 'Add blocklist entry' } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.blocklist.emptyStateImportButtonLabel', + { defaultMessage: 'Import blocklist' } + ), searchPlaceholderInfo: i18n.translate('xpack.securitySolution.blocklist.searchPlaceholderInfo', { defaultMessage: 'Search on the fields below: name, description, value', }), diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/translations.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/translations.tsx index 68a78d62b7771..d58bbb82837b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/translations.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/translations.tsx @@ -25,6 +25,42 @@ export const ENDPOINT_EXCEPTIONS_PAGE_LABELS: ArtifactListPageLabels = { 'xpack.securitySolution.endpointExceptions.pageAddButtonTitle', { defaultMessage: 'Add endpoint exception' } ), + pageImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpointExceptions.pageImportButtonTitle', + { + defaultMessage: 'Import endpoint exception list', + } + ), + pageExportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpointExceptions.pageExportButtonTitle', + { + defaultMessage: 'Export endpoint exception list', + } + ), + pageExportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.endpointExceptions.exportSuccessToastTitle', + { + defaultMessage: 'Endpoint exception list exported successfully', + } + ), + pageExportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.endpointExceptions.exportErrorToastTitle', + { + defaultMessage: 'Endpoint exception list export failed', + } + ), + pageImportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.endpointExceptions.pageImportSuccessToastTitle', + { + defaultMessage: 'Endpoint exception list imported successfully', + } + ), + pageImportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.endpointExceptions.pageImportErrorToastTitle', + { + defaultMessage: 'Endpoint exception list import failed', + } + ), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.endpointExceptions.showingTotal', { defaultMessage: @@ -103,6 +139,10 @@ export const ENDPOINT_EXCEPTIONS_PAGE_LABELS: ArtifactListPageLabels = { 'xpack.securitySolution.endpointExceptions.emptyStatePrimaryButtonLabel', { defaultMessage: 'Add endpoint exception' } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.endpointExceptions.emptyStateImportButtonLabel', + { defaultMessage: 'Import endpoint exception list' } + ), searchPlaceholderInfo: i18n.translate( 'xpack.securitySolution.endpointExceptions.searchPlaceholderInfo', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx index eae0f624b0450..b52010aa45596 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx @@ -13,7 +13,7 @@ import { EuiLink } from '@elastic/eui'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { useHttp } from '../../../../common/lib/kibana'; -import type { ArtifactListPageProps } from '../../../components/artifact_list_page'; +import type { ArtifactListPageLabels } from '../../../components/artifact_list_page'; import { ArtifactListPage } from '../../../components/artifact_list_page'; import { EventFiltersApiClient } from '../service/api_client'; import { EventFiltersForm } from './components/form'; @@ -50,7 +50,7 @@ export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form defaultMessage: 'Endpoint Event Filtering', }); -const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageProps['labels'] = { +const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageLabels = { pageTitle: i18n.translate('xpack.securitySolution.eventFilters.pageTitle', { defaultMessage: 'Event Filters', }), @@ -61,6 +61,42 @@ const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageProps['labels'] = { pageAddButtonTitle: i18n.translate('xpack.securitySolution.eventFilters.pageAddButtonTitle', { defaultMessage: 'Add event filter', }), + pageImportButtonTitle: i18n.translate( + 'xpack.securitySolution.eventFilters.pageImportButtonTitle', + { + defaultMessage: 'Import event filter list', + } + ), + pageExportButtonTitle: i18n.translate( + 'xpack.securitySolution.eventFilters.pageExportButtonTitle', + { + defaultMessage: 'Export event filter list', + } + ), + pageExportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.eventFilters.exportSuccessToastTitle', + { + defaultMessage: 'Event filter list exported successfully', + } + ), + pageExportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.eventFilters.exportErrorToastTitle', + { + defaultMessage: 'Event filter list export failed', + } + ), + pageImportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.eventFilters.pageImportSuccessToastTitle', + { + defaultMessage: 'Event filter list imported successfully', + } + ), + pageImportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.eventFilters.pageImportErrorToastTitle', + { + defaultMessage: 'Event filter list import failed', + } + ), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.eventFilters.showingTotal', { defaultMessage: 'Showing {total} {total, plural, one {event filter} other {event filters}}', @@ -135,6 +171,10 @@ const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageProps['labels'] = { 'xpack.securitySolution.eventFilters.emptyStatePrimaryButtonLabel', { defaultMessage: 'Add event filter' } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.emptyStateImportButtonLabel', + { defaultMessage: 'Import event filter list' } + ), searchPlaceholderInfo: i18n.translate( 'xpack.securitySolution.eventFilters.searchPlaceholderInfo', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index ebdf6c6a0be6d..215c3cca1c8ca 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -9,13 +9,13 @@ import { i18n } from '@kbn/i18n'; import React, { memo } from 'react'; import { useHttp } from '../../../../common/lib/kibana'; import { ArtifactListPage } from '../../../components/artifact_list_page'; -import type { ArtifactListPageProps } from '../../../components/artifact_list_page'; +import type { ArtifactListPageLabels } from '../../../components/artifact_list_page'; import { HostIsolationExceptionsApiClient } from '../host_isolation_exceptions_api_client'; import { SEARCHABLE_FIELDS } from '../constants'; import { HostIsolationExceptionsForm } from './components/form'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; -const HOST_ISOLATION_EXCEPTIONS_LABELS: ArtifactListPageProps['labels'] = Object.freeze({ +const HOST_ISOLATION_EXCEPTIONS_LABELS: ArtifactListPageLabels = Object.freeze({ pageTitle: i18n.translate('xpack.securitySolution.hostIsolationExceptions.pageTitle', { defaultMessage: 'Host isolation exceptions', }), @@ -29,6 +29,42 @@ const HOST_ISOLATION_EXCEPTIONS_LABELS: ArtifactListPageProps['labels'] = Object defaultMessage: 'Add host isolation exception', } ), + pageImportButtonTitle: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.pageImportButtonTitle', + { + defaultMessage: 'Import host isolation exception list', + } + ), + pageExportButtonTitle: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.pageExportButtonTitle', + { + defaultMessage: 'Export host isolation exception list', + } + ), + pageExportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.exportSuccessToastTitle', + { + defaultMessage: 'Host isolation exception list exported successfully', + } + ), + pageExportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.exportErrorToastTitle', + { + defaultMessage: 'Host isolation exception list export failed', + } + ), + pageImportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.pageImportSuccessToastTitle', + { + defaultMessage: 'Host isolation exception list imported successfully', + } + ), + pageImportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.pageImportErrorToastTitle', + { + defaultMessage: 'Host isolation exception list import failed', + } + ), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.hostIsolationExceptions.showingTotal', { defaultMessage: @@ -107,6 +143,10 @@ const HOST_ISOLATION_EXCEPTIONS_LABELS: ArtifactListPageProps['labels'] = Object 'xpack.securitySolution.hostIsolationExceptions.emptyStatePrimaryButtonLabel', { defaultMessage: 'Add host isolation exception' } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.emptyStateImportButtonLabel', + { defaultMessage: 'Import host isolation exception list' } + ), searchPlaceholderInfo: i18n.translate( 'xpack.securitySolution.hostIsolationExceptions.searchPlaceholderInfo', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx index 2ff8a399d3858..041790c7f2621 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx @@ -6,7 +6,8 @@ */ import React, { memo } from 'react'; -import { EuiButton, EuiPageTemplate } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiPageTemplate } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../../../../../common/hooks/use_experimental_features'; import { useGetLinkTo } from './use_policy_artifacts_empty_hooks'; import type { POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS } from './translations'; import type { ArtifactListPageUrlParams } from '../../../../../components/artifact_list_page'; @@ -29,7 +30,11 @@ export const PolicyArtifactsEmptyUnexisting = memo( getPolicyArtifactsPath, getArtifactPath, }) => { - const { onClickHandler, toRouteUrl } = useGetLinkTo( + const isEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( + 'endpointExceptionsMovedUnderManagement' + ); + + const { onClickHandler: onAddClickHandler, toRouteUrl: toAddRouteUrl } = useGetLinkTo( policyId, policyName, getPolicyArtifactsPath, @@ -38,6 +43,17 @@ export const PolicyArtifactsEmptyUnexisting = memo( show: 'create', } ); + + const { onClickHandler: onImportClickHandler, toRouteUrl: toImportRouteUrl } = useGetLinkTo( + policyId, + policyName, + getPolicyArtifactsPath, + getArtifactPath, + { + show: 'import', + } + ); + return ( ( body={labels.emptyUnexistingMessage} actions={ canWriteArtifact ? ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - {labels.emptyUnexistingPrimaryActionButtonTitle} - + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {labels.emptyUnexistingPrimaryActionButtonTitle} + + + {isEndpointExceptionsMovedUnderManagementFFEnabled && ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {labels.emptyUnexistingImportButtonTitle} + + )} + ) : null } /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts index 0b58b724bd837..670984541f837 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts @@ -53,4 +53,8 @@ export const POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.artifacts.empty.unexisting.action', { defaultMessage: 'Add artifacts' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unexisting.importAction', + { defaultMessage: 'Import artifact list' } + ), }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts index e4e56e67d54c0..72a6103ba21a6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/use_policy_artifacts_empty_hooks.ts @@ -17,7 +17,7 @@ export const useGetLinkTo = ( policyName: string, getPolicyArtifactsPath: (policyId: string) => string, getArtifactPath: (location?: Partial) => string, - location?: Partial<{ show: 'create' }> + location?: Partial<{ show: Extract }> ) => { const { getAppUrl } = useAppUrl(); const { toRoutePath, toRouteUrl } = useMemo(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx index 08e47ebcb0431..e0b3347fbae21 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx @@ -39,6 +39,7 @@ let policyItem: ImmutableObject; const generator = new EndpointDocGenerator(); let mockedApi: ReturnType; let history: AppContextTestRender['history']; +let setExperimentalFlag: AppContextTestRender['setExperimentalFlag']; const useUserPrivilegesMock = useUserPrivileges as jest.Mock; const getEventFiltersLabels = () => ({ @@ -61,7 +62,7 @@ describe('Policy artifacts layout', () => { mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); mockedApi.responseProvider.eventFiltersList.mockClear(); policyItem = generator.generatePolicyPackagePolicy(); - ({ history } = mockedContext); + ({ history, setExperimentalFlag } = mockedContext); useUserPrivilegesMock.mockReturnValue({ endpointPrivileges: { @@ -107,12 +108,35 @@ describe('Policy artifacts layout', () => { }); it('should render layout with no assigned artifacts data when there are no artifacts', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); mockedApi.responseProvider.eventFiltersList.mockReturnValue( getFoundExceptionListItemSchemaMock(0) ); await render(); - expect(await renderResult.findByTestId('policy-artifacts-empty-unexisting')).not.toBeNull(); + expect( + await renderResult.findByTestId('policy-artifacts-empty-unexisting') + ).toBeInTheDocument(); + expect(renderResult.queryByTestId('unexisting-manage-artifacts-button')).toBeInTheDocument(); + expect( + renderResult.queryByTestId('unexisting-manage-artifacts-import-button') + ).toBeInTheDocument(); + }); + + it('should not render import button when experimental flag is disabled', async () => { + setExperimentalFlag({ endpointExceptionsMovedUnderManagement: false }); + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(0) + ); + + await render(); + expect( + await renderResult.findByTestId('policy-artifacts-empty-unexisting') + ).toBeInTheDocument(); + expect(renderResult.queryByTestId('unexisting-manage-artifacts-button')).toBeInTheDocument(); + expect( + renderResult.queryByTestId('unexisting-manage-artifacts-import-button') + ).not.toBeInTheDocument(); }); it('should render layout with no assigned artifacts data when there are artifacts', async () => { @@ -127,6 +151,8 @@ describe('Policy artifacts layout', () => { await render(); expect(await renderResult.findByTestId('policy-artifacts-empty-unassigned')).not.toBeNull(); + expect(renderResult.queryByTestId('unassigned-assign-artifacts-button')).toBeInTheDocument(); + expect(renderResult.queryByTestId('unassigned-manage-artifacts-button')).toBeInTheDocument(); }); it('should render layout with data', async () => { @@ -198,6 +224,7 @@ describe('Policy artifacts layout', () => { await render(false); expect(renderResult.queryByTestId('artifacts-assign-button')).toBeNull(); }); + it('should not display assign and manage artifacts buttons on empty state when there are artifacts', async () => { mockedApi.responseProvider.eventFiltersList.mockImplementation((args?: MockedAPIArgs) => { if (!isFilteredByPolicyQuery(args)) { @@ -211,13 +238,23 @@ describe('Policy artifacts layout', () => { expect(renderResult.queryByTestId('unassigned-assign-artifacts-button')).toBeNull(); expect(renderResult.queryByTestId('unassigned-manage-artifacts-button')).toBeNull(); }); + it('should not display manage artifacts button on empty state when there are no artifacts', async () => { mockedApi.responseProvider.eventFiltersList.mockReturnValue( getFoundExceptionListItemSchemaMock(0) ); + await render(false); - expect(await renderResult.findByTestId('policy-artifacts-empty-unexisting')).not.toBeNull(); - expect(renderResult.queryByTestId('unexisting-manage-artifacts-button')).toBeNull(); + + expect( + await renderResult.findByTestId('policy-artifacts-empty-unexisting') + ).toBeInTheDocument(); + expect( + renderResult.queryByTestId('unexisting-manage-artifacts-button') + ).not.toBeInTheDocument(); + expect( + renderResult.queryByTestId('unexisting-manage-artifacts-import-button') + ).not.toBeInTheDocument(); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts index 95716eec16a4b..47a071097cd3f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts @@ -71,6 +71,7 @@ export type PolicyArtifactsPageRequiredLabels = Pick< | 'emptyUnexistingTitle' | 'emptyUnexistingMessage' | 'emptyUnexistingPrimaryActionButtonTitle' + | 'emptyUnexistingImportButtonTitle' | 'listTotalItemCountMessage' | 'listRemoveActionNotAllowedMessage' | 'listSearchPlaceholderMessage' diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts index ca09d768f2051..fda35e93120a9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts @@ -7,8 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyArtifactsPageLabels } from '../artifacts/translations'; -export const POLICY_ARTIFACT_BLOCKLISTS_LABELS = Object.freeze({ +export const POLICY_ARTIFACT_BLOCKLISTS_LABELS: Omit< + PolicyArtifactsPageLabels, + 'layoutAboutMessage' +> = Object.freeze({ deleteModalTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.blocklist.list.removeDialog.title', { @@ -126,6 +130,10 @@ export const POLICY_ARTIFACT_BLOCKLISTS_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.blocklist.empty.unexisting.action', { defaultMessage: 'Add blocklist entry' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklist.empty.unexisting.importAction', + { defaultMessage: 'Import blocklist' } + ), listTotalItemCountMessage: (totalItemsCount: number): string => i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.list.totalItemCount', { defaultMessage: diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/endpoint_exceptions_translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/endpoint_exceptions_translations.ts index e95a4a38fe6f7..f96faf61aadf0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/endpoint_exceptions_translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/endpoint_exceptions_translations.ts @@ -7,8 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyArtifactsPageLabels } from '../artifacts/translations'; -export const POLICY_ARTIFACT_ENDPOINT_EXCEPTIONS_LABELS = Object.freeze({ +export const POLICY_ARTIFACT_ENDPOINT_EXCEPTIONS_LABELS: Omit< + PolicyArtifactsPageLabels, + 'layoutAboutMessage' +> = Object.freeze({ deleteModalTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.endpointExceptions.list.removeDialog.title', { @@ -132,6 +136,10 @@ export const POLICY_ARTIFACT_ENDPOINT_EXCEPTIONS_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.endpointExceptions.empty.unexisting.action', { defaultMessage: 'Add endpoint exception' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.endpointExceptions.empty.unexisting.importAction', + { defaultMessage: 'Import endpoint exception list' } + ), listTotalItemCountMessage: (totalItemsCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.policy.endpointExceptionss.list.totalItemCount', diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts index 090474abf1fa6..be38abfda3a32 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts @@ -7,8 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyArtifactsPageLabels } from '../artifacts/translations'; -export const POLICY_ARTIFACT_EVENT_FILTERS_LABELS = Object.freeze({ +export const POLICY_ARTIFACT_EVENT_FILTERS_LABELS: Omit< + PolicyArtifactsPageLabels, + 'layoutAboutMessage' +> = Object.freeze({ deleteModalTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.title', { @@ -124,6 +128,10 @@ export const POLICY_ARTIFACT_EVENT_FILTERS_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unexisting.action', { defaultMessage: 'Add event filters' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unexisting.importAction', + { defaultMessage: 'Import event filter list' } + ), listTotalItemCountMessage: (totalItemsCount: number): string => i18n.translate('xpack.securitySolution.endpoint.policy.eventFilters.list.totalItemCount', { defaultMessage: diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts index f2ec049940510..b59411991bd6d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts @@ -7,8 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyArtifactsPageLabels } from '../artifacts/translations'; -export const POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS = Object.freeze({ +export const POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS: Omit< + PolicyArtifactsPageLabels, + 'layoutAboutMessage' +> = Object.freeze({ deleteModalTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.removeDialog.title', { @@ -133,6 +137,10 @@ export const POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unexisting.action', { defaultMessage: 'Add host isolation exceptions' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unexisting.importAction', + { defaultMessage: 'Import host isolation exception list' } + ), listTotalItemCountMessage: (totalItemsCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.policy.hostIsolationException.list.totalItemCount', diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts index 505ee0655e4ba..b20dbb42b2256 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts @@ -7,8 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyArtifactsPageLabels } from '../artifacts/translations'; -export const POLICY_ARTIFACT_TRUSTED_APPS_LABELS = Object.freeze({ +export const POLICY_ARTIFACT_TRUSTED_APPS_LABELS: Omit< + PolicyArtifactsPageLabels, + 'layoutAboutMessage' +> = Object.freeze({ deleteModalTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeDialog.title', { @@ -124,6 +128,10 @@ export const POLICY_ARTIFACT_TRUSTED_APPS_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unexisting.action', { defaultMessage: 'Add trusted applications' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unexisting.importAction', + { defaultMessage: 'Import trusted application list' } + ), listTotalItemCountMessage: (totalItemsCount: number): string => i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.totalItemCount', { defaultMessage: diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_devices_translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_devices_translations.ts index 9bf82bcfa0457..6916725de9741 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_devices_translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_devices_translations.ts @@ -7,8 +7,12 @@ import { i18n } from '@kbn/i18n'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyArtifactsPageLabels } from '../artifacts/translations'; -export const POLICY_ARTIFACT_TRUSTED_DEVICES_LABELS = Object.freeze({ +export const POLICY_ARTIFACT_TRUSTED_DEVICES_LABELS: Omit< + PolicyArtifactsPageLabels, + 'layoutAboutMessage' +> = Object.freeze({ deleteModalTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.trustedDevices.list.removeDialog.title', { @@ -127,6 +131,10 @@ export const POLICY_ARTIFACT_TRUSTED_DEVICES_LABELS = Object.freeze({ 'xpack.securitySolution.endpoint.policy.trustedDevices.empty.unexisting.action', { defaultMessage: 'Add trusted devices' } ), + emptyUnexistingImportButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedDevices.empty.unexisting.importAction', + { defaultMessage: 'Import trusted device list' } + ), listTotalItemCountMessage: (totalItemsCount: number): string => i18n.translate('xpack.securitySolution.endpoint.policy.trustedDevices.list.totalItemCount', { defaultMessage: diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx index d29f866f8b389..fe6fd2174184e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx @@ -13,7 +13,7 @@ import { EuiLink } from '@elastic/eui'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { useHttp } from '../../../../common/lib/kibana'; -import type { ArtifactListPageProps } from '../../../components/artifact_list_page'; +import type { ArtifactListPageLabels } from '../../../components/artifact_list_page'; import { ArtifactListPage } from '../../../components/artifact_list_page'; import { TRUSTED_PROCESS_DESCENDANTS_TAG } from '../../../../../common/endpoint/service/artifacts'; import { TrustedAppsApiClient } from '../service'; @@ -25,7 +25,7 @@ import { ProcessDescendantsIndicator } from '../../../components/artifact_entry_ import type { ArtifactEntryCardDecoratorProps } from '../../../components/artifact_entry_card/artifact_entry_card'; import { TRUSTED_APPS_PROCESS_DESCENDANT_DECORATOR_LABELS } from './translations'; -const TRUSTED_APPS_PAGE_LABELS: ArtifactListPageProps['labels'] = { +const TRUSTED_APPS_PAGE_LABELS: ArtifactListPageLabels = { pageTitle: i18n.translate('xpack.securitySolution.trustedApps.pageTitle', { defaultMessage: 'Trusted applications', }), @@ -36,6 +36,42 @@ const TRUSTED_APPS_PAGE_LABELS: ArtifactListPageProps['labels'] = { pageAddButtonTitle: i18n.translate('xpack.securitySolution.trustedApps.pageAddButtonTitle', { defaultMessage: 'Add trusted application', }), + pageImportButtonTitle: i18n.translate( + 'xpack.securitySolution.trustedApps.pageImportButtonTitle', + { + defaultMessage: 'Import trusted application list', + } + ), + pageExportButtonTitle: i18n.translate( + 'xpack.securitySolution.trustedApps.pageExportButtonTitle', + { + defaultMessage: 'Export trusted application list', + } + ), + pageExportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.trustedApps.pageExportSuccessToastTitle', + { + defaultMessage: 'Trusted application list exported successfully', + } + ), + pageExportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.trustedApps.pageExportErrorToastTitle', + { + defaultMessage: 'Trusted application list export failed', + } + ), + pageImportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.trustedApps.pageImportSuccessToastTitle', + { + defaultMessage: 'Trusted application list imported successfully', + } + ), + pageImportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.trustedApps.pageImportErrorToastTitle', + { + defaultMessage: 'Trusted application list import failed', + } + ), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.trustedApps.showingTotal', { defaultMessage: @@ -111,6 +147,10 @@ const TRUSTED_APPS_PAGE_LABELS: ArtifactListPageProps['labels'] = { 'xpack.securitySolution.trustedApps.emptyStatePrimaryButtonLabel', { defaultMessage: 'Add trusted application' } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.trustedApps.emptyStateImportButtonLabel', + { defaultMessage: 'Import trusted application list' } + ), searchPlaceholderInfo: i18n.translate( 'xpack.securitySolution.trustedApps.searchPlaceholderInfo', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/trusted_devices_list.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/trusted_devices_list.tsx index 1d1be68a034aa..dbcd38a1c58b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/trusted_devices_list.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/trusted_devices/view/trusted_devices_list.tsx @@ -10,7 +10,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { DocLinks } from '@kbn/doc-links'; import { EuiLink } from '@elastic/eui'; -import type { ArtifactListPageProps } from '../../../components/artifact_list_page'; +import type { + ArtifactListPageProps, + ArtifactListPageLabels, +} from '../../../components/artifact_list_page'; import { ArtifactListPage } from '../../../components/artifact_list_page'; import { TrustedDevicesApiClient } from '../service/api_client'; import { TrustedDevicesForm } from './components/form'; @@ -25,7 +28,7 @@ type TrustedDevicesListProps = Omit< 'apiClient' | 'ArtifactFormComponent' | 'labels' | 'data-test-subj' >; -const TRUSTED_DEVICES_PAGE_LABELS: ArtifactListPageProps['labels'] = { +const TRUSTED_DEVICES_PAGE_LABELS: ArtifactListPageLabels = { pageTitle: i18n.translate('xpack.securitySolution.trustedDevices.list.pageTitle', { defaultMessage: 'Trusted devices', }), @@ -39,6 +42,42 @@ const TRUSTED_DEVICES_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Add trusted device', } ), + pageImportButtonTitle: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.pageImportButtonTitle', + { + defaultMessage: 'Import trusted device list', + } + ), + pageExportButtonTitle: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.pageExportButtonTitle', + { + defaultMessage: 'Export trusted device list', + } + ), + pageExportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.pageExportSuccessToastTitle', + { + defaultMessage: 'Trusted device list exported successfully', + } + ), + pageExportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.pageExportErrorToastTitle', + { + defaultMessage: 'Trusted device list export failed', + } + ), + pageImportSuccessToastTitle: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.pageImportSuccessToastTitle', + { + defaultMessage: 'Trusted device list imported successfully', + } + ), + pageImportErrorToastTitle: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.pageImportErrorToastTitle', + { + defaultMessage: 'Trusted device list import failed', + } + ), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.trustedDevices.list.showingTotal', { defaultMessage: @@ -124,6 +163,10 @@ const TRUSTED_DEVICES_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Add trusted device', } ), + emptyStateImportButtonLabel: i18n.translate( + 'xpack.securitySolution.trustedDevices.list.emptyStateImportButtonLabel', + { defaultMessage: 'Import trusted device list' } + ), searchPlaceholderInfo: i18n.translate( 'xpack.securitySolution.trustedDevices.list.searchPlaceholderInfo', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts index 0424ff8ce6db3..68193ca192429 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts @@ -6,7 +6,10 @@ */ import type { CoreStart, HttpSetup } from '@kbn/core/public'; -import type { CreateExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + CreateExceptionListSchema, + ImportExceptionsResponseSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL, @@ -246,6 +249,37 @@ describe('Exceptions List Api Client', () => { }); }); + it('import method calls http.post with params', async () => { + const exceptionsListApiClientInstance = getInstance(); + + const apiResponse: ImportExceptionsResponseSchema = { + success: true, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 0, + errors: [], + }; + + fakeHttpServices.post.mockResolvedValueOnce(apiResponse); + + const mockFile = new File(['test content'], 'test.ndjson'); + + const res = await exceptionsListApiClientInstance.import(mockFile); + + expect(res).toBe(apiResponse); + expect(fakeHttpServices.post).toHaveBeenCalledTimes(1); + expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/exception_lists/_import', { + version: apiVersion, + headers: { 'Content-Type': undefined }, + body: expect.any(FormData), + query: { + overwrite: false, + }, + }); + }); + it('hasData method returns true when list has data', async () => { fakeHttpServices.get.mockResolvedValue({ total: 1, diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts b/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts index 8716f4bee93ab..46e8a8eae748b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts @@ -12,6 +12,7 @@ import type { ExceptionListSchema, ExceptionListSummarySchema, FoundExceptionListItemSchema, + ImportExceptionsResponseSchema, ListId, UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -22,6 +23,7 @@ import { INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL, } from '@kbn/securitysolution-list-constants'; import type { HttpStart } from '@kbn/core/public'; +import type { ImportExceptionListRequestQuery } from '@kbn/securitysolution-exceptions-common/api'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants'; /** @@ -308,6 +310,22 @@ export class ExceptionsListApiClient { }); } + async import(file: File): Promise { + const formData = new FormData(); + formData.append('file', file as Blob); + + return this.http.post(`${EXCEPTION_LIST_URL}/_import`, { + version: this.version, + body: formData, + headers: { 'Content-Type': undefined }, + query: { + // Do not overwrite the whole list, as it is space agnostic behind the scenes: + // validator will handle individual item overwrites instead. + overwrite: false, + } as ImportExceptionListRequestQuery, + }); + } + /** * Checks if the given list has any data in it */ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts index 93670f2ef2006..ec6a844682f41 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts @@ -6,64 +6,189 @@ */ import type { ExceptionsListPreImportServerExtension } from '@kbn/lists-plugin/server'; -import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import { + ENDPOINT_ARTIFACT_LIST_IDS, + ENDPOINT_ARTIFACT_LISTS, +} from '@kbn/securitysolution-list-constants'; import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; +import type { ImportQuerySchemaDecoded } from '@kbn/securitysolution-io-ts-types'; +import { hasArtifactOwnerSpaceId } from '../../../../common/endpoint/service/artifacts/utils'; import { stringify } from '../../../endpoint/utils/stringify'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; -import { - ALL_ENDPOINT_ARTIFACT_LIST_IDS, - GLOBAL_ARTIFACT_TAG, -} from '../../../../common/endpoint/service/artifacts/constants'; +import { GLOBAL_ARTIFACT_TAG } from '../../../../common/endpoint/service/artifacts/constants'; import { EndpointArtifactExceptionValidationError } from '../validators/errors'; +import type { ExperimentalFeatures } from '../../../../common/experimental_features'; +import { + BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, + TrustedDeviceValidator, +} from '../validators'; export const getExceptionsPreImportHandler = ( - endpointAppContextService: EndpointAppContextService + endpointAppContext: EndpointAppContextService ): ExceptionsListPreImportServerExtension['callback'] => { - const logger = endpointAppContextService.createLogger('listsPreImportExtensionPoint'); + return async ({ data, context: { request } }): Promise => { + validateCanEndpointArtifactsBeImported(data, endpointAppContext.experimentalFeatures); + provideSpaceAwarenessCompatibilityForOldEndpointExceptions(data, endpointAppContext); - return async ({ data }) => { - const hasEndpointArtifactListOrListItems = [...data.lists, ...data.items].some((item) => { - if ('list_id' in item) { - const NON_IMPORTABLE_ENDPOINT_ARTIFACT_IDS = ALL_ENDPOINT_ARTIFACT_LIST_IDS.filter( - (listId) => listId !== ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ) as string[]; + if (!endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + return data; + } - return NON_IMPORTABLE_ENDPOINT_ARTIFACT_IDS.includes(item.list_id); + const importedListIds = new Set(); + for (const item of [...data.lists, ...data.items]) { + if ('list_id' in item) { + importedListIds.add(item.list_id); } + } + + const query: ImportQuerySchemaDecoded = { + as_new_list: false, + overwrite: false, + overwrite_action_connectors: false, + overwrite_exceptions: false, + ...(request?.query ?? {}), + } as ImportQuerySchemaDecoded; + + console.log( + '🧀 exceptions_pre_import_handler.ts:52 🥭 ', + JSON.stringify({ query }, null, ' '), + query.as_new_list, + query.overwrite, + query.overwrite_action_connectors, + query.overwrite_exceptions + ); - return false; - }); + const hasEndpointArtifact = ENDPOINT_ARTIFACT_LIST_IDS.some((endpointListId) => + importedListIds.has(endpointListId) + ); - if (hasEndpointArtifactListOrListItems) { + if (!hasEndpointArtifact) { + return data; + } + + if (importedListIds.size > 1) { throw new EndpointArtifactExceptionValidationError( - 'Import is not supported for Endpoint artifact exceptions' + 'Importing multiple Endpoint artifact exception lists is not supported' ); } - // Temporary Work-around: - // v9.1.0 introduced support for spaces, which also now requires that each endpoint exception - // have the `global` tag, or else they will not be returned via API. Since Endpoint - // Exceptions continue to be global only in v9.1, we add the global tag to them here if it is - // missing - const adjustedImportItems: PromiseFromStreams['items'] = []; - - for (const item of data.items) { - if ( - !(item instanceof Error) && - item.list_id === ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id && - item.tags?.includes(GLOBAL_ARTIFACT_TAG) === false - ) { - item.tags = item.tags ?? []; - item.tags.push(GLOBAL_ARTIFACT_TAG); - adjustedImportItems.push(item); - } + const importedListId = Array.from(importedListIds)[0]; + + // todo better typing + if (request && (request.query as ImportQuerySchemaDecoded).overwrite) { + (request.query as ImportQuerySchemaDecoded).overwrite = false; + } + + console.log( + '🧀 exceptions_pre_import_handler.ts:86 🥭 ', + JSON.stringify({ newQuery: request?.query }, null, ' ') + ); + + // Validate trusted apps + if (TrustedAppValidator.isTrustedApp({ listId: importedListId })) { + const trustedAppValidator = new TrustedAppValidator(endpointAppContext, request); + await trustedAppValidator.validatePreImport(data); + } + + // Validate trusted devices + if (TrustedDeviceValidator.isTrustedDevice({ listId: importedListId })) { + const trustedDeviceValidator = new TrustedDeviceValidator(endpointAppContext, request); + await trustedDeviceValidator.validatePreImport(data); + } + + // Validate event filter + if (EventFilterValidator.isEventFilter({ listId: importedListId })) { + const eventFilterValidator = new EventFilterValidator(endpointAppContext, request); + await eventFilterValidator.validatePreImport(data); } - if (adjustedImportItems.length > 0) { - logger.debug(`The following Endpoint Exceptions item imports were adjusted to include the Global artifact tag: -${stringify(adjustedImportItems)}`); + // Validate host isolation + if (HostIsolationExceptionsValidator.isHostIsolationException({ listId: importedListId })) { + const hostIsolationExceptionsValidator = new HostIsolationExceptionsValidator( + endpointAppContext, + request + ); + await hostIsolationExceptionsValidator.validatePreImport(data); + } + + // Validate blocklists + if (BlocklistValidator.isBlocklist({ listId: importedListId })) { + const blocklistValidator = new BlocklistValidator(endpointAppContext, request); + await blocklistValidator.validatePreImport(data); + } + + // validate endpoint exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId: importedListId })) { + const endpointExceptionValidator = new EndpointExceptionsValidator( + endpointAppContext, + request + ); + await endpointExceptionValidator.validatePreImport(data); } return data; }; }; + +const validateCanEndpointArtifactsBeImported = ( + data: PromiseFromStreams, + experimentalFeatures: ExperimentalFeatures +) => { + if (experimentalFeatures.endpointExceptionsMovedUnderManagement) { + return; + } + + const NON_IMPORTABLE_ENDPOINT_ARTIFACT_IDS = ENDPOINT_ARTIFACT_LIST_IDS.filter( + (listId) => listId !== ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ) as string[]; + + const hasEndpointArtifactListOrListItems = [...data.lists, ...data.items].some((item) => { + if ('list_id' in item) { + return NON_IMPORTABLE_ENDPOINT_ARTIFACT_IDS.includes(item.list_id); + } + + return false; + }); + + if (hasEndpointArtifactListOrListItems) { + throw new EndpointArtifactExceptionValidationError( + 'Import is not supported for Endpoint artifact exceptions' + ); + } +}; + +/** Temporary Work-around: + * v9.1.0 introduced support for spaces, which also now requires that each endpoint exception + * have the `global` tag, or else they will not be returned via API. Since Endpoint + * Exceptions continue to be global only in v9.1, we add the global tag to them here if it is + * missing + */ +const provideSpaceAwarenessCompatibilityForOldEndpointExceptions = ( + data: PromiseFromStreams, + endpointAppContextService: EndpointAppContextService +) => { + const logger = endpointAppContextService.createLogger('listsPreImportExtensionPoint'); + + const adjustedImportItems: PromiseFromStreams['items'] = []; + + for (const item of data.items) { + if ( + !(item instanceof Error) && + item.list_id === ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id && + !hasArtifactOwnerSpaceId(item) + ) { + item.tags = item.tags ?? []; + item.tags.push(GLOBAL_ARTIFACT_TAG); + adjustedImportItems.push(item); + } + } + + if (adjustedImportItems.length > 0) { + logger.debug(`The following Endpoint Exceptions item imports were adjusted to include the Global artifact tag: + ${stringify(adjustedImportItems)}`); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/types.ts index 94178a6cfeeb9..41c706221413e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/types.ts @@ -15,5 +15,5 @@ import type { CreateExceptionListItemOptions } from '@kbn/lists-plugin/server'; */ export type ExceptionItemLikeOptions = Pick< CreateExceptionListItemOptions, - 'osTypes' | 'tags' | 'description' | 'name' | 'entries' | 'namespaceType' + 'osTypes' | 'tags' | 'description' | 'name' | 'entries' | 'namespaceType' | 'comments' > & { listId?: string }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts index 9d35b1cffc335..3aed5f92dfb20 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts @@ -35,7 +35,12 @@ import { import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks'; import type { EndpointAuthz } from '../../../../common/endpoint/types/authz'; -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ExceptionListItemSchema, + ImportExceptionListItemSchemaDecoded, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; +import { cloneDeep } from 'lodash'; describe('When using Artifacts Exceptions BaseValidator', () => { let endpointAppContextServices: EndpointAppContextService; @@ -502,5 +507,151 @@ describe('When using Artifacts Exceptions BaseValidator', () => { ).rejects.toThrowError(itemNotFoundInSpaceErrorMessage); }); }); + + describe('#validatePreImportItems', () => { + const item1Mock = (): PromiseFromStreams['items'][number] => ({ + item_id: 'itemId1', + name: 'name 1', + description: 'description 1', + entries: [], + os_types: ['macos'], + tags: ['tag1', 'tag2'], + namespace_type: 'agnostic', + list_id: 'list id 1', + type: 'simple', + comments: [], + expire_time: 'sometime', + }); + + const item2Mock = (): PromiseFromStreams['items'][number] => ({ + item_id: 'itemId2', + name: 'name 2', + description: 'description 2', + entries: [], + os_types: ['linux'], + tags: ['tag3'], + namespace_type: 'agnostic', + list_id: 'list id 2', + type: 'simple', + comments: [], + expire_time: 'another time', + }); + + it('should call validator callback on all items with the item type converted to exception item', async () => { + const validateFn = jest.fn().mockResolvedValue(undefined); + const importItems: PromiseFromStreams = { + items: [item1Mock(), item2Mock()], + lists: [], + }; + const expectedItems: PromiseFromStreams = { + items: [item1Mock(), item2Mock()], + lists: [], + }; + + await expect( + validator._validatePreImportItems(importItems, validateFn) + ).resolves.toBeUndefined(); + + expect(validateFn).toHaveBeenCalledTimes(2); + expect(validateFn).toHaveBeenNthCalledWith(1, { + name: 'name 1', + description: 'description 1', + entries: [], + osTypes: ['macos'], + tags: ['tag1', 'tag2'], + namespaceType: 'agnostic', + listId: 'list id 1', + comments: [], + }); + expect(validateFn).toHaveBeenNthCalledWith(2, { + name: 'name 2', + description: 'description 2', + entries: [], + osTypes: ['linux'], + tags: ['tag3'], + namespaceType: 'agnostic', + listId: 'list id 2', + comments: [], + }); + + expect(importItems).toEqual(expectedItems); + }); + + it('should modify data in place', async () => { + const validateFn = jest.fn().mockImplementation(async (item: ExceptionItemLikeOptions) => { + item.name = `modified ${item.name}`; + item.tags = [...item.tags, 'cheese']; + }); + + const importItems: PromiseFromStreams = { + items: [item1Mock(), item2Mock()], + lists: [], + }; + const expectedItems: PromiseFromStreams = { + items: [ + { + ...item1Mock(), + name: 'modified name 1', + tags: [...(item1Mock() as ImportExceptionListItemSchemaDecoded).tags, 'cheese'], + }, + { + ...item2Mock(), + name: 'modified name 2', + tags: [...(item2Mock() as ImportExceptionListItemSchemaDecoded).tags, 'cheese'], + }, + ], + lists: [], + }; + + await expect( + validator._validatePreImportItems(importItems, validateFn) + ).resolves.toBeUndefined(); + + expect(validateFn).toHaveBeenCalledTimes(2); + expect(importItems).toEqual(expectedItems); + }); + + it('should put errors in items array when validator throws', async () => { + const validateFn = jest.fn().mockImplementation(async (item: ExceptionItemLikeOptions) => { + if (item.name === 'name 2') { + throw new Error('houston, we have a problem'); + } + }); + + const importItems: PromiseFromStreams = { + items: [item1Mock(), item2Mock()], + lists: [], + }; + const expectedItems: PromiseFromStreams = { + items: [item1Mock(), new Error('houston, we have a problem')], + lists: [], + }; + + await expect( + validator._validatePreImportItems(importItems, validateFn) + ).resolves.toBeUndefined(); + + expect(validateFn).toHaveBeenCalledTimes(2); + expect(importItems).toEqual(expectedItems); + }); + + it('should pass through decode errors', async () => { + const importItems: PromiseFromStreams = { + items: [item1Mock(), new Error('decode error')], + lists: [], + }; + + const expectedItems = cloneDeep(importItems); + + const validateFn = jest.fn(); + + await expect( + validator._validatePreImportItems(importItems, validateFn) + ).resolves.toBeUndefined(); + + expect(validateFn).toHaveBeenCalledTimes(1); + expect(importItems).toEqual(expectedItems); + }); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts index 7694edd20a344..652539a99b0d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts @@ -15,9 +15,12 @@ import { i18n } from '@kbn/i18n'; import {} from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client_types'; import { groupBy } from 'lodash'; import type { PackagePolicy } from '@kbn/fleet-plugin/common'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; +import { ExceptionItemImportError } from '@kbn/lists-plugin/server/exception_item_import_error'; import { stringify } from '../../../endpoint/utils/stringify'; import { ENDPOINT_AUTHZ_ERROR_MESSAGE } from '../../../endpoint/errors'; import { + buildPerPolicyTag, getArtifactOwnerSpaceIds, isArtifactGlobal, } from '../../../../common/endpoint/service/artifacts/utils'; @@ -31,7 +34,6 @@ import { isArtifactByPolicy, } from '../../../../common/endpoint/service/artifacts'; import { EndpointArtifactExceptionValidationError } from './errors'; -import { EndpointExceptionsValidationError } from './endpoint_exception_errors'; const OWNER_SPACE_ID_TAG_MANAGEMENT_NOT_ALLOWED_MESSAGE = i18n.translate( 'xpack.securitySolution.baseValidator.noGlobalArtifactAuthzApiMessage', @@ -114,14 +116,6 @@ export class BaseValidator { } } - protected async validateHasEndpointExceptionsPrivileges( - privilege: keyof EndpointAuthz - ): Promise { - if (!(await this.endpointAuthzPromise)[privilege]) { - throw new EndpointExceptionsValidationError('Endpoint exceptions authorization failure', 403); - } - } - protected async validateHasPrivilege(privilege: keyof EndpointAuthz): Promise { if (!(await this.endpointAuthzPromise)[privilege]) { throw new EndpointArtifactExceptionValidationError(ENDPOINT_AUTHZ_ERROR_MESSAGE, 403); @@ -439,4 +433,88 @@ export class BaseValidator { 404 ); } + + protected async validatePreImportItems( + items: PromiseFromStreams, + validator: (item: ExceptionItemLikeOptions) => Promise + ): Promise { + const validatedItems: PromiseFromStreams['items'] = []; + + for (const _item of items.items) { + if (_item instanceof Error) { + validatedItems.push(_item); + } else { + const item: ExceptionItemLikeOptions = { + name: _item.name, + description: _item.description, + entries: _item.entries, + osTypes: _item.os_types, + tags: _item.tags, + namespaceType: _item.namespace_type, + comments: _item.comments, + listId: _item.list_id, + }; + + try { + await validator(item); + + validatedItems.push({ + ..._item, + + name: item.name, + description: item.description, + entries: item.entries, + os_types: item.osTypes, + tags: item.tags, + namespace_type: item.namespaceType, + list_id: item.listId ?? _item.list_id, + }); + } catch (error) { + validatedItems.push(new ExceptionItemImportError(error, _item.list_id, _item.item_id)); + } + } + } + + items.items = validatedItems; + } + + protected async removeInvalidPolicyIds(item: ExceptionItemLikeOptions): Promise { + if (this.isItemByPolicy(item)) { + const { packagePolicy, savedObjects } = this.endpointAppContext.getInternalFleetServices(); + const policyIdsInArtifact = getPolicyIdsFromArtifact(item); + const soClient = savedObjects.createInternalUnscopedSoClient(); + + if (policyIdsInArtifact.length === 0) { + return; + } + + const matchingPoliciesFromAllSpaces: PackagePolicy[] = + (await packagePolicy.getByIDs(soClient, policyIdsInArtifact, { + ignoreMissing: true, + spaceIds: ['*'], + })) ?? []; + + const matchingPolicyIdsFromAllSpaces = new Set(); + matchingPoliciesFromAllSpaces.forEach(({ id }) => matchingPolicyIdsFromAllSpaces.add(id)); + + const invalidPolicyIds: string[] = policyIdsInArtifact.filter( + (policyId) => !matchingPolicyIdsFromAllSpaces.has(policyId) + ); + + const invalidPolicyIdTags = new Set(); + invalidPolicyIds.forEach((invalidPolicyId) => + invalidPolicyIdTags.add(buildPerPolicyTag(invalidPolicyId)) + ); + + if (invalidPolicyIdTags.size > 0) { + item.tags = item.tags.filter((tag) => !invalidPolicyIdTags.has(tag)); + + item.comments.push({ + comment: `Please check policy assignment. The following policy IDs have been removed from artifact during import:\n${invalidPolicyIds + .map((id) => `- "${id}"`) + .join('\n')}`, + }); + } + } + } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts index 37468d7bb398c..606a3b075a841 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -15,6 +15,7 @@ import type { UpdateExceptionListItemOptions, } from '@kbn/lists-plugin/server'; import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import { BaseValidator } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; import { isValidHash } from '../../../../common/endpoint/service/artifacts/validations'; @@ -230,6 +231,16 @@ export class BlocklistValidator extends BaseValidator { return super.validateHasPrivilege('canReadBlocklist'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + await this.validateCreateOwnerSpaceIds(item); + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); + }); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts index 7d129d831bb6d..d855fe3b2fe1f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts @@ -11,6 +11,7 @@ import type { } from '@kbn/lists-plugin/server'; import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import { EndpointExceptionsValidationError } from './endpoint_exception_errors'; import { BaseValidator, GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; @@ -21,11 +22,11 @@ export class EndpointExceptionsValidator extends BaseValidator { } protected async validateHasReadPrivilege(): Promise { - return this.validateHasEndpointExceptionsPrivileges('canReadEndpointExceptions'); + return this.validateHasPrivilege('canReadEndpointExceptions'); } protected async validateHasWritePrivilege(): Promise { - await this.validateHasEndpointExceptionsPrivileges('canWriteEndpointExceptions'); + await this.validateHasPrivilege('canWriteEndpointExceptions'); if (!this.endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { // With disabled FF, Endpoint Exceptions are ONLY global, so we need to make sure the user @@ -42,6 +43,16 @@ export class EndpointExceptionsValidator extends BaseValidator { } } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + await this.validateCreateOwnerSpaceIds(item); + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); + }); + } + async validatePreCreateItem(item: CreateExceptionListItemOptions) { await this.validateHasWritePrivilege(); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts index 7a01521db141b..2592878ae8918 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/event_filter_validator.ts @@ -14,6 +14,7 @@ import type { UpdateExceptionListItemOptions, } from '@kbn/lists-plugin/server'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import type { ExceptionItemLikeOptions } from '../types'; import { BaseValidator } from './base_validator'; @@ -49,6 +50,16 @@ export class EventFilterValidator extends BaseValidator { return super.validateHasPrivilege('canReadEventFilters'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + await this.validateCreateOwnerSpaceIds(item); + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); + }); + } + async validatePreCreateItem(item: CreateExceptionListItemOptions) { await this.validateHasWritePrivilege(); await this.validateEventFilterData(item); @@ -127,10 +138,4 @@ export class EventFilterValidator extends BaseValidator { async validatePreMultiListFind(): Promise { await this.validateHasReadPrivilege(); } - - async validatePreImport(): Promise { - throw new EndpointArtifactExceptionValidationError( - 'Import is not supported for Endpoint artifact exceptions' - ); - } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts index fd39bdd713de0..9a62fb115bf9c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts @@ -13,6 +13,7 @@ import type { UpdateExceptionListItemOptions, } from '@kbn/lists-plugin/server'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import { BaseValidator, BasicEndpointExceptionDataSchema } from './base_validator'; import { EndpointArtifactExceptionValidationError } from './errors'; import type { ExceptionItemLikeOptions } from '../types'; @@ -73,6 +74,16 @@ export class HostIsolationExceptionsValidator extends BaseValidator { return this.validateHasPrivilege('canReadHostIsolationExceptions'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + await this.validateCreateOwnerSpaceIds(item); + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); + }); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { @@ -126,12 +137,6 @@ export class HostIsolationExceptionsValidator extends BaseValidator { await this.validateHasReadPrivilege(); } - async validatePreImport(): Promise { - throw new EndpointArtifactExceptionValidationError( - 'Import is not supported for Endpoint artifact exceptions' - ); - } - private async validateHostIsolationData(item: ExceptionItemLikeOptions): Promise { try { HostIsolationBasicDataSchema.validate(item); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts index c3f6377b1b3f0..50b66f986dc19 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts @@ -8,6 +8,7 @@ import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { listMock } from '@kbn/lists-plugin/server/mocks'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import { buildSpaceOwnerIdTag } from '../../../../common/endpoint/service/artifacts/utils'; import { BaseValidator } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; @@ -83,6 +84,13 @@ export class BaseValidatorMock extends BaseValidator { _validateCanReadItemInActiveSpace(currentSavedItem: ExceptionListItemSchema): Promise { return this.validateCanReadItemInActiveSpace(currentSavedItem); } + + _validatePreImportItems( + items: PromiseFromStreams, + validator: (item: ExceptionItemLikeOptions) => Promise + ): Promise { + return this.validatePreImportItems(items, validator); + } } export const createExceptionItemLikeOptionsMock = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index 36a740065cb1f..84db8c8eb8485 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -15,6 +15,7 @@ import type { CreateExceptionListItemOptions, UpdateExceptionListItemOptions, } from '@kbn/lists-plugin/server'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import { TRUSTED_PROCESS_DESCENDANTS_TAG } from '../../../../common/endpoint/service/artifacts/constants'; import { BaseValidator } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; @@ -220,6 +221,16 @@ export class TrustedAppValidator extends BaseValidator { return super.validateHasPrivilege('canReadTrustedApplications'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + await this.validateCreateOwnerSpaceIds(item); + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); + }); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts index 1067b647d04b3..d3f1a2fce688e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_device_validator.ts @@ -17,6 +17,7 @@ import { OperatingSystem, isTrustedDeviceFieldAvailableForOs, } from '@kbn/securitysolution-utils'; +import type { PromiseFromStreams } from '@kbn/lists-plugin/server/services/exception_lists/import_exception_list_and_items'; import { BaseValidator, BasicEndpointExceptionDataSchema } from './base_validator'; import type { ExceptionItemLikeOptions } from '../types'; import { EndpointArtifactExceptionValidationError } from './errors'; @@ -119,6 +120,17 @@ export class TrustedDeviceValidator extends BaseValidator { } } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateTrustedDevicesFeatureEnabled(); + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + await this.validateCreateOwnerSpaceIds(item); + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); + }); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { diff --git a/x-pack/solutions/security/test/security_solution_api_integration/ftr_provider_context_edr_workflows.d.ts b/x-pack/solutions/security/test/security_solution_api_integration/ftr_provider_context_edr_workflows.d.ts index 3a8dad7cbc10c..4eef2059e45de 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/ftr_provider_context_edr_workflows.d.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/ftr_provider_context_edr_workflows.d.ts @@ -8,4 +8,9 @@ import type { GenericFtrProviderContext } from '@kbn/test'; import type { services } from './config/ess/services_edr_workflows'; -export type FtrProviderContext = GenericFtrProviderContext; +import type { svlServices } from './config/serverless/services_edr_workflows'; + +export type FtrProviderContext = GenericFtrProviderContext< + typeof services | typeof svlServices, + {} +>; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts new file mode 100644 index 0000000000000..60ee3a74f1d54 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts @@ -0,0 +1,886 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type TestAgent from 'supertest/lib/agent'; +import expect from '@kbn/expect'; +import { + ENDPOINT_ARTIFACT_LISTS, + ENDPOINT_ARTIFACT_LIST_IDS, + EXCEPTION_LIST_ITEM_URL, + EXCEPTION_LIST_URL, +} from '@kbn/securitysolution-list-constants'; +import { GLOBAL_ARTIFACT_TAG } from '@kbn/security-solution-plugin/common/endpoint/service/artifacts/constants'; +import { ExceptionsListItemGenerator } from '@kbn/security-solution-plugin/common/endpoint/data_generators/exceptions_list_item_generator'; +import type { + ExceptionListItemSchema, + ExportExceptionDetails, + ImportExceptionsResponseSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { PolicyTestResourceInfo } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_policy'; +import { SECURITY_FEATURE_ID } from '@kbn/security-solution-plugin/common'; +import { + buildPerPolicyTag, + buildSpaceOwnerIdTag, +} from '@kbn/security-solution-plugin/common/endpoint/service/artifacts/utils'; +import type { Role } from '@kbn/security-plugin-types-common'; +import type { ArtifactTestData } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_artifacts'; +import type { FindExceptionListItemsResponse } from '@kbn/securitysolution-exceptions-common/api'; +import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users'; +import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; +import { createSupertestErrorLogger } from '../../utils'; + +const ENDPOINT_ARTIFACTS: { + listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number]; + name: string; + read: string; + all: string; +}[] = [ + { + listId: ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + name: 'Endpoint Exceptions', + read: 'endpoint_exceptions_read', + all: 'endpoint_exceptions_all', + }, + { + listId: ENDPOINT_ARTIFACT_LISTS.trustedApps.id, + name: 'Trusted Applications', + read: 'trusted_applications_read', + all: 'trusted_applications_all', + }, + { + listId: ENDPOINT_ARTIFACT_LISTS.eventFilters.id, + name: 'Event Filters', + read: 'event_filters_read', + all: 'event_filters_all', + }, + { + listId: ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id, + name: 'Host Isolation Exceptions', + read: 'host_isolation_exceptions_read', + all: 'host_isolation_exceptions_all', + }, + { + listId: ENDPOINT_ARTIFACT_LISTS.blocklists.id, + name: 'Blocklist', + read: 'blocklist_read', + all: 'blocklist_all', + }, + { + listId: ENDPOINT_ARTIFACT_LISTS.trustedDevices.id, + name: 'Trusted Devices', + read: 'trusted_devices_read', + all: 'trusted_devices_all', + }, +]; + +export default function artifactImportAPIIntegrationTests({ getService }: FtrProviderContext) { + const log = getService('log'); + const rolesUsersProvider = getService('rolesUsersProvider'); + const endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + const utils = getService('securitySolutionUtils'); + const config = getService('config'); + + const IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED = ( + config.get('kbnTestServer.serverArgs', []) as string[] + ) + .find((s) => s.startsWith('--xpack.securitySolution.enableExperimental')) + ?.includes('endpointExceptionsMovedUnderManagement'); + + const createSupertestWithCustomRole = async ( + name: string, + siemPrivileges: string[], + spaces: string[] = ['*'] + ) => { + const role = buildRole(name, siemPrivileges, spaces); + + // custom solution to have custom roles in both ess and serverless utils. + // it'd be nice to have the same interface for both utils services in the future + if ('createSuperTestWithCustomRole' in utils) { + // serverless utils... + return utils.createSuperTestWithCustomRole({ + name: role.name, + privileges: { + elasticsearch: role.elasticsearch, + kibana: role.kibana, + }, + }); + } else { + // ess utils... + const loadedRole = await rolesUsersProvider.loader.create(role); + return utils.createSuperTest(loadedRole.username); + } + }; + + describe('@ess @serverless @skipInServerlessMKI Import Endpoint artifacts API', function () { + let fleetEndpointPolicy: PolicyTestResourceInfo; + let fleetEndpointPolicyOtherSpace: PolicyTestResourceInfo; + let endpointOpsAnalystSupertest: TestAgent; + + before(async () => { + endpointOpsAnalystSupertest = await utils.createSuperTest(ROLE.endpoint_operations_analyst); + + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + fleetEndpointPolicyOtherSpace = await endpointPolicyTestResources.createPolicy({ + options: { spaceId: 'other-space' }, + }); + }); + + after(async () => { + if (fleetEndpointPolicy) { + await fleetEndpointPolicy.cleanup(); + } + if (fleetEndpointPolicyOtherSpace) { + await fleetEndpointPolicyOtherSpace.cleanup(); + } + }); + + if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { + describe('Endpoint exceptions move feature flag enabled', () => { + const DEFAULT_SPACE_OWNER_ID = buildSpaceOwnerIdTag('default'); + const OTHER_SPACE_OWNER_ID = buildSpaceOwnerIdTag('other-space'); + + const supertest: Record< + (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + Record<'none' | 'read' | 'all' | 'allWithGlobalArtifactManagementPrivilege', TestAgent> + > = {} as Record< + (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + Record<'none' | 'read' | 'all' | 'allWithGlobalArtifactManagementPrivilege', TestAgent> + >; + + before(async () => { + for (const artifact of ENDPOINT_ARTIFACTS) { + supertest[artifact.listId] = { + none: await createSupertestWithCustomRole(`${artifact.listId}_none`, ['minimal_all']), + read: await createSupertestWithCustomRole(`${artifact.listId}_read`, [ + 'minimal_all', + artifact.read, + ]), + all: await createSupertestWithCustomRole(`${artifact.listId}_all`, [ + 'minimal_read', + artifact.all, + ]), + allWithGlobalArtifactManagementPrivilege: await createSupertestWithCustomRole( + `${artifact.listId}_allWithGlobal`, + ['minimal_read', artifact.all, 'global_artifact_management_all'] + ), + }; + } + }); + + ENDPOINT_ARTIFACTS.forEach((artifact) => { + describe(`Importing ${artifact.name}`, () => { + beforeEach(async () => { + await endpointArtifactTestResources.deleteList(artifact.listId); + }); + + afterEach(async () => { + await endpointArtifactTestResources.deleteList(artifact.listId); + }); + + describe('ALL privilege', () => { + it(`should error when importing without artifact privileges`, async () => { + await supertest[artifact.listId].none + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log).ignoreCodes([403])) + .attach( + 'file', + buildImportBuffer(artifact.listId, [{ tags: [DEFAULT_SPACE_OWNER_ID] }]), + 'import_data.ndjson' + ) + .expect(403) + .expect(anEndpointArtifactErrorOf('Endpoint authorization failure')); + }); + + it(`should error when importing with ${artifact.read} privileges`, async () => { + await supertest[artifact.listId].read + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log).ignoreCodes([403])) + .attach( + 'file', + buildImportBuffer(artifact.listId, [{ tags: [DEFAULT_SPACE_OWNER_ID] }]), + 'import_data.ndjson' + ) + .expect(403) + .expect(anEndpointArtifactErrorOf('Endpoint authorization failure')); + }); + + it(`should succeed when importing with ${artifact.all} privileges`, async () => { + await supertest[artifact.listId].all + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [{ tags: [DEFAULT_SPACE_OWNER_ID] }]), + 'import_data.ndjson' + ) + .expect(200); + }); + }); + + describe('Space awareness', () => { + describe('when user has no global artifact privilege', () => { + it('should import per-policy artifacts from current space', async () => { + await supertest[artifact.listId].all + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { tags: [DEFAULT_SPACE_OWNER_ID] }, + { + tags: [ + DEFAULT_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + + // even if assigned to policy in other space, the tag is kept + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 3, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 2, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(2); + expect(body.data[0].tags).to.eql([DEFAULT_SPACE_OWNER_ID]); + expect(body.data[1].tags).to.eql([ + DEFAULT_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ]); + }); + + it('should not import per-policy artifacts to other spaces when importing without global artifact privilege', async () => { + await supertest[artifact.listId].all + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { item_id: 'wrong-item', tags: [OTHER_SPACE_OWNER_ID] }, + { + item_id: 'good-item', + tags: [ + DEFAULT_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Management of "ownerSpaceId" tag requires global artifact management privilege', + status_code: 403, + }, + item_id: 'wrong-item', + list_id: artifact.listId, + }, + ], + success: false, + success_count: 2, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: false, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(1); + expect(body.data[0].item_id).to.eql('good-item'); + }); + + it('should not import global artifacts when importing without global artifact privilege', async () => { + await supertest[artifact.listId].all + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { + item_id: 'wrong-item', + tags: [DEFAULT_SPACE_OWNER_ID, GLOBAL_ARTIFACT_TAG], + }, + { + item_id: 'good-item', + tags: [DEFAULT_SPACE_OWNER_ID], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Management of global artifacts requires additional privilege (global artifact management)', + status_code: 403, + }, + item_id: 'wrong-item', + list_id: artifact.listId, + }, + ], + success: false, + success_count: 2, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: false, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(1); + expect(body.data[0].item_id).to.eql('good-item'); + }); + }); + + describe('when with global artifact privilege', () => { + it(`should import global artifacts`, async () => { + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { tags: [DEFAULT_SPACE_OWNER_ID, GLOBAL_ARTIFACT_TAG] }, + { tags: [DEFAULT_SPACE_OWNER_ID, GLOBAL_ARTIFACT_TAG] }, + { tags: [DEFAULT_SPACE_OWNER_ID, GLOBAL_ARTIFACT_TAG] }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 4, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 3, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(3); + }); + + it('should import per-policy artifacts to other space if assigned to policy in current space', async () => { + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { + item_id: 'to-other-space', + tags: [ + OTHER_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 2, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(1); + expect(body.data[0].tags).to.eql([ + OTHER_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + // policy id in other space is kept + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ]); + }); + + // todo do we need this? create API allows this, but import could block it + it('should not import per-policy artifacts to other space if not visible in current space', async () => {}); + }); + + describe('when data is invalid', () => { + // todo do we need this? create allows this, and will use actual space ID instead + it('should not import per-policy artifacts with invalid space id', async () => {}); + + it('should import per-policy artifacts assigned to invalid policy ids and remove invalid ids', async () => { + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { + item_id: 'with-invalid-policy-id', + tags: [ + DEFAULT_SPACE_OWNER_ID, + buildPerPolicyTag('i-do-not-exist'), + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + buildPerPolicyTag('me-neither'), + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 2, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(1); + + // invalid policy ids are removed, valid ones are kept + expect(body.data[0].tags).to.eql([ + DEFAULT_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ]); + + // changes indicated in a comment + expect(body.data[0].comments.length).to.eql(1); + expect(body.data[0].comments[0].comment).to.eql( + `Please check policy assignment. The following policy IDs have been removed from artifact during import:\n- "i-do-not-exist"\n- "me-neither"` + ); + + const username = `${artifact.listId}_allWithGlobal`; + expect(body.data[0].comments[0].created_by).to.eql(username); + }); + }); + + it('should return conflict on list, but import artifacts when list exist', async () => { + await endpointArtifactTestResources.createList(artifact.listId); + + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [{ tags: [DEFAULT_SPACE_OWNER_ID] }]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: `Found that list_id: "${artifact.listId}" already exists. Import of list_id: "${artifact.listId}" skipped.`, + status_code: 409, + }, + list_id: artifact.listId, + }, + ], + success: false, + success_count: 1, + success_exception_lists: false, + success_count_exception_lists: 0, + success_exception_list_items: true, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + }); + + describe.only('when `overwrite` query param is `true`', () => { + let existingGlobalArtifactInCurrentSpace: ArtifactTestData; + let existingGlobalArtifactInOtherSpace: ArtifactTestData; + let existingPerPolicyArtifactInCurrentSpace: ArtifactTestData; + let existingUnassignedPerPolicyArtifactInOtherSpace: ArtifactTestData; + let existingPerPolicyArtifactInOtherSpaceVisibleInCurrentSpace: ArtifactTestData; + + beforeEach(async () => { + await endpointArtifactTestResources.createList(artifact.listId); + + existingGlobalArtifactInCurrentSpace = + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [DEFAULT_SPACE_OWNER_ID, GLOBAL_ARTIFACT_TAG], + item_id: 'existing-global-artifact-in-current-space', + }); + + existingGlobalArtifactInOtherSpace = + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [OTHER_SPACE_OWNER_ID, GLOBAL_ARTIFACT_TAG], + item_id: 'existing-global-artifact-in-other-space', + }); + + existingPerPolicyArtifactInCurrentSpace = + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [ + DEFAULT_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + ], + item_id: 'existing-per-policy-artifact-in-current-space', + }); + + existingUnassignedPerPolicyArtifactInOtherSpace = + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [OTHER_SPACE_OWNER_ID], + item_id: 'existing-per-policy-artifact-in-other-space', + }); + + existingPerPolicyArtifactInOtherSpaceVisibleInCurrentSpace = + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [ + OTHER_SPACE_OWNER_ID, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + ], + item_id: + 'existing-per-policy-artifact-in-other-space-visible-in-current-space', + }); + }); + describe('when without global artifact privilege', () => { + it('should remove existing per-policy artifacts only from the same space', async () => { + await supertest[artifact.listId].all + .post(`${EXCEPTION_LIST_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { name: "i'm imported!", tags: [DEFAULT_SPACE_OWNER_ID] }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 2, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: true, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: artifact.listId, + namespace_type: 'agnostic', + }) + .send() + .expect(200); + + expect(body.data.length).to.eql(5); // 5 existing -1 removed +1 imported + const returnedItemIds = body.data.map((item) => item.item_id); + expect(returnedItemIds).to.contain( + existingGlobalArtifactInCurrentSpace.artifact.item_id + ); + }); + }); + + describe('when with global artifact privilege', () => { + it('should remove existing global artifacts', async () => {}); + it('should remove existing per-policy artifacts that are visible in current space', async () => {}); + }); + }); + }); + + describe('when importing multiple lists', () => { + afterEach(async () => { + await endpointArtifactTestResources.deleteList('some_other_list_id'); + await endpointArtifactTestResources.deleteList('another_list_id'); + }); + + it('should succeed when none of the lists are Endpoint artifacts', async () => { + const generator = new ExceptionsListItemGenerator(); + + const importedJson = ` + ${buildListInfo('some_other_list_id')} + ${JSON.stringify(generator.generate({ list_id: 'some_other_list_id' }))} + ${buildListInfo('another_list_id')} + ${JSON.stringify(generator.generate({ list_id: 'another_list_id' }))} + ${JSON.stringify( + buildDetails({ + exported_exception_list_count: 2, + exported_exception_list_item_count: 2, + }) + )} + `; + + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach('file', Buffer.from(importedJson, 'utf8'), 'import_data.ndjson') + .expect(200); + }); + + it('should error when any list is related to Endpoint artifacts', async () => { + const generator = new ExceptionsListItemGenerator(); + + const importedJson = ` + ${buildListInfo('some_other_list_id')} + ${JSON.stringify(generator.generate({ list_id: 'some_other_list_id' }))} + ${buildListInfo(artifact.listId)} + ${JSON.stringify( + generator.generateEndpointArtifact(artifact.listId, { + tags: [DEFAULT_SPACE_OWNER_ID], + }) + )} + ${JSON.stringify( + buildDetails({ + exported_exception_list_count: 2, + exported_exception_list_item_count: 2, + }) + )} + `; + + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log).ignoreCodes([400])) + .attach('file', Buffer.from(importedJson, 'utf8'), 'import_data.ndjson') + .expect(400) + .expect( + anEndpointArtifactErrorOf( + 'Importing multiple Endpoint artifact exception lists is not supported' + ) + ); + }); + }); + + it('should error when `new_list` query param is `true`', async () => {}); + it('should add a comment to imported artifacts with relevant data', async () => {}); + it('should add a tag to imported artifacts', async () => {}); + + describe('compatibility with artifacts exported before space awareness - when artifacts have no ownerSpaceId', () => { + it('should add/not add global artifact tag to Endpoint exceptions/artifacts', async () => {}); + it('should/should not import artifacts without global artifact privilege', async () => {}); + }); + }); + }); + }); + } else { + describe('Endpoint exceptions move feature flag disabled', () => { + // All non-Endpoint exceptions artifacts are not allowed to import + ENDPOINT_ARTIFACT_LIST_IDS.filter( + (listId) => listId !== ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ).forEach((listId) => { + it(`should error when importing ${listId} artifacts`, async () => { + await endpointArtifactTestResources.deleteList(listId); + + const { body } = await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log).ignoreCodes([400])) + .attach('file', buildImportBuffer(listId), 'import_data.ndjson') + .expect(400); + + expect(body.message).to.eql( + 'EndpointArtifactError: Import is not supported for Endpoint artifact exceptions' + ); + }); + }); + + it('should import endpoint exceptions and add global artifact tag if missing', async () => { + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id), + 'import_exceptions.ndjson' + ) + .expect(200); + + const { body }: { body: FindExceptionListItemsResponse } = + await endpointOpsAnalystSupertest + .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: 'endpoint_list', + namespace_type: 'agnostic', + per_page: 50, + }) + .send() + .expect(200); + + // After import - all items should be returned on a GET `find` request. + expect(body.data.length).to.eql(3); + + for (const endpointException of body.data) { + expect(endpointException.tags).to.include.string(GLOBAL_ARTIFACT_TAG); + } + + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + }); + }); + } + }); +} + +const buildRole = (name: string, siemPrivileges: string[], spaces: string[] = ['*']): Role => ({ + name, + kibana: [ + { + base: [], + feature: { + [SECURITY_FEATURE_ID]: siemPrivileges, + }, + spaces, + }, + ], + elasticsearch: { cluster: [], indices: [], run_as: [] }, +}); + +const anEndpointArtifactErrorOf = (message: string) => (res: { body: { message: string } }) => + expect(res.body.message).to.be(`EndpointArtifactError: ${message}`); + +const buildImportBuffer = ( + listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + itemsArray: Partial[] = [{}, {}, {}] +): Buffer => { + const generator = new ExceptionsListItemGenerator(); + + const items = itemsArray.map((override) => generator.generateEndpointArtifact(listId, override)); + + return Buffer.from( + ` + ${buildListInfo(listId)} + ${items.map((item) => JSON.stringify(item)).join('\n')} + ${JSON.stringify(buildDetails({ exported_exception_list_item_count: items.length }))} + `, + 'utf8' + ); +}; + +const buildListInfo = (listId: string): string => { + const listInfo = Object.values(ENDPOINT_ARTIFACT_LISTS).find((listDefinition) => { + return listDefinition.id === listId; + }) ?? { + id: listId, + name: `random list for ${listId}`, + description: `random description for ${listId}`, + }; + + return `{"_version":"WzEsMV0=","created_at":"2025-08-21T14:20:07.012Z","created_by":"kibana","description":"${listInfo.description}","id":"${listId}","immutable":false,"list_id":"${listId}","name":"${listInfo.name}","namespace_type":"agnostic","os_types":[],"tags":[],"tie_breaker_id":"034d07f4-fa33-43bb-adfa-6f6bda7921ce","type":"endpoint","updated_at":"2025-08-21T14:20:07.012Z","updated_by":"kibana","version":1}`; +}; + +const buildDetails = (override: Partial = {}): ExportExceptionDetails => ({ + exported_exception_list_count: 1, + exported_exception_list_item_count: 3, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + ...override, +}); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ff_enabled.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ff_enabled.ts index 8929c5e665b87..7cb9fc0f74b40 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ff_enabled.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ff_enabled.ts @@ -57,7 +57,8 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider } }); - loadTestFile(require.resolve('./endpoint_exceptions')); + // loadTestFile(require.resolve('./endpoint_exceptions')); loadTestFile(require.resolve('./endpoint_list_api_rbac')); + loadTestFile(require.resolve('./artifact_import')); }); } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts index 919e35217456e..d24750aac07da 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts @@ -7,15 +7,8 @@ import type TestAgent from 'supertest/lib/agent'; import expect from '@kbn/expect'; -import { - ENDPOINT_ARTIFACT_LISTS, - EXCEPTION_LIST_ITEM_URL, - EXCEPTION_LIST_URL, -} from '@kbn/securitysolution-list-constants'; -import { - ALL_ENDPOINT_ARTIFACT_LIST_IDS, - GLOBAL_ARTIFACT_TAG, -} from '@kbn/security-solution-plugin/common/endpoint/service/artifacts/constants'; +import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { GLOBAL_ARTIFACT_TAG } from '@kbn/security-solution-plugin/common/endpoint/service/artifacts/constants'; import { ExceptionsListItemGenerator } from '@kbn/security-solution-plugin/common/endpoint/data_generators/exceptions_list_item_generator'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { SECURITY_FEATURE_ID } from '@kbn/security-solution-plugin/common'; @@ -26,7 +19,6 @@ import { import type { ArtifactTestData } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_artifacts'; import type { PolicyTestResourceInfo } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_policy'; import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users'; -import { createSupertestErrorLogger } from '../../utils'; import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; export default function ({ getService }: FtrProviderContext) { @@ -34,7 +26,6 @@ export default function ({ getService }: FtrProviderContext) { const endpointPolicyTestResources = getService('endpointPolicyTestResources'); const endpointArtifactTestResources = getService('endpointArtifactTestResources'); const utils = getService('securitySolutionUtils'); - const log = getService('log'); const config = getService('config'); const IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED = ( @@ -51,12 +42,10 @@ export default function ({ getService }: FtrProviderContext) { let t1AnalystSupertest: TestAgent; let endpointPolicyManagerSupertest: TestAgent; - let endpointOpsAnalystSupertest: TestAgent; before(async () => { t1AnalystSupertest = await utils.createSuperTest(ROLE.t1_analyst); endpointPolicyManagerSupertest = await utils.createSuperTest(ROLE.endpoint_policy_manager); - endpointOpsAnalystSupertest = await utils.createSuperTest(ROLE.endpoint_operations_analyst); // Create an endpoint policy in fleet we can work with fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); @@ -179,120 +168,6 @@ export default function ({ getService }: FtrProviderContext) { } }); - describe(`and using Import API`, function () { - const buildImportBuffer = ( - listId: (typeof ALL_ENDPOINT_ARTIFACT_LIST_IDS)[number] - ): Buffer => { - const generator = new ExceptionsListItemGenerator(); - const listInfo = Object.values(ENDPOINT_ARTIFACT_LISTS).find((listDefinition) => { - return listDefinition.id === listId; - }); - - if (!listInfo) { - throw new Error(`Unknown listId: ${listId}. Unable to generate exception list item.`); - } - - const createItem = () => { - switch (listId) { - case ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id: - return generator.generateEndpointException(); - - case ENDPOINT_ARTIFACT_LISTS.blocklists.id: - return generator.generateBlocklist(); - - case ENDPOINT_ARTIFACT_LISTS.eventFilters.id: - return generator.generateEventFilter(); - - case ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id: - return generator.generateHostIsolationException(); - - case ENDPOINT_ARTIFACT_LISTS.trustedApps.id: - return generator.generateTrustedApp(); - - case ENDPOINT_ARTIFACT_LISTS.trustedDevices.id: - return generator.generateTrustedDevice(); - - default: - throw new Error(`Unknown listId: ${listId}. Unable to generate exception list item.`); - } - }; - - return Buffer.from( - ` - {"_version":"WzEsMV0=","created_at":"2025-08-21T14:20:07.012Z","created_by":"kibana","description":"${ - listInfo!.description - }","id":"${listId}","immutable":false,"list_id":"${listId}","name":"${ - listInfo!.name - }","namespace_type":"agnostic","os_types":[],"tags":[],"tie_breaker_id":"034d07f4-fa33-43bb-adfa-6f6bda7921ce","type":"endpoint","updated_at":"2025-08-21T14:20:07.012Z","updated_by":"kibana","version":1} - ${JSON.stringify(createItem())} - ${JSON.stringify(createItem())} - ${JSON.stringify(createItem())} - {"exported_exception_list_count":1,"exported_exception_list_item_count":3,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0} - `, - 'utf8' - ); - }; - - // All non-Endpoint exceptions artifacts are not allowed to import - ALL_ENDPOINT_ARTIFACT_LIST_IDS.filter( - (listId) => listId !== ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ).forEach((listId) => { - it(`should error when importing ${listId} artifacts`, async () => { - await endpointArtifactTestResources.deleteList(listId); - - const { body } = await endpointOpsAnalystSupertest - .post(`${EXCEPTION_LIST_URL}/_import`) - .set('kbn-xsrf', 'true') - .on('error', createSupertestErrorLogger(log).ignoreCodes([400])) - .attach('file', buildImportBuffer(listId), 'import_data.ndjson') - .expect(400); - - expect(body.message).to.eql( - 'EndpointArtifactError: Import is not supported for Endpoint artifact exceptions' - ); - }); - }); - - it('should import endpoint exceptions and add global artifact tag if missing', async () => { - await endpointArtifactTestResources.deleteList( - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ); - - await endpointOpsAnalystSupertest - .post(`${EXCEPTION_LIST_URL}/_import`) - .set('kbn-xsrf', 'true') - .on('error', createSupertestErrorLogger(log)) - .attach( - 'file', - buildImportBuffer(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id), - 'import_exceptions.ndjson' - ) - .expect(200); - - const { body } = await endpointOpsAnalystSupertest - .get(`${EXCEPTION_LIST_ITEM_URL}/_find`) - .set('kbn-xsrf', 'true') - .on('error', createSupertestErrorLogger(log)) - .query({ - list_id: 'endpoint_list', - namespace_type: 'agnostic', - per_page: 50, - }) - .send() - .expect(200); - - // After import - all items should be returned on a GET `find` request. - expect(body.data.length).to.eql(3); - - for (const endpointException of body.data) { - expect(endpointException.tags).to.include.string(GLOBAL_ARTIFACT_TAG); - - const deleteUrl = `${EXCEPTION_LIST_ITEM_URL}?item_id=${endpointException.item_id}&namespace_type=${endpointException.namespace_type}`; - await endpointOpsAnalystSupertest.delete(deleteUrl).set('kbn-xsrf', 'true'); - } - }); - }); - describe('and has authorization to manage endpoint security', () => { for (const endpointExceptionApiCall of endpointExceptionCalls) { it(`should work on [${endpointExceptionApiCall.method}] with valid entry`, async () => { diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts index e2f5ac4256f4a..d809a36b8ba31 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts @@ -52,12 +52,13 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider }); // Remember to make sure the suite is enabled in .buildkite/pipelines/security_solution_quality_gate/mki_periodic/mki_periodic_defend_workflows.yml when adding new tests without @skipInServerlessMKI - loadTestFile(require.resolve('./trusted_apps')); - loadTestFile(require.resolve('./trusted_devices')); - loadTestFile(require.resolve('./event_filters')); - loadTestFile(require.resolve('./host_isolation_exceptions')); - loadTestFile(require.resolve('./blocklists')); + // loadTestFile(require.resolve('./trusted_apps')); + // loadTestFile(require.resolve('./trusted_devices')); + // loadTestFile(require.resolve('./event_filters')); + // loadTestFile(require.resolve('./host_isolation_exceptions')); + // loadTestFile(require.resolve('./blocklists')); loadTestFile(require.resolve('./endpoint_exceptions')); loadTestFile(require.resolve('./endpoint_list_api_rbac')); + loadTestFile(require.resolve('./artifact_import')); }); } diff --git a/x-pack/solutions/security/test/security_solution_endpoint/services/endpoint_artifacts.ts b/x-pack/solutions/security/test/security_solution_endpoint/services/endpoint_artifacts.ts index ab92bdff63733..149640c063659 100644 --- a/x-pack/solutions/security/test/security_solution_endpoint/services/endpoint_artifacts.ts +++ b/x-pack/solutions/security/test/security_solution_endpoint/services/endpoint_artifacts.ts @@ -70,10 +70,7 @@ export function EndpointArtifactsTestResourcesProvider({ getService }: FtrProvid * @param listId * @param supertest */ - async deleteList( - listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], - supertest: TestAgent = this.supertest - ): Promise { + async deleteList(listId: string, supertest: TestAgent = this.supertest): Promise { await supertest .delete(`${EXCEPTION_LIST_URL}?list_id=${listId}&namespace_type=agnostic`) .set('kbn-xsrf', 'true') @@ -209,6 +206,34 @@ export function EndpointArtifactsTestResourcesProvider({ getService }: FtrProvid return this.createExceptionItem(trustedDevice, options); } + async createList( + listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + options: ArtifactCreateOptions = {} + ): Promise { + switch (listId) { + case ENDPOINT_ARTIFACT_LISTS.trustedApps.id: { + return this.ensureListExists(TRUSTED_APPS_EXCEPTION_LIST_DEFINITION, options); + } + case ENDPOINT_ARTIFACT_LISTS.trustedDevices.id: { + return this.ensureListExists(TRUSTED_DEVICES_EXCEPTION_LIST_DEFINITION, options); + } + case ENDPOINT_ARTIFACT_LISTS.eventFilters.id: { + return this.ensureListExists(EVENT_FILTER_LIST_DEFINITION, options); + } + case ENDPOINT_ARTIFACT_LISTS.blocklists.id: { + return this.ensureListExists(BLOCKLISTS_LIST_DEFINITION, options); + } + case ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id: { + return this.ensureListExists(HOST_ISOLATION_EXCEPTIONS_LIST_DEFINITION, options); + } + case ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id: { + return this.ensureListExists(ENDPOINT_EXCEPTIONS_LIST_DEFINITION, options); + } + default: + throw new Error(`Unexpected list id ${listId}`); + } + } + async createArtifact( listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], overrides: Partial = {},