diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/cases/types.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/cases/types.ts index 95318a5fb19b5..04452e1bab287 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/cases/types.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/cases/types.ts @@ -29,6 +29,7 @@ export interface CaseConnector { export interface CaseSettings { syncAlerts: boolean; + extractObservables: boolean; } export interface CaseUserProfile { diff --git a/src/platform/packages/shared/kbn-scout/test/scout/fixtures/constants.ts b/src/platform/packages/shared/kbn-scout/test/scout/fixtures/constants.ts index bb8921cca3f0b..208cd15a3ef22 100644 --- a/src/platform/packages/shared/kbn-scout/test/scout/fixtures/constants.ts +++ b/src/platform/packages/shared/kbn-scout/test/scout/fixtures/constants.ts @@ -35,6 +35,7 @@ export const createCasePayload: CaseCreateRequest = { }, settings: { syncAlerts: true, + extractObservables: false, }, owner: '', customFields: [], diff --git a/src/platform/packages/shared/kbn-securitysolution-ecs/src/dll/index.ts b/src/platform/packages/shared/kbn-securitysolution-ecs/src/dll/index.ts index e286dd65ba55d..395f9844232a9 100644 --- a/src/platform/packages/shared/kbn-securitysolution-ecs/src/dll/index.ts +++ b/src/platform/packages/shared/kbn-securitysolution-ecs/src/dll/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { CodeSignature, Ext } from '../file'; +import type { CodeSignature, Ext, Hash } from '../file'; import type { ProcessPe } from '../process'; export interface DllEcs { @@ -15,4 +15,5 @@ export interface DllEcs { path?: string; code_signature?: CodeSignature; pe?: ProcessPe; + hash?: Hash; } diff --git a/src/platform/packages/shared/kbn-securitysolution-ecs/src/file/index.ts b/src/platform/packages/shared/kbn-securitysolution-ecs/src/file/index.ts index ba6a6542e18e8..2b88fda4c38ad 100644 --- a/src/platform/packages/shared/kbn-securitysolution-ecs/src/file/index.ts +++ b/src/platform/packages/shared/kbn-securitysolution-ecs/src/file/index.ts @@ -57,6 +57,11 @@ export interface Hash { md5?: string[]; sha1?: string[]; sha256: string[]; + cdhash?: string[]; + sha384?: string[]; + sha512?: string[]; + ssdeep?: string[]; + tlsh?: string[]; } export interface FileEcs { diff --git a/src/platform/packages/shared/kbn-securitysolution-ecs/src/process/index.ts b/src/platform/packages/shared/kbn-securitysolution-ecs/src/process/index.ts index 0bad44cf7775a..8dea54625b98b 100644 --- a/src/platform/packages/shared/kbn-securitysolution-ecs/src/process/index.ts +++ b/src/platform/packages/shared/kbn-securitysolution-ecs/src/process/index.ts @@ -41,6 +41,11 @@ export interface ProcessHashData { md5?: string[]; sha1?: string[]; sha256?: string[]; + sha384?: string[]; + sha512?: string[]; + ssdeep?: string[]; + tlsh?: string[]; + cdhash?: string[]; } export interface ProcessParentData { diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.test.tsx index 6547548a222b8..513a9a0cc3a84 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.test.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.test.tsx @@ -596,7 +596,10 @@ describe('AlertsTable', () => { children: expect.anything(), owner: ['cases'], permissions: { create: true, read: true }, - features: { alerts: { sync: false } }, + features: { + alerts: { sync: false }, + observables: { enabled: true, autoExtract: false }, + }, }, {} ); @@ -612,7 +615,10 @@ describe('AlertsTable', () => { children: expect.anything(), owner: [], permissions: { create: true, read: true }, - features: { alerts: { sync: false } }, + features: { + alerts: { sync: false }, + observables: { enabled: true, autoExtract: false }, + }, }, {} ); @@ -631,7 +637,10 @@ describe('AlertsTable', () => { children: expect.anything(), owner: [], permissions: { create: false, read: false }, - features: { alerts: { sync: false } }, + features: { + alerts: { sync: false }, + observables: { enabled: true, autoExtract: false }, + }, }, {} ); @@ -656,7 +665,7 @@ describe('AlertsTable', () => { children: expect.anything(), owner: ['cases'], permissions: { create: true, read: true }, - features: { alerts: { sync: true } }, + features: { alerts: { sync: true }, observables: { enabled: true, autoExtract: false } }, }, {} ); diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.tsx index 92634e84f49f1..0509e9eaab27c 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_table.tsx @@ -694,7 +694,13 @@ const AlertsTableContent = typedForwardRef( diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/bulk_actions_toolbar_control.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/bulk_actions_toolbar_control.tsx index 2d0f106308ab0..6c960f3590b98 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/components/bulk_actions_toolbar_control.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/components/bulk_actions_toolbar_control.tsx @@ -12,8 +12,6 @@ import { EuiPopover, EuiButtonEmpty, EuiContextMenu } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { ALERT_CASE_IDS, - ALERT_RULE_NAME, - ALERT_RULE_UUID, ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_TAGS, } from '@kbn/rule-data-utils'; @@ -44,19 +42,24 @@ const selectedIdsToTimelineItemMapper = ( ): TimelineItem[] => { return Array.from(rowSelection.keys()).map((rowIndex: number) => { const alert = alerts[rowIndex]; + const data = Object.entries(alert).map(([key, value]) => ({ + field: key, + value: value ? (value as string[]) : [], + })); + if (!data.some((item) => item.field === ALERT_CASE_IDS)) { + data.push({ field: ALERT_CASE_IDS, value: [] }); + } + if (!data.some((item) => item.field === ALERT_WORKFLOW_TAGS)) { + data.push({ field: ALERT_WORKFLOW_TAGS, value: [] }); + } + if (!data.some((item) => item.field === ALERT_WORKFLOW_ASSIGNEE_IDS)) { + data.push({ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: [] }); + } + return { _id: alert._id, _index: alert._index, - data: [ - { field: ALERT_RULE_NAME, value: alert[ALERT_RULE_NAME] as string[] }, - { field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] as string[] }, - { field: ALERT_CASE_IDS, value: (alert[ALERT_CASE_IDS] ?? []) as string[] }, - { field: ALERT_WORKFLOW_TAGS, value: (alert[ALERT_WORKFLOW_TAGS] ?? []) as string[] }, - { - field: ALERT_WORKFLOW_ASSIGNEE_IDS, - value: (alert[ALERT_WORKFLOW_ASSIGNEE_IDS] ?? []) as string[], - }, - ], + data, ecs: { _id: alert._id, _index: alert._index, diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts index cc91aedb00a1e..a333d1a949eae 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts +++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts @@ -154,9 +154,11 @@ export const useBulkAddToCaseActions = ({ const caseAttachments = alerts ? casesService?.helpers.groupAlertsByRule(alerts) ?? [] : []; - + const dataArray = alerts ? alerts.map((alert) => alert.data) : []; + const observables = casesService?.helpers.getObservablesFromEcs(dataArray); createCaseFlyout.open({ attachments: caseAttachments, + observables, }); }, }, @@ -179,6 +181,11 @@ export const useBulkAddToCaseActions = ({ groupAlertsByRule: casesService?.helpers.groupAlertsByRule, }); }, + getObservables: ({ theCase }) => { + if (!alerts || theCase == null) return []; + const dataArray = alerts.map((alert) => alert.data); + return casesService?.helpers.getObservablesFromEcs(dataArray) ?? []; + }, }); }, }, diff --git a/src/platform/packages/shared/response-ops/alerts-table/mocks/cases.mock.tsx b/src/platform/packages/shared/response-ops/alerts-table/mocks/cases.mock.tsx index 1fe220f592597..91cc623429ec6 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/mocks/cases.mock.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/mocks/cases.mock.tsx @@ -54,6 +54,7 @@ const helpersMock: jest.MockedObject = { canUseCases: jest.fn(), groupAlertsByRule: jest.fn(), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn(), }; export const createCasesServiceMock = (): jest.MaybeMockedDeep => ({ diff --git a/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx index 6c959a446883c..15877ba43a5ce 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx +++ b/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx @@ -281,10 +281,22 @@ describe('AlertsDataGrid bulk actions', () => { _id: 'alert0', _index: 'idx0', data: [ + { + field: '_id', + value: 'alert0', + }, + { + field: '_index', + value: 'idx0', + }, { field: 'kibana.alert.rule.name', value: ['one'], }, + { + field: 'kibana.alert.reason', + value: ['two'], + }, { field: 'kibana.alert.rule.uuid', value: ['uuidone'], @@ -526,10 +538,16 @@ describe('AlertsDataGrid bulk actions', () => { _id: 'alert1', _index: 'idx1', data: [ + { field: '_id', value: 'alert1' }, + { field: '_index', value: 'idx1' }, { field: 'kibana.alert.rule.name', value: ['three'], }, + { + field: 'kibana.alert.reason', + value: ['four'], + }, { field: 'kibana.alert.rule.uuid', value: ['uuidtwo'], @@ -740,10 +758,22 @@ describe('AlertsDataGrid bulk actions', () => { _id: 'alert0', _index: 'idx0', data: [ + { + field: '_id', + value: 'alert0', + }, + { + field: '_index', + value: 'idx0', + }, { field: 'kibana.alert.rule.name', value: ['one'], }, + { + field: 'kibana.alert.reason', + value: ['two'], + }, { field: 'kibana.alert.rule.uuid', value: ['uuidone'], @@ -770,10 +800,16 @@ describe('AlertsDataGrid bulk actions', () => { _id: 'alert1', _index: 'idx1', data: [ + { field: '_id', value: 'alert1' }, + { field: '_index', value: 'idx1' }, { field: 'kibana.alert.rule.name', value: ['three'], }, + { + field: 'kibana.alert.reason', + value: ['four'], + }, { field: 'kibana.alert.rule.uuid', value: ['uuidtwo'], diff --git a/src/platform/packages/shared/response-ops/alerts-table/types.ts b/src/platform/packages/shared/response-ops/alerts-table/types.ts index 520f3732387e8..13f584d3e5825 100644 --- a/src/platform/packages/shared/response-ops/alerts-table/types.ts +++ b/src/platform/packages/shared/response-ops/alerts-table/types.ts @@ -63,6 +63,12 @@ export interface Consumer { name: string; } +interface Observable { + typeKey: string; + value: string; + description: string | null; +} + export type AlertsTableSupportedConsumers = Exclude; export type CellComponent = NonNullable; @@ -78,7 +84,7 @@ export interface SystemCellComponentMap { export type SystemCellId = keyof SystemCellComponentMap; type UseCasesAddToNewCaseFlyout = (props?: Record & { onSuccess: () => void }) => { - open: ({ attachments }: { attachments: any[] }) => void; + open: ({ attachments, observables }: { attachments: any[]; observables?: any[] }) => void; close: () => void; }; @@ -87,8 +93,10 @@ type UseCasesAddToExistingCaseModal = ( ) => { open: ({ getAttachments, + getObservables, }: { getAttachments: ({ theCase }: { theCase?: { id: string } }) => any[]; + getObservables?: ({ theCase }: { theCase?: { id: string } }) => any[]; }) => void; close: () => void; }; @@ -121,6 +129,7 @@ export interface CasesService { groupAlertsByRule: (items: any[]) => any[]; canUseCases: (owners: Array<'securitySolution' | 'observability' | 'cases'>) => any; getRuleIdFromEvent: (event: { data: any[]; ecs: Ecs }) => { id: string; name: string }; + getObservablesFromEcs: (ecsArray: any[][]) => Observable[]; }; } @@ -482,6 +491,7 @@ export interface PublicAlertsDataGridProps owner: Parameters[0]; appId?: string; syncAlerts?: boolean; + extractObservables?: boolean; }; /** * If true, hides the bulk actions controls diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index dca2da0a44948..029d61ee363bd 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -13759,7 +13759,6 @@ "xpack.cases.caseView.observables.type": "Beobachtbarer Typ", "xpack.cases.caseView.observables.updated": "Observable wurde aktualisiert", "xpack.cases.caseView.observables.value": "Beobachtbarer Wert", - "xpack.cases.caseView.observables.valuePlaceholder": "Beobachtbarer Wert", "xpack.cases.caseView.openCase": "Fall öffnen", "xpack.cases.caseView.optional": "Optional", "xpack.cases.caseView.otherEndpoints": "und {endpoints} {endpoints, plural, =1 {other} other {andere}}", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 9632b59d4ae8c..ee3a2879fbbe6 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -13797,7 +13797,6 @@ "xpack.cases.caseView.observables.type": "Type d'observable", "xpack.cases.caseView.observables.updated": "Observable mis à jour", "xpack.cases.caseView.observables.value": "Valeur d'observable", - "xpack.cases.caseView.observables.valuePlaceholder": "Valeur d'observable", "xpack.cases.caseView.openCase": "Ouvrir le cas", "xpack.cases.caseView.optional": "Facultatif", "xpack.cases.caseView.otherEndpoints": "et {endpoints} {endpoints, plural, =1 {other} other {autres}}", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index c324177b8156a..ecbbc502d750d 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -13815,7 +13815,6 @@ "xpack.cases.caseView.observables.type": "オブザーバブルタイプ", "xpack.cases.caseView.observables.updated": "オブザーバブルが更新されました", "xpack.cases.caseView.observables.value": "オブザーバブル値", - "xpack.cases.caseView.observables.valuePlaceholder": "オブザーバブル値", "xpack.cases.caseView.openCase": "ケースを開く", "xpack.cases.caseView.optional": "オプション", "xpack.cases.caseView.otherEndpoints": "および{endpoints} {endpoints, plural, =1 {other} other {件のエンドポイント}}", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 2999efac6a719..c48e9fad0aa58 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -13809,7 +13809,6 @@ "xpack.cases.caseView.observables.type": "可观察对象类型", "xpack.cases.caseView.observables.updated": "可观察对象已更新", "xpack.cases.caseView.observables.value": "可观察对象值", - "xpack.cases.caseView.observables.valuePlaceholder": "可观察对象值", "xpack.cases.caseView.openCase": "创建案例", "xpack.cases.caseView.optional": "可选", "xpack.cases.caseView.otherEndpoints": "以及其他 {endpoints} 个", diff --git a/x-pack/platform/plugins/shared/cases/common/api/helpers.ts b/x-pack/platform/plugins/shared/cases/common/api/helpers.ts index c8c53229fb18e..adaf88960667c 100644 --- a/x-pack/platform/plugins/shared/cases/common/api/helpers.ts +++ b/x-pack/platform/plugins/shared/cases/common/api/helpers.ts @@ -27,6 +27,7 @@ import { INTERNAL_CASE_OBSERVABLES_DELETE_URL, INTERNAL_CASE_SUMMARY_URL, INTERNAL_INFERENCE_CONNECTORS_URL, + INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -110,6 +111,10 @@ export const getCaseDeleteObservableUrl = (id: string, observableId: string): st ); }; +export const getBulkCreateObservablesUrl = (id: string): string => { + return INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL.replace('{case_id}', id); +}; + export const getCaseSimilarCasesUrl = (caseId: string) => { return INTERNAL_CASE_SIMILAR_CASES_URL.replace('{case_id}', caseId); }; diff --git a/x-pack/platform/plugins/shared/cases/common/constants/index.ts b/x-pack/platform/plugins/shared/cases/common/constants/index.ts index 1cfb8ab89e102..5a745c838941f 100644 --- a/x-pack/platform/plugins/shared/cases/common/constants/index.ts +++ b/x-pack/platform/plugins/shared/cases/common/constants/index.ts @@ -10,6 +10,7 @@ import type { CasesFeaturesAllRequired } from '../ui/types'; export * from './owners'; export * from './files'; export * from './application'; +export * from './observables'; export { LENS_ATTACHMENT_TYPE } from './visualizations'; export const DEFAULT_DATE_FORMAT = 'dateFormat' as const; @@ -98,6 +99,7 @@ export const INTERNAL_CASE_SUMMARY_URL = `${CASES_INTERNAL_URL}/{case_id}/summar export const INTERNAL_INFERENCE_CONNECTORS_URL = '/internal/inference/connectors' as const; export const INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL = `${CASES_INTERNAL_URL}/case/alerts/_find_containing_all` as const; +export const INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL = `${CASES_INTERNAL_URL}/{case_id}/observables/_bulk_create`; /** * Action routes @@ -166,7 +168,7 @@ export const MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH = 50 as const; export const DEFAULT_FEATURES: CasesFeaturesAllRequired = Object.freeze({ alerts: { sync: true, enabled: true, isExperimental: false }, metrics: [], - observables: { enabled: true }, + observables: { enabled: true, autoExtract: false }, events: { enabled: true }, }); @@ -291,60 +293,6 @@ export const MAX_OBSERVABLE_TYPE_LABEL_LENGTH = 50; export const MAX_CUSTOM_OBSERVABLE_TYPES = 10; -export const OBSERVABLE_TYPE_EMAIL = { - label: 'Email', - key: 'observable-type-email', -} as const; - -export const OBSERVABLE_TYPE_DOMAIN = { - label: 'Domain', - key: 'observable-type-domain', -} as const; - -export const OBSERVABLE_TYPE_IPV4 = { - label: 'IPv4', - key: 'observable-type-ipv4', -} as const; - -export const OBSERVABLE_TYPE_IPV6 = { - label: 'IPv6', - key: 'observable-type-ipv6', -} as const; - -export const OBSERVABLE_TYPE_URL = { - label: 'URL', - key: 'observable-type-url', -} as const; - -/** - * Exporting an array of built-in observable types for use in the application - */ -export const OBSERVABLE_TYPES_BUILTIN = [ - OBSERVABLE_TYPE_IPV4, - OBSERVABLE_TYPE_IPV6, - OBSERVABLE_TYPE_URL, - { - label: 'Hostname', - key: 'observable-type-hostname', - }, - { - label: 'File hash', - key: 'observable-type-file-hash', - }, - { - label: 'File path', - key: 'observable-type-file-path', - }, - { - ...OBSERVABLE_TYPE_EMAIL, - }, - { - ...OBSERVABLE_TYPE_DOMAIN, - }, -]; - -export const OBSERVABLE_TYPES_BUILTIN_KEYS = OBSERVABLE_TYPES_BUILTIN.map(({ key }) => key); - /** * EBT events */ diff --git a/x-pack/platform/plugins/shared/cases/common/constants/observables.ts b/x-pack/platform/plugins/shared/cases/common/constants/observables.ts new file mode 100644 index 0000000000000..6b643508f8655 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/constants/observables.ts @@ -0,0 +1,62 @@ +/* + * 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. + */ + +export const OBSERVABLE_TYPE_IPV4 = { + label: 'IPv4', + key: 'observable-type-ipv4', +} as const; + +export const OBSERVABLE_TYPE_IPV6 = { + label: 'IPv6', + key: 'observable-type-ipv6', +} as const; + +export const OBSERVABLE_TYPE_URL = { + label: 'URL', + key: 'observable-type-url', +} as const; + +export const OBSERVABLE_TYPE_HOSTNAME = { + label: 'Host name', + key: 'observable-type-hostname', +} as const; + +export const OBSERVABLE_TYPE_FILE_HASH = { + label: 'File hash', + key: 'observable-type-file-hash', +} as const; + +export const OBSERVABLE_TYPE_FILE_PATH = { + label: 'File path', + key: 'observable-type-file-path', +} as const; + +export const OBSERVABLE_TYPE_EMAIL = { + label: 'Email', + key: 'observable-type-email', +} as const; + +export const OBSERVABLE_TYPE_DOMAIN = { + label: 'Domain', + key: 'observable-type-domain', +} as const; + +/** + * Exporting an array of built-in observable types for use in the application + */ +export const OBSERVABLE_TYPES_BUILTIN: { label: string; key: string }[] = [ + OBSERVABLE_TYPE_IPV4, + OBSERVABLE_TYPE_IPV6, + OBSERVABLE_TYPE_URL, + OBSERVABLE_TYPE_HOSTNAME, + OBSERVABLE_TYPE_FILE_HASH, + OBSERVABLE_TYPE_FILE_PATH, + OBSERVABLE_TYPE_EMAIL, + OBSERVABLE_TYPE_DOMAIN, +]; + +export const OBSERVABLE_TYPES_BUILTIN_KEYS = OBSERVABLE_TYPES_BUILTIN.map(({ key }) => key); diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/observable/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/api/observable/v1.ts index c665184a3d20c..62ce28ca3dc35 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/observable/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/observable/v1.ts @@ -29,5 +29,11 @@ export const UpdateObservableRequestRt = rt.strict({ observable: ObservablePatchRt, }); +export const BulkAddObservablesRequestRt = rt.strict({ + caseId: rt.string, + observables: rt.array(ObservablePostRt), +}); + export type AddObservableRequest = rt.TypeOf; export type UpdateObservableRequest = rt.TypeOf; +export type BulkAddObservablesRequest = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts index d4de4ad549da6..cbff90a3b79f6 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts @@ -26,6 +26,7 @@ export const UserActionTypes = { delete_case: 'delete_case', category: 'category', customFields: 'customFields', + observables: 'observables', } as const; type UserActionActionTypeKeys = keyof typeof UserActionTypes; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/create_case/v1.test.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/create_case/v1.test.ts index 80f5b8612828a..2fc49fc8f6eb4 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/create_case/v1.test.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/create_case/v1.test.ts @@ -32,6 +32,7 @@ describe('Create case', () => { title: 'sample title', settings: { syncAlerts: false, + extractObservables: false, }, owner: 'cases', }, @@ -144,6 +145,7 @@ describe('Create case', () => { title: 'sample title', settings: { syncAlerts: false, + extractObservables: false, }, owner: 'cases', }, diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/latest.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/latest.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/v1.test.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/v1.test.ts new file mode 100644 index 0000000000000..8986e498db8fd --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/v1.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { UserActionTypes } from '../action/v1'; +import { ObservablesUserActionPayloadRt, ObservablesUserActionRt } from './v1'; + +describe('Observables', () => { + describe('ObservablesUserActionPayloadRt', () => { + const defaultRequest = { + observables: { + count: 1, + actionType: 'add', + }, + }; + + it('has expected attributes in request', () => { + const query = ObservablesUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ObservablesUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from observables', () => { + const query = ObservablesUserActionPayloadRt.decode({ + observables: { ...defaultRequest.observables, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + describe('ObservablesUserActionRt', () => { + const defaultRequest = { + type: UserActionTypes.observables, + payload: { + observables: { + count: 1, + actionType: 'add', + }, + }, + }; + + it('has expected attributes in request', () => { + const query = ObservablesUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ObservablesUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = ObservablesUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/v1.ts new file mode 100644 index 0000000000000..5c3c77dd99922 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/observables/v1.ts @@ -0,0 +1,29 @@ +/* + * 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 * as rt from 'io-ts'; +import { UserActionTypes } from '../action/v1'; + +const ObservablesActionTypeRt = rt.union([ + rt.literal('add'), + rt.literal('delete'), + rt.literal('update'), +]); + +export const ObservablePayloadRt = rt.strict({ + count: rt.number, + actionType: ObservablesActionTypeRt, +}); + +export const ObservablesUserActionPayloadRt = rt.strict({ observables: ObservablePayloadRt }); + +export const ObservablesUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.observables), + payload: ObservablesUserActionPayloadRt, +}); + +export type ObservablesActionType = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/settings/v1.test.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/settings/v1.test.ts index 00634afee919b..f0955c2ef65f7 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/settings/v1.test.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/settings/v1.test.ts @@ -11,7 +11,7 @@ import { SettingsUserActionPayloadRt, SettingsUserActionRt } from './v1'; describe('Settings', () => { describe('SettingsUserActionPayloadRt', () => { const defaultRequest = { - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, }; it('has expected attributes in request', () => { @@ -25,14 +25,14 @@ describe('Settings', () => { it('removes foo:bar attributes from request', () => { const query = SettingsUserActionPayloadRt.decode({ - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, foo: 'bar', }); expect(query).toStrictEqual({ _tag: 'Right', right: { - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, }); }); @@ -42,7 +42,7 @@ describe('Settings', () => { const defaultRequest = { type: UserActionTypes.settings, payload: { - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, }, }; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts index fdef2a9530e54..9a8a388f03f2b 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts @@ -23,7 +23,7 @@ import { StatusUserActionRt } from './status/v1'; import { TagsUserActionRt } from './tags/v1'; import { TitleUserActionRt } from './title/v1'; import { CustomFieldsUserActionRt } from './custom_fields/v1'; - +import { ObservablesUserActionRt } from './observables/v1'; export { UserActionTypes, UserActionActions } from './action/v1'; export { StatusUserActionRt } from './status/v1'; @@ -61,6 +61,7 @@ const BasicUserActionsRt = rt.union([ DeleteCaseUserActionRt, CategoryUserActionRt, CustomFieldsUserActionRt, + ObservablesUserActionRt, ]); const CommonUserActionsWithIdsRt = rt.union([BasicUserActionsRt, CommentUserActionRt]); @@ -154,3 +155,4 @@ export type CreateCaseUserActionWithoutConnectorId = UserActionWithAttributes< rt.TypeOf >; export type CustomFieldsUserAction = UserAction>; +export type ObservablesUserAction = UserAction>; diff --git a/x-pack/platform/plugins/shared/cases/common/ui/types.ts b/x-pack/platform/plugins/shared/cases/common/ui/types.ts index 7341e17e7fdfb..b2a78e9846249 100644 --- a/x-pack/platform/plugins/shared/cases/common/ui/types.ts +++ b/x-pack/platform/plugins/shared/cases/common/ui/types.ts @@ -58,7 +58,7 @@ type DeepRequired = { [K in keyof T]: DeepRequired } & Required; export interface CasesContextFeatures { alerts: { sync?: boolean; enabled?: boolean; isExperimental?: boolean }; metrics: SingleCaseMetricsFeature[]; - observables?: { enabled: boolean }; + observables?: { enabled: boolean; autoExtract?: boolean }; events?: { enabled: boolean }; } diff --git a/x-pack/platform/plugins/shared/cases/public/client/helpers/get_observables_from_ecs.test.ts b/x-pack/platform/plugins/shared/cases/public/client/helpers/get_observables_from_ecs.test.ts new file mode 100644 index 0000000000000..3ef0209630dc8 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/client/helpers/get_observables_from_ecs.test.ts @@ -0,0 +1,201 @@ +/* + * 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 { ObservablePost } from '../../../common/types/api'; +import { getIPType, getObservablesFromEcs, processObservable } from './get_observables_from_ecs'; + +describe('getIPType', () => { + it('should return IPV4 for a valid IPv4 address', () => { + expect(getIPType('192.168.1.1')).toBe('IPV4'); + }); + it('should return IPV6 for a valid IPv6 address', () => { + expect(getIPType('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe('IPV6'); + }); + it('should return IPV6 for a valid IPv6 address with a port', () => { + expect(getIPType('2001:0db8:85a3:0000:0000:8a2e:0370:7334:80')).toBe('IPV6'); + }); +}); + +describe('processObservable', () => { + it('should add an observable to the map', () => { + const observablesMap = new Map(); + processObservable(observablesMap, 'value', 'key', 'Description'); + expect(observablesMap.get('key-value')).toEqual({ + typeKey: 'key', + value: 'value', + description: 'Description', + }); + }); + + it('should allow different type keys with the same value', () => { + const observablesMap = new Map(); + processObservable(observablesMap, 'value', 'key1', 'Description'); + processObservable(observablesMap, 'value', 'key2', 'Description'); + expect(Array.from(observablesMap.values())).toEqual([ + { + typeKey: 'key1', + value: 'value', + description: 'Description', + }, + { + typeKey: 'key2', + value: 'value', + description: 'Description', + }, + ]); + }); + + it('should preserve the initial value if a duplicate is added with a different description', () => { + const observablesMap = new Map(); + processObservable(observablesMap, 'value', 'key', 'Description'); + processObservable(observablesMap, 'value', 'key', 'Another Description'); + expect(observablesMap.get('key-value')).toEqual({ + typeKey: 'key', + value: 'value', + description: 'Description', + }); + }); +}); + +describe('getObservablesFromEcsDataArray', () => { + it('should return an array of observables for a valid Ecs data', () => { + expect( + getObservablesFromEcs([ + [ + { field: 'source.ip', value: ['192.168.1.1'] }, + { field: 'destination.ip', value: ['023:023:023:023:023:023:023:023'] }, + { field: 'host.name', value: ['host1'] }, + { field: 'file.hash.sha256', value: ['sha256hash', 'sha256hash2'] }, + { field: 'dns.question.name', value: ['example.com', 'example.org'] }, + ], + ]) + ).toEqual([ + { + typeKey: 'observable-type-ipv4', + value: '192.168.1.1', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-ipv6', + value: '023:023:023:023:023:023:023:023', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-hostname', + value: 'host1', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-file-hash', + value: 'sha256hash', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-file-hash', + value: 'sha256hash2', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-domain', + value: 'example.com', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-domain', + value: 'example.org', + description: 'Auto extracted observable', + }, + ]); + }); + + it('should return unique observables', () => { + expect( + getObservablesFromEcs([ + [ + { field: 'file.hash.sha512', value: ['sha'] }, + { field: 'file.hash.sha256', value: ['sha'] }, + ], + ]) + ).toEqual([ + { + typeKey: 'observable-type-file-hash', + value: 'sha', + description: 'Auto extracted observable', + }, + ]); + }); + it('should not include observables with no value', () => { + expect( + getObservablesFromEcs([ + [{ field: 'host.name' }, { field: 'file.hash.sha512', value: ['sha'] }], + ]) + ).toEqual([ + { + typeKey: 'observable-type-file-hash', + value: 'sha', + description: 'Auto extracted observable', + }, + ]); + }); + + it('should return observables with different key value pairs', () => { + expect( + getObservablesFromEcs([ + [ + { field: 'host.name', value: ['name'] }, + { field: 'file.path', value: ['name'] }, + ], + ]) + ).toEqual([ + { + typeKey: 'observable-type-hostname', + value: 'name', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-file-path', + value: 'name', + description: 'Auto extracted observable', + }, + ]); + }); + + it('should return correct observables from multiple ecs data arrays', () => { + expect( + getObservablesFromEcs([ + [ + { field: 'host.name', value: ['host1'] }, + { field: 'file.path', value: ['path1'] }, + ], + [ + { field: 'host.name', value: ['host2'] }, + { field: 'file.path', value: ['path2'] }, + ], + ]) + ).toEqual([ + { + typeKey: 'observable-type-hostname', + value: 'host1', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-file-path', + value: 'path1', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-hostname', + value: 'host2', + description: 'Auto extracted observable', + }, + { + typeKey: 'observable-type-file-path', + value: 'path2', + description: 'Auto extracted observable', + }, + ]); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/client/helpers/get_observables_from_ecs.ts b/x-pack/platform/plugins/shared/cases/public/client/helpers/get_observables_from_ecs.ts new file mode 100644 index 0000000000000..fe4e25f73d556 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/client/helpers/get_observables_from_ecs.ts @@ -0,0 +1,140 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { castArray } from 'lodash'; +import type { ObservablePost } from '../../../common/types/api'; +import { + OBSERVABLE_TYPE_DOMAIN, + OBSERVABLE_TYPE_FILE_HASH, + OBSERVABLE_TYPE_FILE_PATH, + OBSERVABLE_TYPE_IPV4, + OBSERVABLE_TYPE_IPV6, + OBSERVABLE_TYPE_HOSTNAME, +} from '../../../common/constants/observables'; + +export const getIPType = (ip: string): 'IPV4' | 'IPV6' => { + if (ip.includes(':')) { + return 'IPV6'; + } + return 'IPV4'; +}; + +// https://www.elastic.co/docs/reference/ecs/ecs-hash +const HASH_FIELDS = [ + 'cdhash', + 'md5', + 'sha1', + 'sha256', + 'sha384', + 'sha512', + 'ssdeep', + 'tlsh', +] as const; + +// https://www.elastic.co/docs/reference/ecs/ecs-hash +const HASH_PARENTS = ['dll', 'file', 'process'] as const; + +export type Maybe = T | null; +export interface FlattedEcsData { + field: string; + value?: Maybe; +} +export const getHashFields = (): string[] => + HASH_PARENTS.map((parent) => HASH_FIELDS.map((field) => `${parent}.hash.${field}`)).flat(); +export const processObservable = ( + observablesMap: Map, + value: string, + typeKey: string, + description: string +) => { + const key = `${typeKey}-${value}`; + if (observablesMap.has(key)) { + return; + } + observablesMap.set(key, { + typeKey, + value, + description, + }); +}; + +// helper function to get observables from array of flattened ECS data +export const getObservablesFromEcs = (ecsDataArray: FlattedEcsData[][]): ObservablePost[] => { + const observablesMap = new Map(); + + const description = i18n.translate('xpack.cases.caseView.observables.autoExtract.description', { + defaultMessage: 'Auto extracted observable', + }); + const hashFields = getHashFields(); + for (const ecsData of ecsDataArray) { + for (const datum of ecsData) { + if (datum.value) { + // Source IP + if (datum.field === 'source.ip') { + const ips = castArray(datum.value); + ips.forEach((ip) => { + const ipType = getIPType(ip); + processObservable( + observablesMap, + ip, + ipType === 'IPV4' ? OBSERVABLE_TYPE_IPV4.key : OBSERVABLE_TYPE_IPV6.key, + description + ); + }); + } + + // Destination IP + if (datum.field === 'destination.ip') { + const ips = castArray(datum.value); + ips.forEach((ip) => { + const ipType = getIPType(ip); + processObservable( + observablesMap, + ip, + ipType === 'IPV4' ? OBSERVABLE_TYPE_IPV4.key : OBSERVABLE_TYPE_IPV6.key, + description + ); + }); + } + // Host name + if (datum.field === 'host.name') { + const hostnames = castArray(datum.value); + hostnames.forEach((name) => { + if (name) { + processObservable(observablesMap, name, OBSERVABLE_TYPE_HOSTNAME.key, description); + } + }); + } + + // File hash + if (hashFields.includes(datum.field)) { + const hashValues = castArray(datum.value); + hashValues.forEach((hash) => { + processObservable(observablesMap, hash, OBSERVABLE_TYPE_FILE_HASH.key, description); + }); + } + // File path + if (datum.field === 'file.path') { + const paths = castArray(datum.value); + paths.forEach((path) => { + processObservable(observablesMap, path, OBSERVABLE_TYPE_FILE_PATH.key, description); + }); + } + // Domain + if (datum.field === 'dns.question.name') { + const names = castArray(datum.value); + names.forEach((name) => { + processObservable(observablesMap, name, OBSERVABLE_TYPE_DOMAIN.key, description); + }); + } + } + } + } + + // remove duplicates of key type and value pairs + return Array.from(observablesMap.values()); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/client/ui/get_create_case_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/client/ui/get_create_case_flyout.tsx index e52a14033a614..f76ffa573f9e5 100644 --- a/x-pack/platform/plugins/shared/cases/public/client/ui/get_create_case_flyout.tsx +++ b/x-pack/platform/plugins/shared/cases/public/client/ui/get_create_case_flyout.tsx @@ -33,6 +33,7 @@ export const getCreateCaseFlyoutLazy = ({ onClose, onSuccess, attachments, + observables, }: GetCreateCaseFlyoutPropsInternal) => ( diff --git a/x-pack/platform/plugins/shared/cases/public/common/mock/test_providers.tsx b/x-pack/platform/plugins/shared/cases/public/common/mock/test_providers.tsx index 6819ee4f96647..e8211f538d529 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/mock/test_providers.tsx +++ b/x-pack/platform/plugins/shared/cases/public/common/mock/test_providers.tsx @@ -20,7 +20,7 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { FilesContext } from '@kbn/shared-ux-file-context'; import { coreMock } from '@kbn/core/public/mocks'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; - +import { I18nProvider } from '@kbn/i18n-react'; import type { CoreStart } from '@kbn/core/public'; import type { BaseFilesClient } from '@kbn/shared-ux-file-types'; import type { CasesFeatures, CasesPermissions } from '../../../common/ui/types'; @@ -156,13 +156,15 @@ const TestProvidersComponent: React.FC = ({ return ( - - - - {children} - - - + + + + + {children} + + + + ); }; diff --git a/x-pack/platform/plugins/shared/cases/public/common/test_utils.tsx b/x-pack/platform/plugins/shared/cases/public/common/test_utils.tsx index 1cbf5e2a5d454..e187521785aae 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/test_utils.tsx +++ b/x-pack/platform/plugins/shared/cases/public/common/test_utils.tsx @@ -45,7 +45,7 @@ export const createQueryWithMarkup = return hasText(node) && childrenDontHaveText; }); -interface FormTestComponentProps { +export interface FormTestComponentProps { formDefaultValue?: Record; onSubmit?: jest.Mock; schema?: FormSchema>; diff --git a/x-pack/platform/plugins/shared/cases/public/common/translations.ts b/x-pack/platform/plugins/shared/cases/public/common/translations.ts index 3c8db09013bef..e9b8f9fe59122 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/common/translations.ts @@ -281,6 +281,34 @@ export const SYNC_ALERTS_HELP = i18n.translate('xpack.cases.components.create.sy defaultMessage: 'Enabling this option will sync the alert statuses with the case status.', }); +export const EXTRACT_OBSERVABLES_SWITCH_LABEL_ON = i18n.translate( + 'xpack.cases.settings.extractObservablesSwitchLabelOn', + { + defaultMessage: 'On', + } +); + +export const EXTRACT_OBSERVABLES_SWITCH_LABEL_OFF = i18n.translate( + 'xpack.cases.settings.extractObservablesSwitchLabelOff', + { + defaultMessage: 'Off', + } +); + +export const EXTRACT_OBSERVABLES_LABEL = i18n.translate( + 'xpack.cases.settings.extractObservablesLabel', + { + defaultMessage: 'Extract observables', + } +); + +export const EXTRACT_OBSERVABLES_HELP = i18n.translate( + 'xpack.cases.components.create.extractObservablesHelpText', + { + defaultMessage: 'Enabling this option will extract observables from the alert automatically.', + } +); + export const ALERT = i18n.translate('xpack.cases.common.alertLabel', { defaultMessage: 'Alert', }); @@ -339,6 +367,13 @@ export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( } ); +export const CASE_ALERT_SUCCESS_OBSERVABLES_TEXT = i18n.translate( + 'xpack.cases.actions.caseAlertSuccessObservablesText', + { + defaultMessage: 'Observables are extracted and added to the case.', + } +); + export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { defaultMessage: 'View case', }); diff --git a/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.test.tsx b/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.test.tsx index 07f9014c1f57e..393ee74551c38 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.test.tsx @@ -17,7 +17,7 @@ import { CaseMetricsFeature } from '../../common/types/api'; describe('useCasesFeatures', () => { // isAlertsEnabled, isSyncAlertsEnabled, alerts - const tests: Array<[boolean, boolean, CasesContextFeatures['alerts']]> = [ + const alertTests: Array<[boolean, boolean, CasesContextFeatures['alerts']]> = [ [true, true, { enabled: true, sync: true }], [true, false, { enabled: true, sync: false }], [false, false, { enabled: false, sync: true }], @@ -33,7 +33,7 @@ describe('useCasesFeatures', () => { [true, true, {}], ]; - it.each(tests)( + it.each(alertTests)( 'returns isAlertsEnabled=%s and isSyncAlertsEnabled=%s if feature.alerts=%s', async (isAlertsEnabled, isSyncAlertsEnabled, alerts) => { const { result } = renderHook(() => useCasesFeatures(), { @@ -48,6 +48,41 @@ describe('useCasesFeatures', () => { pushToServiceAuthorized: false, observablesAuthorized: false, isObservablesFeatureEnabled: true, + isExtractObservablesEnabled: false, + connectorsAuthorized: false, + }); + } + ); + + // isObservablesFeatureEnabled, isExtractObservablesEnabled, observables + const observableTests: Array<[boolean, boolean, CasesContextFeatures['observables']]> = [ + [true, true, { enabled: true, autoExtract: true }], + [true, false, { enabled: true, autoExtract: false }], + [false, false, { enabled: false, autoExtract: true }], + [false, false, { enabled: false, autoExtract: false }], + [false, false, { enabled: false }], + // if observables is enabled and autoExtract is by defaultfalse + [true, false, { enabled: true }], + ]; + + it.each(observableTests)( + 'returns isObservablesFeatureEnabled=%s and isExtractObservablesEnabled=%s if feature.observables=%s', + async (isObservablesFeatureEnabled, isExtractObservablesEnabled, observables) => { + const { result } = renderHook(() => useCasesFeatures(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toEqual({ + isAlertsEnabled: true, + isSyncAlertsEnabled: true, + metricsFeatures: [], + caseAssignmentAuthorized: false, + pushToServiceAuthorized: false, + observablesAuthorized: false, + isObservablesFeatureEnabled, + isExtractObservablesEnabled, connectorsAuthorized: false, }); } @@ -70,6 +105,7 @@ describe('useCasesFeatures', () => { pushToServiceAuthorized: false, observablesAuthorized: false, isObservablesFeatureEnabled: true, + isExtractObservablesEnabled: false, connectorsAuthorized: false, }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.tsx b/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.tsx index d07f068603f14..fe33304946098 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.tsx +++ b/x-pack/platform/plugins/shared/cases/public/common/use_cases_features.tsx @@ -19,6 +19,7 @@ export interface UseCasesFeatures { pushToServiceAuthorized: boolean; metricsFeatures: SingleCaseMetricsFeature[]; isObservablesFeatureEnabled: boolean; + isExtractObservablesEnabled: boolean; } export const useCasesFeatures = (): UseCasesFeatures => { @@ -47,7 +48,9 @@ export const useCasesFeatures = (): UseCasesFeatures => { pushToServiceAuthorized: hasLicenseGreaterThanPlatinum, observablesAuthorized: hasLicenseGreaterThanPlatinum, connectorsAuthorized: hasLicenseWithAtLeastGold, - isObservablesFeatureEnabled: features.observables.enabled, + isObservablesFeatureEnabled: !!features.observables.enabled, + isExtractObservablesEnabled: + !!features.observables.enabled && !!features.observables.autoExtract, }), [ features.alerts.enabled, @@ -56,6 +59,7 @@ export const useCasesFeatures = (): UseCasesFeatures => { hasLicenseGreaterThanPlatinum, assign, features.observables?.enabled, + features.observables?.autoExtract, hasLicenseWithAtLeastGold, ] ); diff --git a/x-pack/platform/plugins/shared/cases/public/common/use_cases_toast.tsx b/x-pack/platform/plugins/shared/cases/public/common/use_cases_toast.tsx index 3292a648a9b24..9e193b3fdf37c 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/use_cases_toast.tsx +++ b/x-pack/platform/plugins/shared/cases/public/common/use_cases_toast.tsx @@ -25,6 +25,7 @@ import { generateCaseViewPath } from './navigation'; import type { CaseAttachmentsWithoutOwner, ServerError } from '../types'; import { CASE_ALERT_SUCCESS_SYNC_TEXT, + CASE_ALERT_SUCCESS_OBSERVABLES_TEXT, CASE_ALERT_SUCCESS_TOAST, CASE_SUCCESS_TOAST, VIEW_CASE, @@ -32,6 +33,7 @@ import { import { OWNER_INFO } from '../../common/constants'; import { useApplication } from './lib/kibana/use_application'; import { TruncatedText } from '../components/truncated_text'; +import type { ObservablePost } from '../../common/types/api'; function getAlertsCount(attachments: CaseAttachmentsWithoutOwner): number { let alertsCount = 0; @@ -82,14 +84,23 @@ function getToastContent({ if (content !== undefined) { return content; } + + let toastContent; if (attachments !== undefined) { for (const attachment of attachments) { - if (attachment.type === AttachmentType.alert && theCase.settings.syncAlerts) { - return CASE_ALERT_SUCCESS_SYNC_TEXT; + if (attachment.type === AttachmentType.alert) { + if (theCase.settings.syncAlerts && theCase.settings.extractObservables) { + toastContent = `${CASE_ALERT_SUCCESS_SYNC_TEXT} ${CASE_ALERT_SUCCESS_OBSERVABLES_TEXT}`; + } else if (theCase.settings.syncAlerts) { + toastContent = CASE_ALERT_SUCCESS_SYNC_TEXT; + } else if (theCase.settings.extractObservables) { + toastContent = CASE_ALERT_SUCCESS_OBSERVABLES_TEXT; + } } } } - return undefined; + + return toastContent; } const isServerError = (error: Error | ServerError): error is ServerError => @@ -123,11 +134,13 @@ export const useCasesToast = () => { showSuccessAttach: ({ theCase, attachments, + observables, title, content, }: { theCase: CaseUI; attachments?: CaseAttachmentsWithoutOwner; + observables?: ObservablePost[]; title?: string; content?: string; }) => { @@ -173,8 +186,8 @@ export const useCasesToast = () => { showSuccessToast: (title: string) => { toasts.addSuccess({ title, className: 'eui-textBreakWord' }); }, - showDangerToast: (title: string) => { - toasts.addDanger({ title, className: 'eui-textBreakWord' }); + showDangerToast: (title: string, text?: string) => { + toasts.addDanger({ title, text, className: 'eui-textBreakWord' }); }, showInfoToast: (title: string, text?: string) => { toasts.addInfo({ diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index 002605d38b701..d11f48fe26596 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -16,6 +16,7 @@ import { allCasesPermissions, renderWithTestingProviders } from '../../../common import { useCasesToast } from '../../../common/use_cases_toast'; import { alertComment } from '../../../containers/mock'; import { useCreateAttachments } from '../../../containers/use_create_attachments'; +import { useBulkPostObservables } from '../../../containers/use_bulk_post_observables'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/state/cases_context_reducer'; import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry'; @@ -26,6 +27,7 @@ import { PersistableStateAttachmentTypeRegistry } from '../../../client/attachme jest.mock('../../../common/use_cases_toast'); jest.mock('../../../common/lib/kibana/use_application'); jest.mock('../../../containers/use_create_attachments'); +jest.mock('../../../containers/use_bulk_post_observables'); // dummy mock, will call onRowclick when rendering jest.mock('./all_cases_selector_modal', () => { return { @@ -52,6 +54,7 @@ const TestComponent: React.FC = ( }; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; +const useBulkPostObservablesMock = useBulkPostObservables as jest.Mock; const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(); const persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(); @@ -61,6 +64,10 @@ describe('use cases add to existing case modal hook', () => { mutateAsync: jest.fn(), }); + useBulkPostObservablesMock.mockReturnValue({ + mutateAsync: jest.fn(), + }); + const dispatch = jest.fn(); const wrapper: FC> = ({ children }) => { @@ -76,7 +83,7 @@ describe('use cases add to existing case modal hook', () => { features: { alerts: { sync: true, enabled: true, isExperimental: false }, metrics: [], - observables: { enabled: true }, + observables: { enabled: true, autoExtract: true }, events: { enabled: true }, }, releasePhase: 'ga', diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 18fd9a3f1949f..cd228b9f8acd2 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -18,6 +18,8 @@ import type { CaseAttachmentsWithoutOwner } from '../../../types'; import { useCreateAttachments } from '../../../containers/use_create_attachments'; import { useAddAttachmentToExistingCaseTransaction } from '../../../common/apm/use_cases_transactions'; import { NO_ATTACHMENTS_ADDED } from '../translations'; +import { useBulkPostObservables } from '../../../containers/use_bulk_post_observables'; +import type { ObservablePost } from '../../../../common/types/api'; export type AddToExistingCaseModalProps = Omit & { successToaster?: { @@ -59,6 +61,7 @@ export const useCasesAddToExistingCaseModal = ({ const { appId } = useApplication(); const casesToasts = useCasesToast(); const { mutateAsync: createAttachments } = useCreateAttachments(); + const { mutateAsync: bulkPostObservables } = useBulkPostObservables(); const { startTransaction } = useAddAttachmentToExistingCaseTransaction(); const closeModal = useCallback(() => { @@ -75,15 +78,17 @@ export const useCasesAddToExistingCaseModal = ({ const handleOnRowClick = useCallback( async ( theCase: CaseUI | undefined, - getAttachments?: ({ theCase }: { theCase?: CaseUI }) => CaseAttachmentsWithoutOwner + getAttachments?: ({ theCase }: { theCase?: CaseUI }) => CaseAttachmentsWithoutOwner, + getObservables?: ({ theCase }: { theCase?: CaseUI }) => ObservablePost[] ) => { const attachments = getAttachments?.({ theCase }) ?? []; + const observables = getObservables?.({ theCase }) ?? []; // when the case is undefined in the modal // the user clicked "create new case" if (theCase === undefined) { closeModal(); - openCreateNewCaseFlyout({ attachments }); + openCreateNewCaseFlyout({ attachments, observables }); return; } @@ -105,11 +110,16 @@ export const useCasesAddToExistingCaseModal = ({ attachments, }); + if (theCase.settings?.extractObservables && observables.length > 0) { + await bulkPostObservables({ caseId: theCase.id, observables }); + } + onSuccess?.(theCase); casesToasts.showSuccessAttach({ theCase, attachments, + observables, title: successToaster?.title, content: successToaster?.content, }); @@ -123,6 +133,7 @@ export const useCasesAddToExistingCaseModal = ({ casesToasts, closeModal, createAttachments, + bulkPostObservables, openCreateNewCaseFlyout, successToaster?.title, successToaster?.content, @@ -136,8 +147,10 @@ export const useCasesAddToExistingCaseModal = ({ const openModal = useCallback( ({ getAttachments, + getObservables, }: { getAttachments?: GetAttachments; + getObservables?: ({ theCase }: { theCase?: CaseUI }) => ObservablePost[]; } = {}) => { dispatch({ type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, @@ -146,7 +159,7 @@ export const useCasesAddToExistingCaseModal = ({ onCreateCaseClicked, getAttachments, onRowClick: (theCase?: CaseUI) => { - handleOnRowClick(theCase, getAttachments); + handleOnRowClick(theCase, getAttachments, getObservables); }, onClose: (theCase?: CaseUI, isCreateCase?: boolean) => { closeModal(); diff --git a/x-pack/platform/plugins/shared/cases/public/components/app/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/app/index.tsx index 86cf83064e4b5..d38d79d6dd005 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/app/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/app/index.tsx @@ -41,7 +41,10 @@ const CasesAppComponent: React.FC = ({ useFetchAlertData: () => [false, {}], permissions: userCapabilities.generalCasesV3, basePath: '/', - features: { alerts: { enabled: true, sync: false } }, + features: { + alerts: { enabled: true, sync: false }, + observables: { enabled: true, autoExtract: false }, + }, })} ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/observables_toggle.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/observables_toggle.test.tsx new file mode 100644 index 0000000000000..db656167ee25e --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/observables_toggle.test.tsx @@ -0,0 +1,73 @@ +/* + * 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 React from 'react'; +import { screen, within, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ObservablesToggle } from './observables_toggle'; +import { schema } from '../create/schema'; +import { FormTestComponent } from '../../common/test_utils'; + +const CASE_OBSERVABLES_TOGGLE_TEST_ID = 'caseObservablesToggle'; + +describe('ObservablesToggle', () => { + const onSubmit = jest.fn(); + const defaultFormProps = { + onSubmit, + formDefaultValue: { extractObservables: true }, + schema: { + extractObservables: schema.extractObservables, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('it render toggle correctly', async () => { + render( + + + + ); + + const extractObservablesToggle = await screen.findByTestId(CASE_OBSERVABLES_TOGGLE_TEST_ID); + expect(extractObservablesToggle).toBeInTheDocument(); + expect(within(extractObservablesToggle).getByRole('switch')).toHaveAttribute( + 'aria-checked', + 'true' + ); + expect(within(extractObservablesToggle).getByText('Extract observables')).toBeInTheDocument(); + }); + + it('it toggles the switch', async () => { + render( + + + + ); + + const extractObservablesToggle = await screen.findByTestId(CASE_OBSERVABLES_TOGGLE_TEST_ID); + + await userEvent.click(within(extractObservablesToggle).getByRole('switch')); + expect(within(extractObservablesToggle).getByRole('switch')).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('renders disabled toggle when loading', async () => { + render( + + + + ); + const extractObservablesToggle = await screen.findByTestId(CASE_OBSERVABLES_TOGGLE_TEST_ID); + expect(extractObservablesToggle).toBeInTheDocument(); + expect(within(extractObservablesToggle).getByRole('switch')).toBeDisabled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/observables_toggle.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/observables_toggle.tsx new file mode 100644 index 0000000000000..ac810c39e752f --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/observables_toggle.tsx @@ -0,0 +1,47 @@ +/* + * 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 React, { memo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import * as i18n from '../create/translations'; + +interface Props { + /** + * Whether the component is loading + */ + isLoading: boolean; + /** + * The default value of the toggle + */ + defaultValue?: boolean; +} + +/** + * This component is used to toggle the extract observables feature in the create case flyout. + */ +const ObservablesToggleComponent: React.FC = ({ isLoading, defaultValue = true }) => { + return ( + + ); +}; + +ObservablesToggleComponent.displayName = 'ObservablesToggleComponent'; + +export const ObservablesToggle = memo(ObservablesToggleComponent); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/schema.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/schema.tsx index 29ff9fea51d16..e929bd15d7ce5 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/schema.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/schema.tsx @@ -30,6 +30,7 @@ export type CaseFormFieldsSchemaProps = Omit< connectorId: string; fields: ConnectorTypeFields['fields']; syncAlerts: boolean; + extractObservables: boolean; customFields: Record; }; @@ -98,6 +99,10 @@ export const schema: FormSchema = { helpText: i18n.SYNC_ALERTS_HELP, defaultValue: true, }, + extractObservables: { + helpText: i18n.EXTRACT_OBSERVABLES_HELP, + defaultValue: true, + }, customFields: {}, connectorId: { label: i18n.CONNECTORS, diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx index 08a421127e3ba..a7b96a1421737 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx @@ -54,6 +54,30 @@ describe('SyncAlertsToggle', () => { expect(await screen.findByText('Sync alert status with case status')).toBeInTheDocument(); }); + it('it renders with default value', async () => { + render( + + + + ); + + const syncAlerts = await screen.findByTestId('caseSyncAlerts'); + expect(syncAlerts).toBeInTheDocument(); + expect(within(syncAlerts).getByRole('switch')).toHaveAttribute('aria-checked', 'false'); + }); + + it('it toggles the switch with default value', async () => { + render( + + + + ); + + const syncAlerts = await screen.findByTestId('caseSyncAlerts'); + await userEvent.click(within(syncAlerts).getByRole('switch')); + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'true'); + }); + it('calls onSubmit with correct data', async () => { render( diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.tsx index 4293e56561b83..d8620f20088ed 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_form_fields/sync_alerts_toggle.tsx @@ -12,14 +12,15 @@ import * as i18n from '../create/translations'; interface Props { isLoading: boolean; + defaultValue?: boolean; } -const SyncAlertsToggleComponent: React.FC = ({ isLoading }) => { +const SyncAlertsToggleComponent: React.FC = ({ isLoading, defaultValue = true }) => { return ( { + it('it renders', () => { + render( + + ); + const toggle = screen.getByTestId('extract-observables-switch'); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('it toggles the switch', async () => { + render( + + ); + const toggle = screen.getByTestId('extract-observables-switch'); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('it disables the switch', async () => { + render( + + ); + + expect(await screen.findByTestId('extract-observables-switch')).toHaveProperty( + 'disabled', + true + ); + }); + + it('it start as off', async () => { + render( + + ); + + expect(await screen.findByText('Off')).toBeInTheDocument(); + expect(screen.queryByText('On')).not.toBeInTheDocument(); + }); + + it('it shows the correct labels', async () => { + render( + + ); + + expect(await screen.findByText('On')).toBeInTheDocument(); + expect(screen.queryByText('Off')).not.toBeInTheDocument(); + + await userEvent.click(await screen.findByTestId('extract-observables-switch')); + + expect(await screen.findByText('Off')).toBeInTheDocument(); + expect(screen.queryByText('On')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_settings/extract_observables_switch.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_settings/extract_observables_switch.tsx new file mode 100644 index 0000000000000..d2d5da5de082f --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/case_settings/extract_observables_switch.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { memo, useCallback, useState } from 'react'; +import { EuiSwitch } from '@elastic/eui'; + +import * as i18n from '../../common/translations'; + +interface Props { + /** + * Whether the component is disabled + */ + disabled: boolean; + /** + * Whether the auto extract observables setting is enabled + */ + isEnabled: boolean; + /** + * Whether to show the label + */ + showLabel?: boolean; + /** + * Callback when the switch is changed + */ + onSwitchChange: (isOn: boolean) => void; +} + +/** + * This component is used to toggle the extract observables feature in the case view page. + */ +const ExtractObservablesSwitchComponent: React.FC = ({ + disabled, + isEnabled, + showLabel = false, + onSwitchChange, +}) => { + const [isOn, setIsOn] = useState(isEnabled); + const onChange = useCallback(() => { + if (onSwitchChange) { + onSwitchChange(isOn); + } + + setIsOn(!isOn); + }, [isOn, onSwitchChange]); + + return ( + + ); +}; + +ExtractObservablesSwitchComponent.displayName = 'ExtractObservablesSwitchComponent'; + +export const ExtractObservablesSwitch = memo(ExtractObservablesSwitchComponent); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx index 30d34ab28f64a..21267e2fd365e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx @@ -140,7 +140,11 @@ export const CaseViewPage = React.memo( )} {activeTabId === CASE_VIEW_PAGE_TABS.FILES && } {activeTabId === CASE_VIEW_PAGE_TABS.OBSERVABLES && ( - + )} {activeTabId === CASE_VIEW_PAGE_TABS.SIMILAR_CASES && ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.test.tsx index 7c17273858109..489ab48561bf6 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.test.tsx @@ -358,7 +358,10 @@ describe('CaseViewTabs', () => { renderWithTestingProviders( , { - wrapperProps: { license: basicLicense, features: { observables: { enabled: false } } }, + wrapperProps: { + license: basicLicense, + features: { observables: { enabled: false, autoExtract: false } }, + }, } ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx index e6033ed9374f9..288e1f422011d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx @@ -18,13 +18,17 @@ describe('Case View Page observables tab', () => { }); it('should render the utility bar for the observables table', async () => { - renderWithTestingProviders(); + renderWithTestingProviders( + + ); expect((await screen.findAllByTestId('cases-observables-add')).length).toBe(2); }); it('should render the observable table', async () => { - renderWithTestingProviders(); + renderWithTestingProviders( + + ); expect(await screen.findByTestId('cases-observables-table')).toBeInTheDocument(); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx index 65fa3d634207a..1040cfe865df7 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; @@ -13,15 +13,20 @@ import type { CaseUI } from '../../../../common/ui/types'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import { CaseViewTabs } from '../case_view_tabs'; import { ObservablesTable } from '../../observables/observables_table'; -import { ObservablesUtilityBar } from '../../observables/observables_utility_bar'; import { useCaseObservables } from '../use_case_observables'; +import type { OnUpdateFields } from '../types'; interface CaseViewObservablesProps { caseData: CaseUI; isLoading: boolean; + onUpdateField: (args: OnUpdateFields) => void; } -export const CaseViewObservables = ({ caseData, isLoading }: CaseViewObservablesProps) => { +export const CaseViewObservables = ({ + caseData, + isLoading, + onUpdateField, +}: CaseViewObservablesProps) => { const { observables, isLoading: isLoadingObservables } = useCaseObservables(caseData); const caseDataWithFilteredObservables: CaseUI = useMemo(() => { @@ -31,16 +36,26 @@ export const CaseViewObservables = ({ caseData, isLoading }: CaseViewObservables }; }, [caseData, observables]); + const onExtractObservablesChanged = useCallback( + (isOn: boolean) => { + onUpdateField({ + key: 'settings', + value: { ...caseData.settings, extractObservables: !isOn }, + }); + }, + [caseData.settings, onUpdateField] + ); + return ( - diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts index 59871d69171f7..8f2a843d63944 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts @@ -158,6 +158,13 @@ export const SYNC_ALERTS_LC = i18n.translate('xpack.cases.caseView.syncAlertsLow defaultMessage: `sync alerts`, }); +export const EXTRACT_OBSERVABLES_LC = i18n.translate( + 'xpack.cases.caseView.extractObservablesLowercaseLabel', + { + defaultMessage: `extract observables`, + } +); + export const DOES_NOT_EXIST_TITLE = i18n.translate('xpack.cases.caseView.doesNotExist.title', { defaultMessage: 'This case does not exist', }); @@ -244,3 +251,24 @@ export const TOTAL_USERS_ASSIGNED = (total: number) => export const CASE_SUMMARY_TITLE = i18n.translate('xpack.cases.caseSummary.title', { defaultMessage: 'Case summary', }); + +export const ADDED_OBSERVABLES = (totalObservables: number): string => + i18n.translate('xpack.cases.caseView.observables.addedObservables', { + values: { totalObservables }, + defaultMessage: + 'added {totalObservables, plural, =1 {an} other {{totalObservables}}} {totalObservables, plural, =1 {observable} other {observables}}', + }); + +export const DELETED_OBSERVABLES = (totalObservables: number): string => + i18n.translate('xpack.cases.caseView.observables.deletedObservables', { + values: { totalObservables }, + defaultMessage: + 'deleted {totalObservables, plural, =1 {an} other {{totalObservables}}} {totalObservables, plural, =1 {observable} other {observables}}', + }); + +export const UPDATED_OBSERVABLES = (totalObservables: number): string => + i18n.translate('xpack.cases.caseView.observables.updatedObservables', { + values: { totalObservables }, + defaultMessage: + 'updated {totalObservables, plural, =1 {an} other {{totalObservables}}} {totalObservables, plural, =1 {observable} other {observables}}', + }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_observables.test.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_observables.test.ts index 8598660670bb7..4eb40e5e208de 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_observables.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_observables.test.ts @@ -8,7 +8,7 @@ import { renderHook } from '@testing-library/react'; import { useCaseObservables } from './use_case_observables'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; -import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../../common/constants'; +import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../../common/constants/observables'; import { caseData } from './mocks'; const mockCaseData = { diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx index 2bc5ed82c4eef..f7e25e5a290ae 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/flyout.test.tsx @@ -457,7 +457,12 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField form correctly', async () => { - renderWithTestingProviders({renderBody}); + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + renderWithTestingProviders({renderBody}, { + wrapperProps: { license }, + }); await userEvent.click(await screen.findByTestId('template-name-input')); await userEvent.paste('Template name'); @@ -483,6 +488,7 @@ describe('CommonFlyout ', () => { customFields: [], settings: { syncAlerts: true, + extractObservables: false, }, }, description: 'Template description', @@ -493,6 +499,9 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with case fields correctly', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( { /> ); - renderWithTestingProviders({newRenderBody}); + renderWithTestingProviders({newRenderBody}, { + wrapperProps: { license }, + }); const caseTitle = await screen.findByTestId('caseTitle'); await userEvent.click(within(caseTitle).getByTestId('input')); @@ -541,6 +552,7 @@ describe('CommonFlyout ', () => { customFields: [], settings: { syncAlerts: true, + extractObservables: false, }, }, }); @@ -549,6 +561,9 @@ describe('CommonFlyout ', () => { it('calls onSaveField form with custom fields correctly', async () => { const newConfig = { ...currentConfiguration, customFields: customFieldsConfigurationMock }; + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( { /> ); - renderWithTestingProviders({newRenderBody}); + renderWithTestingProviders({newRenderBody}, { + wrapperProps: { license }, + }); const textCustomField = await screen.findByTestId( `${customFieldsConfigurationMock[0].key}-text-create-custom-field` @@ -590,6 +607,7 @@ describe('CommonFlyout ', () => { }, settings: { syncAlerts: true, + extractObservables: false, }, customFields: [ { @@ -630,6 +648,9 @@ describe('CommonFlyout ', () => { it('calls onSaveField form with connector fields correctly', async () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); const connector = { id: 'servicenow-1', @@ -657,7 +678,9 @@ describe('CommonFlyout ', () => { /> ); - renderWithTestingProviders({newRenderBody}); + renderWithTestingProviders({newRenderBody}, { + wrapperProps: { license }, + }); expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); @@ -686,6 +709,7 @@ describe('CommonFlyout ', () => { }, settings: { syncAlerts: true, + extractObservables: false, }, }, }); @@ -760,6 +784,7 @@ describe('CommonFlyout ', () => { description: 'case desc', settings: { syncAlerts: true, + extractObservables: false, }, severity: 'low', tags: ['sample-4'], diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.test.tsx index fdd025ffc8fa3..1e3fc506afe36 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.test.tsx @@ -1109,6 +1109,7 @@ describe('ConfigureCases', () => { }, settings: { syncAlerts: true, + extractObservables: false, }, customFields: [ { @@ -1253,6 +1254,7 @@ describe('ConfigureCases', () => { customFields: [], settings: { syncAlerts: true, + extractObservables: false, }, }, }, @@ -1287,7 +1289,7 @@ describe('ConfigureCases', () => { it('should not render observable types section if observable feature is not enabled', async () => { renderWithTestingProviders(, { - wrapperProps: { features: { observables: { enabled: false } } }, + wrapperProps: { features: { observables: { enabled: false, autoExtract: false } } }, }); expect(screen.queryByTestId('observable-types-form-group')).not.toBeInTheDocument(); diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/flyout/create_case_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/flyout/create_case_flyout.tsx index 12566025520f7..d31d2bd28e0ce 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/flyout/create_case_flyout.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/flyout/create_case_flyout.tsx @@ -11,7 +11,7 @@ import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody, useEuiTheme } from import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { noop } from 'lodash'; -import type { CasePostRequest } from '../../../../common/types/api'; +import type { CasePostRequest, ObservablePost } from '../../../../common/types/api'; import * as i18n from '../translations'; import type { CaseUI } from '../../../../common/ui/types'; import { CreateCaseForm } from '../form'; @@ -28,10 +28,19 @@ export interface CreateCaseFlyoutProps { attachments?: CaseAttachmentsWithoutOwner; headerContent?: React.ReactNode; initialValue?: Pick; + observables?: ObservablePost[]; } export const CreateCaseFlyout = React.memo( - ({ afterCaseCreated, attachments, headerContent, initialValue, onClose, onSuccess }) => { + ({ + afterCaseCreated, + attachments, + headerContent, + initialValue, + onClose, + onSuccess, + observables = [], + }) => { const handleCancel = onClose || noop; const handleOnSuccess = onSuccess || noop; const { euiTheme } = useEuiTheme(); @@ -42,6 +51,7 @@ export const CreateCaseFlyout = React.memo( ( onSuccess={handleOnSuccess} withSteps={false} initialValue={initialValue} + observables={observables} /> diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx index b4833219d988d..9a8a2961c6040 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -39,7 +39,7 @@ describe('use cases add to new case flyout hook', () => { features: { alerts: { sync: true, enabled: true, isExperimental: false }, metrics: [], - observables: { enabled: true }, + observables: { enabled: true, autoExtract: true }, events: { enabled: true }, }, releasePhase: 'ga', diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index 6ee5b87bde9fc..dce386415ccec 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { useCallback, useMemo } from 'react'; +import type { ObservablePost } from '../../../../common/types/api'; import type { CaseAttachmentsWithoutOwner } from '../../../types'; import { useCasesToast } from '../../../common/use_cases_toast'; import type { CaseUI } from '../../../containers/types'; @@ -41,13 +42,19 @@ export const useCasesAddToNewCaseFlyout = ({ ({ attachments, headerContent, - }: { attachments?: CaseAttachmentsWithoutOwner; headerContent?: React.ReactNode } = {}) => { + observables, + }: { + attachments?: CaseAttachmentsWithoutOwner; + headerContent?: React.ReactNode; + observables?: ObservablePost[]; + } = {}) => { dispatch({ type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT, payload: { initialValue, attachments, headerContent, + observables, onClose: () => { closeFlyout(); if (onClose) { @@ -59,6 +66,7 @@ export const useCasesAddToNewCaseFlyout = ({ casesToasts.showSuccessAttach({ theCase, attachments: attachments ?? [], + observables, title: toastTitle, content: toastContent, }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/form.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/form.tsx index db6df19308e51..b9bcac9cf78e8 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/form.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/form.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useState, useMemo } from 'react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { CasePostRequest } from '../../../common/types/api'; +import type { CasePostRequest, ObservablePost } from '../../../common/types/api'; import { fieldName as descriptionFieldName } from '../case_form_fields/description'; import * as i18n from './translations'; import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; @@ -32,6 +32,7 @@ import { getConfigurationByOwner } from '../../containers/configure/utils'; import { CreateCaseOwnerSelector } from './owner_selector'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { getInitialCaseValue, getOwnerDefaultValue } from './utils'; +import { useCasesFeatures } from '../../common/use_cases_features'; export interface CreateCaseFormProps extends Pick, 'withSteps'> { onCancel: () => void; @@ -42,6 +43,7 @@ export interface CreateCaseFormProps extends Pick Promise; timelineIntegration?: CasesTimelineIntegration; attachments?: CaseAttachmentsWithoutOwner; + observables?: ObservablePost[]; initialValue?: Pick; } @@ -118,6 +120,7 @@ export const CreateCaseForm: React.FC = React.memo( onSuccess, timelineIntegration, attachments, + observables = [], initialValue, }) => { const { owner } = useCasesContext(); @@ -125,6 +128,11 @@ export const CreateCaseForm: React.FC = React.memo( const defaultOwnerValue = owner[0] ?? getOwnerDefaultValue(availableOwners); const [selectedOwner, onSelectedOwner] = useState(defaultOwnerValue); + const { observablesAuthorized, isExtractObservablesEnabled, isObservablesFeatureEnabled } = + useCasesFeatures(); + const canExtractObservables = + observablesAuthorized && isObservablesFeatureEnabled && isExtractObservablesEnabled; + const { data: configurations, isLoading: isLoadingCaseConfiguration } = useGetAllCaseConfigurations(); @@ -167,6 +175,7 @@ export const CreateCaseForm: React.FC = React.memo( initialValue={initialValue} currentConfiguration={currentConfiguration} selectedOwner={selectedOwner} + observables={canExtractObservables ? observables : []} > { const onFormSubmitSuccess = jest.fn(); const afterCaseCreated = jest.fn(); const createAttachments = jest.fn(); + const bulkPostObservables = jest.fn(); let user: UserEvent; // eslint-disable-next-line prefer-object-spread @@ -207,6 +214,7 @@ describe('Create case', () => { }); usePostCaseMock.mockImplementation(() => defaultPostCase); useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments })); + useBulkPostObservablesMock.mockImplementation(() => ({ mutateAsync: bulkPostObservables })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useGetConnectorsMock.mockReturnValue(sampleConnectorData); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); @@ -421,7 +429,7 @@ describe('Create case', () => { expect(postCase).toBeCalledWith({ request: { ...sampleDataWithoutTags, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, }); }); @@ -458,7 +466,7 @@ describe('Create case', () => { expect(postCase).toBeCalledWith({ request: { ...sampleDataWithoutTags, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, }); }); @@ -977,7 +985,89 @@ describe('Create case', () => { expect(createAttachments).not.toHaveBeenCalled(); }); + it('should call bulkPostObservables if the observables are not empty', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const observables = [ + { + typeKey: OBSERVABLE_TYPE_HOSTNAME.key, + value: 'host1', + description: null, + }, + { + typeKey: OBSERVABLE_TYPE_HOSTNAME.key, + value: 'host2', + description: null, + }, + ]; + + renderWithTestingProviders( + + + + , + { + wrapperProps: { license, features: { observables: { enabled: true, autoExtract: true } } }, + } + ); + + await waitForFormToRender(); + await fillFormReactTestingLib({ user }); + + expect(screen.getByTestId('caseObservablesToggle')).toBeInTheDocument(); + + await user.click(screen.getByTestId('create-case-submit')); + + await waitFor(() => { + expect(bulkPostObservables).toHaveBeenCalledTimes(1); + }); + + expect(bulkPostObservables).toHaveBeenCalledWith({ + caseId: 'case-id', + observables, + }); + }); + + it('should NOT call bulkPostObservables if the observables are an empty array', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + renderWithTestingProviders( + + + + , + { + wrapperProps: { license, features: { observables: { enabled: true, autoExtract: true } } }, + } + ); + + await waitForFormToRender(); + await fillFormReactTestingLib({ user }); + + expect(screen.getByTestId('caseObservablesToggle')).toBeInTheDocument(); + await user.click(screen.getByTestId('create-case-submit')); + + await waitForComponentToUpdate(); + + expect(createAttachments).not.toHaveBeenCalled(); + }); + it(`should call callbacks in correct order`, async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); useGetConnectorsMock.mockReturnValue({ ...sampleConnectorData, data: connectorsMock, @@ -995,6 +1085,14 @@ describe('Create case', () => { }, ]; + const observables = [ + { + typeKey: OBSERVABLE_TYPE_HOSTNAME.key, + value: 'host1', + description: null, + }, + ]; + renderWithTestingProviders( { onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated} attachments={attachments} + observables={observables} > - + , + { + wrapperProps: { + license, + features: { + ...DEFAULT_FEATURES, + observables: { enabled: true, autoExtract: true }, + }, + }, + } ); await waitForFormToRender(); @@ -1026,8 +1134,12 @@ describe('Create case', () => { }); expect(createAttachments).toHaveBeenCalled(); + expect(bulkPostObservables).toHaveBeenCalled(); expect(afterCaseCreated).toHaveBeenCalled(); - expect(pushCaseToExternalService).toHaveBeenCalled(); + + await waitFor(() => { + expect(pushCaseToExternalService).toHaveBeenCalled(); + }); await waitFor(() => { expect(onFormSubmitSuccess).toHaveBeenCalled(); @@ -1035,13 +1147,15 @@ describe('Create case', () => { const postCaseOrder = postCase.mock.invocationCallOrder[0]; const createAttachmentsOrder = createAttachments.mock.invocationCallOrder[0]; + const bulkPostObservablesOrder = bulkPostObservables.mock.invocationCallOrder[0]; const afterCaseOrder = afterCaseCreated.mock.invocationCallOrder[0]; const pushCaseToExternalServiceOrder = pushCaseToExternalService.mock.invocationCallOrder[0]; const onFormSubmitSuccessOrder = onFormSubmitSuccess.mock.invocationCallOrder[0]; expect( postCaseOrder < createAttachmentsOrder && - createAttachmentsOrder < afterCaseOrder && + createAttachmentsOrder < bulkPostObservablesOrder && + bulkPostObservablesOrder < afterCaseOrder && afterCaseOrder < pushCaseToExternalServiceOrder && pushCaseToExternalServiceOrder < onFormSubmitSuccessOrder ).toBe(true); diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/form_context.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/form_context.tsx index 184154d177a9b..b712867ff6203 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/form_context.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/form_context.tsx @@ -12,7 +12,7 @@ import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; -import type { CasePostRequest } from '../../../common/types/api'; +import type { CasePostRequest, ObservablePost } from '../../../common/types/api'; import type { UseCreateAttachments } from '../../containers/use_create_attachments'; import { useCreateAttachments } from '../../containers/use_create_attachments'; import type { CaseAttachmentsWithoutOwner } from '../../types'; @@ -21,6 +21,7 @@ import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_ca import { useApplication } from '../../common/lib/kibana/use_application'; import { createFormSerializer, createFormDeserializer, getInitialCaseValue } from './utils'; import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { useBulkPostObservables } from '../../containers/use_bulk_post_observables'; interface Props { afterCaseCreated?: ( @@ -33,6 +34,7 @@ interface Props { initialValue?: Pick; currentConfiguration: CasesConfigurationUI; selectedOwner: string; + observables?: ObservablePost[]; } export const FormContext: React.FC = ({ @@ -43,11 +45,13 @@ export const FormContext: React.FC = ({ initialValue, currentConfiguration, selectedOwner, + observables, }) => { const { appId } = useApplication(); const { data: connectors = [] } = useGetSupportedActionConnectors(); const { mutateAsync: postCase } = usePostCase(); const { mutateAsync: createAttachments } = useCreateAttachments(); + const { mutateAsync: bulkPostObservables } = useBulkPostObservables(); const { mutateAsync: pushCaseToExternalService } = usePostPushToService(); const { startTransaction } = useCreateCaseWithAttachmentsTransaction(); @@ -69,6 +73,12 @@ export const FormContext: React.FC = ({ }); } + if (theCase && Array.isArray(observables) && observables.length > 0) { + if (data.settings.extractObservables) { + await bulkPostObservables({ caseId: theCase.id, observables }); + } + } + if (afterCaseCreated && theCase) { await afterCaseCreated(theCase, createAttachments); } @@ -94,6 +104,8 @@ export const FormContext: React.FC = ({ onSuccess, createAttachments, pushCaseToExternalService, + observables, + bulkPostObservables, ] ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/form_fields.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/form_fields.tsx index 9b71a7d48f6ee..7f36286830f0b 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/form_fields.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/form_fields.tsx @@ -29,6 +29,7 @@ import { TemplateSelector } from './templates'; import { getInitialCaseValue } from './utils'; import { CaseFormFields } from '../case_form_fields'; import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; +import { ObservablesToggle } from '../case_form_fields/observables_toggle'; export interface CreateCaseFormFieldsProps { configuration: CasesConfigurationUI; @@ -66,7 +67,13 @@ const DEFAULT_EMPTY_TEMPLATE_KEY = 'defaultEmptyTemplateKey'; export const CreateCaseFormFields: React.FC = React.memo( ({ configuration, connectors, isLoading, withSteps, draftStorageKey }) => { const { reset, updateFieldValues, isSubmitting, setFieldValue } = useFormContext(); - const { isSyncAlertsEnabled, connectorsAuthorized } = useCasesFeatures(); + const { + isSyncAlertsEnabled, + isExtractObservablesEnabled, + observablesAuthorized, + connectorsAuthorized, + } = useCasesFeatures(); + const canExtractObservables = observablesAuthorized && isExtractObservablesEnabled; const configurationOwner = configuration.owner; /** @@ -136,13 +143,18 @@ export const CreateCaseFormFields: React.FC = React.m }), [configuration.customFields, draftStorageKey, isSubmitting] ); - + const showThirdStep = isSyncAlertsEnabled || canExtractObservables; const thirdStep = useMemo( () => ({ title: i18n.STEP_THREE_TITLE, - children: , + children: ( + <> + {isSyncAlertsEnabled && } + {canExtractObservables && } + + ), }), - [isSubmitting] + [isSubmitting, isSyncAlertsEnabled, canExtractObservables] ); const fourthStep = useMemo( @@ -164,10 +176,10 @@ export const CreateCaseFormFields: React.FC = React.m () => [ firstStep, secondStep, - ...(isSyncAlertsEnabled ? [thirdStep] : []), + ...(showThirdStep ? [thirdStep] : []), ...(connectorsAuthorized ? [fourthStep] : []), ], - [firstStep, secondStep, isSyncAlertsEnabled, thirdStep, connectorsAuthorized, fourthStep] + [firstStep, secondStep, showThirdStep, thirdStep, connectorsAuthorized, fourthStep] ); return ( @@ -210,7 +222,7 @@ export const CreateCaseFormFields: React.FC = React.m {secondStep.children} - {isSyncAlertsEnabled && ( + {showThirdStep && ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/create/index.tsx index 74250334d5d86..61fd305bac5c9 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/create/index.tsx @@ -20,7 +20,7 @@ import { CasesDeepLinkId } from '../../common/navigation'; export const CommonUseField = getUseField({ component: Field }); export const CreateCase = React.memo( - ({ afterCaseCreated, onCancel, onSuccess, timelineIntegration, withSteps }) => { + ({ afterCaseCreated, onCancel, onSuccess, timelineIntegration, withSteps, observables }) => { useCasesBreadcrumbs(CasesDeepLinkId.casesCreate); return ( @@ -32,6 +32,7 @@ export const CreateCase = React.memo( onSuccess={onSuccess} timelineIntegration={timelineIntegration} withSteps={withSteps} + observables={observables} /> ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/mock.ts b/x-pack/platform/plugins/shared/cases/public/components/create/mock.ts index 0dc6168106786..72b2a90a98be0 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/mock.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/create/mock.ts @@ -25,6 +25,7 @@ export const sampleData: CasePostRequest = { }, settings: { syncAlerts: true, + extractObservables: false, }, owner: SECURITY_SOLUTION_OWNER, assignees: [], diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/create/translations.ts index 89e384502d3ed..0e05213534c27 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/create/translations.ts @@ -10,6 +10,10 @@ import { i18n } from '@kbn/i18n'; export * from '../../common/translations'; export * from '../user_profiles/translations'; +export const CREATE_CASE_LABEL = i18n.translate('xpack.cases.create.createCaseFlyoutAriaLabel', { + defaultMessage: 'Create case', +}); + export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { defaultMessage: 'Select template', }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/utils.test.ts b/x-pack/platform/plugins/shared/cases/public/components/create/utils.test.ts index a7111f47a848e..7f84975de4abb 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/create/utils.test.ts @@ -35,6 +35,7 @@ describe('utils', () => { description: '', settings: { syncAlerts: true, + extractObservables: true, }, severity: 'low', tags: [], @@ -58,6 +59,7 @@ describe('utils', () => { owner: 'foobar', settings: { syncAlerts: true, + extractObservables: true, }, severity: 'low', tags: [], @@ -78,7 +80,7 @@ describe('utils', () => { category: 'categorty', severity: CaseSeverity.HIGH as const, description: 'Cool description', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, customFields: [{ key: 'key', type: CustomFieldTypes.TEXT as const, value: 'text' }], }; @@ -149,6 +151,7 @@ describe('utils', () => { fields: { incidentTypes: null, severityCode: null }, customFields: {}, syncAlerts: false, + extractObservables: false, }; const serializedFormData = { title: 'title', @@ -156,6 +159,7 @@ describe('utils', () => { customFields: [], settings: { syncAlerts: false, + extractObservables: false, }, tags: [], connector: { @@ -176,6 +180,7 @@ describe('utils', () => { description: '', settings: { syncAlerts: true, + extractObservables: true, }, severity: 'low', tags: [], @@ -287,6 +292,7 @@ describe('utils', () => { description: 'description', settings: { syncAlerts: false, + extractObservables: false, }, tags: [], connector: { @@ -306,6 +312,7 @@ describe('utils', () => { title: 'title', description: 'description', syncAlerts: false, + extractObservables: false, tags: [], owner: casesConfigurationsMock.owner, connectorId: 'foobar', @@ -325,6 +332,7 @@ describe('utils', () => { description: 'description', settings: { syncAlerts: false, + extractObservables: false, }, tags: [], connector: { @@ -360,6 +368,7 @@ describe('utils', () => { title: 'title', description: 'description', syncAlerts: false, + extractObservables: false, tags: [], owner: casesConfigurationsMock.owner, connectorId: 'foobar', diff --git a/x-pack/platform/plugins/shared/cases/public/components/create/utils.ts b/x-pack/platform/plugins/shared/cases/public/components/create/utils.ts index daeac67066c9e..398bf15b238c6 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/create/utils.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/create/utils.ts @@ -34,7 +34,7 @@ export const getInitialCaseValue = ({ category: undefined, severity: CaseSeverity.LOW as const, description: '', - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, customFields: [], ...restFields, connector: connector ?? getNoneConnector(), @@ -44,7 +44,7 @@ export const getInitialCaseValue = ({ export const trimUserFormData = ( userFormData: Omit< CaseFormFieldsSchemaProps, - 'connectorId' | 'fields' | 'syncAlerts' | 'customFields' + 'connectorId' | 'fields' | 'syncAlerts' | 'extractObservables' | 'customFields' > ) => { let formData = { @@ -72,6 +72,7 @@ export const createFormDeserializer = (data: CasePostRequest): CaseFormFieldsSch connectorId: connector.id, fields: connector.fields, syncAlerts: settings.syncAlerts, + extractObservables: settings.extractObservables ?? false, customFields: customFieldsFormDeserializer(customFields) ?? {}, }; }; @@ -88,7 +89,14 @@ export const createFormSerializer = ( }); } - const { connectorId: dataConnectorId, fields, syncAlerts, customFields, ...restData } = data; + const { + connectorId: dataConnectorId, + fields, + syncAlerts, + extractObservables, + customFields, + ...restData + } = data; const serializedConnectorFields = getConnectorsFormSerializer({ fields }); const caseConnector = getConnectorById(dataConnectorId, connectors); @@ -106,7 +114,7 @@ export const createFormSerializer = ( return { ...trimmedData, connector: connectorToUpdate, - settings: { syncAlerts: syncAlerts ?? false }, + settings: { syncAlerts: syncAlerts ?? false, extractObservables: extractObservables ?? false }, owner: currentConfiguration.owner, customFields: transformedCustomFields, }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.test.tsx index 09afd64886cf3..5b95b2ce3aebc 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.test.tsx @@ -13,7 +13,6 @@ import { mockCase } from '../../containers/mock'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import userEvent from '@testing-library/user-event'; import { screen } from '@testing-library/react'; -import { OBSERVABLE_TYPE_IPV4 } from '../../../common/constants'; import { postObservable } from '../../containers/api'; jest.mock('../../containers/api'); @@ -67,11 +66,8 @@ describe('AddObservable', () => { }); await userEvent.click(screen.getByTestId('cases-observables-add')); - - await userEvent.selectOptions( - screen.getByTestId('observable-type-select'), - OBSERVABLE_TYPE_IPV4.key - ); + await userEvent.click(screen.getByTestId('observable-type-select')); + await userEvent.click(screen.getByRole('option', { name: 'IPv4' })); await userEvent.click(screen.getByTestId('observable-value-field')); await userEvent.paste('127.0.0.1'); diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.tsx index b36e5c3fb9f16..acc6d5c1a422d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/add_observable.tsx @@ -31,7 +31,7 @@ export interface AddObservableProps { const AddObservableComponent: React.FC = ({ caseData }) => { const { permissions } = useCasesContext(); const [isModalVisible, setIsModalVisible] = useState(false); - const { isLoading, mutateAsync: postObservables } = usePostObservable(caseData.id); + const { isLoading, mutateAsync: postObservable } = usePostObservable(caseData.id); const { observablesAuthorized: isObservablesEnabled } = useCasesFeatures(); const closeModal = () => setIsModalVisible(false); @@ -39,13 +39,13 @@ const AddObservableComponent: React.FC = ({ caseData }) => { const handleCreateObservable = useCallback( async (observable: ObservablePost) => { - await postObservables({ + await postObservable({ observable, }); closeModal(); }, - [postObservables] + [postObservable] ); const modalTitleId = useGeneratedHtmlId(); diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/builder.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/builder.tsx index 9c715f03d854f..2d7bea31eefc4 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/builder.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/builder.tsx @@ -23,9 +23,9 @@ import * as i18n from './translations'; const sharedProps = { path: 'value', componentProps: { - placeholder: i18n.VALUE_PLACEHOLDER, euiFieldProps: { 'data-test-subj': 'observable-value-field', + placeholder: i18n.SELECT_OBSERVABLE_VALUE_PLACEHOLDER, }, }, component: TextField, diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/default_observable_types_modal.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/default_observable_types_modal.test.tsx new file mode 100644 index 0000000000000..22b7208f60c6a --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/default_observable_types_modal.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DefaultObservableTypesModal } from './default_observable_types_modal'; +import { TestProviders } from '../../common/mock'; + +describe('DefaultObservableTypesModal', () => { + it('renders', () => { + render(, { wrapper: TestProviders }); + + expect(screen.queryByTestId('default-observable-types-modal-body')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('default-observable-types-modal-header-title') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('default-observable-types-modal-button')).toBeInTheDocument(); + }); + + it('it opens the modal', async () => { + render(, { wrapper: TestProviders }); + expect(screen.queryByTestId('default-observable-types-modal-body')).not.toBeInTheDocument(); + + const button = screen.getByTestId('default-observable-types-modal-button'); + button.click(); + expect(await screen.findByTestId('default-observable-types-modal-body')).toBeInTheDocument(); + expect( + await screen.findByTestId('default-observable-types-modal-header-title') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/default_observable_types_modal.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/default_observable_types_modal.tsx new file mode 100644 index 0000000000000..97a5a1ac87a5c --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/default_observable_types_modal.tsx @@ -0,0 +1,140 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiBadge, + EuiButtonIcon, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EXTRACT_OBSERVABLES_LABEL } from '../create/translations'; +import * as i18n from './translations'; + +const HASH_FIELDS = [ + 'cdhash', + 'md5', + 'sha1', + 'sha256', + 'sha384', + 'sha512', + 'ssdeep', + 'tlsh', +] as const; + +// https://www.elastic.co/docs/reference/ecs/ecs-hash +// TODO - support 'email.attachments.file' +const HASH_PARENTS = ['dll', 'file', 'process'] as const; + +const defaultObservableTypes = [ + { + label: i18n.HOST_NAME, + value: ['host.name'], + }, + { + label: i18n.IP, + value: ['source.ip', 'destination.ip'], + }, + { + label: i18n.FILE_PATH, + value: ['file.path'], + }, + { + label: i18n.DOMAIN, + value: ['dns.question.name'], + }, + { + label: i18n.FILE_HASH, + value: HASH_PARENTS.map((parent) => + HASH_FIELDS.map((field) => `${parent}.hash.${field}`) + ).flat(), + }, +]; + +/** + * Renders a gear icon that opens a modal with the default observable types. + */ +export const DefaultObservableTypesModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const onButtonClick = () => setIsModalOpen((isOpen) => !isOpen); + const onCancel = useCallback(() => setIsModalOpen(false), []); + + const modalTitleId = useGeneratedHtmlId(); + const modal = useMemo( + () => ( + + + + {EXTRACT_OBSERVABLES_LABEL} + + + + + + + + + {defaultObservableTypes.map((observableType) => ( + + + {observableType.label} + + + + + {observableType.value.map((value) => ( + + {value} + + ))} + + + ))} + + + + ), + [onCancel, modalTitleId] + ); + + return ( + <> + + {isModalOpen && modal} + + ); +}; + +DefaultObservableTypesModal.displayName = 'DefaultObservableTypesModal'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/observable_form.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/observable_form.tsx index 5e2cb9656b14e..fe31d3f028c3a 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/observable_form.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/observable_form.tsx @@ -14,7 +14,7 @@ import { } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { EuiButton, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; -import { TextAreaField, SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { TextAreaField, SuperSelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { OBSERVABLE_TYPES_BUILTIN } from '../../../common/constants'; import type { ObservablePatch, ObservablePost } from '../../../common/types/api'; @@ -37,7 +37,7 @@ export const ObservableFormFields = memo(({ observable }: ObservableFormFieldsPr const options = useMemo(() => { return [...OBSERVABLE_TYPES_BUILTIN, ...data.observableTypes].map((observableType) => ({ value: observableType.key, - text: observableType.label, + inputDisplay: observableType.label, })); }, [data.observableTypes]); @@ -59,13 +59,13 @@ export const ObservableFormFields = memo(({ observable }: ObservableFormFieldsPr <> {!observable && ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.test.tsx index c69b8ddd6077a..7739250a972fd 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.test.tsx @@ -18,6 +18,7 @@ describe('ObservablesTable', () => { observables: mockObservables, }, isLoading: false, + onExtractObservablesChanged: jest.fn(), }; beforeEach(() => { @@ -30,8 +31,8 @@ describe('ObservablesTable', () => { expect(screen.getByTestId('cases-observables-table')).toBeInTheDocument(); expect(screen.getByText('Showing 2 observables')).toBeInTheDocument(); - expect(screen.getByText('Observable type')).toBeInTheDocument(); - expect(screen.getByText('Observable value')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); }); it('renders loading indicator when loading', async () => { diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.tsx index 423fd31a07ea6..3459b637d7add 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/observables_table.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiBasicTable, EuiSkeletonText, EuiSpacer, EuiText, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiInMemoryTable, EuiSkeletonText, EuiSpacer, EuiEmptyPrompt } from '@elastic/eui'; import { OBSERVABLE_TYPES_BUILTIN } from '../../../common/constants'; import type { Observable, ObservableType } from '../../../common/types/domain'; @@ -17,16 +17,16 @@ import * as i18n from './translations'; import { AddObservable } from './add_observable'; import { ObservableActionsPopoverButton } from './observable_actions_popover_button'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { ObservablesUtilityBar } from './observables_utility_bar'; const getColumns = ( caseData: CaseUI, observableTypes: ObservableType[] ): Array> => [ { - name: i18n.DATE_ADDED, - field: 'createdAt', - 'data-test-subj': 'cases-observables-table-date-added', - dataType: 'date', + name: i18n.OBSERVABLE_VALUE, + field: 'value', + 'data-test-subj': 'cases-observables-table-value', }, { name: i18n.OBSERVABLE_TYPE, @@ -36,9 +36,15 @@ const getColumns = ( observableTypes.find((observableType) => observableType.key === typeKey)?.label || '-', }, { - name: i18n.OBSERVABLE_VALUE, - field: 'value', - 'data-test-subj': 'cases-observables-table-value', + name: i18n.OBSERVABLE_DESCRIPTION, + field: 'description', + 'data-test-subj': 'cases-observables-table-description', + }, + { + name: i18n.DATE_ADDED, + field: 'createdAt', + 'data-test-subj': 'cases-observables-table-date-added', + dataType: 'date', }, { name: i18n.OBSERVABLE_ACTIONS, @@ -70,9 +76,14 @@ EmptyObservablesTable.displayName = 'EmptyObservablesTable'; export interface ObservablesTableProps { caseData: CaseUI; isLoading: boolean; + onExtractObservablesChanged: (isOn: boolean) => void; } -export const ObservablesTable = ({ caseData, isLoading }: ObservablesTableProps) => { +export const ObservablesTable = ({ + caseData, + isLoading, + onExtractObservablesChanged, +}: ObservablesTableProps) => { const filesTableRowProps = useCallback( (observable: Observable) => ({ 'data-test-subj': `cases-observables-table-row-${observable.id}`, @@ -82,11 +93,11 @@ export const ObservablesTable = ({ caseData, isLoading }: ObservablesTableProps) const { data: currentConfiguration, isLoading: loadingCaseConfigure } = useGetCaseConfiguration(); - const columns = useMemo( - () => - getColumns(caseData, [...OBSERVABLE_TYPES_BUILTIN, ...currentConfiguration.observableTypes]), - [caseData, currentConfiguration.observableTypes] + const observableTypes = useMemo( + () => [...OBSERVABLE_TYPES_BUILTIN, ...currentConfiguration.observableTypes], + [currentConfiguration.observableTypes] ); + const columns = useMemo(() => getColumns(caseData, observableTypes), [caseData, observableTypes]); return isLoading || loadingCaseConfigure ? ( <> @@ -95,16 +106,13 @@ export const ObservablesTable = ({ caseData, isLoading }: ObservablesTableProps) ) : ( <> - {caseData.observables.length > 0 && ( - <> - - - {i18n.SHOWING_OBSERVABLES(caseData.observables.length)} - - - )} - - + + { - const props: AddObservableProps = { - caseData: mockCase, + const props = { + caseData: { ...mockCase, observables: mockObservables }, + isLoading: false, + isEnabled: true, + onExtractObservablesChanged: jest.fn(), }; beforeEach(() => { jest.clearAllMocks(); }); - it('renders correctly', async () => { + it('renders correctly', () => { + (useCasesFeatures as jest.Mock).mockReturnValue({ + isExtractObservablesEnabled: true, + observablesAuthorized: true, + }); renderWithTestingProviders(); + expect(screen.getByTestId('cases-observables-table-results-count')).toBeInTheDocument(); + expect(screen.getByTestId('cases-observables-table-results-count')).toHaveTextContent( + 'Showing 2 observables' + ); + expect(screen.getByTestId('extract-observables-switch')).toBeInTheDocument(); expect(screen.getByTestId('cases-observables-add')).toBeInTheDocument(); }); + + it('does not render auto extract observable section without platinum+ license', () => { + (useCasesFeatures as jest.Mock).mockReturnValue({ + isExtractObservablesEnabled: true, + observablesAuthorized: false, + }); + renderWithTestingProviders(); + + expect(screen.getByTestId('cases-observables-table-results-count')).toBeInTheDocument(); + expect(screen.getByTestId('cases-observables-add')).toBeInTheDocument(); + + expect(screen.queryByTestId('extract-observables-switch')).not.toBeInTheDocument(); + expect(screen.queryByTestId('default-observable-types-modal-body')).not.toBeInTheDocument(); + }); + + it('does not render auto extract observable section when extract observables is disabled', () => { + (useCasesFeatures as jest.Mock).mockReturnValue({ + isExtractObservablesEnabled: false, + observablesAuthorized: true, + }); + renderWithTestingProviders(); + + expect(screen.getByTestId('cases-observables-table-results-count')).toBeInTheDocument(); + expect(screen.getByTestId('cases-observables-add')).toBeInTheDocument(); + + expect(screen.queryByTestId('extract-observables-switch')).not.toBeInTheDocument(); + expect(screen.queryByTestId('default-observable-types-modal-body')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/observables_utility_bar.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/observables_utility_bar.tsx index 3888b1d31c1a0..c0c8bcd72b727 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/observables_utility_bar.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/observables_utility_bar.tsx @@ -6,19 +6,66 @@ */ import React from 'react'; -import { EuiFlexGroup } from '@elastic/eui'; - +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import type { CaseUI } from '../../../common'; import { AddObservable } from './add_observable'; +import { ExtractObservablesSwitch } from '../case_settings/extract_observables_switch'; +import { DefaultObservableTypesModal } from './default_observable_types_modal'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; -interface ObservablesUtilityBarProps { +export interface ObservablesUtilityBarProps { caseData: CaseUI; + isLoading: boolean; + onExtractObservablesChanged: (isOn: boolean) => void; } -export const ObservablesUtilityBar = ({ caseData }: ObservablesUtilityBarProps) => { +export const ObservablesUtilityBar = ({ + caseData, + isLoading, + onExtractObservablesChanged, +}: ObservablesUtilityBarProps) => { + const { permissions } = useCasesContext(); + const { isExtractObservablesEnabled, observablesAuthorized } = useCasesFeatures(); + return ( - - + + + {caseData.observables.length > 0 && ( + + {i18n.SHOWING_OBSERVABLES(caseData.observables.length)} + + )} + + + {permissions.update && observablesAuthorized && isExtractObservablesEnabled ? ( + <> + + + + + + + +

{i18n.EXTRACT_OBSERVABLES_LABEL}

+
+
+
+
+ + + + + ) : null} + + + +
); }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/observables/translations.tsx b/x-pack/platform/plugins/shared/cases/public/components/observables/translations.tsx index 5eb77528e52a5..ee66c4e5cb8ce 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/observables/translations.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/observables/translations.tsx @@ -48,13 +48,20 @@ export const DATE_ADDED = i18n.translate('xpack.cases.caseView.observables.dateA }); export const OBSERVABLE_TYPE = i18n.translate('xpack.cases.caseView.observables.type', { - defaultMessage: 'Observable type', + defaultMessage: 'Type', }); export const OBSERVABLE_VALUE = i18n.translate('xpack.cases.caseView.observables.value', { - defaultMessage: 'Observable value', + defaultMessage: 'Name', }); +export const OBSERVABLE_DESCRIPTION = i18n.translate( + 'xpack.cases.caseView.observables.description', + { + defaultMessage: 'Description', + } +); + export const OBSERVABLE_ACTIONS = i18n.translate('xpack.cases.caseView.observables.actions', { defaultMessage: 'Actions', }); @@ -67,10 +74,24 @@ export const CANCEL = i18n.translate('xpack.cases.caseView.observables.cancel', defaultMessage: 'Cancel', }); -export const VALUE_PLACEHOLDER = i18n.translate( - 'xpack.cases.caseView.observables.valuePlaceholder', +export const SELECT_OBSERVABLE_VALUE_PLACEHOLDER = i18n.translate( + 'xpack.cases.caseView.observables.addObservableModal.selectValue', + { + defaultMessage: 'Name', + } +); + +export const SELECT_OBSERVABLE_TYPE_PLACEHOLDER = i18n.translate( + 'xpack.cases.caseView.observables.addObservableModal.selectType', + { + defaultMessage: 'Select type', + } +); + +export const SELECT_OBSERVABLE_DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.cases.caseView.observables.addObservableModal.selectDescription', { - defaultMessage: 'Observable value', + defaultMessage: 'Describe what was observed', } ); @@ -122,3 +143,37 @@ export const FIELD_LABEL_DESCRIPTION = i18n.translate( export const FIELD_LABEL_TYPE = i18n.translate('xpack.cases.caseView.observables.labelType', { defaultMessage: 'Type', }); + +export const EXTRACT_OBSERVABLES_LABEL = i18n.translate( + 'xpack.cases.caseView.observables.extractObservablesLabel', + { + defaultMessage: 'Auto-extract observables', + } +); + +export const HOST_NAME = i18n.translate('xpack.cases.caseView.observables.hostName', { + defaultMessage: 'Host name', +}); + +export const IP = i18n.translate('xpack.cases.caseView.observables.ip', { + defaultMessage: 'IP', +}); + +export const FILE_PATH = i18n.translate('xpack.cases.caseView.observables.filePath', { + defaultMessage: 'File path', +}); + +export const FILE_HASH = i18n.translate('xpack.cases.caseView.observables.fileHash', { + defaultMessage: 'File hash', +}); + +export const DOMAIN = i18n.translate('xpack.cases.caseView.observables.domain', { + defaultMessage: 'Domain', +}); + +export const DEFAULT_OBSERVABLE_TYPES_MODAL_BUTTON_ARIA_LABEL = i18n.translate( + 'xpack.cases.caseView.observables.defaultObservableTypesModalButtonAriaLabel', + { + defaultMessage: 'Default observable types modal button', + } +); diff --git a/x-pack/platform/plugins/shared/cases/public/components/templates/form.tsx b/x-pack/platform/plugins/shared/cases/public/components/templates/form.tsx index acd6855fe4706..81ab1dd906076 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/templates/form.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/templates/form.tsx @@ -65,6 +65,7 @@ const FormComponent: React.FC = ({ connectors={connectors} currentConfiguration={currentConfiguration} isEditMode={isEditMode} + initialValue={initialValue} /> ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/templates/form_fields.tsx b/x-pack/platform/plugins/shared/cases/public/components/templates/form_fields.tsx index 9a69d1c4590dc..621b519183397 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/templates/form_fields.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/templates/form_fields.tsx @@ -10,6 +10,7 @@ import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiSteps } from '@elastic/eui'; import { uniq } from 'lodash'; +import type { TemplateConfiguration } from '../../../common/types/domain'; import { CaseFormFields } from '../case_form_fields'; import * as i18n from './translations'; import type { ActionConnector } from '../../containers/configure/types'; @@ -17,6 +18,7 @@ import type { CasesConfigurationUI } from '../../containers/types'; import { TemplateFields } from './template_fields'; import { useCasesFeatures } from '../../common/use_cases_features'; import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import { ObservablesToggle } from '../case_form_fields/observables_toggle'; import { Connector } from '../case_form_fields/connector'; interface FormFieldsProps { @@ -24,6 +26,7 @@ interface FormFieldsProps { connectors: ActionConnector[]; currentConfiguration: CasesConfigurationUI; isEditMode?: boolean; + initialValue?: TemplateConfiguration | null; } const FormFieldsComponent: React.FC = ({ @@ -31,8 +34,10 @@ const FormFieldsComponent: React.FC = ({ connectors, currentConfiguration, isEditMode, + initialValue, }) => { - const { isSyncAlertsEnabled } = useCasesFeatures(); + const { isSyncAlertsEnabled, isExtractObservablesEnabled, observablesAuthorized } = + useCasesFeatures(); const { customFields: configurationCustomFields, templates } = currentConfiguration; const configurationTemplateTags = getTemplateTags(templates); @@ -67,10 +72,33 @@ const FormFieldsComponent: React.FC = ({ const thirdStep = useMemo( () => ({ title: i18n.CASE_SETTINGS, - children: , + children: ( + <> + {isSyncAlertsEnabled && ( + + )} + {observablesAuthorized && isExtractObservablesEnabled && ( + + )} + + ), }), - [isSubmitting] + [ + isSubmitting, + initialValue?.caseFields?.settings?.syncAlerts, + initialValue?.caseFields?.settings?.extractObservables, + isExtractObservablesEnabled, + isSyncAlertsEnabled, + observablesAuthorized, + ] ); + const showThirdStep = isSyncAlertsEnabled || isExtractObservablesEnabled; const fourthStep = useMemo( () => ({ @@ -83,8 +111,8 @@ const FormFieldsComponent: React.FC = ({ ); const allSteps = useMemo( - () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], - [firstStep, secondStep, thirdStep, fourthStep, isSyncAlertsEnabled] + () => [firstStep, secondStep, ...(showThirdStep ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, thirdStep, fourthStep, showThirdStep] ); return ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/templates/schema.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/templates/schema.test.tsx index da2df9d1f1f4a..a1163ff9814c5 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/templates/schema.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/templates/schema.test.tsx @@ -57,6 +57,17 @@ describe('Template schema', () => { }, ], }, + "extractObservables": Object { + "defaultValue": true, + "helpText": "Enabling this option will extract observables from the alert automatically.", + "labelAppend": + Optional + , + }, "fields": Object { "defaultValue": null, }, diff --git a/x-pack/platform/plugins/shared/cases/public/components/templates/utils.test.ts b/x-pack/platform/plugins/shared/cases/public/components/templates/utils.test.ts index 639facc690752..91c17bcb21cfc 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/templates/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/templates/utils.test.ts @@ -44,6 +44,7 @@ describe('utils', () => { customFields: [], settings: { syncAlerts: false, + extractObservables: false, }, }, description: undefined, @@ -72,6 +73,7 @@ describe('utils', () => { customFields: [], settings: { syncAlerts: false, + extractObservables: false, }, }, description: undefined, @@ -102,6 +104,7 @@ describe('utils', () => { customFields: [], settings: { syncAlerts: false, + extractObservables: false, }, }, description: 'description 1', @@ -138,6 +141,7 @@ describe('utils', () => { ], settings: { syncAlerts: false, + extractObservables: false, }, }, description: undefined, @@ -172,6 +176,7 @@ describe('utils', () => { customFields: [], settings: { syncAlerts: false, + extractObservables: false, }, }, description: undefined, diff --git a/x-pack/platform/plugins/shared/cases/public/components/templates/utils.ts b/x-pack/platform/plugins/shared/cases/public/components/templates/utils.ts index 29dda56fbe0e6..1b58bec66bb3a 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/templates/utils.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/templates/utils.ts @@ -93,6 +93,7 @@ export const templateSerializer = ( const { connectorId, syncAlerts = false, + extractObservables = false, templateTags, templateDescription, ...otherCaseFields @@ -117,7 +118,7 @@ export const templateSerializer = ( ...otherCaseFields, connector: transformedConnector, customFields: transformedCustomFields, - settings: { syncAlerts }, + settings: { syncAlerts, extractObservables }, }, }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx index b0b3a9f7a7de9..197f0f52e315a 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx @@ -19,6 +19,7 @@ import { createCaseUserActionBuilder } from './create_case'; import type { UserActionBuilderMap } from './types'; import { createCategoryUserActionBuilder } from './category'; import { createCustomFieldsUserActionBuilder } from './custom_fields/custom_fields'; +import { createObservablesUserActionBuilder } from './observables'; export const builderMap: UserActionBuilderMap = { create_case: createCaseUserActionBuilder, @@ -34,4 +35,5 @@ export const builderMap: UserActionBuilderMap = { assignees: createAssigneesUserActionBuilder, category: createCategoryUserActionBuilder, customFields: createCustomFieldsUserActionBuilder, + observables: createObservablesUserActionBuilder, }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/observables.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/observables.test.tsx new file mode 100644 index 0000000000000..3bef4352db63d --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/observables.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 React from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { screen } from '@testing-library/react'; +import { UserActionActions } from '../../../common/types/domain'; + +import { renderWithTestingProviders } from '../../common/mock'; +import { getUserAction } from '../../containers/mock'; +import { getMockBuilderArgs } from './mock'; +import { createObservablesUserActionBuilder } from './observables'; +import type { ObservablesActionType } from '../../../common/types/domain/user_action/observables/v1'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createObservablesUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const tests: [number, ObservablesActionType, string][] = [ + [1, 'add', 'added an observable'], + [1, 'delete', 'deleted an observable'], + [1, 'update', 'updated an observable'], + [10, 'add', 'added 10 observables'], + ]; + + it.each(tests)( + 'renders correctly when changed observables to %s', + async (count, actionType, label) => { + const userAction = getUserAction('observables', UserActionActions.update, { + payload: { observables: { count, actionType } }, + }); + const builder = createObservablesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + renderWithTestingProviders(); + + expect(screen.getByTestId(`observables-${actionType}-action`)).toHaveTextContent(label); + } + ); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/observables.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/observables.tsx new file mode 100644 index 0000000000000..7e5ae01eb7f33 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/observables.tsx @@ -0,0 +1,63 @@ +/* + * 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 React, { type ReactNode } from 'react'; +import { EuiText } from '@elastic/eui'; +import type { SnakeToCamelCase } from '../../../common/types'; +import type { ObservablesUserAction } from '../../../common/types/domain'; +import type { UserActionBuilder } from './types'; + +import { createCommonUpdateUserActionBuilder } from './common'; +import { ADDED_OBSERVABLES, DELETED_OBSERVABLES, UPDATED_OBSERVABLES } from './translations'; +import type { ObservablesActionType } from '../../../common/types/domain/user_action/observables/v1'; + +const getLabel: (actionType: ObservablesActionType, count: number) => ReactNode = ( + actionType, + count +) => { + let label = ''; + switch (actionType) { + case 'add': + label = ADDED_OBSERVABLES(count); + break; + case 'delete': + label = DELETED_OBSERVABLES(count); + break; + case 'update': + label = UPDATED_OBSERVABLES(count); + break; + } + return ( + + {label} + + ); +}; +export const createObservablesUserActionBuilder: UserActionBuilder = ({ + userAction, + userProfiles, + handleOutlineComment, +}) => ({ + build: () => { + const action = userAction as SnakeToCamelCase; + const { count, actionType } = action?.payload?.observables; + const label = getLabel(actionType, count); + + if (count > 0) { + const commonBuilder = createCommonUpdateUserActionBuilder({ + userProfiles, + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + } + return []; + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.test.tsx index 9b0b809386afc..80943b13cc402 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.test.tsx @@ -18,7 +18,7 @@ import { createSettingsUserActionBuilder } from './settings'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); -describe('createStatusUserActionBuilder ', () => { +describe('createSettingsUserActionBuilder ', () => { const builderArgs = getMockBuilderArgs(); beforeEach(() => { @@ -34,7 +34,7 @@ describe('createStatusUserActionBuilder ', () => { 'renders correctly when changed setting sync-alerts to %s', async (syncAlerts, label) => { const userAction = getUserAction('settings', UserActionActions.update, { - payload: { settings: { syncAlerts } }, + payload: { settings: { syncAlerts, extractObservables: true } }, }); const builder = createSettingsUserActionBuilder({ ...builderArgs, @@ -45,7 +45,7 @@ describe('createStatusUserActionBuilder ', () => { renderWithTestingProviders(); expect(screen.getByTestId('settings-update-action-settings-update')).toBeTruthy(); - expect(screen.getByText(`${label} sync alerts`)).toBeTruthy(); + expect(screen.getByText(`${label} sync alerts and enabled extract observables`)).toBeTruthy(); } ); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.tsx index 6f25e52d24557..0b4ef00bc090f 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/settings.tsx @@ -11,14 +11,29 @@ import type { SettingsUserAction } from '../../../common/types/domain'; import type { UserActionBuilder } from './types'; import { createCommonUpdateUserActionBuilder } from './common'; -import { DISABLED_SETTING, ENABLED_SETTING, SYNC_ALERTS_LC } from './translations'; +import { + DISABLED_SETTING, + ENABLED_SETTING, + SYNC_ALERTS_LC, + EXTRACT_OBSERVABLES_LC, +} from './translations'; +// TODO: Separate into 2 user actions, https://github.com/elastic/security-team/issues/13709 function getSettingsLabel(userAction: SnakeToCamelCase): ReactNode { + let label = ''; + if (userAction.payload.settings.syncAlerts) { - return `${ENABLED_SETTING} ${SYNC_ALERTS_LC}`; + label = `${ENABLED_SETTING} ${SYNC_ALERTS_LC}`; + } else { + label = `${DISABLED_SETTING} ${SYNC_ALERTS_LC}`; + } + + if (userAction.payload.settings.extractObservables) { + label = `${label} and ${ENABLED_SETTING} ${EXTRACT_OBSERVABLES_LC}`; } else { - return `${DISABLED_SETTING} ${SYNC_ALERTS_LC}`; + label = `${label} and ${DISABLED_SETTING} ${EXTRACT_OBSERVABLES_LC}`; } + return label; } export const createSettingsUserActionBuilder: UserActionBuilder = ({ @@ -28,7 +43,8 @@ export const createSettingsUserActionBuilder: UserActionBuilder = ({ }) => ({ build: () => { const action = userAction as SnakeToCamelCase; - if (action?.payload?.settings?.syncAlerts !== undefined) { + const { syncAlerts, extractObservables } = action?.payload?.settings; + if (syncAlerts !== undefined || extractObservables !== undefined) { const commonBuilder = createCommonUpdateUserActionBuilder({ userProfiles, userAction, diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts index e57bd98ca6a42..e4e45d531b262 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts @@ -151,6 +151,10 @@ export const CUSTOM_FIELDS = i18n.translate('xpack.cases.caseView.userActions.cu defaultMessage: 'Custom Fields', }); +export const OBSERVABLES = i18n.translate('xpack.cases.caseView.userActions.observables', { + defaultMessage: 'Observables', +}); + export const USER_ACTION_EDITED = (type: string) => i18n.translate('xpack.cases.caseView.userActions.edited', { values: { type }, diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx index 84a0f2b41fcdd..c889b76b31d38 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx @@ -24,6 +24,7 @@ export const getUserActionAriaLabel = (type: keyof typeof UserActionTypes) => { delete_case: i18n.CASE_DELETED, category: i18n.CATEGORY, customFields: i18n.CUSTOM_FIELDS, + observables: i18n.OBSERVABLES, }; switch (type) { diff --git a/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.test.tsx index 8437da2f28b9c..7554c7b154636 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.test.tsx @@ -61,6 +61,10 @@ describe('ActionWrapper', () => { "alerts": Object { "sync": true, }, + "observables": Object { + "autoExtract": true, + "enabled": true, + }, }, "owner": Array [ "securitySolution", @@ -86,6 +90,10 @@ describe('ActionWrapper', () => { "alerts": Object { "sync": false, }, + "observables": Object { + "autoExtract": false, + "enabled": true, + }, }, "owner": Array [ "cases", @@ -111,6 +119,10 @@ describe('ActionWrapper', () => { "alerts": Object { "sync": false, }, + "observables": Object { + "autoExtract": false, + "enabled": true, + }, }, "owner": Array [ "observability", @@ -136,6 +148,10 @@ describe('ActionWrapper', () => { "alerts": Object { "sync": false, }, + "observables": Object { + "autoExtract": false, + "enabled": true, + }, }, "owner": Array [], "permissions": Object { diff --git a/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.tsx b/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.tsx index e5f931492dabd..7a309055b12de 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/visualizations/actions/action_wrapper.tsx @@ -35,6 +35,7 @@ const ActionWrapperWithContext: React.FC> = ({ const casePermissions = canUseCases(application.capabilities)(owner ? [owner] : undefined); // TODO: Remove when https://github.com/elastic/kibana/issues/143201 is developed const syncAlerts = owner === SECURITY_SOLUTION_OWNER; + const extractObservables = owner === SECURITY_SOLUTION_OWNER; return ( @@ -43,7 +44,10 @@ const ActionWrapperWithContext: React.FC> = ({ ...casesActionContextProps, owner: owner ? [owner] : [], permissions: casePermissions, - features: { alerts: { sync: syncAlerts } }, + features: { + alerts: { sync: syncAlerts }, + observables: { enabled: true, autoExtract: extractObservables }, + }, }} > {children} diff --git a/x-pack/platform/plugins/shared/cases/public/containers/__mocks__/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/__mocks__/api.ts index f19cefb52b498..0779e422ea290 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/__mocks__/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/__mocks__/api.ts @@ -199,5 +199,6 @@ export const getSimilarCases = async () => allCasesSnake; export const postObservable = jest.fn(); export const patchObservable = jest.fn(); export const deleteObservable = jest.fn(); +export const bulkPostObservables = jest.fn(); export const searchEvents = jest.fn(); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/api.test.tsx b/x-pack/platform/plugins/shared/cases/public/containers/api.test.tsx index a416ef5541663..14f05a1b52d10 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/api.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/api.test.tsx @@ -43,6 +43,7 @@ import { getSimilarCases, patchObservable, deleteObservable, + bulkPostObservables, } from './api'; import { @@ -836,6 +837,7 @@ describe('Cases API', () => { }, settings: { syncAlerts: true, + extractObservables: true, }, owner: SECURITY_SOLUTION_OWNER, category: 'test', @@ -1308,4 +1310,62 @@ describe('Cases API', () => { expect(resp).toEqual(undefined); }); }); + + describe('bulkPostObservables', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + + it('should be called with correct check url, method, signal', async () => { + await bulkPostObservables( + { + caseId: mockCase.id, + observables: [ + { + typeKey: '18b62f19-8c60-415e-8a08-706d1078c556', + value: 'test value', + description: '', + }, + ], + }, + abortCtrl.signal + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_INTERNAL_URL}/${mockCase.id}/observables/_bulk_create`, + { + method: 'POST', + body: JSON.stringify({ + caseId: mockCase.id, + observables: [ + { + typeKey: '18b62f19-8c60-415e-8a08-706d1078c556', + value: 'test value', + description: '', + }, + ], + }), + signal: abortCtrl.signal, + } + ); + }); + + it('should return correct response', async () => { + const resp = await bulkPostObservables( + { + caseId: mockCase.id, + observables: [ + { + typeKey: '18b62f19-8c60-415e-8a08-706d1078c556', + value: 'test value', + description: '', + }, + ], + }, + abortCtrl.signal + ); + expect(resp).toEqual(basicCase); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/api.ts index 5b85e7d33a012..1db56e5c0eca7 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/api.ts @@ -28,6 +28,7 @@ import type { CaseSummaryResponse, InferenceConnectorsResponse, FindCasesContainingAllAlertsResponse, + BulkAddObservablesRequest, } from '../../common/types/api'; import type { CaseConnectors, @@ -65,6 +66,7 @@ import { getCaseSimilarCasesUrl, getCaseSummaryUrl, getInferenceConnectorsUrl, + getBulkCreateObservablesUrl, } from '../../common/api'; import { CASE_REPORTERS_URL, @@ -697,6 +699,21 @@ export const deleteObservable = async ( }); }; +export const bulkPostObservables = async ( + request: BulkAddObservablesRequest, + signal?: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + getBulkCreateObservablesUrl(request.caseId), + { + method: 'POST', + body: JSON.stringify({ caseId: request.caseId, observables: request.observables }), + signal, + } + ); + return convertCaseToCamelCase(decodeCaseResponse(response)); +}; + export const getSimilarCases = async ({ caseId, signal, diff --git a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts index 5c5158204374f..8fdef0c8e82c2 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts @@ -71,6 +71,7 @@ export const casesMutationsKeys = { postObservable: ['post-observable'] as const, patchObservable: ['patch-observable'] as const, deleteObservable: ['delete-observable'] as const, + bulkPostObservables: ['bulk-post-observables'] as const, }; export const inferenceKeys = { diff --git a/x-pack/platform/plugins/shared/cases/public/containers/translations.ts b/x-pack/platform/plugins/shared/cases/public/containers/translations.ts index 4c1e332f7b67a..a4c7020072a7b 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/translations.ts @@ -51,6 +51,12 @@ export const SYNC_CASE = (caseTitle: string) => defaultMessage: 'Alerts in "{caseTitle}" have been synced', }); +export const EXTRACT_OBSERVABLES = (caseTitle: string) => + i18n.translate('xpack.cases.containers.extractObservables', { + values: { caseTitle }, + defaultMessage: 'Auto-extract observables in "{caseTitle}" have been updated', + }); + export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate( 'xpack.cases.containers.statusChangeToasterText', { @@ -80,3 +86,17 @@ export const OBSERVABLE_REMOVED = i18n.translate('xpack.cases.caseView.observabl export const OBSERVABLE_UPDATED = i18n.translate('xpack.cases.caseView.observables.updated', { defaultMessage: 'Observable updated', }); + +export const OBSERVABLE_BULK_CREATED = i18n.translate( + 'xpack.cases.caseView.observables.bulkCreated', + { + defaultMessage: 'Observables added', + } +); + +export const OBSERVABLE_MAX_REACHED = (maxObservables: number) => + i18n.translate('xpack.cases.caseView.observables.maxReached', { + values: { maxObservables }, + defaultMessage: + 'The maximum number of observables is {maxObservables}. Some observables were not added.', + }); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_bulk_post_observables.test.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_bulk_post_observables.test.tsx new file mode 100644 index 0000000000000..8679c82117cde --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_bulk_post_observables.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { act, waitFor, renderHook } from '@testing-library/react'; +import { useBulkPostObservables } from './use_bulk_post_observables'; +import { mockCase, mockObservables } from './mock'; +import { useCasesToast } from '../common/use_cases_toast'; +import type { CaseUI } from '../../common/ui/types'; +import * as api from './api'; +import { MAX_OBSERVABLES_PER_CASE } from '../../common/constants'; +import type { ObservablePost } from '../../common/types/api'; +import { TestProviders } from '../common/mock'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); +jest.mock('../common/use_cases_toast'); + +const showErrorToast = jest.fn(); +const showInfoToast = jest.fn(); + +describe('useBulkPostObservables', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useCasesToast as jest.Mock).mockReturnValue({ showErrorToast, showInfoToast }); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'bulkPostObservables'); + const { result } = renderHook(() => useBulkPostObservables(), { + wrapper: TestProviders, + }); + + act(() => { + result.current.mutate({ caseId: mockCase.id, observables: mockObservables }); + }); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith({ caseId: mockCase.id, observables: mockObservables }) + ); + }); + + it('shows an info toast when the api call is successful and the maximum number of observables is reached', async () => { + const manyObservables: ObservablePost[] = []; + for (let i = 0; i < 100; i++) { + manyObservables.push({ + typeKey: 'observable-type-ipv4', + value: `192.168.1.${i}`, + description: 'test', + }); + } + + const spy = jest.spyOn(api, 'bulkPostObservables'); + spy.mockResolvedValue({ + observables: manyObservables.slice(0, MAX_OBSERVABLES_PER_CASE + 1), + } as CaseUI); + const { result } = renderHook(() => useBulkPostObservables(), { + wrapper: TestProviders, + }); + + await act(async () => { + await result.current.mutate({ caseId: mockCase.id, observables: manyObservables }); + }); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith({ caseId: mockCase.id, observables: manyObservables }) + ); + await waitFor(() => expect(showInfoToast).toHaveBeenCalled()); + }); + + it('does not show an info toast when the api call is successful and the maximum number of observables is not reached', async () => { + const { result } = renderHook(() => useBulkPostObservables(), { + wrapper: TestProviders, + }); + + act(() => { + result.current.mutate({ caseId: mockCase.id, observables: mockObservables }); + }); + + await waitFor(() => expect(showInfoToast).not.toHaveBeenCalled()); + }); + + it('shows a toast error when the api return an error', async () => { + jest + .spyOn(api, 'bulkPostObservables') + .mockRejectedValue(new Error('useBulkPostObservables: Test error')); + + const { result } = renderHook(() => useBulkPostObservables(), { + wrapper: TestProviders, + }); + + act(() => { + result.current.mutate({ caseId: mockCase.id, observables: mockObservables }); + }); + + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_bulk_post_observables.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_bulk_post_observables.tsx new file mode 100644 index 0000000000000..cdcb2e9614d9b --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_bulk_post_observables.tsx @@ -0,0 +1,42 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import { MAX_OBSERVABLES_PER_CASE } from '../../common/constants'; +import type { BulkAddObservablesRequest } from '../../common/types/api'; +import { bulkPostObservables } from './api'; +import * as i18n from './translations'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys } from './constants'; +import { OBSERVABLE_MAX_REACHED } from './translations'; + +export const useBulkPostObservables = () => { + const { showErrorToast, showInfoToast } = useCasesToast(); + + return useMutation( + (request: BulkAddObservablesRequest) => { + return bulkPostObservables(request); + }, + { + mutationKey: casesMutationsKeys.bulkPostObservables, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: (response) => { + if (response.observables.length >= MAX_OBSERVABLES_PER_CASE) { + showInfoToast( + i18n.OBSERVABLE_BULK_CREATED, + OBSERVABLE_MAX_REACHED(MAX_OBSERVABLES_PER_CASE) + ); + } + }, + } + ); +}; + +export type UseBulkPostObservables = ReturnType; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_post_case.test.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_post_case.test.tsx index b4a41f8ebe1e3..ae3986236cbb1 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/use_post_case.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_post_case.test.tsx @@ -31,6 +31,7 @@ describe('usePostCase', () => { }, settings: { syncAlerts: true, + extractObservables: true, }, owner: SECURITY_SOLUTION_OWNER, }; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts b/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts index bb784686df6f1..0f684d5bd2297 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts @@ -25,6 +25,7 @@ const caseBeforeUpdate = { ], settings: { syncAlerts: true, + extractObservables: false, }, } as CaseUI; @@ -56,6 +57,7 @@ describe('utils', () => { // We remove the id as is randomly generated const toast = createUpdateSuccessToaster(caseBeforeUpdate, caseAfterUpdate, 'settings', { syncAlerts: true, + extractObservables: false, }); expect(toast).toEqual({ @@ -64,6 +66,18 @@ describe('utils', () => { }); }); + it('creates the correct toast when extract observables is turned on', () => { + const toast = createUpdateSuccessToaster(caseBeforeUpdate, caseAfterUpdate, 'settings', { + syncAlerts: false, + extractObservables: true, + }); + + expect(toast).toEqual({ + title: 'Auto-extract observables in "My case" have been updated', + className: 'eui-textBreakWord', + }); + }); + it('creates the correct toast when sync alerts is turned on and case does NOT have alerts', () => { // We remove the id as is randomly generated const toast = createUpdateSuccessToaster( @@ -72,6 +86,7 @@ describe('utils', () => { 'settings', { syncAlerts: true, + extractObservables: false, } ); @@ -85,6 +100,7 @@ describe('utils', () => { // We remove the id as is randomly generated const toast = createUpdateSuccessToaster(caseBeforeUpdate, caseAfterUpdate, 'settings', { syncAlerts: false, + extractObservables: false, }); expect(toast).toEqual({ @@ -112,7 +128,7 @@ describe('utils', () => { it('creates the correct toast when the status change, case has alerts, and sync alerts is off', () => { // We remove the id as is randomly generated const toast = createUpdateSuccessToaster( - { ...caseBeforeUpdate, settings: { syncAlerts: false } }, + { ...caseBeforeUpdate, settings: { syncAlerts: false, extractObservables: true } }, caseAfterUpdate, 'status', 'closed' diff --git a/x-pack/platform/plugins/shared/cases/public/containers/utils.ts b/x-pack/platform/plugins/shared/cases/public/containers/utils.ts index 06e8b891479f5..d6b2365b4bfac 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/utils.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/utils.ts @@ -154,11 +154,21 @@ export const createUpdateSuccessToaster = ( className: 'eui-textBreakWord', }; - if (valueToUpdateIsSettings(key, value) && value?.syncAlerts && caseHasAlerts) { - return { - ...toast, - title: i18n.SYNC_CASE(caseAfterUpdate.title), - }; + if ( + valueToUpdateIsSettings(key, value) && + ((value?.syncAlerts && caseHasAlerts) || value?.extractObservables) + ) { + if (value?.extractObservables !== caseBeforeUpdate.settings.extractObservables) { + return { + ...toast, + title: i18n.EXTRACT_OBSERVABLES(caseAfterUpdate.title), + }; + } else { + return { + ...toast, + title: i18n.SYNC_CASE(caseAfterUpdate.title), + }; + } } if (valueToUpdateIsStatus(key, value) && caseHasAlerts && caseBeforeUpdate.settings.syncAlerts) { diff --git a/x-pack/platform/plugins/shared/cases/public/mocks.ts b/x-pack/platform/plugins/shared/cases/public/mocks.ts index a4f1150b7e1ee..69a0df5466b6d 100644 --- a/x-pack/platform/plugins/shared/cases/public/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/public/mocks.ts @@ -54,6 +54,7 @@ const helpersMock: jest.Mocked = { }), getRuleIdFromEvent: jest.fn(), groupAlertsByRule: jest.fn(), + getObservablesFromEcs: jest.fn(), }; export interface CaseUiClientMock { diff --git a/x-pack/platform/plugins/shared/cases/public/plugin.test.ts b/x-pack/platform/plugins/shared/cases/public/plugin.test.ts index 3208addce1a14..56a148ada52c9 100644 --- a/x-pack/platform/plugins/shared/cases/public/plugin.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/plugin.test.ts @@ -153,6 +153,7 @@ describe('Cases Ui Plugin', () => { getRuleIdFromEvent: expect.any(Function), getUICapabilities: expect.any(Function), groupAlertsByRule: expect.any(Function), + getObservablesFromEcs: expect.any(Function), }, hooks: { useCasesAddToExistingCaseModal: expect.any(Function), diff --git a/x-pack/platform/plugins/shared/cases/public/plugin.ts b/x-pack/platform/plugins/shared/cases/public/plugin.ts index 16a41410ab892..b0c058ddb4544 100644 --- a/x-pack/platform/plugins/shared/cases/public/plugin.ts +++ b/x-pack/platform/plugins/shared/cases/public/plugin.ts @@ -39,6 +39,7 @@ import type { } from './types'; import { registerSystemActions } from './components/system_actions'; import { registerAnalytics } from './analytics'; +import { getObservablesFromEcs } from './client/helpers/get_observables_from_ecs'; /** * @public @@ -203,6 +204,7 @@ export class CasesUiPlugin getUICapabilities, getRuleIdFromEvent, groupAlertsByRule, + getObservablesFromEcs, }, }; } diff --git a/x-pack/platform/plugins/shared/cases/public/types.ts b/x-pack/platform/plugins/shared/cases/public/types.ts index e6e4a5721e9c8..f5619992ecbc9 100644 --- a/x-pack/platform/plugins/shared/cases/public/types.ts +++ b/x-pack/platform/plugins/shared/cases/public/types.ts @@ -37,6 +37,7 @@ import type { UseCasesAddToNewCaseFlyout } from './components/create/flyout/use_ import type { UseIsAddToCaseOpen } from './components/cases_context/state/use_is_add_to_case_open'; import type { canUseCases } from './client/helpers/can_use_cases'; import type { getRuleIdFromEvent } from './client/helpers/get_rule_id_from_event'; +import type { getObservablesFromEcs } from './client/helpers/get_observables_from_ecs'; import type { GetCasesContextProps } from './client/ui/get_cases_context'; import type { GetCasesProps } from './client/ui/get_cases'; import type { GetAllCasesSelectorModalProps } from './client/ui/get_all_cases_selector_modal'; @@ -174,6 +175,7 @@ export interface CasesPublicStart { getUICapabilities: typeof getUICapabilities; getRuleIdFromEvent: typeof getRuleIdFromEvent; groupAlertsByRule: GroupAlertsByRule; + getObservablesFromEcs: typeof getObservablesFromEcs; }; } diff --git a/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts b/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts index 40fe0538d3e10..3fa7214f2ab4e 100644 --- a/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts +++ b/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts @@ -123,6 +123,7 @@ const createCase = (counter: number, owner: string, reqId: string): CasePostRequ }, settings: { syncAlerts: false, + extractObservables: false, }, owner: owner ?? 'cases', customFields: [], diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/client.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/client.ts index c615cb4ac6508..ef3a1db247422 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/client.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/client.ts @@ -24,6 +24,7 @@ import type { CasesSimilarResponse, AddObservableRequest, UpdateObservableRequest, + BulkAddObservablesRequest, } from '../../../common/types/api'; import type { CasesClient } from '../client'; import type { CasesClientInternal } from '../client_internal'; @@ -41,7 +42,12 @@ import { bulkCreate } from './bulk_create'; import type { ReplaceCustomFieldArgs } from './replace_custom_field'; import { replaceCustomField } from './replace_custom_field'; import { similar } from './similar'; -import { addObservable, deleteObservable, updateObservable } from './observables'; +import { + addObservable, + bulkAddObservables, + deleteObservable, + updateObservable, +} from './observables'; /** * API for interacting with the cases entities. @@ -128,6 +134,10 @@ export interface CasesSubClient { * Removes observable */ deleteObservable(caseId: string, observableId: string): Promise; + /** + * Bulk adds observables to the case + */ + bulkAddObservables(params: BulkAddObservablesRequest): Promise; } /** @@ -164,6 +174,8 @@ export const createCasesSubClient = ( updateObservable(caseId, observableId, params, clientArgs, casesClient), deleteObservable: (caseId: string, observableId: string) => deleteObservable(caseId, observableId, clientArgs, casesClient), + bulkAddObservables: (params: BulkAddObservablesRequest) => + bulkAddObservables(params, clientArgs, casesClient), }; return Object.freeze(casesSubClient); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/mock.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/mock.ts index bca6b12b2efd5..e0594237210bc 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/mock.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/mock.ts @@ -276,7 +276,7 @@ export const userActions: CaseUserActionsDeprecatedResponse = [ subcategory: '45', }, }, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, status: 'open', severity: 'low', owner: SECURITY_SOLUTION_OWNER, diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/observables.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/observables.test.ts index 10677043b8da3..d0e74aff0fe1c 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/observables.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/observables.test.ts @@ -4,12 +4,23 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { addObservable, deleteObservable, updateObservable } from './observables'; +import { + addObservable, + deleteObservable, + updateObservable, + bulkAddObservables, +} from './observables'; import Boom from '@hapi/boom'; import { LICENSING_CASE_OBSERVABLES_FEATURE } from '../../common/constants'; import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; import { mockCases } from '../../mocks'; -import { OBSERVABLE_TYPE_IPV4 } from '../../../common/constants'; +import { + OBSERVABLE_TYPE_IPV4, + OBSERVABLE_TYPE_IPV6, + MAX_OBSERVABLES_PER_CASE, +} from '../../../common/constants'; +import type { ObservablePost } from '../../../common/types/api'; +import { UserActionTypes } from '../../../common/types/domain/user_action/v1'; const caseSO = mockCases[0]; @@ -18,12 +29,16 @@ const mockClientArgs = createCasesClientMockArgs(); const mockLicensingService = mockClientArgs.services.licensingService; const mockCaseService = mockClientArgs.services.caseService; +const mockUserActionService = mockClientArgs.services.userActionService; -const mockObservable = { +const mockObservablePost = { value: '127.0.0.1', typeKey: OBSERVABLE_TYPE_IPV4.key, - id: '5c431380-c6ef-459f-b0fe-1699e978517b', description: null, +}; +const mockObservable = { + ...mockObservablePost, + id: '5c431380-c6ef-459f-b0fe-1699e978517b', createdAt: '2024-12-05', updatedAt: '2024-12-05', }; @@ -137,6 +152,26 @@ describe('addObservable', () => { ) ).rejects.toThrow(); }); + + it('should create a user action with the correct payload', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + await addObservable( + caseSO.id, + { observable: { typeKey: OBSERVABLE_TYPE_IPV4.key, value: '127.0.0.1', description: '' } }, + mockClientArgs, + mockCasesClient + ); + + expect(mockUserActionService.creator.createUserAction).toHaveBeenCalledWith({ + userAction: { + type: UserActionTypes.observables, + caseId: caseSO.id, + owner: 'securitySolution', + user: mockClientArgs.user, + payload: { observables: { count: 1, actionType: 'add' } }, + }, + }); + }); }); describe('updateObservable', () => { @@ -226,6 +261,27 @@ describe('updateObservable', () => { ) ).rejects.toThrow(); }); + + it('should create a user action with the correct payload', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + await updateObservable( + caseSO.id, + mockObservable.id, + { observable: { value: '192.168.0.1', description: 'Updated description' } }, + mockClientArgs, + mockCasesClient + ); + + expect(mockUserActionService.creator.createUserAction).toHaveBeenCalledWith({ + userAction: { + type: UserActionTypes.observables, + caseId: caseSO.id, + owner: 'securitySolution', + user: mockClientArgs.user, + payload: { observables: { count: 1, actionType: 'update' } }, + }, + }); + }); }); describe('deleteObservable', () => { @@ -264,4 +320,147 @@ describe('deleteObservable', () => { deleteObservable('case-id', 'observable-id', mockClientArgs, mockCasesClient) ).rejects.toThrow(); }); + + it('should create a user action with the correct payload', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + await deleteObservable(caseSO.id, mockObservable.id, mockClientArgs, mockCasesClient); + + expect(mockUserActionService.creator.createUserAction).toHaveBeenCalledWith({ + userAction: { + type: UserActionTypes.observables, + caseId: caseSO.id, + owner: 'securitySolution', + user: mockClientArgs.user, + payload: { observables: { count: 1, actionType: 'delete' } }, + }, + }); + }); +}); + +describe('bulkAddObservables', () => { + beforeEach(() => { + mockCaseService.patchCase.mockResolvedValue(caseSOWithObservables); + mockCaseService.getCase.mockResolvedValue(caseSOWithObservables); + jest.clearAllMocks(); + }); + + const createObservableMatcher = (observable: ObservablePost) => + expect.objectContaining({ typeKey: observable.typeKey, value: observable.value }); + + it('should bulk add observables successfully', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + const observables = [ + { typeKey: OBSERVABLE_TYPE_IPV4.key, value: 'ip1', description: '' }, + { typeKey: OBSERVABLE_TYPE_IPV6.key, value: 'ip2', description: '' }, + ]; + const result = await bulkAddObservables( + { + caseId: 'case-id', + observables, + }, + mockClientArgs, + mockCasesClient + ); + expect(result).toBeDefined(); + + const expectedObservables = [mockObservable, observables[0], observables[1]]; + expect(mockCaseService.patchCase).toHaveBeenCalledWith( + expect.objectContaining({ + updatedAttributes: expect.objectContaining({ + observables: expect.arrayContaining( + expectedObservables.map((observable) => + expect.objectContaining({ typeKey: observable.typeKey, value: observable.value }) + ) + ), + }), + }) + ); + }); + + it('should throw an error if license is not platinum', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(false); + await expect( + bulkAddObservables( + { caseId: 'case-id', observables: [mockObservablePost] }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow( + Boom.forbidden( + 'In order to assign observables to cases, you must be subscribed to an Elastic Platinum license' + ) + ); + }); + + it('should handle errors and throw boom', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + mockCaseService.getCase.mockRejectedValue(new Error('Case not found')); + await expect( + bulkAddObservables( + { caseId: 'case-id', observables: [mockObservablePost] }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow(); + }); + + it('should return the max number of observables', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + const moreThanMaxObservables = []; + for (let i = 0; i < MAX_OBSERVABLES_PER_CASE; i++) { + moreThanMaxObservables.push({ ...mockObservablePost, value: `192.168.0.${i}` }); + } + await bulkAddObservables( + { caseId: 'case-id', observables: moreThanMaxObservables }, + mockClientArgs, + mockCasesClient + ); + + const expectedObservables = [ + mockObservable, + // offset by one to account for the existing observable in the case + ...moreThanMaxObservables.slice(0, MAX_OBSERVABLES_PER_CASE - 1), + ]; + const excludedObservable = moreThanMaxObservables[moreThanMaxObservables.length - 1]; + + expect(mockCaseService.patchCase).toHaveBeenCalledWith( + expect.objectContaining({ + updatedAttributes: expect.objectContaining({ + observables: expect.arrayContaining(expectedObservables.map(createObservableMatcher)), + }), + }) + ); + expect(mockCaseService.patchCase).toHaveBeenCalledWith( + expect.objectContaining({ + updatedAttributes: expect.objectContaining({ + observables: expect.not.arrayContaining([createObservableMatcher(excludedObservable)]), + }), + }) + ); + }); + + it('should create a user action with the correct payload', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + await bulkAddObservables( + { + caseId: caseSO.id, + observables: [ + { ...mockObservablePost, value: 'ip2' }, + { ...mockObservablePost, value: 'ip3' }, + ], + }, + mockClientArgs, + mockCasesClient + ); + + expect(mockUserActionService.creator.createUserAction).toHaveBeenCalledWith({ + userAction: { + type: UserActionTypes.observables, + caseId: caseSO.id, + owner: 'securitySolution', + user: mockClientArgs.user, + payload: { observables: { count: 2, actionType: 'add' } }, + }, + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/observables.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/observables.ts index 8295a8f2853dd..697057ed1ba32 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/observables.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/observables.ts @@ -10,12 +10,16 @@ import { v4 } from 'uuid'; import Boom from '@hapi/boom'; import { MAX_OBSERVABLES_PER_CASE } from '../../../common/constants'; -import { CaseRt } from '../../../common/types/domain'; +import type { Observable } from '../../../common/types/domain'; +import { CaseRt, UserActionTypes } from '../../../common/types/domain'; import { AddObservableRequestRt, type AddObservableRequest, type UpdateObservableRequest, UpdateObservableRequestRt, + type BulkAddObservablesRequest, + BulkAddObservablesRequestRt, + type ObservablePost, } from '../../../common/types/api'; import type { CasesClient } from '../client'; import type { CasesClientArgs } from '../types'; @@ -30,6 +34,7 @@ import { validateObservableTypeKeyExists, validateObservableValue, } from '../validators'; +import { processObservables } from './utils'; const ensureUpdateAuthorized = async ( authorization: PublicMethodsOf, @@ -53,8 +58,9 @@ export const addObservable = async ( casesClient: CasesClient ) => { const { - services: { caseService, licensingService }, + services: { caseService, licensingService, userActionService }, authorization, + user, } = clientArgs; const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); @@ -107,6 +113,18 @@ export const addObservable = async ( }, }); + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.observables, + caseId: retrievedCase.id, + owner: retrievedCase.attributes.owner, + user, + payload: { + observables: { count: 1, actionType: 'add' }, + }, + }, + }); + const res = flattenCaseSavedObject({ savedObject: { ...retrievedCase, @@ -130,8 +148,9 @@ export const updateObservable = async ( casesClient: CasesClient ) => { const { - services: { caseService, licensingService }, + services: { caseService, licensingService, userActionService }, authorization, + user, } = clientArgs; const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); @@ -183,6 +202,18 @@ export const updateObservable = async ( }, }); + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.observables, + caseId: retrievedCase.id, + owner: retrievedCase.attributes.owner, + user, + payload: { + observables: { count: 1, actionType: 'update' }, + }, + }, + }); + const res = flattenCaseSavedObject({ savedObject: { ...retrievedCase, @@ -205,8 +236,9 @@ export const deleteObservable = async ( casesClient: CasesClient ) => { const { - services: { caseService, licensingService }, + services: { caseService, licensingService, userActionService }, authorization, + user, } = clientArgs; const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); @@ -237,7 +269,105 @@ export const deleteObservable = async ( originalCase: retrievedCase, updatedAttributes: { observables: updatedObservables }, }); + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.observables, + caseId: retrievedCase.id, + owner: retrievedCase.attributes.owner, + user, + payload: { + observables: { count: 1, actionType: 'delete' }, + }, + }, + }); } catch (error) { throw Boom.badRequest(`Failed to delete observable id: ${observableId}: ${error}`); } }; + +export const bulkAddObservables = async ( + params: BulkAddObservablesRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +) => { + const { + services: { caseService, licensingService, userActionService }, + authorization, + user, + } = clientArgs; + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign observables to cases, you must be subscribed to an Elastic Platinum license' + ); + } + + licensingService.notifyUsage(LICENSING_CASE_OBSERVABLES_FEATURE); + + try { + const paramArgs = decodeWithExcessOrThrow(BulkAddObservablesRequestRt)(params); + const retrievedCase = await caseService.getCase({ id: paramArgs.caseId }); + await ensureUpdateAuthorized(authorization, retrievedCase); + + await Promise.all( + params.observables.map((observable: ObservablePost) => + validateObservableTypeKeyExists(casesClient, { + caseOwner: retrievedCase.attributes.owner, + observableTypeKey: observable.typeKey, + }) + ) + ); + + const currentObservables = retrievedCase.attributes.observables ?? []; + const updatedObservablesMap = new Map(); + currentObservables.forEach((observable) => { + processObservables(updatedObservablesMap, observable); + }); + + paramArgs.observables.forEach((observable) => + processObservables(updatedObservablesMap, observable) + ); + + const finalObservables = Array.from(updatedObservablesMap.values()).slice( + 0, + MAX_OBSERVABLES_PER_CASE + ); + + const updatedCase = await caseService.patchCase({ + caseId: retrievedCase.id, + originalCase: retrievedCase, + updatedAttributes: { + observables: finalObservables, + }, + }); + + const newObservablesCount = finalObservables.length - currentObservables.length; + + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.observables, + caseId: retrievedCase.id, + owner: retrievedCase.attributes.owner, + user, + payload: { + observables: { count: newObservablesCount, actionType: 'add' }, + }, + }, + }); + + const res = flattenCaseSavedObject({ + savedObject: { + ...retrievedCase, + ...updatedCase, + attributes: { ...retrievedCase.attributes, ...updatedCase?.attributes }, + references: retrievedCase.references, + }, + }); + + return decodeOrThrow(CaseRt)(res); + } catch (error) { + throw Boom.badRequest(`Failed to add observable: ${error}`); + } +}; diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts index 4da6bcc3bd5df..8e44a418ef4e1 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts @@ -33,8 +33,14 @@ import { normalizeCreateCaseRequest, getInProgressInfoForUpdate, getTimingMetricsForUpdate, + isObservable, + processObservables, } from './utils'; -import type { CaseCustomFields, CustomFieldsConfiguration } from '../../../common/types/domain'; +import type { + CaseCustomFields, + CustomFieldsConfiguration, + Observable, +} from '../../../common/types/domain'; import { CaseStatuses, CustomFieldTypes, @@ -47,6 +53,7 @@ import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { casesConnectors } from '../../connectors'; import { userProfiles, userProfilesMap } from '../user_profiles.mock'; import { mappings, mockCases } from '../../mocks'; +import type { ObservablePost } from '../../../common/types/api'; const allComments = [ commentObj, @@ -1914,7 +1921,7 @@ describe('normalizeCreateCaseRequest', () => { type: ConnectorTypes.none, fields: null, }, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, severity: CaseSeverity.LOW, owner: SECURITY_SOLUTION_OWNER, assignees: [{ uid: '1' }], @@ -1995,3 +2002,83 @@ describe('normalizeCreateCaseRequest', () => { }); }); }); + +describe('isObservable', () => { + it('should return true if the observable is an Observable', () => { + expect( + isObservable({ + id: '1', + typeKey: 'ip', + value: '127.0.0.1', + description: null, + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + }) + ).toBe(true); + }); + + it('should return false if the observable is not an Observable', () => { + expect(isObservable({ typeKey: 'ip', value: '127.0.0.1', description: null })).toBe(false); + }); +}); + +describe('processObservables', () => { + const mockObservablePost: ObservablePost = { + typeKey: 'ip', + value: '127.0.0.1', + description: null, + }; + + const mockObservable: Observable = { + ...mockObservablePost, + id: '1', + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + }; + + it('should process the current observable', () => { + const observablesMap = new Map(); + processObservables(observablesMap, mockObservable); + expect(observablesMap.get('ip-127.0.0.1')).toBeDefined(); + }); + + it('should process the new observable post', () => { + const observablesMap = new Map(); + processObservables(observablesMap, mockObservablePost); + expect(observablesMap.get('ip-127.0.0.1')).toBeDefined(); + }); + + it('should not add the observable if it already exists', () => { + const observablesMap = new Map(); + processObservables(observablesMap, mockObservable); + processObservables(observablesMap, mockObservable); + expect(observablesMap.get('ip-127.0.0.1')).toBeDefined(); + expect(observablesMap.size).toBe(1); + + processObservables(observablesMap, mockObservablePost); + expect(observablesMap.size).toBe(1); + }); + + it('should add a new observable if the key-value pair does not exist', () => { + const observablesMap = new Map(); + processObservables(observablesMap, mockObservablePost); + processObservables(observablesMap, { ...mockObservablePost, typeKey: 'ip2' }); + expect(observablesMap.get('ip-127.0.0.1')).toBeDefined(); + expect(observablesMap.get('ip2-127.0.0.1')).toBeDefined(); + expect(observablesMap.size).toBe(2); + }); + + it('should not override the existing observable if the key-value pair already exists', () => { + const observablesMap = new Map(); + processObservables(observablesMap, mockObservable); + processObservables(observablesMap, { + ...mockObservable, + id: '2', + createdAt: '2021-01-02', + updatedAt: '2021-01-02', + }); + expect(observablesMap.get('ip-127.0.0.1')).toBeDefined(); + expect(observablesMap.get('ip-127.0.0.1')).toEqual(mockObservable); + expect(observablesMap.size).toBe(1); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts index ef20c2876c9ec..b09fb974f36c6 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts @@ -10,6 +10,7 @@ import type { UserProfile } from '@kbn/security-plugin/common'; import type { IBasePath } from '@kbn/core-http-browser'; import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { v4 } from 'uuid'; import type { ActionConnector, Attachment, @@ -22,6 +23,7 @@ import type { ConnectorMappingTarget, CustomFieldsConfiguration, ExternalService, + Observable, User, } from '../../../common/types/domain'; import { AttachmentType, CaseStatuses, UserActionTypes } from '../../../common/types/domain'; @@ -29,6 +31,7 @@ import type { CasePostRequest, CaseRequestCustomFields, CaseUserActionsDeprecatedResponse, + ObservablePost, } from '../../../common/types/api'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { isPushedUserAction } from '../../../common/utils/user_actions'; @@ -645,3 +648,27 @@ export const normalizeCreateCaseRequest = ( customFieldsConfiguration, }), }); + +export const isObservable = (observable: ObservablePost | Observable): observable is Observable => + 'id' in observable && 'typeKey' in observable && 'value' in observable; + +export const processObservables = ( + observablesMap: Map, + observable: ObservablePost | Observable +) => { + const key = `${observable.typeKey}-${observable.value}`; + const isExistingObservable = observablesMap.has(key); + if (isExistingObservable) { + return; + } + if (isObservable(observable)) { + observablesMap.set(key, observable); + } else { + observablesMap.set(key, { + ...observable, + id: v4(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } +}; diff --git a/x-pack/platform/plugins/shared/cases/server/client/mocks.ts b/x-pack/platform/plugins/shared/cases/server/client/mocks.ts index 6d55effe02574..e4f4693dcc42b 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/mocks.ts @@ -77,6 +77,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => { addObservable: jest.fn(), updateObservable: jest.fn(), deleteObservable: jest.fn(), + bulkAddObservables: jest.fn(), }); }; diff --git a/x-pack/platform/plugins/shared/cases/server/common/types/case.test.ts b/x-pack/platform/plugins/shared/cases/server/common/types/case.test.ts index 3a2b93f69d0a2..0fb530ee65986 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/types/case.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/types/case.test.ts @@ -48,6 +48,7 @@ describe('case types', () => { }, settings: { syncAlerts: true, + extractObservables: true, }, owner: SECURITY_SOLUTION_OWNER, assignees: [], diff --git a/x-pack/platform/plugins/shared/cases/server/common/utils.test.ts b/x-pack/platform/plugins/shared/cases/server/common/utils.test.ts index 01c3bca91b948..70ffbc9a17ae0 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/utils.test.ts @@ -154,6 +154,7 @@ describe('common utils', () => { "observables": Array [], "owner": "securitySolution", "settings": Object { + "extractObservables": true, "syncAlerts": true, }, "severity": "low", @@ -211,6 +212,7 @@ describe('common utils', () => { "observables": Array [], "owner": "securitySolution", "settings": Object { + "extractObservables": true, "syncAlerts": true, }, "severity": "medium", @@ -272,6 +274,7 @@ describe('common utils', () => { "observables": Array [], "owner": "securitySolution", "settings": Object { + "extractObservables": true, "syncAlerts": true, }, "severity": "low", @@ -339,6 +342,7 @@ describe('common utils', () => { "observables": Array [], "owner": "securitySolution", "settings": Object { + "extractObservables": true, "syncAlerts": true, }, "severity": "low", diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/__mocks__/request_responses.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/__mocks__/request_responses.ts index 652e93fc520cf..dc30250f6d28d 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/__mocks__/request_responses.ts @@ -21,6 +21,7 @@ export const newCase: CasePostRequest = { }, settings: { syncAlerts: true, + extractObservables: true, }, owner: SECURITY_SOLUTION_OWNER, }; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts index 5ec26c79994d4..0a0c33e329642 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts @@ -21,6 +21,7 @@ import { getCasesMetricRoute } from './internal/get_cases_metrics'; import { searchCasesRoute } from './internal/search_cases'; import { replaceCustomFieldRoute } from './internal/replace_custom_field'; import { postObservableRoute } from './observables/post_observable'; +import { bulkPostObservableRoute } from './observables/bulk_post_observable'; import { similarCaseRoute } from './cases/similar'; import { patchObservableRoute } from './observables/patch_observable'; import { deleteObservableRoute } from './observables/delete_observable'; @@ -45,6 +46,7 @@ export const getInternalRoutes = (userProfileService: UserProfileService, config searchCasesRoute, replaceCustomFieldRoute, postObservableRoute, + bulkPostObservableRoute, patchObservableRoute, deleteObservableRoute, similarCaseRoute, diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/observables/bulk_post_observable.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/observables/bulk_post_observable.ts new file mode 100644 index 0000000000000..096a4a1e85cb1 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/observables/bulk_post_observable.ts @@ -0,0 +1,51 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { + INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL, + MAX_OBSERVABLES_PER_CASE, +} from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import type { observableApiV1 } from '../../../../common/types/api'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +export const bulkPostObservableRoute = createCasesRoute({ + method: 'post', + path: INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + params: { + params: schema.object({ + case_id: schema.string(), + }), + }, + routerOptions: { + access: 'internal', + summary: 'Bulk add case observables', + description: `Each case can have a maximum of ${MAX_OBSERVABLES_PER_CASE} observables.`, + // You must have `all` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating. + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + const caseId = request.params.case_id; + const { observables } = request.body as observableApiV1.BulkAddObservablesRequest; + const theCase = await casesClient.cases.bulkAddObservables({ caseId, observables }); + + return response.ok({ + body: theCase, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to post observable in route case id: ${request.params.case_id}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts index 28f702dd92702..0351803b8ceeb 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts @@ -2286,6 +2286,7 @@ describe('CasesService', () => { 'category', 'customFields', 'observables', + 'settings', 'incremental_id', 'settings', 'total_alerts', diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts index fdf30f13a5e39..260c0c058925a 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts @@ -671,6 +671,7 @@ describe('UserActionBuilder', () => { "description": "testing sir", "owner": "securitySolution", "settings": Object { + "extractObservables": true, "syncAlerts": true, }, "severity": "low", @@ -776,6 +777,43 @@ describe('UserActionBuilder', () => { } `); }); + + it('builds an add observables user action correctly', () => { + const builder = builderFactory.getBuilder(UserActionTypes.observables)!; + const userAction = builder.build({ + payload: { observables: { count: 1, actionType: 'add' } }, + ...commonArgs, + }); + + expect(userAction!.parameters).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "action": "create", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "observables": Object { + "actionType": "add", + "count": 1, + }, + }, + "type": "observables", + }, + "references": Array [ + Object { + "id": "123", + "name": "associated-cases", + "type": "cases", + }, + ], + } + `); + }); }); describe('eventDetails', () => { @@ -1238,5 +1276,26 @@ describe('UserActionBuilder', () => { `"User updated the category for case id: 123 - user action id: 123"` ); }); + + it('builds an update observables user action correctly', () => { + const builder = builderFactory.getBuilder(UserActionTypes.observables)!; + const userAction = builder.build({ + payload: { observables: { count: 1, actionType: 'update' } }, + ...commonArgs, + }); + + expect(userAction!.eventDetails).toMatchInlineSnapshot(` + Object { + "action": "create", + "descriptiveAction": "case_user_action_observables", + "getMessage": [Function], + "savedObjectId": "123", + "savedObjectType": "cases", + } + `); + expect(userAction!.eventDetails.getMessage('123')).toMatchInlineSnapshot( + `"User added observables to case id: 123 - user action id: 123"` + ); + }); }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts index 53a19dccd11bd..fd9645244dfff 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts @@ -23,6 +23,7 @@ import { AssigneesUserActionBuilder } from './builders/assignees'; import { NoopUserActionBuilder } from './builders/noop'; import { CategoryUserActionBuilder } from './builders/category'; import { CustomFieldsUserActionBuilder } from './builders/custom_fields'; +import { ObservablesUserActionBuilder } from './builders/observables'; const builderMap = { assignees: AssigneesUserActionBuilder, @@ -39,6 +40,7 @@ const builderMap = { settings: SettingsUserActionBuilder, delete_case: NoopUserActionBuilder, customFields: CustomFieldsUserActionBuilder, + observables: ObservablesUserActionBuilder, }; export class BuilderFactory { diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/observables.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/observables.ts new file mode 100644 index 0000000000000..7bf743e62a475 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/observables.ts @@ -0,0 +1,42 @@ +/* + * 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 { UserActionActions, UserActionTypes } from '../../../../common/types/domain'; +import { CASE_SAVED_OBJECT } from '../../../../common/constants'; +import { UserActionBuilder } from '../abstract_builder'; +import type { EventDetails, UserActionParameters, UserActionEvent } from '../types'; + +export class ObservablesUserActionBuilder extends UserActionBuilder { + build(args: UserActionParameters<'observables'>): UserActionEvent { + const { caseId } = args; + const action = UserActionActions.create; + + const parameters = this.buildCommonUserAction({ + ...args, + action, + valueKey: 'observables', + value: args.payload.observables, + type: UserActionTypes.observables, + }); + + const getMessage = (id?: string) => + `User added observables to case id: ${caseId} - user action id: ${id}`; + + const eventDetails: EventDetails = { + getMessage, + action, + descriptiveAction: 'case_user_action_observables', + savedObjectId: caseId, + savedObjectType: CASE_SAVED_OBJECT, + }; + + return { + parameters, + eventDetails, + }; + } +} diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/index.test.ts index 9e5b7589f1626..6b9cb634c3e85 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/index.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/index.test.ts @@ -144,7 +144,7 @@ describe('CaseUserActionService', () => { }, description: 'testing sir', owner: 'securitySolution', - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, status: 'open', severity: 'low', tags: ['sir'], @@ -740,7 +740,7 @@ describe('CaseUserActionService', () => { }, type: 'settings', owner: 'securitySolution', - payload: { settings: { syncAlerts: false } }, + payload: { settings: { syncAlerts: false, extractObservables: false } }, }, references: [{ id: '2', name: 'associated-cases', type: 'cases' }], type: 'cases-user-actions', diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/mocks.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/mocks.ts index 359e2ae67f3a0..72b48a9611fe6 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/mocks.ts @@ -38,7 +38,7 @@ export const casePayload: CasePostRequest = { subcategory: '45', }, }, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, severity: CaseSeverity.LOW, owner: SECURITY_SOLUTION_OWNER, assignees: [{ uid: '1' }], @@ -82,7 +82,7 @@ export const patchCasesRequest = { updatedAttributes: { description: 'updated desc', tags: ['one', 'two'], - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, severity: CaseSeverity.CRITICAL, }, originalCase: originalCases[1], @@ -555,6 +555,7 @@ export const getBuiltUserActions = ({ isMock }: { isMock: boolean }): UserAction payload: { settings: { syncAlerts: false, + extractObservables: false, }, }, type: 'settings', diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/test_utils.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/test_utils.ts index 3006faf916bd0..c3bf08255b286 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/test_utils.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/test_utils.ts @@ -186,7 +186,7 @@ export const createCaseUserAction = (): SavedObject { diff --git a/x-pack/platform/plugins/shared/ml/public/application/explorer/alerts/alerts_panel.tsx b/x-pack/platform/plugins/shared/ml/public/application/explorer/alerts/alerts_panel.tsx index 4708629a2cb91..0f014b522ffa9 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/explorer/alerts/alerts_panel.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/explorer/alerts/alerts_panel.tsx @@ -219,6 +219,7 @@ export const AlertsPanel: FC = () => { featureId: CASE_GENERAL_ID, owner: [CASE_APP_ID], syncAlerts: false, + extractObservables: false, }} showAlertStatusWithFlapping services={{ diff --git a/x-pack/platform/plugins/shared/osquery/cypress/tasks/api_fixtures.ts b/x-pack/platform/plugins/shared/osquery/cypress/tasks/api_fixtures.ts index 9d4e49c5b8116..611f16238e410 100644 --- a/x-pack/platform/plugins/shared/osquery/cypress/tasks/api_fixtures.ts +++ b/x-pack/platform/plugins/shared/osquery/cypress/tasks/api_fixtures.ts @@ -344,7 +344,7 @@ export const loadCase = (owner: string) => description: 'Test security case', assignees: [], connector: { id: 'none', name: 'none', type: '.none', fields: null }, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, owner, }, }).then((response) => response.body); diff --git a/x-pack/platform/test/cases_api_integration/common/lib/alerts.ts b/x-pack/platform/test/cases_api_integration/common/lib/alerts.ts index 25f336a1fa5b6..c0ef0e5500434 100644 --- a/x-pack/platform/test/cases_api_integration/common/lib/alerts.ts +++ b/x-pack/platform/test/cases_api_integration/common/lib/alerts.ts @@ -165,7 +165,7 @@ export const createCaseAndAttachAlert = async ({ { ...postCaseReq, owner, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/find_user_actions.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/find_user_actions.ts index 65c79b8996f98..6007cde3bc479 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/find_user_actions.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/find_user_actions.ts @@ -563,7 +563,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: theCase.id, version: theCase.version, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, ], }, @@ -585,7 +585,9 @@ export default ({ getService }: FtrProviderContext): void => { expect(settingsUserAction.type).to.eql('settings'); expect(settingsUserAction.action).to.eql('update'); - expect(settingsUserAction.payload).to.eql({ settings: { syncAlerts: false } }); + expect(settingsUserAction.payload).to.eql({ + settings: { syncAlerts: false, extractObservables: false }, + }); }); it('retrieves only the severity user actions', async () => { diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/find_user_actions.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/find_user_actions.ts index 59a5db2ee1423..8e14d2e7f5509 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/find_user_actions.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/find_user_actions.ts @@ -508,7 +508,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: theCase.id, version: theCase.version, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, ], }, @@ -529,7 +529,9 @@ export default ({ getService }: FtrProviderContext): void => { expect(settingsUserAction.type).to.eql('settings'); expect(settingsUserAction.action).to.eql('update'); - expect(settingsUserAction.payload).to.eql({ settings: { syncAlerts: false } }); + expect(settingsUserAction.payload).to.eql({ + settings: { syncAlerts: false, extractObservables: false }, + }); }); it('retrieves only the severity user actions', async () => { diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_user_action_stats.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_user_action_stats.ts index f6e59fab412d1..a6d4b81b286a5 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_user_action_stats.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_user_action_stats.ts @@ -48,6 +48,7 @@ const getCaseUpdateData = (id: string, version: string) => ({ description: 'new desc', settings: { syncAlerts: false, + extractObservables: false, }, tags: ['one', 'two'], connector: { diff --git a/x-pack/platform/test/functional/services/cases/helpers.ts b/x-pack/platform/test/functional/services/cases/helpers.ts index d910d2c613677..78cd25fa75b1b 100644 --- a/x-pack/platform/test/functional/services/cases/helpers.ts +++ b/x-pack/platform/test/functional/services/cases/helpers.ts @@ -22,6 +22,7 @@ export function generateRandomCaseWithoutConnector(owner = 'cases'): CasePostReq } as CaseConnector, settings: { syncAlerts: false, + extractObservables: false, }, owner, }; diff --git a/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts b/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts index aa98363bab740..ec3a9ce19f154 100644 --- a/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts +++ b/x-pack/platform/test/security_api_integration/tests/features/deprecated_features.ts @@ -378,7 +378,7 @@ export default function ({ getService }: FtrProviderContext) { tags: ['defacement'], severity: CaseSeverity.LOW, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, owner: 'cases_owner_one', assignees: [], ...props, diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_case_links.test.ts b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_case_links.test.ts index e28993d708ee8..a525b08e5f709 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_case_links.test.ts +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/hooks/use_case_links.test.ts @@ -94,6 +94,7 @@ const mockCase: Cases[0] = { totalAlerts: 0, settings: { syncAlerts: true, + extractObservables: false, }, observables: [], severity: CaseSeverity.LOW, diff --git a/x-pack/solutions/observability/plugins/observability_shared/public/components/add_page_attachment_to_case_modal/add_page_attachment_to_case_modal.test.tsx b/x-pack/solutions/observability/plugins/observability_shared/public/components/add_page_attachment_to_case_modal/add_page_attachment_to_case_modal.test.tsx index bdba45110cb0d..6002ba6ccee11 100644 --- a/x-pack/solutions/observability/plugins/observability_shared/public/components/add_page_attachment_to_case_modal/add_page_attachment_to_case_modal.test.tsx +++ b/x-pack/solutions/observability/plugins/observability_shared/public/components/add_page_attachment_to_case_modal/add_page_attachment_to_case_modal.test.tsx @@ -64,6 +64,7 @@ describe('AddPageAttachmentToCaseModal', () => { getUICapabilities: jest.fn().mockReturnValue({}), getRuleIdFromEvent: jest.fn().mockReturnValue({}), groupAlertsByRule: jest.fn().mockReturnValue({}), + getObservablesFromEcs: jest.fn().mockReturnValue({}), }; }); diff --git a/x-pack/solutions/search/test/serverless/api_integration/test_suites/cases/post_case.ts b/x-pack/solutions/search/test/serverless/api_integration/test_suites/cases/post_case.ts index 3f4bce2e80f31..52724e42607e5 100644 --- a/x-pack/solutions/search/test/serverless/api_integration/test_suites/cases/post_case.ts +++ b/x-pack/solutions/search/test/serverless/api_integration/test_suites/cases/post_case.ts @@ -45,6 +45,7 @@ export default ({ getService }: FtrProviderContext): void => { }, settings: { syncAlerts: true, + extractObservables: true, }, owner: 'cases', assignees: [], diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts index 34c9c2fbc83af..28c8d993fd909 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/data_loaders/index_case.ts @@ -47,6 +47,7 @@ export const indexCase = async ( }, settings: { syncAlerts: true, + extractObservables: true, }, owner: 'securitySolution', ...newCase, diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/routes.tsx b/x-pack/solutions/security/plugins/security_solution/public/app/routes.tsx index 16e2987ec78ec..3afe09873c16c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/app/routes.tsx @@ -19,6 +19,7 @@ import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes'; import { NotFoundPage } from './404'; import { HomePage } from './home'; import { AlertDetailsRedirect } from '../detections/pages/alerts/alert_details_redirect'; +import { CASES_FEATURES } from '../cases'; interface RouterProps { children: React.ReactNode; @@ -51,7 +52,11 @@ const PageRouterComponent: FC = ({ children, history }) => { component={AlertDetailsRedirect} /> - + {children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/index.ts b/x-pack/solutions/security/plugins/security_solution/public/cases/index.ts index 2fd472756adc5..3e7a8c102a6e6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cases/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/index.ts @@ -8,6 +8,13 @@ import type { SecuritySubPlugin } from '../app/types'; import { routes } from './routes'; +export const CASES_FEATURES = { + observables: { + enabled: true, + autoExtract: true, + }, +} as const; + export class Cases { public setup() {} diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx index 8730973e43763..6d004777cb301 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/pages/index.tsx @@ -38,6 +38,7 @@ import { useFetchNotes } from '../../notes/hooks/use_fetch_notes'; import { DocumentEventTypes } from '../../common/lib/telemetry'; import { AiForSOCAlertsTable } from '../components/ai_for_soc/wrapper'; import { EventsTableForCases } from '../components/case_events/table'; +import { CASES_FEATURES } from '..'; const CaseContainerComponent: React.FC = () => { const { @@ -145,6 +146,7 @@ const CaseContainerComponent: React.FC = () => { basePath: CASES_PATH, owner: [APP_ID], features: { + ...CASES_FEATURES, metrics: [ CaseMetricsFeature.ALERTS_COUNT, CaseMetricsFeature.ALERTS_USERS, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx index 1c16d53c02f71..84e7eef72b03f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/more_actions_row_control_column.test.tsx @@ -33,6 +33,7 @@ describe('MoreActionsRowControlColumn', () => { createComment: true, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, @@ -69,6 +70,7 @@ describe('MoreActionsRowControlColumn', () => { createComment: false, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, @@ -106,6 +108,7 @@ describe('MoreActionsRowControlColumn', () => { createComment: true, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx index 3ae616806094b..6a060b30136ee 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx @@ -90,6 +90,7 @@ export const CASES_CONFIGURATION = { featureId: CASES_FEATURE_ID, owner: [APP_ID], syncAlerts: true, + extractObservables: true, }; // This will guarantee that ALL cells will have their values vertically centered. diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 16ce23e91f620..e09def95d1c97 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -140,7 +140,12 @@ const initialSort: GetSecurityAlertsTableProp<'initialSort'> = [ }, }, ]; -const casesConfiguration = { featureId: CASES_FEATURE_ID, owner: [APP_ID], syncAlerts: true }; +const casesConfiguration = { + featureId: CASES_FEATURE_ID, + owner: [APP_ID], + syncAlerts: true, + extractObservables: true, +}; const emptyInputFilters: Filter[] = []; const AlertsTableComponent: FC> = ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index f79b3c7c67747..c780b146310a5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -83,6 +83,7 @@ const mockUseKibanaReturnValue = { reopenCase: true, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 5098b9fcb5749..4b6cb9988df07 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -14,6 +14,7 @@ import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { get, getOr } from 'lodash/fp'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { TableId } from '@kbn/securitysolution-data-table'; +import { flattenObject } from '@kbn/object-utils'; import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '../../../../common/components/header_actions'; import { isActiveTimeline } from '../../../../helpers'; @@ -96,8 +97,17 @@ const AlertContextMenuComponent: React.FC = ({ const ruleRuleId = get(0, ecsRowData?.kibana?.alert?.rule?.rule_id); const ruleName = get(0, ecsRowData?.kibana?.alert?.rule?.name); + const flattenedEcsData = useMemo(() => { + const flattened = flattenObject(ecsRowData); + return Object.entries(flattened).map(([key, value]) => ({ + field: key, + value: value as string[], + })); + }, [ecsRowData]); + const { addToCaseActionItems } = useAddToCaseActions({ ecsData: ecsRowData, + nonEcsData: flattenedEcsData, onMenuItemClick, ariaLabel: ATTACH_ALERT_TO_CASE_FOR_ROW({ ariaRowindex, columnValues }), refetch, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx index b6cdfa38cb6e0..44a1da2f65e4a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx @@ -39,10 +39,21 @@ const defaultProps = { event: { kind: ['signal'], }, + host: { + name: ['test-host'], + }, }, refetch, }; +const mockObservable = [ + { + typeKey: 'observable-type-hostname', + value: 'test-host', + description: 'Auto extracted observable', + }, +]; + const addToNewCase = jest.fn().mockReturnValue(caseHooksReturnedValue); const addToExistingCase = jest.fn().mockReturnValue(caseHooksReturnedValue); const useKibanaMock = useKibana as jest.Mock; @@ -74,6 +85,7 @@ describe('useAddToCaseActions', () => { helpers: { getRuleIdFromEvent: () => null, canUseCases: jest.fn().mockReturnValue(allCasesPermissions()), + getObservablesFromEcs: jest.fn().mockReturnValue(mockObservable), }, }, }, @@ -113,6 +125,7 @@ describe('useAddToCaseActions', () => { }); expect(open).toHaveBeenCalledWith({ attachments: [{ alertId: '123', index: '', rule: null, type: 'alert' }], + observables: mockObservable, }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 5e3c914c53e64..0c4dbc1d39e18 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -91,20 +91,27 @@ export const useAddToCaseActions = ({ }, [onMenuItemClick, onCaseSuccess]); const selectCaseModal = casesUi.hooks.useCasesAddToExistingCaseModal(selectCaseArgs); - + const observables = useMemo( + () => casesUi.helpers.getObservablesFromEcs(nonEcsData ? [nonEcsData] : []), + [casesUi.helpers, nonEcsData] + ); const handleAddToNewCaseClick = useCallback(() => { // TODO rename this, this is really `closePopover()` onMenuItemClick(); createCaseFlyout.open({ attachments: caseAttachments, + observables, }); - }, [onMenuItemClick, createCaseFlyout, caseAttachments]); + }, [onMenuItemClick, createCaseFlyout, caseAttachments, observables]); const handleAddToExistingCaseClick = useCallback(() => { // TODO rename this, this is really `closePopover()` onMenuItemClick(); - selectCaseModal.open({ getAttachments: () => caseAttachments }); - }, [caseAttachments, onMenuItemClick, selectCaseModal]); + selectCaseModal.open({ + getAttachments: () => caseAttachments, + getObservables: observables ? () => observables : undefined, + }); + }, [caseAttachments, onMenuItemClick, observables, selectCaseModal]); const addToCaseActionItems: AlertTableContextMenuItem[] = useMemo(() => { if (userCasesPermissions.createComment && userCasesPermissions.read) { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx index 89ad94da6abf1..e30cfdab38bcb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/components/take_action_button.test.tsx @@ -31,6 +31,7 @@ describe('TakeActionButton', () => { createComment: true, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, @@ -68,6 +69,7 @@ describe('TakeActionButton', () => { createComment: false, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, @@ -104,6 +106,7 @@ describe('TakeActionButton', () => { createComment: true, }), getRuleIdFromEvent: jest.fn(), + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx index 30f064370b934..c1186bfda6cfc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/components/take_action_dropdown.test.tsx @@ -102,6 +102,7 @@ describe('take action dropdown', () => { helpers: { canUseCases: jest.fn().mockReturnValue(allCasesPermissions()), getRuleIdFromEvent: () => null, + getObservablesFromEcs: jest.fn().mockReturnValue([]), }, }, osquery: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts index bead433e74f88..a32de9db55151 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts @@ -111,7 +111,7 @@ export const loadCase = (owner: string) => description: 'Test security case', assignees: [], connector: { id: 'none', name: 'none', type: '.none', fields: null }, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, owner, }, }).then((response) => response.body); diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/cases_table/mock_data.ts b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/cases_table/mock_data.ts index 56430ec83691d..2a6abd30043c1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/cases_table/mock_data.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/cases_table/mock_data.ts @@ -20,6 +20,7 @@ export const mockCasesResult = { description: 'klklk', settings: { syncAlerts: true, + extractObservables: true, }, owner: 'securitySolution', closed_at: '2022-04-25T01:50:40.435Z', @@ -60,6 +61,7 @@ export const mockCasesResult = { description: 'sssss', settings: { syncAlerts: true, + extractObservables: true, }, owner: 'securitySolution', closed_at: '2022-04-25T13:45:18.317Z', @@ -100,6 +102,7 @@ export const mockCasesResult = { description: 'dsdd', settings: { syncAlerts: true, + extractObservables: true, }, owner: 'securitySolution', closed_at: '2022-04-25T13:45:22.539Z', diff --git a/x-pack/solutions/security/plugins/security_solution/tsconfig.json b/x-pack/solutions/security/plugins/security_solution/tsconfig.json index 2bbf868620ce3..ba91c0c749239 100644 --- a/x-pack/solutions/security/plugins/security_solution/tsconfig.json +++ b/x-pack/solutions/security/plugins/security_solution/tsconfig.json @@ -260,6 +260,7 @@ "@kbn/licensing-types", "@kbn/core-metrics-server", "@kbn/rrule", - "@kbn/core-analytics-server-mocks" + "@kbn/core-analytics-server-mocks", + "@kbn/object-utils" ] } diff --git a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts index 25d3cfc827bde..1ec6f03ad1549 100644 --- a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts +++ b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -2090,7 +2090,7 @@ export default ({ getService }: FtrProviderContext): void => { const postedCase = await createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }); const { id } = await createRule(supertest, log, rule); @@ -2138,7 +2138,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: caseStatusUpdated[0].id, version: caseStatusUpdated[0].version, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, }, ], }, @@ -2196,7 +2196,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: caseUpdated.id, version: caseUpdated.version, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, ], }, diff --git a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts index 43d195b9d8ccb..f3e3796d68482 100644 --- a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts +++ b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts @@ -43,6 +43,7 @@ export default ({ getService }: FtrProviderContext): void => { ...postCaseReq, settings: { syncAlerts: false, + extractObservables: false, }, }); @@ -62,6 +63,7 @@ export default ({ getService }: FtrProviderContext): void => { ...postCaseReq, settings: { syncAlerts: false, + extractObservables: false, }, }); @@ -142,7 +144,7 @@ export default ({ getService }: FtrProviderContext): void => { cases: updatedIndWithStatus.map((caseInfo) => ({ id: caseInfo.id, version: caseInfo.version, - settings: { syncAlerts: true }, + settings: { syncAlerts: true, extractObservables: true }, })), }) .expect(200); diff --git a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts index ee290495805b6..a470f81c11c8f 100644 --- a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts +++ b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts @@ -592,12 +592,14 @@ export default ({ getService }: FtrProviderContext): void => { const bulkCreateAlertsAndVerifyAlertStatus = async ({ syncAlerts, + extractObservables, expectedAlertStatus, caseAuth, attachmentExpectedHttpCode, attachmentAuth, }: { syncAlerts: boolean; + extractObservables: boolean; expectedAlertStatus: string; caseAuth?: { user: User; space: string | null }; attachmentExpectedHttpCode?: number; @@ -607,7 +609,7 @@ export default ({ getService }: FtrProviderContext): void => { supertestWithoutAuth, { ...postCaseReq, - settings: { syncAlerts }, + settings: { syncAlerts, extractObservables }, }, 200, caseAuth @@ -652,7 +654,7 @@ export default ({ getService }: FtrProviderContext): void => { [...Array(totalCases).keys()].map((index) => createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }) ) ); @@ -679,6 +681,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should change the status of the alert if sync alert is on', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'acknowledged', }); }); @@ -686,6 +689,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should NOT change the status of the alert if sync alert is off', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: false, + extractObservables: false, expectedAlertStatus: 'open', }); }); @@ -693,6 +697,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should change the status of the alert when the user has write access to the indices and only read access to the siem solution', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'acknowledged', caseAuth: { user: superUser, @@ -705,6 +710,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should NOT change the status of the alert when the user does NOT have access to the alert', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'open', caseAuth: { user: superUser, @@ -718,6 +724,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should NOT change the status of the alert when the user has read access to the kibana feature but no read access to the ES index', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'open', caseAuth: { user: superUser, @@ -759,7 +766,7 @@ export default ({ getService }: FtrProviderContext): void => { const postedCase = await createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }); await createCommentAndRefreshIndex({ @@ -775,7 +782,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -798,7 +805,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -821,7 +828,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -858,7 +865,7 @@ export default ({ getService }: FtrProviderContext): void => { createCase(supertest, { ...postCaseReq, owner: 'observabilityFixture', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }) ) ); @@ -936,7 +943,7 @@ export default ({ getService }: FtrProviderContext): void => { const postedCase = await createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }); await createComment({ @@ -962,7 +969,7 @@ export default ({ getService }: FtrProviderContext): void => { { ...postCaseReq, owner: 'observabilityFixture', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -992,7 +999,7 @@ export default ({ getService }: FtrProviderContext): void => { { ...postCaseReq, owner: 'observabilityFixture', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } diff --git a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts index 1febfb0b72db1..0881ad534de8b 100644 --- a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts +++ b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts @@ -836,12 +836,14 @@ export default ({ getService }: FtrProviderContext): void => { const bulkCreateAlertsAndVerifyAlertStatus = async ({ syncAlerts, + extractObservables, expectedAlertStatus, caseAuth, attachmentExpectedHttpCode, attachmentAuth, }: { syncAlerts: boolean; + extractObservables: boolean; expectedAlertStatus: string; caseAuth?: { user: User; space: string | null }; attachmentExpectedHttpCode?: number; @@ -851,7 +853,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts }, + settings: { syncAlerts, extractObservables }, }, 200, caseAuth @@ -908,7 +910,7 @@ export default ({ getService }: FtrProviderContext): void => { [...Array(totalCases).keys()].map((index) => createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }) ) ); @@ -936,6 +938,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should change the status of the alerts if sync alert is on', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'acknowledged', }); }); @@ -943,6 +946,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should NOT change the status of the alert if sync alert is off', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: false, + extractObservables: false, expectedAlertStatus: 'open', }); }); @@ -950,6 +954,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should change the status of the alert when the user has write access to the indices and only read access to the siem solution', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'acknowledged', caseAuth: { user: superUser, @@ -962,6 +967,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should NOT change the status of the alert when the user does NOT have access to the alert', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'open', caseAuth: { user: superUser, @@ -975,6 +981,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should NOT change the status of the alert when the user has read access to the kibana feature but no read access to the ES index', async () => { await bulkCreateAlertsAndVerifyAlertStatus({ syncAlerts: true, + extractObservables: true, expectedAlertStatus: 'open', caseAuth: { user: superUser, @@ -1015,7 +1022,7 @@ export default ({ getService }: FtrProviderContext): void => { const postedCase = await createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }); await bulkCreateAttachmentsAndRefreshIndex({ @@ -1030,7 +1037,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -1052,7 +1059,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -1074,7 +1081,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -1110,7 +1117,7 @@ export default ({ getService }: FtrProviderContext): void => { createCase(supertest, { ...postCaseReq, owner: 'observabilityFixture', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }) ) ); @@ -1192,7 +1199,7 @@ export default ({ getService }: FtrProviderContext): void => { const postedCase = await createCase(supertest, { ...postCaseReq, - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }); await bulkCreateAttachments({ @@ -1220,7 +1227,7 @@ export default ({ getService }: FtrProviderContext): void => { { ...postCaseReq, owner: 'observabilityFixture', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } @@ -1252,7 +1259,7 @@ export default ({ getService }: FtrProviderContext): void => { { ...postCaseReq, owner: 'observabilityFixture', - settings: { syncAlerts: false }, + settings: { syncAlerts: false, extractObservables: false }, }, 200, { user: superUser, space: 'space1' } diff --git a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 69c8dd82b3410..f7dbc01d93311 100644 --- a/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/solutions/security/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -637,9 +637,13 @@ export default ({ getService }: FtrProviderContext): void => { const attachAlertsAndPush = async ({ syncAlerts = true, - }: { syncAlerts?: boolean } = {}) => { + extractObservables = true, + }: { syncAlerts?: boolean; extractObservables?: boolean } = {}) => { const { postedCase, connector } = await createCaseWithConnector({ - createCaseReq: { ...getPostCaseRequest(), settings: { syncAlerts } }, + createCaseReq: { + ...getPostCaseRequest(), + settings: { syncAlerts, extractObservables }, + }, configureReq: { closure_type: 'close-by-pushing', }, diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/cases/search_ai_lake_tier/dummy_test.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/cases/search_ai_lake_tier/dummy_test.ts index d986cf1edb8e2..617f0acaa40e5 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/cases/search_ai_lake_tier/dummy_test.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/ai4dsoc/cases/search_ai_lake_tier/dummy_test.ts @@ -30,6 +30,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Case title 1', settings: { syncAlerts: true, + extractObservables: false, }, connector: { id: '131d4448-abe0-4789-939d-8ef60680b498', diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/cases.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/cases.ts index 8d1021b666eb7..3d123ccb44ad0 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/cases.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/cases.ts @@ -25,6 +25,7 @@ export const createCase = (newCase: TestCase) => }, settings: { syncAlerts: true, + extractObservables: true, }, owner: newCase.owner, },