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/moon.yml b/x-pack/solutions/security/plugins/lists/moon.yml index d29f2a0d25f64..52b8f9f6ba44f 100644 --- a/x-pack/solutions/security/plugins/lists/moon.yml +++ b/x-pack/solutions/security/plugins/lists/moon.yml @@ -40,7 +40,6 @@ dependsOn: - '@kbn/core-http-server' - '@kbn/securitysolution-es-utils' - '@kbn/securitysolution-io-ts-types' - - '@kbn/std' - '@kbn/utils' - '@kbn/logging-mocks' - '@kbn/utility-types' 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/bulk_delete_exception_list_items.ts b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/bulk_delete_exception_list_items.ts new file mode 100644 index 0000000000000..49c9bdd44fbb6 --- /dev/null +++ b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/bulk_delete_exception_list_items.ts @@ -0,0 +1,31 @@ +/* + * 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 { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; +import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; +import type { SavedObjectsBulkDeleteObject, SavedObjectsClientContract } from '@kbn/core/server'; + +interface BulkDeleteExceptionListItemsOptions { + ids: string[]; + namespaceType: NamespaceType; + savedObjectsClient: SavedObjectsClientContract; +} + +export const bulkDeleteExceptionListItems = async ({ + ids, + namespaceType, + savedObjectsClient, +}: BulkDeleteExceptionListItemsOptions): Promise => { + const savedObjectType = getSavedObjectType({ namespaceType }); + + const bulkDeleteObjects = ids.map((id) => ({ + id, + type: savedObjectType, + })); + + await savedObjectsClient.bulkDelete(bulkDeleteObjects); +}; diff --git a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts index 2e4d719d2a596..3cafacb49fb18 100644 --- a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts +++ b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts @@ -10,11 +10,10 @@ import type { ListId, NamespaceType, } from '@kbn/securitysolution-io-ts-list-types'; -import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; -import { asyncForEach } from '@kbn/std'; import type { SavedObjectsClientContract } from '@kbn/core/server'; import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; +import { bulkDeleteExceptionListItems } from './bulk_delete_exception_list_items'; interface DeleteExceptionListItemByListOptions { listId: ListId; @@ -28,7 +27,7 @@ export const deleteExceptionListItemByList = async ({ namespaceType, }: DeleteExceptionListItemByListOptions): Promise => { const ids = await getExceptionListItemIds({ listId, namespaceType, savedObjectsClient }); - await deleteFoundExceptionListItems({ ids, namespaceType, savedObjectsClient }); + await bulkDeleteExceptionListItems({ ids, namespaceType, savedObjectsClient }); }; export const getExceptionListItemIds = async ({ @@ -56,30 +55,3 @@ export const getExceptionListItemIds = async ({ }); return ids; }; - -/** - * NOTE: This is slow and terrible as we are deleting everything one at a time. - * TODO: Replace this with a bulk call or a delete by query would be more useful - */ -export const deleteFoundExceptionListItems = async ({ - ids, - savedObjectsClient, - namespaceType, -}: { - ids: string[]; - savedObjectsClient: SavedObjectsClientContract; - namespaceType: NamespaceType; -}): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType }); - await asyncForEach(ids, async (id) => { - try { - await savedObjectsClient.delete(savedObjectType, id); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (err) { - // This can happen from race conditions or networking issues so deleting the id's - // like this is considered "best effort" and it is possible to get dangling pieces - // of data sitting around in which case the user has to manually clean up the data - // I am very hopeful this does not happen often or at all. - } - }); -}; diff --git a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.test.ts b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.test.ts index 32f06b3007ee7..6bb6c73c9b234 100644 --- a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.test.ts +++ b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client.test.ts @@ -338,6 +338,18 @@ describe('exception_list_client', () => { return extensionPointStorageContext.exceptionPreDelete.callback; }, ], + [ + 'bulkDeleteExceptionListItems', + (): ReturnType => { + return exceptionListClient.bulkDeleteExceptionListItems({ + ids: ['1', '2', '3'], + namespaceType: 'agnostic', + }); + }, + (): ExtensionPointStorageContextMock['exceptionPreDelete']['callback'] => { + return extensionPointStorageContext.exceptionPreDelete.callback; + }, + ], [ 'getEndpointListItem', (): ReturnType => { 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 2a8d457c0cbc3..1dfb98ed47eb7 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 @@ -33,6 +33,7 @@ import type { } from '../extension_points'; import type { + BulkDeleteExceptionListItemsOptions, ClosePointInTimeOptions, ConstructorOptions, CreateEndpointListItemOptions, @@ -100,6 +101,7 @@ import { findValueListExceptionListItemsPointInTimeFinder } from './find_value_l import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; import { duplicateExceptionListAndItems } from './duplicate_exception_list'; import { updateOverwriteExceptionListItem } from './update_overwrite_exception_list_item'; +import { bulkDeleteExceptionListItems } from './bulk_delete_exception_list_items'; /** * Class for use for exceptions that are with trusted applications or @@ -831,6 +833,33 @@ export class ExceptionListClient { }); }; + /** + * Bulk delete exception list items by an `id` array + * @param options + * @param options.ids the array of ids of exception list items to delete + * @param options.namespaceType saved object namespace (single | agnostic) + */ + public bulkDeleteExceptionListItems = async ({ + ids, + namespaceType, + }: BulkDeleteExceptionListItemsOptions): Promise => { + const { savedObjectsClient } = this; + + if (this.enableServerExtensionPoints) { + // todo: this is not ideal, items will be checked one-by-one. we'd need an `exceptionsListPreBulkDeleteItems` + // callback, but then that also needs a bulkGet function in exceptionListClient, which we don't have yet. + for (const id of ids) { + await this.serverExtensionsClient.pipeRun( + 'exceptionsListPreDeleteItem', + { id, itemId: undefined, namespaceType }, + this.getServerExtensionCallbackContext() + ); + } + } + + return bulkDeleteExceptionListItems({ ids, namespaceType, savedObjectsClient }); + }; + /** * This is the same as "deleteExceptionListItem" except it applies specifically to the endpoint list. * Either id or itemId has to be defined to delete but not both is required. If both are provided, the id @@ -1168,18 +1197,21 @@ export class ExceptionListClient { ...readStream, ]); + let shouldListApiPerformOverwrite = overwrite; if (this.enableServerExtensionPoints) { - await this.serverExtensionsClient.pipeRun( + const result = await this.serverExtensionsClient.pipeRun( 'exceptionsListPreImport', - parsedObjects, + { data: parsedObjects, overwrite }, this.getServerExtensionCallbackContext() ); + + shouldListApiPerformOverwrite = result.overwrite; } return importExceptions({ exceptions: parsedObjects, generateNewListId, - overwrite, + overwrite: shouldListApiPerformOverwrite, savedObjectsClient, user, }); @@ -1203,18 +1235,21 @@ export class ExceptionListClient { // validation of import and sorting of lists and items const parsedObjects = exceptionsChecksFromArray(exceptionsToImport, maxExceptionsImportSize); + let shouldListApiPerformOverwrite = overwrite; if (this.enableServerExtensionPoints) { - await this.serverExtensionsClient.pipeRun( + const result = await this.serverExtensionsClient.pipeRun( 'exceptionsListPreImport', - parsedObjects, + { data: parsedObjects, overwrite }, this.getServerExtensionCallbackContext() ); + + shouldListApiPerformOverwrite = result.overwrite; } return importExceptions({ exceptions: parsedObjects, generateNewListId: false, - overwrite, + overwrite: shouldListApiPerformOverwrite, savedObjectsClient, user, }); diff --git a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 6225ad869a383..e74d50d6ee46d 100644 --- a/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/solutions/security/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -202,6 +202,18 @@ export interface DeleteExceptionListItemByIdOptions { namespaceType: NamespaceType; } +/** + * ExceptionListClient.bulkDeleteExceptionListItems + * {@link ExceptionListClient.bulkDeleteExceptionListItems} + */ +export interface BulkDeleteExceptionListItemsOptions { + /** the "ids" of the exception list items */ + ids: Id[]; + + /** saved object namespace (single | agnostic) */ + namespaceType: NamespaceType; +} + /** * ExceptionListClient.deleteEndpointListItem * {@link ExceptionListClient.deleteEndpointListItem} 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/lists/server/services/extension_points/types.ts b/x-pack/solutions/security/plugins/lists/server/services/extension_points/types.ts index ac77d17ebdcbc..5dd1b03162d9e 100644 --- a/x-pack/solutions/security/plugins/lists/server/services/extension_points/types.ts +++ b/x-pack/solutions/security/plugins/lists/server/services/extension_points/types.ts @@ -75,7 +75,7 @@ interface ServerExtensionPointDefinition< */ export type ExceptionsListPreImportServerExtension = ServerExtensionPointDefinition< 'exceptionsListPreImport', - PromiseFromStreams + { data: PromiseFromStreams; overwrite: boolean } >; /** diff --git a/x-pack/solutions/security/plugins/lists/tsconfig.json b/x-pack/solutions/security/plugins/lists/tsconfig.json index e305e33d57b2c..826551ced78ad 100644 --- a/x-pack/solutions/security/plugins/lists/tsconfig.json +++ b/x-pack/solutions/security/plugins/lists/tsconfig.json @@ -34,7 +34,6 @@ "@kbn/core-http-server", "@kbn/securitysolution-es-utils", "@kbn/securitysolution-io-ts-types", - "@kbn/std", "@kbn/utils", "@kbn/logging-mocks", "@kbn/utility-types", 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 bd027fcc43db9..52cc6e56d6694 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 } 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/common/endpoint/service/artifacts/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/constants.ts index 4284273306926..9cc2da06cff2b 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/constants.ts @@ -13,6 +13,8 @@ export const BY_POLICY_ARTIFACT_TAG_PREFIX = 'policy:'; export const GLOBAL_ARTIFACT_TAG = `${BY_POLICY_ARTIFACT_TAG_PREFIX}all`; +export const IMPORTED_ARTIFACT_TAG = 'imported_artifact'; + export const ADVANCED_MODE_TAG = 'form_mode:advanced'; /** The tag name for process descendants in event filters */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/index.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/index.ts index ef58075f0abba..c3a4476e8baa5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/artifacts/index.ts @@ -16,6 +16,7 @@ export { export { BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG, + IMPORTED_ARTIFACT_TAG, ADVANCED_MODE_TAG, FILTER_PROCESS_DESCENDANTS_TAG, TRUSTED_PROCESS_DESCENDANTS_TAG, diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.test.tsx index 03ff902b6fc8d..83d265050c53e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_import_flyout.test.tsx @@ -94,7 +94,7 @@ describe('When the flyout is opened in the ArtifactListPage component', () => { expect(mockedTrustedAppApi.responseProvider.trustedAppImportList).toHaveBeenCalledWith( expect.objectContaining({ version: '2023-10-31', - query: { overwrite: false } as HttpFetchOptionsWithPath['query'], + query: { overwrite: true } as HttpFetchOptionsWithPath['query'], }) ); }); @@ -120,15 +120,16 @@ describe('When the flyout is opened in the ArtifactListPage component', () => { await userEvent.click(ui.getImportButton()); expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ - text: '2 items imported', + text: '2 artifacts imported.', title: 'Artifact list imported successfully', + toastLifeTimeMs: 60_000, }); 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'); + throw new Error('Fail message from server'); }); await render(); @@ -137,8 +138,8 @@ describe('When the flyout is opened in the ArtifactListPage component', () => { await userEvent.click(ui.getImportButton()); expect(coreStart.notifications.toasts.addError).toHaveBeenCalledWith( - expect.objectContaining(new Error('Import failed')), - { title: 'Artifact list import failed' } + expect.objectContaining(new Error('Fail message from server')), + { title: 'Artifact list import failed', toastMessage: 'Fail message from server' } ); }); }); 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 index 1ae1b104edb84..4d0cb0edf1624 100644 --- 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 @@ -55,14 +55,27 @@ export const ArtifactImportFlyout: React.FC = ({ { file }, { onError: (error) => { - toasts.addError(error, { title: labels.pageImportErrorToastTitle }); + toasts.addError(error, { + title: labels.pageImportErrorToastTitle, + toastMessage: error.body?.message || error.message, + }); }, onSuccess: (response) => { toasts.addSuccess({ title: labels.pageImportSuccessToastTitle, - text: labels.getPageImportSuccessToastText?.( + text: `${labels.getPageImportSuccessToastText?.( response.success_count_exception_list_items - ), + )}${ + response.errors.length + ? ` ${response.errors.length} errors happened: ${response.errors + .map( + (item, index) => + `(${index + 1}) item (${item.item_id}): ${item.error.message}.` + ) + .join(' -- ')}` + : '' + }`, + toastLifeTimeMs: 60_000, }); onSuccess(); }, 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 b6242477ed967..2307fd6f13c42 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 @@ -55,7 +55,8 @@ export const artifactListPageLabels = Object.freeze({ ), getPageImportSuccessToastText: (successCount: number): string => i18n.translate('xpack.securitySolution.artifactListPage.importSuccessToastText', { - defaultMessage: '{successCount} items imported', + defaultMessage: + '{successCount} {successCount, plural, one {artifact} other {artifacts}} imported.', values: { successCount }, }), pageImportErrorToastTitle: i18n.translate( 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 index 232175aca64e0..8447b5e667723 100644 --- 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 @@ -31,7 +31,7 @@ describe('Import artifact list hook', () => { ); }); - it('import an artifact list', async () => { + it('should import an artifact list', async () => { const apiResponse: ImportExceptionsResponseSchema = { success: true, success_count: 1, @@ -69,13 +69,13 @@ describe('Import artifact list hook', () => { headers: { 'Content-Type': undefined }, body: expect.any(FormData), query: { - overwrite: false, + overwrite: true, }, }) ); }); - it('throw when importing an artifact list', async () => { + it('should throw when importing an artifact list', async () => { const expectedError = { response: { status: 500, 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 68193ca192429..b5a5a75294fdf 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 @@ -275,7 +275,7 @@ describe('Exceptions List Api Client', () => { headers: { 'Content-Type': undefined }, body: expect.any(FormData), query: { - overwrite: false, + overwrite: true, }, }); }); 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 46e8a8eae748b..f8cb56e4e8544 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 @@ -319,9 +319,7 @@ export class ExceptionsListApiClient { 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, + overwrite: true, } as ImportExceptionListRequestQuery, }); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index dbfd22d6bf623..33237eaa715d5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -465,6 +465,23 @@ export class EndpointAppContextService { return this.startDependencies.spacesService.getActiveSpace(httpRequest); } + public getActiveSpaceId(httpRequest: KibanaRequest): string { + if (!this.startDependencies?.spacesService) { + throw new EndpointAppContentServicesNotStartedError(); + } + + return this.startDependencies.spacesService.getSpaceId(httpRequest); + } + + public getAccessibleSpaces(httpRequest: KibanaRequest): Promise { + if (!this.startDependencies?.spacesService) { + throw new EndpointAppContentServicesNotStartedError(); + } + + const spacesClient = this.startDependencies.spacesService.createSpacesClient(httpRequest); + return spacesClient.getAll(); + } + public getReferenceDataClient(): ReferenceDataClientInterface { if (!this.startDependencies?.savedObjectsServiceStart) { throw new EndpointAppContentServicesNotStartedError(); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts index f12c4e9f6d2b3..a37b2df7fa6e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts @@ -146,24 +146,38 @@ export const createMockEndpointAppContextService = ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion getExceptionListsClient: jest.fn().mockReturnValue(exceptionListsClient!), getMessageSigningService: jest.fn().mockReturnValue(messageSigningService), - getFleetActionsClient: jest.fn(async (_) => fleetActionsClientMock), + getFleetActionsClient: jest.fn(async () => fleetActionsClientMock), getTelemetryService: jest.fn().mockReturnValue(telemetryServiceMock), - getInternalResponseActionsClient: jest.fn(() => { + getInternalResponseActionsClient: jest.fn((_) => { return responseActionsClientMock.create(); }), savedObjects: createSavedObjectsClientFactoryMock({ savedObjectsServiceStart }).service, isServerless: jest.fn().mockReturnValue(false), getInternalEsClient: jest.fn().mockReturnValue(esClient), - getActiveSpace: jest.fn(async () => ({ + getActiveSpace: jest.fn(async (_) => ({ id: DEFAULT_SPACE_ID, name: 'default', disabledFeatures: [], })), - getSpaceId: jest.fn().mockReturnValue('default'), + getActiveSpaceId: jest.fn().mockReturnValue(DEFAULT_SPACE_ID), + getAccessibleSpaces: jest.fn(async (_) => [ + { + id: DEFAULT_SPACE_ID, + name: 'default', + disabledFeatures: [], + }, + ]), getReferenceDataClient: jest.fn().mockReturnValue(referenceDataMocks.createClient()), getServerConfigValue: jest.fn(), getScriptsLibraryClient: jest.fn().mockReturnValue(scriptsClient), - } as unknown as jest.Mocked; + } as Omit< + jest.Mocked, + | 'config' + | 'security' + | 'fleetStartServices' + | 'savedObjectsServiceStart' + | 'exceptionListsClient' + > as jest.Mocked; }; /** diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts index bd8bae7ddbc77..9174e5dbbb79f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts @@ -111,7 +111,7 @@ export const getExceptionsPreCreateItemHandler = ( throw new EndpointArtifactExceptionValidationError(`Missing HTTP Request object`); } - const spaceId = (await endpointAppContext.getActiveSpace(request)).id; + const spaceId = endpointAppContext.getActiveSpaceId(request); setArtifactOwnerSpaceId(validatedItem, spaceId); return validatedItem; 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..0cd0bc18bae2a 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 @@ -5,65 +5,346 @@ * 2.0. */ -import type { ExceptionsListPreImportServerExtension } from '@kbn/lists-plugin/server'; -import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import type { + ExceptionListClient, + ExceptionsListPreImportServerExtension, +} from '@kbn/lists-plugin/server'; +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 { KibanaRequest, Logger } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { + FoundExceptionListItemSchema, + ImportExceptionListItemSchemaDecoded, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + buildSpaceOwnerIdTag, + 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, + IMPORTED_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'; +import { buildSpaceDataFilter } from '../utils'; + +const MULTIPLE_TYPES_OF_ENDPOINT_ARTIFACT_IMPORT_NOT_ALLOWED = i18n.translate( + 'xpack.securitySolution.importValidator.multipleTypesOfEndpointArtifactImportNotAllowed', + { + defaultMessage: + 'Importing multiple Endpoint artifact exception list types at the same time is not supported', + } +); export const getExceptionsPreImportHandler = ( - endpointAppContextService: EndpointAppContextService + endpointAppContext: EndpointAppContextService ): ExceptionsListPreImportServerExtension['callback'] => { - const logger = endpointAppContextService.createLogger('listsPreImportExtensionPoint'); + return async ({ + data: { data, overwrite }, + context: { request, exceptionListClient }, + }): ReturnType => { + const logger = endpointAppContext.createLogger('listsPreImportExtensionPoint'); - 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[]; + validateCanEndpointArtifactsBeImported(data, endpointAppContext.experimentalFeatures); + provideSpaceAwarenessCompatibilityForOldEndpointExceptions(data, logger); - return NON_IMPORTABLE_ENDPOINT_ARTIFACT_IDS.includes(item.list_id); + if (!endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + return { data, overwrite }; + } + + // --- Below are the validations/operations when Import/Export is allowed for all endpoint artifacts based on FF --- + + const importedListIdsAndNamespaces = new Set(); + for (const item of [...data.lists, ...data.items]) { + if ('list_id' in item) { + importedListIdsAndNamespaces.add(`${item.list_id}:${item.namespace_type}`); } + } - return false; - }); + const hasEndpointArtifact = ENDPOINT_ARTIFACT_LIST_IDS.some((endpointListId) => + importedListIdsAndNamespaces.has(`${endpointListId}:agnostic`) + ); + + if (!hasEndpointArtifact) { + return { data, overwrite }; + } - if (hasEndpointArtifactListOrListItems) { + if (importedListIdsAndNamespaces.size > 1) { throw new EndpointArtifactExceptionValidationError( - 'Import is not supported for Endpoint artifact exceptions' + MULTIPLE_TYPES_OF_ENDPOINT_ARTIFACT_IMPORT_NOT_ALLOWED + ); + } + + const importedListId = Array.from(importedListIdsAndNamespaces)[0].split(':')[0]; + + // 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); + } + + // Validate host isolation + if (HostIsolationExceptionsValidator.isHostIsolationException({ listId: importedListId })) { + const hostIsolationExceptionsValidator = new HostIsolationExceptionsValidator( + endpointAppContext, + request ); + await hostIsolationExceptionsValidator.validatePreImport(data); } - // 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'] = []; + // 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); + } + + // --- Below are operations to prepare the imported data --- + const spaceId = getSpaceId(endpointAppContext, request); 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); + if (!(item instanceof Error)) { + addOwnerSpaceIdTagToItem(item, spaceId); + addImportedCommentToItem(item); + addImportedTagToItem(item); } } - if (adjustedImportItems.length > 0) { - logger.debug(`The following Endpoint Exceptions item imports were adjusted to include the Global artifact tag: -${stringify(adjustedImportItems)}`); + if (!overwrite) { + return { data, overwrite }; } - return data; + // --- From this point, we're starting to perform operations on SOs in order to prepare OVERWRITING EXISTING LIST. --- + // All validations must be above + + await deleteExistingItemsForOverwrite({ + data, + exceptionListClient, + endpointAppContext, + request, + importedListId, + logger, + }); + + return { + data, + + // We're not allowing the list API to overwrite the list, as it would delete all items. + // Instead, we're 'simulating' the overwrite behaviour here, in the extension. + overwrite: false, + }; }; }; + +const validateCanEndpointArtifactsBeImported = ( + data: PromiseFromStreams, + experimentalFeatures: ExperimentalFeatures +): void => { + 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, + logger: Logger +): void => { + const adjustedImportItems: PromiseFromStreams['items'] = []; + + for (const item of data.items) { + if ( + !(item instanceof Error) && + item.list_id === ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id && + item.namespace_type === 'agnostic' && + !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)}`); + } +}; + +const deleteExistingItemsForOverwrite = async ({ + data, + exceptionListClient, + endpointAppContext, + request, + importedListId, + logger, +}: { + data: PromiseFromStreams; + exceptionListClient: ExceptionListClient; + endpointAppContext: EndpointAppContextService; + request: KibanaRequest | undefined; + importedListId: string; + logger: Logger; +}): Promise => { + if (!request) { + throw new EndpointArtifactExceptionValidationError( + 'Unable to determine space id. Missing HTTP Request object', + 500 + ); + } + + // Let's not pass the `list` to the list API, to avoid conflict issue due to the `overwrite` query param is disabled on that level. + data.lists = []; + + // Delete items from the list API if overwrite is true, to simulate the overwrite behaviour, + // as we disabled the overwrite query param on the list API level to avoid conflict issue. + const spaceId = endpointAppContext.getActiveSpaceId(request); + + const canManageGlobalArtifacts = (await endpointAppContext.getEndpointAuthz(request)) + .canManageGlobalArtifacts; + + let filter: string; + if (canManageGlobalArtifacts) { + const filterForAllItemsVisibleInCurrentSpace = ( + await buildSpaceDataFilter(endpointAppContext, request) + ).filter; + + filter = filterForAllItemsVisibleInCurrentSpace; + } else { + const notGlobalExceptionFilter = `NOT exception-list-agnostic.attributes.tags:"${GLOBAL_ARTIFACT_TAG}"`; + const createdInCurrentSpaceExceptionFilter = `exception-list-agnostic.attributes.tags:"${buildSpaceOwnerIdTag( + spaceId + )}"`; + + const filterForNonGlobalItemsOwnedByCurrentSpace = `${notGlobalExceptionFilter} AND ${createdInCurrentSpaceExceptionFilter} `; + + filter = filterForNonGlobalItemsOwnedByCurrentSpace; + } + + let itemIdsToDelete: string[] = []; + const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { + const responseIds = response.data.map((exceptionListItem) => exceptionListItem.id); + itemIdsToDelete = [...itemIdsToDelete, ...responseIds]; + }; + await exceptionListClient.findExceptionListItemPointInTimeFinder({ + executeFunctionOnStream, + listId: importedListId, + namespaceType: 'agnostic', + filter, + perPage: 1_000, + maxSize: undefined, + sortField: undefined, + sortOrder: undefined, + }); + + if (itemIdsToDelete.length > 0) { + await exceptionListClient.bulkDeleteExceptionListItems({ + ids: itemIdsToDelete, + namespaceType: 'agnostic', + }); + + logger.debug( + `Deleted ${ + itemIdsToDelete.length + } items from list [${importedListId}] in space [${spaceId}] to prepare for import with overwrite${ + canManageGlobalArtifacts ? ', including global artifacts' : '' + }.` + ); + } else { + logger.debug('No exception list items found to delete on import with overwrite.'); + } +}; + +const addImportedCommentToItem = (item: ImportExceptionListItemSchemaDecoded): void => { + item.comments = [ + ...(item.comments ?? []), + { + comment: `Imported artifact.\nOriginally created by "${item.created_by ?? 'unknown'}" at "${ + item.created_at ?? 'unknown' + }".`, + }, + ]; +}; + +const addImportedTagToItem = (item: ImportExceptionListItemSchemaDecoded): void => { + item.tags = [...(item.tags ?? []), IMPORTED_ARTIFACT_TAG]; +}; + +const addOwnerSpaceIdTagToItem = ( + item: ImportExceptionListItemSchemaDecoded, + spaceId: string +): void => { + if (!hasArtifactOwnerSpaceId(item)) { + item.tags = [...(item.tags ?? []), buildSpaceOwnerIdTag(spaceId)]; + } +}; + +const getSpaceId = ( + endpointAppContext: EndpointAppContextService, + request: KibanaRequest | undefined +) => { + if (!request) { + throw new EndpointArtifactExceptionValidationError( + 'Unable to determine space id. Missing HTTP Request object', + 500 + ); + } + + return endpointAppContext.getActiveSpaceId(request); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 10ba9535e13d8..837128ac65484 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -153,7 +153,7 @@ export const getExceptionsPreUpdateItemHandler = ( if (!request) { throw new EndpointArtifactExceptionValidationError(`Missing HTTP Request object`); } - const spaceId = (await endpointAppContextService.getActiveSpace(request)).id; + const spaceId = endpointAppContextService.getActiveSpaceId(request); setArtifactOwnerSpaceId(validatedItem, spaceId); } 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/utils/build_space_data_filter.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/utils/build_space_data_filter.ts index 51c394e77bd31..4326bbd632c06 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/utils/build_space_data_filter.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/utils/build_space_data_filter.ts @@ -24,7 +24,7 @@ export const buildSpaceDataFilter = async ( httpRequest: KibanaRequest ): Promise<{ filter: string }> => { const logger = endpointServices.createLogger('buildSpaceDataFilter'); - const spaceId = (await endpointServices.getActiveSpace(httpRequest)).id; + const spaceId = endpointServices.getActiveSpaceId(httpRequest); const fleetServices = endpointServices.getInternalFleetServices(spaceId); const soScopedClient = fleetServices.savedObjects.createInternalScopedSoClient({ spaceId }); const { items: allEndpointPolicyIds } = await fleetServices.packagePolicy.listIds( 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..5a12b473efe89 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,355 @@ 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); + }); + }); + + describe('#validateImportOwnerSpaceIds()', () => { + it('should do nothing when item has no tags', async () => { + exceptionLikeItem.tags = []; + + await expect( + validator._validateImportOwnerSpaceIds(exceptionLikeItem) + ).resolves.toBeUndefined(); + }); + + describe('when the user has global artifact management privilege', () => { + it('should allow import when spaces are accessible and artifact is visible in current space', async () => { + setArtifactOwnerSpaceId(exceptionLikeItem, DEFAULT_SPACE_ID); + + await expect( + validator._validateImportOwnerSpaceIds(exceptionLikeItem) + ).resolves.toBeUndefined(); + }); + + it('should error when owner space is not accessible', async () => { + setArtifactOwnerSpaceId(exceptionLikeItem, 'inaccessible-space'); + + await expect(validator._validateImportOwnerSpaceIds(exceptionLikeItem)).rejects.toThrow( + /invalid owner space IDs/ + ); + }); + + it('should error when artifact is not visible in current space', async () => { + // Artifact owned by another space with no policies assigned (not visible) + exceptionLikeItem.tags = [buildSpaceOwnerIdTag('other-space')]; + (endpointAppContextServices.getAccessibleSpaces as jest.Mock).mockResolvedValue([ + { id: DEFAULT_SPACE_ID, name: 'default', disabledFeatures: [] }, + { id: 'other-space', name: 'other', disabledFeatures: [] }, + ]); + + await expect(validator._validateImportOwnerSpaceIds(exceptionLikeItem)).rejects.toThrow( + /Importing artifacts that are not visible in the current space/ + ); + }); + }); + + describe('when the user does NOT have global artifact management privilege', () => { + it('should allow import when ownerSpaceId matches active space', async () => { + authzMock.canManageGlobalArtifacts = false; + setArtifactOwnerSpaceId(exceptionLikeItem, DEFAULT_SPACE_ID); + + await expect( + validator._validateImportOwnerSpaceIds(exceptionLikeItem) + ).resolves.toBeUndefined(); + }); + + it('should error when ownerSpaceId does not match active space', async () => { + authzMock.canManageGlobalArtifacts = false; + setArtifactOwnerSpaceId(exceptionLikeItem, 'other-space'); + + await expect(validator._validateImportOwnerSpaceIds(exceptionLikeItem)).rejects.toThrow( + /Importing artifacts to a different space requires global artifact management privilege/ + ); + }); + + it("should error when there's an additional owner space ID", async () => { + authzMock.canManageGlobalArtifacts = false; + setArtifactOwnerSpaceId(exceptionLikeItem, DEFAULT_SPACE_ID); + setArtifactOwnerSpaceId(exceptionLikeItem, 'other-space'); + + await expect(validator._validateImportOwnerSpaceIds(exceptionLikeItem)).rejects.toThrow( + /Importing artifacts to a different space requires global artifact management privilege/ + ); + }); + }); + }); + + describe('#isArtifactVisibleInCurrentSpace()', () => { + it('should return true when ownerSpaceIds includes the active space', async () => { + await expect( + validator._isArtifactVisibleInCurrentSpace( + [DEFAULT_SPACE_ID, 'other-space'], + DEFAULT_SPACE_ID, + exceptionLikeItem + ) + ).resolves.toBe(true); + }); + + it('should return true when item is a global artifact', async () => { + exceptionLikeItem.tags = [GLOBAL_ARTIFACT_TAG]; + + await expect( + validator._isArtifactVisibleInCurrentSpace( + ['other-space'], + DEFAULT_SPACE_ID, + exceptionLikeItem + ) + ).resolves.toBe(true); + }); + + it('should return true when at least one assigned policy is visible in the current space', async () => { + exceptionLikeItem.tags = [ + buildPerPolicyTag('policy-1'), + buildSpaceOwnerIdTag('other-space'), + ]; + + await expect( + validator._isArtifactVisibleInCurrentSpace( + ['other-space'], + DEFAULT_SPACE_ID, + exceptionLikeItem + ) + ).resolves.toBe(true); + }); + + it('should return false when item has no assigned policies and owner is a different space', async () => { + exceptionLikeItem.tags = [buildSpaceOwnerIdTag('other-space')]; + + await expect( + validator._isArtifactVisibleInCurrentSpace( + ['other-space'], + DEFAULT_SPACE_ID, + exceptionLikeItem + ) + ).resolves.toBe(false); + }); + + it('should return false when no assigned policies are visible in the current space', async () => { + exceptionLikeItem.tags = [ + buildPerPolicyTag('invisible-policy'), + buildSpaceOwnerIdTag('other-space'), + ]; + packagePolicyService.getByIDs.mockResolvedValue([]); + + await expect( + validator._isArtifactVisibleInCurrentSpace( + ['other-space'], + DEFAULT_SPACE_ID, + exceptionLikeItem + ) + ).resolves.toBe(false); + }); + }); + + describe('#removeInvalidPolicyIds()', () => { + it('should do nothing when item is not by-policy', async () => { + exceptionLikeItem.tags = [GLOBAL_ARTIFACT_TAG]; + const originalTags = [...exceptionLikeItem.tags]; + + await validator._removeInvalidPolicyIds(exceptionLikeItem); + + expect(exceptionLikeItem.tags).toEqual(originalTags); + }); + + it('should do nothing when item has no policy IDs', async () => { + exceptionLikeItem.tags = [buildPerPolicyTag('some-id')]; + // Clear the per-policy tags but keep it "by policy" by having the prefix + exceptionLikeItem.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}`]; + + await validator._removeInvalidPolicyIds(exceptionLikeItem); + }); + + it('should not modify tags when all policy IDs are valid', async () => { + exceptionLikeItem.tags = [buildPerPolicyTag('policy-1'), buildPerPolicyTag('policy-2')]; + + await validator._removeInvalidPolicyIds(exceptionLikeItem); + + expect(exceptionLikeItem.tags).toEqual([ + buildPerPolicyTag('policy-1'), + buildPerPolicyTag('policy-2'), + ]); + }); + + it('should remove invalid policy ID tags and add a comment', async () => { + exceptionLikeItem.tags = [ + buildPerPolicyTag('policy-1'), + buildPerPolicyTag('invalid-policy'), + ]; + exceptionLikeItem.comments = []; + + await validator._removeInvalidPolicyIds(exceptionLikeItem); + + expect(exceptionLikeItem.tags).toEqual([buildPerPolicyTag('policy-1')]); + expect(exceptionLikeItem.comments).toHaveLength(1); + expect(exceptionLikeItem.comments[0]).toEqual({ + comment: expect.stringContaining('invalid-policy'), + }); + }); + + it('should remove all invalid policy ID tags when multiple are invalid', async () => { + exceptionLikeItem.tags = [ + buildPerPolicyTag('policy-1'), + buildPerPolicyTag('bad-1'), + buildPerPolicyTag('bad-2'), + ]; + exceptionLikeItem.comments = []; + + await validator._removeInvalidPolicyIds(exceptionLikeItem); + + expect(exceptionLikeItem.tags).toEqual([buildPerPolicyTag('policy-1')]); + expect(exceptionLikeItem.comments).toHaveLength(1); + expect(exceptionLikeItem.comments[0]).toEqual({ + comment: expect.stringContaining('bad-1'), + }); + expect(exceptionLikeItem.comments[0]).toEqual({ + comment: expect.stringContaining('bad-2'), + }); + }); + }); }); }); 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..472fc2853ce8e 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', @@ -41,6 +43,14 @@ const OWNER_SPACE_ID_TAG_MANAGEMENT_NOT_ALLOWED_MESSAGE = i18n.translate( } ); +const IMPORTING_TO_OTHER_SPACE_NOT_ALLOWED_MESSAGE = i18n.translate( + 'xpack.securitySolution.baseValidator.importingToOtherSpaceNotAllowedMessage', + { + defaultMessage: + 'Importing artifacts to a different space requires global artifact management privilege', + } +); + export const GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE = i18n.translate( 'xpack.securitySolution.baseValidator.noGlobalArtifactManagementMessage', { @@ -49,6 +59,23 @@ export const GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE = i18n.translate( } ); +const IMPORTING_ARTIFACT_NOT_VISIBLE_IN_CURRENT_SPACE_NOT_ALLOWED_MESSAGE = i18n.translate( + 'xpack.securitySolution.baseValidator.importingArtifactNotVisibleInCurrentSpace', + { + defaultMessage: 'Importing artifacts that are not visible in the current space is not allowed', + } +); + +const IMPORTING_ARTIFACT_WITH_INVALID_OWNER_SPACE_ID = (spaceIds: string[]): string => + i18n.translate('xpack.securitySolution.baseValidator.invalidOwnerSpaceId', { + defaultMessage: + 'Importing artifacts with invalid owner space IDs is not allowed. The following space {numberOfSpaces, plural, one {ID is} other {IDs are} } invalid or unaccessible by current user: {invalidSpaceIds}', + values: { + invalidSpaceIds: spaceIds.join(', '), + numberOfSpaces: spaceIds.length, + }, + }); + const ITEM_CANNOT_BE_MANAGED_IN_CURRENT_SPACE_MESSAGE = (spaceIds: string[]): string => i18n.translate('xpack.securitySolution.baseValidator.cannotManageItemInCurrentSpace', { defaultMessage: `Updates to this shared item can only be done from the following space {numberOfSpaces, plural, one {ID} other {IDs} }: {itemOwnerSpaces} (or by someone having global artifact management privilege)`, @@ -114,14 +141,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); @@ -182,7 +201,7 @@ export class BaseValidator { currentItem?: ExceptionListItemSchema ): Promise { if (this.isItemByPolicy(item)) { - const spaceId = await this.getActiveSpaceId(); + const spaceId = this.getActiveSpaceId(); const { packagePolicy, savedObjects } = this.endpointAppContext.getInternalFleetServices(spaceId); const policyIds = getPolicyIdsFromArtifact(item); @@ -279,12 +298,9 @@ export class BaseValidator { } const ownerSpaceIds = getArtifactOwnerSpaceIds(item); - const activeSpaceId = await this.getActiveSpaceId(); + const activeSpaceId = this.getActiveSpaceId(); - if ( - ownerSpaceIds.length > 1 || - (ownerSpaceIds.length === 1 && ownerSpaceIds[0] !== activeSpaceId) - ) { + if (ownerSpaceIds.some((spaceId) => spaceId !== activeSpaceId)) { throw new EndpointArtifactExceptionValidationError( `${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${OWNER_SPACE_ID_TAG_MANAGEMENT_NOT_ALLOWED_MESSAGE}`, 403 @@ -293,6 +309,79 @@ export class BaseValidator { } } + protected async validateImportOwnerSpaceIds(item: ExceptionItemLikeOptions): Promise { + if (item.tags && item.tags.length > 0) { + const ownerSpaceIds = getArtifactOwnerSpaceIds(item); + const activeSpaceId = this.getActiveSpaceId(); + + if ((await this.endpointAuthzPromise).canManageGlobalArtifacts) { + await this.validateSpacesAreAccessible(ownerSpaceIds); + + if (!(await this.isArtifactVisibleInCurrentSpace(ownerSpaceIds, activeSpaceId, item))) { + throw new EndpointArtifactExceptionValidationError( + `${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${IMPORTING_ARTIFACT_NOT_VISIBLE_IN_CURRENT_SPACE_NOT_ALLOWED_MESSAGE}`, + 403 + ); + } + + return; + } + + if (ownerSpaceIds.some((spaceId) => spaceId !== activeSpaceId)) { + throw new EndpointArtifactExceptionValidationError( + `${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${IMPORTING_TO_OTHER_SPACE_NOT_ALLOWED_MESSAGE}`, + 403 + ); + } + } + } + + private async validateSpacesAreAccessible(ownerSpaceIds: string[]) { + const accessibleSpacesIds = new Set(await this.getAccessibleSpaceIds()); + const invalidSpaceIds = ownerSpaceIds.filter((spaceId) => !accessibleSpacesIds.has(spaceId)); + + if (invalidSpaceIds.length > 0) { + throw new EndpointArtifactExceptionValidationError( + IMPORTING_ARTIFACT_WITH_INVALID_OWNER_SPACE_ID(invalidSpaceIds), + 403 + ); + } + } + + protected async isArtifactVisibleInCurrentSpace( + ownerSpaceIds: string[], + activeSpaceId: string, + item: ExceptionItemLikeOptions + ): Promise { + if (ownerSpaceIds.includes(activeSpaceId) || isArtifactGlobal(item)) { + return true; + } + + const assignedPolicyIds = getPolicyIdsFromArtifact(item); + if (assignedPolicyIds.length === 0) { + // not assigned to any policy while existing in another space, so not visible in current space + return false; + } + + const { packagePolicy, savedObjects } = + this.endpointAppContext.getInternalFleetServices(activeSpaceId); + + const soClient = savedObjects.createInternalScopedSoClient({ spaceId: activeSpaceId }); + + const assignedPackagePoliciesVisibleInCurrentSpace = await packagePolicy.getByIDs( + soClient, + assignedPolicyIds, + { ignoreMissing: true } + ); + + if (assignedPackagePoliciesVisibleInCurrentSpace.length > 0) { + // assigned to at least one policy visible in current space + return true; + } + + return false; + } + protected wasOwnerSpaceIdTagsChanged( updatedItem: Partial>, currentItem: Pick @@ -300,7 +389,7 @@ export class BaseValidator { return !isEqual(getArtifactOwnerSpaceIds(updatedItem), getArtifactOwnerSpaceIds(currentItem)); } - protected async getActiveSpaceId(): Promise { + protected getActiveSpaceId(): string { if (!this.request) { throw new EndpointArtifactExceptionValidationError( 'Unable to determine space id. Missing HTTP Request object', @@ -308,7 +397,20 @@ export class BaseValidator { ); } - return (await this.endpointAppContext.getActiveSpace(this.request)).id; + return this.endpointAppContext.getActiveSpaceId(this.request); + } + + protected async getAccessibleSpaceIds(): Promise { + if (!this.request) { + throw new EndpointArtifactExceptionValidationError( + 'Unable to determine space id. Missing HTTP Request object', + 500 + ); + } + + const accessibleSpaces = await this.endpointAppContext.getAccessibleSpaces(this.request); + + return accessibleSpaces.map((space) => space.id); } protected async validateCanCreateGlobalArtifacts(item: ExceptionItemLikeOptions): Promise { @@ -341,7 +443,7 @@ export class BaseValidator { const itemOwnerSpaces = getArtifactOwnerSpaceIds(currentSavedItem); // Per-space items can only be managed from one of the `ownerSpaceId`'s - if (!itemOwnerSpaces.includes(await this.getActiveSpaceId())) { + if (!itemOwnerSpaces.includes(this.getActiveSpaceId())) { throw new EndpointArtifactExceptionValidationError( ITEM_CANNOT_BE_MANAGED_IN_CURRENT_SPACE_MESSAGE(itemOwnerSpaces), 403 @@ -368,7 +470,7 @@ export class BaseValidator { const itemOwnerSpaces = getArtifactOwnerSpaceIds(currentSavedItem); // Per-space items can only be deleted from one of the `ownerSpaceId`'s - if (!itemOwnerSpaces.includes(await this.getActiveSpaceId())) { + if (!itemOwnerSpaces.includes(this.getActiveSpaceId())) { throw new EndpointArtifactExceptionValidationError( ITEM_CANNOT_BE_MANAGED_IN_CURRENT_SPACE_MESSAGE(itemOwnerSpaces), 403 @@ -389,7 +491,7 @@ export class BaseValidator { return; } - const activeSpaceId = await this.getActiveSpaceId(); + const activeSpaceId = this.getActiveSpaceId(); const ownerSpaceIds = getArtifactOwnerSpaceIds(currentSavedItem); const policyIds = getPolicyIdsFromArtifact(currentSavedItem); @@ -439,4 +541,92 @@ 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, + comments: item.comments, + 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 = [ + ...(item.comments ?? []), + { + 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..9e634b46f7c5e 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,24 @@ export class BlocklistValidator extends BaseValidator { return super.validateHasPrivilege('canReadBlocklist'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + // import specific validations + await this.validateImportOwnerSpaceIds(item); // instead of validateCreateOwnerSpaceIds + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); // instead of validateByPolicyItem + + // usual validators from pre-create + (item.entries as BlocklistConditionEntry[]) = removeDuplicateEntryValues( + item.entries as BlocklistConditionEntry[] + ); + await this.validateBlocklistData(item); + await this.validateCanCreateByPolicyArtifacts(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..96957b3509f01 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,20 @@ export class EndpointExceptionsValidator extends BaseValidator { } } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + // import specific validations + await this.validateImportOwnerSpaceIds(item); // instead of validateCreateOwnerSpaceIds + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); // instead of validateByPolicyItem + + // usual validators from pre-create + await this.validateCanCreateByPolicyArtifacts(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 8608ce0303c80..271209f458d37 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,21 @@ export class EventFilterValidator extends BaseValidator { return super.validateHasPrivilege('canReadEventFilters'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + // import specific validations + await this.validateImportOwnerSpaceIds(item); // instead of validateCreateOwnerSpaceIds + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); // instead of validateByPolicyItem + + // usual validators from pre-create + await this.validateEventFilterData(item); + await this.validateCanCreateByPolicyArtifacts(item); + }); + } + async validatePreCreateItem(item: CreateExceptionListItemOptions) { await this.validateHasWritePrivilege(); await this.validateEventFilterData(item); @@ -127,10 +143,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 4c9fa4fe5e7a2..c2a272400d8db 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,20 @@ export class HostIsolationExceptionsValidator extends BaseValidator { return this.validateHasPrivilege('canReadHostIsolationExceptions'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + // import specific validations + await this.validateImportOwnerSpaceIds(item); // instead of validateCreateOwnerSpaceIds + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); // instead of validateByPolicyItem + + // usual validators from pre-create + await this.validateHostIsolationData(item); + }); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { @@ -126,12 +141,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..7025f7b478fa4 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,29 @@ 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); + } + + _validateImportOwnerSpaceIds(item: ExceptionItemLikeOptions): Promise { + return this.validateImportOwnerSpaceIds(item); + } + + _isArtifactVisibleInCurrentSpace( + ownerSpaceIds: string[], + activeSpaceId: string, + item: ExceptionItemLikeOptions + ): Promise { + return this.isArtifactVisibleInCurrentSpace(ownerSpaceIds, activeSpaceId, item); + } + + _removeInvalidPolicyIds(item: ExceptionItemLikeOptions): Promise { + return this.removeInvalidPolicyIds(item); + } } 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..00d72ec85d5ef 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,21 @@ export class TrustedAppValidator extends BaseValidator { return super.validateHasPrivilege('canReadTrustedApplications'); } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + // import specific validations + await this.validateImportOwnerSpaceIds(item); // instead of validateCreateOwnerSpaceIds + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); // instead of validateByPolicyItem + + // usual validators from pre-create + await this.validateTrustedAppData(item); + await this.validateCanCreateByPolicyArtifacts(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..7fe07fe66ce11 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,22 @@ export class TrustedDeviceValidator extends BaseValidator { } } + async validatePreImport(items: PromiseFromStreams): Promise { + await this.validateTrustedDevicesFeatureEnabled(); + await this.validateHasWritePrivilege(); + + await this.validatePreImportItems(items, async (item) => { + // import specific validations + await this.validateImportOwnerSpaceIds(item); // instead of validateCreateOwnerSpaceIds + await this.validateCanCreateGlobalArtifacts(item); + await this.removeInvalidPolicyIds(item); // instead of validateByPolicyItem + + // usual validators from pre-create + await this.validateTrustedDeviceData(item); + await this.validateCanCreateByPolicyArtifacts(item); + }); + } + async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { diff --git a/x-pack/solutions/security/test/security_solution_api_integration/moon.yml b/x-pack/solutions/security/test/security_solution_api_integration/moon.yml index b45051cb4ae49..0bbe881da6717 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/moon.yml +++ b/x-pack/solutions/security/test/security_solution_api_integration/moon.yml @@ -67,6 +67,7 @@ dependsOn: - '@kbn/cloud-security-posture-common' - '@kbn/detections-response-ftr-services' - '@kbn/connector-schemas' + - '@kbn/spaces-utils' tags: - functional-tests - package 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..a1324c7ee6279 --- /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,1586 @@ +/* + * 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 '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, + IMPORTED_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, RULES_FEATURE_ID } from '@kbn/security-solution-plugin/common'; +import { + buildPerPolicyTag, + buildSpaceOwnerIdTag, +} from '@kbn/security-solution-plugin/common/endpoint/service/artifacts/utils'; +import type { FeaturesPrivileges } from '@kbn/security-plugin-types-common'; +import type { + FindExceptionListItemsRequestQueryInput, + FindExceptionListItemsResponse, +} from '@kbn/securitysolution-exceptions-common/api'; +import { ensureSpaceIdExists } from '@kbn/security-solution-plugin/scripts/endpoint/common/spaces'; +import { addSpaceIdToPath } from '@kbn/spaces-utils'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { CustomRole } from '../../../../config/services/types'; +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: readonly { + readonly listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number]; + readonly name: string; + readonly read: string; + readonly all: string; +}[] = Object.freeze([ + { + 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 endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + const utils = getService('securitySolutionUtils'); + const config = getService('config'); + const kbnServer = getService('kibanaServer'); + + 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 (...roleParameters: Parameters) => + utils.createSuperTestWithCustomRole(buildRole(...roleParameters)); + + describe('@ess @serverless @skipInServerlessMKI Import Endpoint artifacts API', function () { + const CURRENT_SPACE_ID = 'default'; + const OTHER_SPACE_ID = 'other-space'; + let fleetEndpointPolicy: PolicyTestResourceInfo; + let fleetEndpointPolicyOtherSpace: PolicyTestResourceInfo; + let endpointOpsAnalystSupertest: TestAgent; + + before(async () => { + await ensureSpaceIdExists(kbnServer, OTHER_SPACE_ID, { log }); + + endpointOpsAnalystSupertest = await utils.createSuperTest(ROLE.endpoint_operations_analyst); + + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + fleetEndpointPolicyOtherSpace = await endpointPolicyTestResources.createPolicy({ + options: { spaceId: OTHER_SPACE_ID }, + }); + }); + + 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 CURRENT_SPACE_OWNER_TAG = buildSpaceOwnerIdTag(CURRENT_SPACE_ID); + const OTHER_SPACE_OWNER_TAG = buildSpaceOwnerIdTag(OTHER_SPACE_ID); + + type SupertestContainer = Record< + (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + Record<'none' | 'read' | 'all' | 'allWithGlobalArtifactManagementPrivilege', TestAgent> + >; + + const supertest: SupertestContainer = {} as SupertestContainer; + + before(async () => { + for (const artifact of ENDPOINT_ARTIFACTS) { + supertest[artifact.listId] = { + none: await createSupertestWithCustomRole(`${artifact.listId}_none`, { + // user is authorized to use _import API in general, but missing artifact-specific privileges + [SECURITY_FEATURE_ID]: ['minimal_all'], + [RULES_FEATURE_ID]: ['all'], + }), + + read: await createSupertestWithCustomRole(`${artifact.listId}_read`, { + // user is authorized to use _import API in general, but missing artifact-specific privileges + [SECURITY_FEATURE_ID]: ['minimal_all', artifact.read], + [RULES_FEATURE_ID]: ['all'], + }), + + all: await createSupertestWithCustomRole(`${artifact.listId}_all`, { + // user is authorized for artifact import, but not rule exceptions import + [SECURITY_FEATURE_ID]: ['minimal_read', artifact.all], + }), + + allWithGlobalArtifactManagementPrivilege: await createSupertestWithCustomRole( + `${artifact.listId}_allWithGlobal`, + { + [SECURITY_FEATURE_ID]: [ + 'minimal_read', + artifact.all, + 'global_artifact_management_all', + ], + } + ), + }; + } + }); + + ENDPOINT_ARTIFACTS.forEach((artifact) => { + describe(`Importing ${artifact.name}`, () => { + let fetchArtifacts: ReturnType; + + before(() => { + fetchArtifacts = getFetchArtifacts(endpointOpsAnalystSupertest, log, artifact.listId); + }); + + beforeEach(async () => { + await endpointArtifactTestResources.deleteList(artifact.listId); + }); + + afterEach(async () => { + await endpointArtifactTestResources.deleteList(artifact.listId); + }); + + describe('when checking privileges', () => { + 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: [CURRENT_SPACE_OWNER_TAG] }]), + '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: [CURRENT_SPACE_OWNER_TAG] }]), + '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: [CURRENT_SPACE_OWNER_TAG] }]), + '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, [ + { + item_id: 'assigned-per-policy-artifact', + tags: [ + CURRENT_SPACE_OWNER_TAG, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + + // even if assigned to policy in other space, the tag is kept + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ], + }, + { + item_id: 'unassigned-per-policy-artifact', + tags: [CURRENT_SPACE_OWNER_TAG], + }, + ]), + '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 items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(2); + expect(items[0]).toEqual( + expect.objectContaining({ + item_id: 'assigned-per-policy-artifact', + tags: [ + CURRENT_SPACE_OWNER_TAG, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + IMPORTED_ARTIFACT_TAG, + ], + }) + ); + expect(items[1]).toEqual( + expect.objectContaining({ + item_id: 'unassigned-per-policy-artifact', + tags: [CURRENT_SPACE_OWNER_TAG, IMPORTED_ARTIFACT_TAG], + }) + ); + }); + + 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_TAG] }, + { + item_id: 'good-item', + tags: [ + CURRENT_SPACE_OWNER_TAG, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Importing artifacts to a different space 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 items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(1); + expect(items[0].item_id).toEqual('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: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + }, + { + item_id: 'good-item', + tags: [CURRENT_SPACE_OWNER_TAG], + }, + ]), + '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 items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(1); + expect(items[0].item_id).toEqual('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: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG] }, + { tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG] }, + { tags: [CURRENT_SPACE_OWNER_TAG, 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 items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(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_TAG, + 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 items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(1); + expect(items[0].tags).toEqual([ + OTHER_SPACE_OWNER_TAG, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + // policy id in other space is kept + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + IMPORTED_ARTIFACT_TAG, + ]); + }); + + it('should not import per-policy artifacts to other space if not visible 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: 'visible-in-current-space', + tags: [OTHER_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + }, + { + item_id: 'not-visible-in-current-space-because-unassigned', + tags: [OTHER_SPACE_OWNER_TAG], + }, + { + item_id: 'not-visible-in-current-space-because-assigned-only-there', + tags: [ + OTHER_SPACE_OWNER_TAG, + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Importing artifacts that are not visible in the current space is not allowed', + status_code: 403, + }, + item_id: 'not-visible-in-current-space-because-unassigned', + list_id: artifact.listId, + }, + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Importing artifacts that are not visible in the current space is not allowed', + status_code: 403, + }, + item_id: 'not-visible-in-current-space-because-assigned-only-there', + 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 items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(1); + }); + }); + + describe('when data is invalid', () => { + describe('when the space id does not exist', () => { + it('should not import artifacts without global privilege based on missing 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: 'global-artifact-with-invalid-space-id', + tags: [buildSpaceOwnerIdTag('i-dont-exist'), GLOBAL_ARTIFACT_TAG], + }, + { + item_id: 'per-policy-artifact-with-invalid-space-id', + tags: [buildSpaceOwnerIdTag('i-dont-exist')], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Importing artifacts to a different space requires global artifact management privilege', + status_code: 403, + }, + item_id: 'global-artifact-with-invalid-space-id', + list_id: artifact.listId, + }, + { + error: { + message: + 'EndpointArtifactError: Endpoint authorization failure. Importing artifacts to a different space requires global artifact management privilege', + status_code: 403, + }, + item_id: 'per-policy-artifact-with-invalid-space-id', + list_id: artifact.listId, + }, + ], + success: false, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: false, + success_count_exception_list_items: 0, + } as ImportExceptionsResponseSchema); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(0); + }); + + it('should not import artifacts with global privilege', 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: 'global-artifact-with-invalid-space-id', + tags: [buildSpaceOwnerIdTag('i-dont-exist-1'), GLOBAL_ARTIFACT_TAG], + }, + { + item_id: 'per-policy-artifact-with-invalid-space-id', + tags: [ + buildSpaceOwnerIdTag('i-dont-exist-2'), + buildSpaceOwnerIdTag('i-dont-exist-3'), + ], + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [ + { + error: { + message: + 'EndpointArtifactError: Importing artifacts with invalid owner space IDs is not allowed. The following space ID is invalid or unaccessible by current user: i-dont-exist-1', + status_code: 403, + }, + item_id: 'global-artifact-with-invalid-space-id', + list_id: artifact.listId, + }, + { + error: { + message: + 'EndpointArtifactError: Importing artifacts with invalid owner space IDs is not allowed. The following space IDs are invalid or unaccessible by current user: i-dont-exist-2, i-dont-exist-3', + status_code: 403, + }, + item_id: 'per-policy-artifact-with-invalid-space-id', + list_id: artifact.listId, + }, + ], + success: false, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: false, + success_count_exception_list_items: 0, + } as ImportExceptionsResponseSchema); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(0); + }); + }); + + 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: [ + CURRENT_SPACE_OWNER_TAG, + 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 items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(1); + + // invalid policy ids are removed, valid ones are kept + expect(items[0].tags).toEqual([ + CURRENT_SPACE_OWNER_TAG, + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + buildPerPolicyTag(fleetEndpointPolicyOtherSpace.packagePolicy.id), + IMPORTED_ARTIFACT_TAG, + ]); + + // changes indicated in a comment + expect(items[0].comments).toContainEqual( + expect.objectContaining({ + comment: `Please check policy assignment. The following policy IDs have been removed from artifact during import:\n- "i-do-not-exist"\n- "me-neither"`, + }) + ); + }); + }); + + describe('when trying to import "single" namespace', () => { + beforeEach(async () => { + await deleteExceptionList(endpointOpsAnalystSupertest, artifact.listId, 'single'); + }); + + afterEach(async () => { + await deleteExceptionList(endpointOpsAnalystSupertest, artifact.listId, 'single'); + }); + + it("should skip validation when all lists/items are in 'single' namespace (similarly to pre-create hook)", async () => { + await endpointArtifactTestResources.createList(artifact.listId); + + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer( + artifact.listId, + [ + { + item_id: 'per-policy-artifact', + tags: [ + buildSpaceOwnerIdTag( + 'not-existing-space-to-trigger-validation-error' + ), + ], + namespace_type: 'single', + }, + ], + 'single' + ), + '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 items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(0); + }); + + it('should fail when namespaces are mixed - as it counts as multiple lists', async () => { + await endpointArtifactTestResources.createList(artifact.listId); + + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log).ignoreCodes([400])) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { + item_id: 'global-artifact-with-single-namespace', + tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + namespace_type: 'single', + }, + { + item_id: 'per-policy-artifact-with-agnostic-namespace', + tags: [CURRENT_SPACE_OWNER_TAG], + namespace_type: 'agnostic', + }, + ]), + 'import_data.ndjson' + ) + .expect(400) + .expect( + anEndpointArtifactErrorOf( + 'Importing multiple Endpoint artifact exception list types at the same time is not supported' + ) + ); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(0); + }); + }); + + it('should return conflict on list, but import artifacts when list exist without deleting existing ones', async () => { + await endpointArtifactTestResources.createList(artifact.listId); + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [GLOBAL_ARTIFACT_TAG], + item_id: 'existing-global-artifact-in-current-space', + }); + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [], + item_id: 'existing-per-policy-artifact-in-current-space', + }); + + 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: 'imported-artifact', tags: [CURRENT_SPACE_OWNER_TAG] }, + ]), + '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); + + const artifactsInCurrentSpace = (await fetchArtifacts(CURRENT_SPACE_ID)).map( + (item) => item.item_id + ); + + expect(artifactsInCurrentSpace).toEqual([ + 'existing-global-artifact-in-current-space', + 'existing-per-policy-artifact-in-current-space', + 'imported-artifact', + ]); + }); + + describe('when `overwrite` query param is `true`', () => { + beforeEach(async () => { + await endpointArtifactTestResources.createList(artifact.listId); + + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [GLOBAL_ARTIFACT_TAG], + item_id: 'existing-global-artifact-in-current-space', + }); + + await endpointArtifactTestResources.createArtifact( + artifact.listId, + { + tags: [GLOBAL_ARTIFACT_TAG], + item_id: 'existing-global-artifact-in-other-space', + }, + { spaceId: OTHER_SPACE_ID } + ); + + await endpointArtifactTestResources.createArtifact(artifact.listId, { + tags: [buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id)], + item_id: 'existing-per-policy-artifact-in-current-space', + }); + + await endpointArtifactTestResources.createArtifact( + artifact.listId, + { tags: [], item_id: 'existing-per-policy-artifact-in-other-space' }, + { spaceId: OTHER_SPACE_ID } + ); + + const createdArtifact = await endpointArtifactTestResources.createArtifact( + artifact.listId, + { + tags: [GLOBAL_ARTIFACT_TAG], // start with global + item_id: + 'existing-per-policy-artifact-in-other-space-visible-in-current-space', + }, + { spaceId: OTHER_SPACE_ID } + ); + + // then update to have per-policy tags, which should make it visible in current space, and removed on import with overwrite + await endpointArtifactTestResources.updateExceptionItem({ + ...createdArtifact.artifact, + tags: [ + buildPerPolicyTag(fleetEndpointPolicy.packagePolicy.id), + OTHER_SPACE_OWNER_TAG, + ], + }); + }); + + describe('when without global artifact privilege', () => { + it('should remove existing per-policy artifacts OWNED by current 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: [CURRENT_SPACE_OWNER_TAG], + item_id: 'imported-artifact', + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 0, + success_exception_list_items: true, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const artifactsInCurrentSpace = (await fetchArtifacts(CURRENT_SPACE_ID)).map( + (item) => item.item_id + ); + const artifactsInOtherSpace = (await fetchArtifacts(OTHER_SPACE_ID)).map( + (item) => item.item_id + ); + + expect(artifactsInCurrentSpace).toEqual([ + 'existing-global-artifact-in-current-space', + 'existing-global-artifact-in-other-space', + // 'existing-per-policy-artifact-in-current-space', => deleted + 'existing-per-policy-artifact-in-other-space-visible-in-current-space', + 'imported-artifact', + ]); + + expect(artifactsInOtherSpace).toEqual([ + 'existing-global-artifact-in-current-space', + 'existing-global-artifact-in-other-space', + 'existing-per-policy-artifact-in-other-space', + 'existing-per-policy-artifact-in-other-space-visible-in-current-space', + ]); + }); + }); + + describe('when with global artifact privilege', () => { + it('should remove existing per-policy artifacts VISIBLE in current space and all global artifacts', async () => { + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .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: [CURRENT_SPACE_OWNER_TAG], + item_id: 'imported-artifact', + }, + ]), + 'import_data.ndjson' + ) + .expect(200) + .expect({ + errors: [], + success: true, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 0, + success_exception_list_items: true, + success_count_exception_list_items: 1, + } as ImportExceptionsResponseSchema); + + const artifactsInCurrentSpace = (await fetchArtifacts(CURRENT_SPACE_ID)).map( + (item) => item.item_id + ); + const artifactsInOtherSpace = (await fetchArtifacts(OTHER_SPACE_ID)).map( + (item) => item.item_id + ); + + expect(artifactsInCurrentSpace).toEqual([ + // all visible artifacts are deleted, even if not owned by current space + 'imported-artifact', + ]); + + expect(artifactsInOtherSpace).toEqual([ + // 'existing-global-artifact-in-current-space', => deleted + // 'existing-global-artifact-in-other-space', => deleted + 'existing-per-policy-artifact-in-other-space', + // 'existing-per-policy-artifact-in-other-space-visible-in-current-space', => deleted + ]); + }); + }); + }); + }); + + 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: [CURRENT_SPACE_OWNER_TAG], + }) + )} + ${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 list types at the same time is not supported' + ) + ); + }); + }); + + describe('`new_list` query parameter', () => { + it('should error when list exists and `new_list` query param is `true`, but import items', async () => { + await endpointArtifactTestResources.createList(artifact.listId); + await endpointArtifactTestResources.createArtifact(artifact.listId, { + item_id: 'existing-artifact', + tags: [GLOBAL_ARTIFACT_TAG], + }); + + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .query({ new_list: true }) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { + item_id: 'imported-artifact', + tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + }, + ]), + '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); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.map(({ item_id }) => item_id)).toEqual([ + 'existing-artifact', + 'imported-artifact', + ]); + }); + + it('should succeed when list does not exist and `new_list` query param is `true`', async () => { + await supertest[artifact.listId].allWithGlobalArtifactManagementPrivilege + .post(`${EXCEPTION_LIST_URL}/_import`) + .query({ new_list: true }) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(artifact.listId, [ + { + item_id: 'imported-artifact', + tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + }, + ]), + '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 items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.map(({ item_id }) => item_id)).toEqual(['imported-artifact']); + }); + }); + + it('should add a comment to imported artifacts with relevant data', 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: 'imported-artifact-without-existing-comment', + tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + created_by: 'original-creator-1', + created_at: 'random-date-1', + }, + { + item_id: 'imported-artifact-with-existing-comment', + tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + created_by: 'original-creator-2', + created_at: 'random-date-2', + comments: [ + { + comment: 'I am a comment!', + created_at: '2026-02-26T09:46:53.602Z', + created_by: 'some_user', + id: '9414e275-3c14-4814-9a6c-e789589bc9b1', + }, + ], + }, + ]), + '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 items = await fetchArtifacts(CURRENT_SPACE_ID); + expect( + items.map(({ item_id, comments }) => ({ + item_id, + comments, + })) + ).toEqual([ + { + item_id: 'imported-artifact-with-existing-comment', + comments: [ + expect.objectContaining({ comment: 'I am a comment!' }), + expect.objectContaining({ + comment: `Imported artifact.\nOriginally created by "original-creator-2" at "random-date-2".`, + }), + ], + }, + { + item_id: 'imported-artifact-without-existing-comment', + comments: [ + { + comment: `Imported artifact.\nOriginally created by "original-creator-1" at "random-date-1".`, + created_at: expect.any(String), + created_by: expect.any(String), // we got different test usernames on ESS and Serverless + id: expect.any(String), + }, + ], + }, + ]); + }); + + it('should add a tag to imported 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, [ + { + item_id: 'imported-artifact', + tags: [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG], + }, + ]), + '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 items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.map(({ tags }) => tags)).toEqual([ + [CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG, IMPORTED_ARTIFACT_TAG], + ]); + }); + + describe('compatibility with artifacts exported before space awareness - when artifacts have no ownerSpaceId', () => { + if (artifact.listId === ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id) { + it('should add global artifact tag to Endpoint exceptions as they have been global', 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: 'imported-artifact', + tags: [], + }, + ]), + 'import_data.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(1); + expect(items[0].tags).toContain(GLOBAL_ARTIFACT_TAG); + }); + + it('should not add global artifact tag to Endpoint exceptions when namespace is "single"', async () => { + await endpointArtifactTestResources.createList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer( + artifact.listId, + [ + { + item_id: 'imported-artifact', + tags: [], + namespace_type: 'single', + }, + ], + 'single' + ), + 'import_data.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID, 'single'); + expect(items.length).toEqual(1); + expect(items[0].tags).toEqual([]); + }); + } else { + it(`should not add global artifact tag to ${artifact.name}`, 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: 'imported-artifact', + tags: [], + }, + ]), + 'import_data.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(1); + expect(items[0].tags).not.toContain(GLOBAL_ARTIFACT_TAG); + }); + } + + it(`should add owner space ID tag to ${artifact.name}`, 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: 'imported-artifact', + tags: [], + }, + ]), + 'import_data.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(1); + expect(items[0].tags).toContain(CURRENT_SPACE_OWNER_TAG); + }); + + if (artifact.listId === ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id) { + it('should not import Endpoint exceptions without global artifact privilege, as they have been global', 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: 'imported-artifact', tags: [] }, + ]), + '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: 'imported-artifact', + list_id: artifact.listId, + }, + ], + success: false, + success_count: 1, + success_exception_lists: true, + success_count_exception_lists: 1, + success_exception_list_items: false, + success_count_exception_list_items: 0, + } as ImportExceptionsResponseSchema); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(0); + }); + } else { + it('should import artifacts without global artifact privilege, as they are not global', 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: 'imported-artifact', tags: [] }, + ]), + 'import_data.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + expect(items.length).toEqual(1); + }); + } + }); + }); + }); + }); + } 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).toEqual( + 'EndpointArtifactError: Import is not supported for Endpoint artifact exceptions' + ); + }); + }); + + describe('when importing endpoint exceptions', () => { + let fetchArtifacts: ReturnType; + + before(() => { + fetchArtifacts = getFetchArtifacts( + endpointOpsAnalystSupertest, + log, + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + }); + + beforeEach(async () => { + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + + await deleteExceptionList( + endpointOpsAnalystSupertest, + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + 'single' + ); + }); + + afterEach(async () => { + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + + await deleteExceptionList( + endpointOpsAnalystSupertest, + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + 'single' + ); + }); + + it('should add global artifact tag if owner space ID is missing', async () => { + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, [ + { tags: [] }, + { tags: [] }, + ]), + 'import_exceptions.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(2); + for (const endpointException of items) { + expect(endpointException.tags).toContain(GLOBAL_ARTIFACT_TAG); + } + }); + + it('should not add global artifact tag if namespace is "single"', async () => { + await endpointArtifactTestResources.createList( + 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, + [{ namespace_type: 'single' }], + 'single' + ), + 'import_exceptions.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID, 'single'); + expect(items.length).toEqual(1); + expect(items[0].tags).not.toContain(GLOBAL_ARTIFACT_TAG); + }); + }); + }); + } + }); +} + +const getFetchArtifacts = + (supertest: TestAgent, log: ToolingLog, listId: string) => + async (spaceId: string, namespace: 'agnostic' | 'single' = 'agnostic') => { + const { body }: { body: FindExceptionListItemsResponse } = await supertest + .get(addSpaceIdToPath('/', spaceId, `${EXCEPTION_LIST_ITEM_URL}/_find`)) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .query({ + list_id: listId, + namespace_type: namespace, + sort_field: 'item_id', + sort_order: 'asc', + } as FindExceptionListItemsRequestQueryInput) + .send() + .expect(200); + + return body.data; + }; + +const buildRole = ( + name: string, + featurePrivileges: FeaturesPrivileges, + spaces: string[] = ['*'] +): CustomRole => ({ + name, + privileges: { + kibana: [ + { + base: [], + feature: featurePrivileges, + spaces, + }, + ], + elasticsearch: { cluster: [], indices: [] }, + }, +}); + +const anEndpointArtifactErrorOf = (message: string) => (res: { body: { message: string } }) => + expect(res.body.message).toBe(`EndpointArtifactError: ${message}`); + +const buildImportBuffer = ( + listId: (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + itemsArray: Partial[] = [{}, {}, {}], + listNamespace: 'agnostic' | 'single' = 'agnostic' +): Buffer => { + const generator = new ExceptionsListItemGenerator(); + + const items = itemsArray.map((override) => generator.generateEndpointArtifact(listId, override)); + + return Buffer.from( + ` + ${buildListInfo(listId, listNamespace)} + ${items.map((item) => JSON.stringify(item)).join('\n')} + ${JSON.stringify(buildDetails({ exported_exception_list_item_count: items.length }))} + `, + 'utf8' + ); +}; + +const buildListInfo = (listId: string, namespace: 'agnostic' | 'single' = 'agnostic'): 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":"${namespace}","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, +}); + +const deleteExceptionList = async ( + supertest: TestAgent, + listId: string, + namespace: 'single' | 'agnostic' +) => + supertest + .delete(`${EXCEPTION_LIST_URL}?list_id=${listId}&namespace_type=${namespace}`) + .set('kbn-xsrf', 'true') + .send() + .expect(({ status }) => expect([200, 404]).toContain(status)); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts index 98996ac30713c..bf5cd2a5f54d6 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts @@ -27,7 +27,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), - testFiles: [require.resolve('../endpoint_exceptions.ff_enabled.ts')], + testFiles: [require.resolve('../index.endpoint_exceptions_moved_ff.ts')], junit: { reportName: 'EDR Workflows - Endpoint Exceptions Integration Tests - ESS Env - Trial License', }, diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.serverless.config.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.serverless.config.ts index cdd60ae333507..91457a7764b74 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.serverless.config.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.serverless.config.ts @@ -27,7 +27,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), - testFiles: [require.resolve('../endpoint_exceptions.ff_enabled.ts')], + testFiles: [require.resolve('../index.endpoint_exceptions_moved_ff.ts')], junit: { reportName: 'EDR Workflows - Endpoint Exceptions Integration Tests - Serverless Env - Complete', 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 fd0b04df776f2..dd390d3e9c39c 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,11 +7,7 @@ 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 { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { ALL_ENDPOINT_ARTIFACT_LIST_IDS, GLOBAL_ARTIFACT_TAG, @@ -28,14 +24,12 @@ import type { PolicyTestResourceInfo } from '@kbn/test-suites-xpack-security-end import { getHunter } from '@kbn/security-solution-plugin/scripts/endpoint/common/roles_users'; import type { CustomRole } from '../../../../config/services/types'; 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) { 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 +45,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(); @@ -186,120 +178,6 @@ export default function ({ getService }: FtrProviderContext) { await Promise.all(promises); }); - 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/endpoint_exceptions.ff_enabled.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.endpoint_exceptions_moved_ff.ts similarity index 94% rename from x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ff_enabled.ts rename to x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.endpoint_exceptions_moved_ff.ts index 97f5f22b6a9e9..09c6168608fb1 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/index.endpoint_exceptions_moved_ff.ts @@ -18,8 +18,7 @@ import { ROLE } from '../../../../config/services/security_solution_edr_workflow export default function endpointAPIIntegrationTests(providerContext: FtrProviderContext) { const { loadTestFile, getService } = providerContext; - // FLAKY: https://github.com/elastic/kibana/issues/250470 - describe.skip('Endpoint Exceptions with feature flag enabled', function () { + describe('Endpoint Exceptions with feature flag enabled', function () { const ingestManager = getService('ingestManager'); const rolesUsersProvider = getService('rolesUsersProvider'); const kbnClient = getService('kibanaServer'); @@ -60,5 +59,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider 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/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..c2f343198815e 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 @@ -59,5 +59,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider 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_api_integration/tsconfig.json b/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json index 9741473cbe8d1..4f6ef0ab46392 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json +++ b/x-pack/solutions/security/test/security_solution_api_integration/tsconfig.json @@ -66,5 +66,6 @@ "@kbn/cloud-security-posture-common", "@kbn/detections-response-ftr-services", "@kbn/connector-schemas", + "@kbn/spaces-utils", ] } 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..d2d14fb6f451e 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 @@ -9,6 +9,7 @@ import type { CreateExceptionListItemSchema, CreateExceptionListSchema, ExceptionListItemSchema, + UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import type { ENDPOINT_ARTIFACT_LIST_IDS } from '@kbn/securitysolution-list-constants'; import { @@ -70,10 +71,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') @@ -123,6 +121,36 @@ export function EndpointArtifactsTestResourcesProvider({ getService }: FtrProvid }; } + async updateExceptionItem( + updatePayload: UpdateExceptionListItemSchema, + { supertest = this.supertest, spaceId = DEFAULT_SPACE_ID }: ArtifactCreateOptions = {} + ): Promise { + this.log.verbose(`Updating exception item:\n${JSON.stringify(updatePayload)}`); + + const artifact = await supertest + .put(addSpaceIdToPath('/', spaceId, EXCEPTION_LIST_ITEM_URL)) + .set('kbn-xsrf', 'true') + .send(updatePayload) + .then(this.getHttpResponseFailureHandler()) + .then((response) => response.body as ExceptionListItemSchema); + + const { item_id: itemId, list_id: listId } = artifact; + const artifactAssignment = isArtifactGlobal(artifact) ? 'Global' : 'Per-Policy'; + + this.log.info( + `Updated [${artifactAssignment}] exception list item in space [${spaceId}], List ID [${listId}], Item ID [${itemId}]` + ); + + const cleanup = async () => { + await this.deleteExceptionItem(artifact, { supertest, spaceId }); + }; + + return { + artifact, + cleanup, + }; + } + async deleteExceptionItem( { list_id: listId, @@ -209,6 +237,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 = {},