From a71fcb72c819a9e133a432ba0925a1b9b8f96fee Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 16 Feb 2022 12:29:51 -0500 Subject: [PATCH] [Security Solution][Lists] - Fix exception list with comments import bug (#124909) (#125807) ### Summary Addresses https://github.com/elastic/kibana/issues/124742 #### Issue TLDR Import of rules that reference exception items with comments fail. Failure message states that comments cannot include `created_at`, `created_by`, `id`. (cherry picked from commit f894d8673b30c5eb8f239f740b1b8fa27057ee64) Co-authored-by: Yara Tercero --- .../index.test.ts | 87 ++++++++++ .../default_import_comments_array/index.ts | 27 +++ .../index.test.ts | 4 +- .../default_update_comments_array/index.ts | 10 +- .../src/common/import_comment/index.test.ts | 135 +++++++++++++++ .../src/common/import_comment/index.ts | 19 +++ .../src/common/index.ts | 2 + .../src/common/update_comment/index.test.ts | 2 +- .../index.test.ts | 30 ++++ .../import_exception_item_schema/index.ts | 8 +- .../create_exceptions_stream_logic.test.ts | 46 +++++ .../import/create_exceptions_stream_logic.ts | 25 ++- .../tests/import_export_rules.ts | 126 ++++++++++++++ .../security_and_spaces/tests/import_rules.ts | 159 +++++++++++++++++- .../security_and_spaces/tests/index.ts | 1 + .../detection_engine_api_integration/utils.ts | 2 +- 16 files changed, 666 insertions(+), 17 deletions(-) create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.test.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.test.ts create mode 100644 packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.ts create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_export_rules.ts diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.test.ts new file mode 100644 index 0000000000000..8584edfabeebd --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.test.ts @@ -0,0 +1,87 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { ImportCommentsArray } from '../import_comment'; +import { DefaultImportCommentsArray } from '../default_import_comments_array'; +import { getCommentsArrayMock } from '../comment/index.mock'; +import { getCreateCommentsArrayMock } from '../create_comment/index.mock'; + +describe('default_import_comments_array', () => { + test('it should pass validation when supplied an empty array', () => { + const payload: ImportCommentsArray = []; + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should pass validation when supplied an array of comments', () => { + const payload: ImportCommentsArray = getCommentsArrayMock(); + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should pass validation when supplied an array of new comments', () => { + const payload: ImportCommentsArray = getCreateCommentsArrayMock(); + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should pass validation when supplied an array of new and existing comments', () => { + const payload: ImportCommentsArray = [ + ...getCommentsArrayMock(), + ...getCreateCommentsArrayMock(), + ]; + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should fail validation when supplied an array of numbers', () => { + const payload = [1]; + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "DefaultImportComments"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should fail validation when supplied an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "DefaultImportComments"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultImportCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.ts new file mode 100644 index 0000000000000..2e3ec1e00375a --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_import_comments_array/index.ts @@ -0,0 +1,27 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; +import { importComment, ImportCommentsArray } from '../import_comment'; + +/** + * Types the DefaultImportCommentsArray as: + * - If null or undefined, then a default array of type ImportCommentsArray will be set + */ +export const DefaultImportCommentsArray = new t.Type< + ImportCommentsArray, + ImportCommentsArray, + unknown +>( + 'DefaultImportComments', + t.array(importComment).is, + (input, context): Either => + input == null ? t.success([]) : t.array(importComment).validate(input, context), + t.identity +); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.test.ts index fa6613538b18e..05c4e91355da4 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.test.ts @@ -38,7 +38,7 @@ describe('default_update_comments_array', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"', + 'Invalid value "1" supplied to "DefaultUpdateComments"', ]); expect(message.schema).toEqual({}); }); @@ -49,7 +49,7 @@ describe('default_update_comments_array', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "some string" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"', + 'Invalid value "some string" supplied to "DefaultUpdateComments"', ]); expect(message.schema).toEqual({}); }); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.ts index 26bfdad597100..cc70ba1d095b8 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/default_update_comments_array/index.ts @@ -11,17 +11,17 @@ import { Either } from 'fp-ts/lib/Either'; import { updateCommentsArray, UpdateCommentsArray } from '../update_comment'; /** - * Types the DefaultCommentsUpdate as: - * - If null or undefined, then a default array of type entry will be set + * Types the DefaultUpdateComments as: + * - If null or undefined, then a default array of type UpdateCommentsArray will be set */ export const DefaultUpdateCommentsArray = new t.Type< UpdateCommentsArray, UpdateCommentsArray, unknown >( - 'DefaultCreateComments', + 'DefaultUpdateComments', updateCommentsArray.is, - (input): Either => - input == null ? t.success([]) : updateCommentsArray.decode(input), + (input, context): Either => + input == null ? t.success([]) : updateCommentsArray.validate(input, context), t.identity ); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.test.ts new file mode 100644 index 0000000000000..53383d80f441d --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.test.ts @@ -0,0 +1,135 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; +import { getCommentsArrayMock, getCommentsMock } from '../comment/index.mock'; +import { getCreateCommentsArrayMock } from '../create_comment/index.mock'; +import { + importComment, + ImportCommentsArray, + importCommentsArray, + ImportCommentsArrayOrUndefined, + importCommentsArrayOrUndefined, +} from '.'; +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; + +describe('ImportComment', () => { + describe('importComment', () => { + test('it passes validation with a typical comment', () => { + const payload = getCommentsMock(); + const decoded = importComment.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it passes validation with a new comment', () => { + const payload = { comment: 'new comment' }; + const decoded = importComment.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it fails validation when undefined', () => { + const payload = undefined; + const decoded = importComment.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "(({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: NonEmptyString |})"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('importCommentsArray', () => { + test('it passes validation an array of Comment', () => { + const payload = getCommentsArrayMock(); + const decoded = importCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it passes validation an array of CreateComment', () => { + const payload = getCreateCommentsArrayMock(); + const decoded = importCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it passes validation an array of Comment and CreateComment', () => { + const payload = [...getCommentsArrayMock(), ...getCreateCommentsArrayMock()]; + const decoded = importCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it fails validation when undefined', () => { + const payload = undefined; + const decoded = importCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<(({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: NonEmptyString |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it fails validation when array includes non ImportComment types', () => { + const payload = [1] as unknown as ImportCommentsArray; + const decoded = importCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: NonEmptyString |})>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('importCommentsArrayOrUndefined', () => { + test('it passes validation an array of ImportComment', () => { + const payload = [...getCommentsArrayMock(), ...getCreateCommentsArrayMock()]; + const decoded = importCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it passes validation when undefined', () => { + const payload = undefined; + const decoded = importCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it fails validation when array includes non ImportComment types', () => { + const payload = [1] as unknown as ImportCommentsArrayOrUndefined; + const decoded = importCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: NonEmptyString |})>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.ts new file mode 100644 index 0000000000000..43a0755bb5b51 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/import_comment/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; +import { createComment } from '../create_comment'; +import { comment } from '../comment'; + +export const importComment = t.union([comment, createComment]); + +export type ImportComment = t.TypeOf; +export const importCommentsArray = t.array(importComment); +export type ImportCommentsArray = t.TypeOf; +export const importCommentsArrayOrUndefined = t.union([importCommentsArray, t.undefined]); +export type ImportCommentsArrayOrUndefined = t.TypeOf; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts index 81ecd58cb397c..b9f12c93d4207 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts @@ -13,6 +13,7 @@ export * from './created_by'; export * from './cursor'; export * from './default_namespace'; export * from './default_namespace_array'; +export * from './default_import_comments_array'; export * from './description'; export * from './deserializer'; export * from './endpoint'; @@ -29,6 +30,7 @@ export * from './exception_list_item_type'; export * from './filter'; export * from './id'; export * from './immutable'; +export * from './import_comment'; export * from './item_id'; export * from './list_id'; export * from './list_operator'; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/update_comment/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/update_comment/index.test.ts index 7ddbdf31ca317..04a0d49306b36 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/update_comment/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/update_comment/index.test.ts @@ -19,7 +19,7 @@ import { } from '.'; import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; -describe('CommentsUpdate', () => { +describe('UpdateComment', () => { describe('updateComment', () => { test('it should pass validation when supplied typical comment update', () => { const payload = getUpdateCommentMock(); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts index d202f65b57ab5..a5cb57dfb56a8 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.test.ts @@ -15,6 +15,7 @@ import { getImportExceptionsListItemSchemaDecodedMock, getImportExceptionsListItemSchemaMock, } from './index.mock'; +import { getCommentsArrayMock } from '../../common/comment/index.mock'; describe('import_list_item_schema', () => { test('it should validate a typical item request', () => { @@ -27,6 +28,35 @@ describe('import_list_item_schema', () => { expect(message.schema).toEqual(getImportExceptionsListItemSchemaDecodedMock()); }); + test('it should validate a typical item request with comments', () => { + const payload = { + ...getImportExceptionsListItemSchemaMock(), + comments: getCommentsArrayMock(), + }; + const decoded = importExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ + ...getImportExceptionsListItemSchemaDecodedMock(), + comments: [ + { + comment: 'some old comment', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + id: 'uuid_here', + }, + { + comment: 'some old comment', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + id: 'uuid_here', + }, + ], + }); + }); + test('it should NOT accept an undefined for "item_id"', () => { const payload: Partial> = getImportExceptionsListItemSchemaMock(); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts index 3da30a21a0115..dc5a48597211e 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts @@ -29,8 +29,8 @@ import { nonEmptyEntriesArray } from '../../common/non_empty_entries_array'; import { exceptionListItemType } from '../../common/exception_list_item_type'; import { ItemId } from '../../common/item_id'; import { EntriesArray } from '../../common/entries'; -import { CreateCommentsArray } from '../../common/create_comment'; -import { DefaultCreateCommentsArray } from '../../common/default_create_comments_array'; +import { DefaultImportCommentsArray } from '../../common/default_import_comments_array'; +import { ImportCommentsArray } from '../../common'; /** * Differences from this and the createExceptionsListItemSchema are @@ -56,7 +56,7 @@ export const importExceptionListItemSchema = t.intersection([ t.exact( t.partial({ id, // defaults to undefined if not set during decode - comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode + comments: DefaultImportCommentsArray, // defaults to empty array if not set during decode created_at, // defaults undefined if not set during decode updated_at, // defaults undefined if not set during decode created_by, // defaults undefined if not set during decode @@ -78,7 +78,7 @@ export type ImportExceptionListItemSchemaDecoded = Omit< ImportExceptionListItemSchema, 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' > & { - comments: CreateCommentsArray; + comments: ImportCommentsArray; tags: Tags; item_id: ItemId; entries: EntriesArray; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts index 684be2de2e030..37e3201dcfe42 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.test.ts @@ -24,6 +24,7 @@ import { PromiseStream } from '../../import_exception_list_and_items'; import { createExceptionsStreamFromNdjson, exceptionsChecksFromArray, + manageExceptionComments, } from './create_exceptions_stream_logic'; describe('create_exceptions_stream_logic', () => { @@ -338,4 +339,49 @@ describe('create_exceptions_stream_logic', () => { }); }); }); + + describe('manageExceptionComments', () => { + test('returns empty array if passed in "comments" undefined', () => { + const result = manageExceptionComments(undefined); + expect(result).toEqual([]); + }); + + test('returns empty array if passed in "comments" empty array', () => { + const result = manageExceptionComments([]); + expect(result).toEqual([]); + }); + + test('returns formatted existing comment', () => { + const result = manageExceptionComments([ + { + comment: 'some old comment', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'kibana', + id: 'uuid_here', + updated_at: '2020-05-20T15:25:31.830Z', + updated_by: 'lily', + }, + ]); + + expect(result).toEqual([ + { + comment: 'some old comment', + }, + ]); + }); + + test('returns formatted new comment', () => { + const result = manageExceptionComments([ + { + comment: 'some new comment', + }, + ]); + + expect(result).toEqual([ + { + comment: 'some new comment', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts index af39936b26142..eb009d2492b6a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/create_exceptions_stream_logic.ts @@ -12,7 +12,9 @@ import { has } from 'lodash/fp'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { + CreateCommentsArray, ExportExceptionDetails, + ImportCommentsArray, ImportExceptionListItemSchema, ImportExceptionListItemSchemaDecoded, ImportExceptionListSchemaDecoded, @@ -120,6 +122,24 @@ export const sortExceptionsStream = (): Transform => { ); }; +/** + * Updates any comments associated with exception items to resemble + * comment creation schema. + * See issue for context https://github.com/elastic/kibana/issues/124742#issuecomment-1033082093 + * @returns {array} comments reformatted properly for import + */ +export const manageExceptionComments = ( + comments: ImportCommentsArray | undefined +): CreateCommentsArray => { + if (comments == null || !comments.length) { + return []; + } else { + return comments.map(({ comment }) => ({ + comment, + })); + } +}; + /** * * Validating exceptions logic @@ -206,8 +226,9 @@ export const validateExceptionsItems = ( return items.map((item: ImportExceptionListItemSchema | Error) => { if (!(item instanceof Error)) { - const decodedItem = importExceptionListItemSchema.decode(item); - const checkedItem = exactCheck(item, decodedItem); + const itemWithUpdatedComments = { ...item, comments: manageExceptionComments(item.comments) }; + const decodedItem = importExceptionListItemSchema.decode(itemWithUpdatedComments); + const checkedItem = exactCheck(itemWithUpdatedComments, decodedItem); return pipe(checkedItem, fold(onLeft, onRight)); } else { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_export_rules.ts new file mode 100644 index 0000000000000..21d545aef0f04 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_export_rules.ts @@ -0,0 +1,126 @@ +/* + * 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 expect from '@kbn/expect'; +import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; + +import { deleteAllExceptions } from '../../../lists_api_integration/utils'; +import { getCreateExceptionListItemMinimalSchemaMock } from '../../../../plugins/lists/common/schemas/request/create_exception_list_item_schema.mock'; +import { getCreateExceptionListMinimalSchemaMock } from '../../../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + binaryToString, + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, +} from '../../utils'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution'; + +// This test was meant to be more full flow, ensuring that +// exported rules are able to be reimported as opposed to +// testing the import/export functionality separately +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const log = getService('log'); + + describe('import_export_rules_flow', () => { + beforeEach(async () => { + await createSignalsIndex(supertest, log); + await createUserAndRole(getService, ROLES.soc_manager); + }); + + afterEach(async () => { + await deleteUserAndRole(getService, ROLES.soc_manager); + await deleteAllExceptions(supertest, log); + await deleteSignalsIndex(supertest, log); + await deleteAllAlerts(supertest, log); + }); + + it('should be able to reimport a rule referencing an exception list with existing comments', async () => { + // create an exception list + const { body: exceptionBody } = await supertestWithoutAuth + .post(EXCEPTION_LIST_URL) + .auth(ROLES.soc_manager, 'changeme') + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListMinimalSchemaMock()) + .expect(200); + + // create an exception list item + const { body: exceptionItemBody } = await supertestWithoutAuth + .post(EXCEPTION_LIST_ITEM_URL) + .auth(ROLES.soc_manager, 'changeme') + .set('kbn-xsrf', 'true') + .send({ + ...getCreateExceptionListItemMinimalSchemaMock(), + comments: [{ comment: 'this exception item rocks' }], + }) + .expect(200); + + const { body: exceptionItem } = await supertest + .get(`${EXCEPTION_LIST_ITEM_URL}?item_id=${exceptionItemBody.item_id}`) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(exceptionItem.comments).to.eql([ + { + comment: 'this exception item rocks', + created_at: `${exceptionItem.comments[0].created_at}`, + created_by: 'soc_manager', + id: `${exceptionItem.comments[0].id}`, + }, + ]); + + await createRule(supertest, log, { + ...getSimpleRule(), + exceptions_list: [ + { + id: exceptionBody.id, + list_id: exceptionBody.list_id, + type: exceptionBody.type, + namespace_type: exceptionBody.namespace_type, + }, + ], + }); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .send() + .expect(200) + .parse(binaryToString); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true&overwrite_exceptions=true`) + .set('kbn-xsrf', 'true') + .attach('file', Buffer.from(body), 'rules.ndjson') + .expect(200); + + const { body: exceptionItemFind2 } = await supertest + .get(`${EXCEPTION_LIST_ITEM_URL}?item_id=${exceptionItemBody.item_id}`) + .set('kbn-xsrf', 'true') + .expect(200); + + // NOTE: Existing comment is uploaded successfully + // however, the meta now reflects who imported it, + // not who created the initial comment + expect(exceptionItemFind2.comments).to.eql([ + { + comment: 'this exception item rocks', + created_at: `${exceptionItemFind2.comments[0].created_at}`, + created_by: 'elastic', + id: `${exceptionItemFind2.comments[0].id}`, + }, + ]); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index 81e0374b96738..ab509a7cba825 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; -import { EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { getCreateExceptionListMinimalSchemaMock } from '../../../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -831,7 +831,33 @@ export default ({ getService }: FtrProviderContext): void => { type: 'detection', namespace_type: 'single', }, - getImportExceptionsListItemSchemaMock('test_item_id', 'i_exist'), + { + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + item_id: 'item_id_1', + list_id: 'i_exist', + name: 'Query with a rule id', + type: 'simple', + }, ]) ), 'rules.ndjson' @@ -871,6 +897,135 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_success_count: 2, }); }); + + it('should resolve exception references that include comments', async () => { + // So importing a rule that references an exception list + // Keep in mind, no exception lists or rules exist yet + const simpleRule: ReturnType = { + ...getSimpleRule('rule-1'), + exceptions_list: [ + { + id: 'abc', + list_id: 'i_exist', + type: 'detection', + namespace_type: 'single', + }, + ], + }; + + // Importing the "simpleRule", along with the exception list + // it's referencing and the list's item + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach( + 'file', + Buffer.from( + toNdJsonString([ + simpleRule, + { + ...getImportExceptionsListSchemaMock('i_exist'), + id: 'abc', + type: 'detection', + namespace_type: 'single', + }, + { + comments: [ + { + comment: 'This is an exception to the rule', + created_at: '2022-02-04T02:27:40.938Z', + created_by: 'elastic', + id: '845fc456-91ff-4530-bcc1-5b7ebd2f75b5', + }, + { + comment: 'I decided to add a new comment', + }, + ], + description: 'some description', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + item_id: 'item_id_1', + list_id: 'i_exist', + name: 'Query with a rule id', + type: 'simple', + }, + ]) + ), + 'rules.ndjson' + ) + .expect(200); + + const { body: ruleResponse } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) + .send() + .expect(200); + const bodyToCompare = removeServerGeneratedProperties(ruleResponse); + const referencedExceptionList = ruleResponse.exceptions_list[0]; + + // create an exception list + const { body: exceptionBody } = await supertest + .get( + `${EXCEPTION_LIST_URL}?list_id=${referencedExceptionList.list_id}&id=${referencedExceptionList.id}` + ) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(bodyToCompare.exceptions_list).to.eql([ + { + id: exceptionBody.id, + list_id: 'i_exist', + namespace_type: 'single', + type: 'detection', + }, + ]); + + const { body: exceptionItemBody } = await supertest + .get(`${EXCEPTION_LIST_ITEM_URL}?item_id="item_id_1"`) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(exceptionItemBody.comments).to.eql([ + { + comment: 'This is an exception to the rule', + created_at: `${exceptionItemBody.comments[0].created_at}`, + created_by: 'elastic', + id: `${exceptionItemBody.comments[0].id}`, + }, + { + comment: 'I decided to add a new comment', + created_at: `${exceptionItemBody.comments[1].created_at}`, + created_by: 'elastic', + id: `${exceptionItemBody.comments[1].id}`, + }, + ]); + + expect(body).to.eql({ + success: true, + success_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 2, + }); + }); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index 55b7327670631..a9bda19638041 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -33,6 +33,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./generating_signals')); loadTestFile(require.resolve('./get_prepackaged_rules_status')); loadTestFile(require.resolve('./import_rules')); + loadTestFile(require.resolve('./import_export_rules')); loadTestFile(require.resolve('./read_rules')); loadTestFile(require.resolve('./resolve_read_rules')); loadTestFile(require.resolve('./update_rules')); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 9cbaef3ad0fe2..496faa3c5e421 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -1043,8 +1043,8 @@ export const countDownTest = async ( * and error about the race condition. * rule a second attempt. It only re-tries adding the rule if it encounters a conflict once. * @param supertest The supertest deps - * @param rule The rule to create * @param log The tooling logger + * @param rule The rule to create */ export const createRule = async ( supertest: SuperTest.SuperTest,