diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index b179232a3f8cf..719e4f3b735a1 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -46558,10 +46558,12 @@ components: - minLength: 1 type: string Security_Endpoint_Management_API_AgentTypes: + description: The host agent type (optional). Defaults to endpoint. enum: - endpoint - sentinel_one - crowdstrike + - microsoft_defender_endpoint type: string Security_Endpoint_Management_API_AlertIds: description: A list of alerts ids. diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 834ae63c72dcd..d06897f5db6e2 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -53434,10 +53434,12 @@ components: - minLength: 1 type: string Security_Endpoint_Management_API_AgentTypes: + description: The host agent type (optional). Defaults to endpoint. enum: - endpoint - sentinel_one - crowdstrike + - microsoft_defender_endpoint type: string Security_Endpoint_Management_API_AlertIds: description: A list of alerts ids. diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts index a159f1259f986..9b265ec559b28 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts @@ -142,8 +142,16 @@ export const Comment = z.string(); export type Parameters = z.infer; export const Parameters = z.object({}); +/** + * The host agent type (optional). Defaults to endpoint. + */ export type AgentTypes = z.infer; -export const AgentTypes = z.enum(['endpoint', 'sentinel_one', 'crowdstrike']); +export const AgentTypes = z.enum([ + 'endpoint', + 'sentinel_one', + 'crowdstrike', + 'microsoft_defender_endpoint', +]); export type AgentTypesEnum = typeof AgentTypes.enum; export const AgentTypesEnum = AgentTypes.enum; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml index a0c7220d26ef0..ff36bd0e4c645 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml @@ -141,10 +141,12 @@ components: description: Optional parameters object AgentTypes: type: string + description: The host agent type (optional). Defaults to endpoint. enum: - endpoint - sentinel_one - crowdstrike + - microsoft_defender_endpoint BaseActionSchema: x-inline: true diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts index b6970222d0d55..abbc374a1de66 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts @@ -12,7 +12,12 @@ export type ResponseActionStatus = (typeof RESPONSE_ACTION_STATUS)[number]; export const RESPONSE_ACTION_TYPE = ['automated', 'manual'] as const; export type ResponseActionType = (typeof RESPONSE_ACTION_TYPE)[number]; -export const RESPONSE_ACTION_AGENT_TYPE = ['endpoint', 'sentinel_one', 'crowdstrike'] as const; +export const RESPONSE_ACTION_AGENT_TYPE = [ + 'endpoint', + 'sentinel_one', + 'crowdstrike', + 'microsoft_defender_endpoint', +] as const; export type ResponseActionAgentType = (typeof RESPONSE_ACTION_AGENT_TYPE)[number]; /** @@ -181,6 +186,7 @@ export const RESPONSE_ACTIONS_ZIP_PASSCODE: Readonly = Object.values( diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts index 0e1fc072b2604..5776ff0dd3432 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts @@ -23,11 +23,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: true, crowdstrike: true, + microsoft_defender_endpoint: true, }, }, unisolate: { @@ -35,11 +37,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: true, crowdstrike: true, + microsoft_defender_endpoint: true, }, }, upload: { @@ -47,11 +51,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, 'get-file': { @@ -59,11 +65,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: true, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, 'kill-process': { @@ -71,11 +79,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: true, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, execute: { @@ -83,11 +93,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, 'suspend-process': { @@ -95,11 +107,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, 'running-processes': { @@ -107,11 +121,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: true, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, scan: { @@ -119,11 +135,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: true, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, }, runscript: { @@ -131,11 +149,13 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = { endpoint: false, sentinel_one: false, crowdstrike: false, + microsoft_defender_endpoint: false, }, manual: { endpoint: false, sentinel_one: false, crowdstrike: true, + microsoft_defender_endpoint: false, }, }, }; diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/index.ts index d21b20795b1e3..da02b6bd59322 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/index.ts @@ -15,6 +15,7 @@ export * from './trusted_apps'; export * from './utility_types'; export * from './agents'; export * from './sentinel_one'; +export * from './microsoft_defender_endpoint'; export type { ConditionEntriesMap, ConditionEntry } from './exception_list_items'; /** diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts new file mode 100644 index 0000000000000..e0e6c498b5621 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/microsoft_defender_endpoint.ts @@ -0,0 +1,11 @@ +/* + * 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 interface MicrosoftDefenderEndpointActionRequestCommonMeta { + /** The ID of the action in Microsoft Defender's system */ + machineActionId: string; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index c94167964b71b..5b9c1086d84f1 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -272,6 +272,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the Asset Inventory feature */ assetInventoryUXEnabled: false, + + /** + * Enabled Microsoft Defender for Endpoint actions client + */ + responseActionsMSDefenderEndpointEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml index 62d604ec727db..46b9d014c6267 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml @@ -480,10 +480,12 @@ components: - minLength: 1 type: string AgentTypes: + description: The host agent type (optional). Defaults to endpoint. enum: - endpoint - sentinel_one - crowdstrike + - microsoft_defender_endpoint type: string AlertIds: description: A list of alerts ids. diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml index ee89d61a58b52..c0a391c686e08 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml @@ -480,10 +480,12 @@ components: - minLength: 1 type: string AgentTypes: + description: The host agent type (optional). Defaults to endpoint. enum: - endpoint - sentinel_one - crowdstrike + - microsoft_defender_endpoint type: string AlertIds: description: A list of alerts ids. diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/agents/agent_type_vendor_logo/agent_type_vendor_logo.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/agents/agent_type_vendor_logo/agent_type_vendor_logo.test.tsx index a2195004dfb03..be0b062d48162 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/agents/agent_type_vendor_logo/agent_type_vendor_logo.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/agents/agent_type_vendor_logo/agent_type_vendor_logo.test.tsx @@ -28,7 +28,10 @@ describe('AgentTypeVendorLogo component', () => { }; }); - it.each(RESPONSE_ACTION_AGENT_TYPE)('should display logo for: %s', async (agentType) => { + // FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012 + it.each( + RESPONSE_ACTION_AGENT_TYPE.filter((agentType) => agentType !== 'microsoft_defender_endpoint') + )('should display logo for: %s', async (agentType) => { props.agentType = agentType; const { getByTitle } = render(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/responder/from_alerts/use_responder_action_data.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/responder/from_alerts/use_responder_action_data.test.ts index 6cbd0ced12387..d098731277cbf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/responder/from_alerts/use_responder_action_data.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/endpoint/responder/from_alerts/use_responder_action_data.test.ts @@ -110,7 +110,12 @@ describe('use responder action data hooks', () => { expect(onClickMock).not.toHaveBeenCalled(); }); - it.each([...RESPONSE_ACTION_AGENT_TYPE])( + it.each([ + // FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012 + ...RESPONSE_ACTION_AGENT_TYPE.filter( + (agentType) => agentType !== 'microsoft_defender_endpoint' + ), + ])( 'should show action disabled with tooltip for %s if agent id field is missing', (agentType) => { const agentTypeField = RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS[agentType][0]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/endpoint/use_alert_response_actions_support.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/endpoint/use_alert_response_actions_support.test.ts index 451e1454f1ed0..0443c8b9d0056 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/endpoint/use_alert_response_actions_support.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/endpoint/use_alert_response_actions_support.test.ts @@ -76,16 +76,15 @@ describe('When using `useAlertResponseActionsSupport()` hook', () => { appContextMock.renderHook(() => useAlertResponseActionsSupport(alertDetailItemData)); }); - it.each(RESPONSE_ACTION_AGENT_TYPE)( - 'should return expected response for agentType: `%s`', - (agentType) => { - alertDetailItemData = - endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType); - const { result } = renderHook(); - - expect(result.current).toEqual(getExpectedResult({ details: { agentType } })); - } - ); + it.each( + // FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012 + RESPONSE_ACTION_AGENT_TYPE.filter((agentType) => agentType !== 'microsoft_defender_endpoint') + )('should return expected response for agentType: `%s`', (agentType) => { + alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType); + const { result } = renderHook(); + + expect(result.current).toEqual(getExpectedResult({ details: { agentType } })); + }); it('should set `isSupported` to `false` if no alert details item data is provided', () => { alertDetailItemData = []; @@ -178,7 +177,9 @@ describe('When using `useAlertResponseActionsSupport()` hook', () => { }); it.each( - RESPONSE_ACTION_AGENT_TYPE.filter((agentType) => agentType !== 'endpoint') as Array< + RESPONSE_ACTION_AGENT_TYPE.filter((agentType) => agentType !== 'endpoint') + // FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012 + .filter((agentType) => agentType !== 'microsoft_defender_endpoint') as Array< Exclude > )('should set `isSupported` to `false` for [%s] if feature flag is disabled', (agentType) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/endpoint/utils/get_event_details_agent_id_field.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/endpoint/utils/get_event_details_agent_id_field.test.ts index bd34bc529b202..3c7ea874f4679 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/endpoint/utils/get_event_details_agent_id_field.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/endpoint/utils/get_event_details_agent_id_field.test.ts @@ -13,7 +13,11 @@ import { import { getEventDetailsAgentIdField, parseEcsFieldPath } from '..'; describe('getEventDetailsAgentIdField()', () => { - it.each(RESPONSE_ACTION_AGENT_TYPE)(`should return agent id info for %s`, (agentType) => { + it.each( + RESPONSE_ACTION_AGENT_TYPE + // FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012 + .filter((agentType) => agentType !== 'microsoft_defender_endpoint') + )(`should return agent id info for %s`, (agentType) => { const field = RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS[agentType][0]; const eventDetails = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType); @@ -25,26 +29,24 @@ describe('getEventDetailsAgentIdField()', () => { }); }); - it.each(RESPONSE_ACTION_AGENT_TYPE)( - 'should include a field when agent id is not found: %s', - (agentType) => { - const field = RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS[agentType][0]; - const eventDetails = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType( - agentType, - { - 'event.dataset': { values: ['foo'], originalValue: ['foo'] }, - [field]: undefined, - } - ); + it.each( + RESPONSE_ACTION_AGENT_TYPE + // FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012 + .filter((agentType) => agentType !== 'microsoft_defender_endpoint') + )('should include a field when agent id is not found: %s', (agentType) => { + const field = RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS[agentType][0]; + const eventDetails = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType, { + 'event.dataset': { values: ['foo'], originalValue: ['foo'] }, + [field]: undefined, + }); - expect(getEventDetailsAgentIdField(agentType, eventDetails)).toEqual({ - found: false, - category: parseEcsFieldPath(field).category, - field, - agentId: '', - }); - } - ); + expect(getEventDetailsAgentIdField(agentType, eventDetails)).toEqual({ + found: false, + category: parseEcsFieldPath(field).category, + field, + agentId: '', + }); + }); it.each(RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS.sentinel_one)( 'should return field [%s] for sentinelone when agent is not found and event.dataset matches', diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx index 6748b15422c8f..bf2d40a350390 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx @@ -177,6 +177,9 @@ const useTypesFilterInitialState = ({ const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled( 'responseActionsSentinelOneV1Enabled' ); + const isMicrosoftDefenderEnabled = useIsExperimentalFeatureEnabled( + 'responseActionsMSDefenderEndpointEnabled' + ); const isCrowdstrikeEnabled = useIsExperimentalFeatureEnabled( 'responseActionsCrowdstrikeManualHostIsolationEnabled' ); @@ -207,14 +210,21 @@ const useTypesFilterInitialState = ({ // v8.13 onwards // for showing agent types and action types in the same filter - if (isSentinelOneV1Enabled || isCrowdstrikeEnabled) { + if (isSentinelOneV1Enabled || isCrowdstrikeEnabled || isMicrosoftDefenderEnabled) { if (!isFlyout) { return [ { label: FILTER_NAMES.agentTypes, isGroupLabel: true, }, - ...RESPONSE_ACTION_AGENT_TYPE.map((type) => + ...RESPONSE_ACTION_AGENT_TYPE.filter((agentType) => { + switch (agentType) { + case 'microsoft_defender_endpoint': + return isMicrosoftDefenderEnabled; + default: + return true; + } + }).map((type) => getFilterOptions({ key: type, label: getAgentTypeName(type), diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx index 8a967da55c732..e2a3c73cab572 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx @@ -1877,6 +1877,7 @@ describe('Response actions history', () => { mockedContext.setExperimentalFlag({ responseActionsSentinelOneV1Enabled: true, responseActionsCrowdstrikeManualHostIsolationEnabled: true, + responseActionsMSDefenderEndpointEnabled: true, }); render({ isFlyout: false }); const { getByTestId, getAllByTestId } = renderResult; @@ -1891,6 +1892,7 @@ describe('Response actions history', () => { 'Elastic Defend', 'SentinelOne', 'Crowdstrike', + 'microsoft_defender_endpoint', 'Triggered by rule', 'Triggered manually', ]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx index a8725a68c89e3..659da5fd70986 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.tsx @@ -70,6 +70,9 @@ export const ResponseActionsLog = memo< const isCrowdstrikeEnabled = useIsExperimentalFeatureEnabled( 'responseActionsCrowdstrikeManualHostIsolationEnabled' ); + const isMicrosoftDefenderEnabled = useIsExperimentalFeatureEnabled( + 'responseActionsMSDefenderEndpointEnabled' + ); // Used to decide if display global loader or not (only the fist time tha page loads) const [isFirstAttempt, setIsFirstAttempt] = useState(true); @@ -92,7 +95,7 @@ export const ResponseActionsLog = memo< setQueryParams((prevState) => ({ ...prevState, agentTypes: - isSentinelOneV1Enabled || isCrowdstrikeEnabled + isSentinelOneV1Enabled || isCrowdstrikeEnabled || isMicrosoftDefenderEnabled ? agentTypesFromUrl?.length ? agentTypesFromUrl : prevState.agentTypes @@ -121,6 +124,7 @@ export const ResponseActionsLog = memo< isFlyout, isCrowdstrikeEnabled, isSentinelOneV1Enabled, + isMicrosoftDefenderEnabled, statusesFromUrl, setQueryParams, usersFromUrl, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts index d37929c15a3f4..78c7331519048 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts @@ -115,13 +115,26 @@ describe('CompleteExternalTaskRunner class', () => { expect(esClientMock.bulk).toHaveBeenCalledWith({ index: ENDPOINT_ACTION_RESPONSES_INDEX, + // Array below will have records for each type of external EDR, so as new ones are + // added, a new response should be added to the array below operations: [ + // for SentinelOne { create: { _index: ENDPOINT_ACTION_RESPONSES_INDEX } }, expect.objectContaining({ '@timestamp': expect.any(String), EndpointActions: expect.any(Object), agent: expect.any(Object), }), + + // for crowdstrike + { create: { _index: ENDPOINT_ACTION_RESPONSES_INDEX } }, + expect.objectContaining({ + '@timestamp': expect.any(String), + EndpointActions: expect.any(Object), + agent: expect.any(Object), + }), + + // for Microsoft Defender { create: { _index: ENDPOINT_ACTION_RESPONSES_INDEX } }, expect.objectContaining({ '@timestamp': expect.any(String), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts index 7a3c3fe0162f1..80a079c0cccda 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts @@ -1277,4 +1277,102 @@ describe('Response actions', () => { expect(httpResponseMock.ok).toHaveBeenCalled(); }); }); + + describe('and agent type if Microsoft Defender', () => { + let testSetup: HttpApiTestSetupMock; + let httpRequestMock: ReturnType; + let httpHandlerContextMock: HttpApiTestSetupMock['httpHandlerContextMock']; + let httpResponseMock: HttpApiTestSetupMock['httpResponseMock']; + let callHandler: () => ReturnType; + + beforeEach(async () => { + testSetup = createHttpApiTestSetupMock(); + + ({ httpHandlerContextMock, httpResponseMock } = testSetup); + httpRequestMock = testSetup.createRequestMock(); + + testSetup.endpointAppContextMock.experimentalFeatures = { + ...testSetup.endpointAppContextMock.experimentalFeatures, + responseActionsMSDefenderEndpointEnabled: true, + }; + + httpHandlerContextMock.actions = Promise.resolve({ + getActionsClient: () => sentinelOneMock.createConnectorActionsClient(), + } as unknown as jest.Mocked); + + // Set the esClient to be used in the handler context + // eslint-disable-next-line require-atomic-updates + httpHandlerContextMock.core = Promise.resolve( + set( + await httpHandlerContextMock.core, + 'elasticsearch.client.asInternalUser', + responseActionsClientMock.createConstructorOptions().esClient + ) + ); + + httpRequestMock = testSetup.createRequestMock({ + body: { + endpoint_ids: ['123-456'], + agent_type: 'microsoft_defender_endpoint', + }, + }); + registerResponseActionRoutes(testSetup.routerMock, testSetup.endpointAppContextMock); + + (testSetup.endpointAppContextMock.service.getEndpointMetadataService as jest.Mock) = jest + .fn() + .mockReturnValue({ + getMetadataForEndpoints: jest.fn().mockResolvedValue([ + { + elastic: { + agent: { + id: '123-456', + }, + }, + agent: { + id: '123-456', + }, + host: { + hostname: 'test-host', + }, + }, + ]), + }); + + const handler = testSetup.getRegisteredVersionedRoute( + 'post', + ISOLATE_HOST_ROUTE_V2, + '2023-10-31' + ).routeHandler as RequestHandler; + + callHandler = () => handler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should use the Microsoft Defender response actions client', async () => { + await callHandler(); + expect(getResponseActionsClientMock).toHaveBeenCalledWith( + 'microsoft_defender_endpoint', + expect.anything() + ); + }); + + it('should error if feature is disabled', async () => { + testSetup.endpointAppContextMock.experimentalFeatures = { + ...testSetup.endpointAppContextMock.experimentalFeatures, + responseActionsMSDefenderEndpointEnabled: false, + }; + + await callHandler(); + + expect(httpResponseMock.customError).toHaveBeenCalledWith({ + body: expect.objectContaining({ + message: '[request body.agent_type]: feature is disabled', + }), + statusCode: 400, + }); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts index a85ebb4a03916..1c77f93162c45 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts @@ -412,7 +412,9 @@ function isThirdPartyFeatureDisabled( return ( (agentType === 'sentinel_one' && !experimentalFeatures.responseActionsSentinelOneV1Enabled) || (agentType === 'crowdstrike' && - !experimentalFeatures.responseActionsCrowdstrikeManualHostIsolationEnabled) + !experimentalFeatures.responseActionsCrowdstrikeManualHostIsolationEnabled) || + (agentType === 'microsoft_defender_endpoint' && + !experimentalFeatures.responseActionsMSDefenderEndpointEnabled) ); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts index a8261727d32f4..74e165d7442e5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts @@ -29,51 +29,61 @@ const COMMANDS_WITH_ACCESS_TO_FILES: CommandsWithFileAccess = deepFreeze { 'getFile', 'execute', 'upload', + 'getFileDownload', + 'getFileInfo', ]; it.each(methods)('should throw Not Supported error for %s()', async (method) => { @@ -121,17 +123,6 @@ describe('ResponseActionsClientImpl base class', () => { await expect(responsePromise).rejects.toBeInstanceOf(ResponseActionsNotSupportedError); await expect(responsePromise).rejects.toHaveProperty('statusCode', 405); }); - - it.each(['getFileDownload', 'getFileInfo'])( - 'should throw not implemented error for %s()', - async (method) => { - // @ts-expect-error ignoring input type to method since they all should throw - const responsePromise = baseClassMock[method]({}); - - await expect(responsePromise).rejects.toThrow(`Method ${method}() not implemented`); - await expect(responsePromise).rejects.toHaveProperty('statusCode', 501); - } - ); }); describe('#updateCases()', () => { @@ -836,7 +827,13 @@ class MockClassWithExposedProtectedMembers extends ResponseActionsClientImpl { return super.writeActionResponseToEndpointIndex(options); } - public fetchAllPendingActions(): AsyncIterable { - return super.fetchAllPendingActions(); + public fetchAllPendingActions< + TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TMeta extends {} = {} + >(): AsyncIterable< + Array> + > { + return super.fetchAllPendingActions(); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts index 3e4c21d403bf7..8973dd0c30a9d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts @@ -654,7 +654,13 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient } } - protected fetchAllPendingActions(): AsyncIterable { + protected fetchAllPendingActions< + TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TMeta extends {} = {} + >(): AsyncIterable< + Array> + > { const esClient = this.options.esClient; const query: QueryDslQueryContainer = { bool: { @@ -861,10 +867,10 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient actionId: string, fileId: string ): Promise { - throw new ResponseActionsClientError(`Method getFileDownload() not implemented`, 501); + throw new ResponseActionsNotSupportedError('getFileDownload'); } public async getFileInfo(actionId: string, fileId: string): Promise { - throw new ResponseActionsClientError(`Method getFileInfo() not implemented`, 501); + throw new ResponseActionsNotSupportedError('getFileInfo'); } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts new file mode 100644 index 0000000000000..e5f607e386efd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts @@ -0,0 +1,167 @@ +/* + * 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 { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; +import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types'; +import { + MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID, + MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION, +} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants'; +import type { + MicrosoftDefenderEndpointGetActionsResponse, + MicrosoftDefenderEndpointMachine, + MicrosoftDefenderEndpointMachineAction, +} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; +import type { NormalizedExternalConnectorClient } from '../../../../..'; +import { responseActionsClientMock, type ResponseActionsClientOptionsMock } from '../../../mocks'; + +export interface MicrosoftDefenderActionsClientOptionsMock + extends ResponseActionsClientOptionsMock { + connectorActions: NormalizedExternalConnectorClient; +} + +const createMsDefenderClientConstructorOptionsMock = () => { + return { + ...responseActionsClientMock.createConstructorOptions(), + connectorActions: responseActionsClientMock.createNormalizedExternalConnectorClient( + createMsConnectorActionsClientMock() + ), + }; +}; + +const createMsConnectorActionsClientMock = (): ActionsClientMock => { + const client = responseActionsClientMock.createConnectorActionsClient(); + + (client.getAll as jest.Mock).mockImplementation(async () => { + const result: ConnectorWithExtraFindData[] = [ + // return a MS connector + responseActionsClientMock.createConnector({ + actionTypeId: MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID, + id: 'ms-connector-instance-id', + }), + ]; + + return result; + }); + + (client.execute as jest.Mock).mockImplementation( + async (options: Parameters[0]) => { + const subAction = options.params.subAction; + + // Mocks for the different connector methods + switch (subAction) { + case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_DETAILS: + return responseActionsClientMock.createConnectorActionExecuteResponse({ + data: createMicrosoftMachineMock(), + }); + + case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.ISOLATE_HOST: + return responseActionsClientMock.createConnectorActionExecuteResponse({ + data: createMicrosoftMachineActionMock({ type: 'Isolate' }), + }); + + case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.RELEASE_HOST: + return responseActionsClientMock.createConnectorActionExecuteResponse({ + data: createMicrosoftMachineActionMock({ type: 'Unisolate' }), + }); + + case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS: + return responseActionsClientMock.createConnectorActionExecuteResponse({ + data: { + '@odata.context': 'some-context', + '@odata.count': 1, + total: 1, + page: 1, + pageSize: 0, + value: [createMicrosoftMachineActionMock()], + }, + }); + + default: + return responseActionsClientMock.createConnectorActionExecuteResponse(); + } + } + ); + + return client; +}; + +const createMicrosoftMachineMock = ( + overrides: Partial = {} +): MicrosoftDefenderEndpointMachine => { + return { + id: '1-2-3', + computerDnsName: 'mymachine1.contoso.com', + firstSeen: '2018-08-02T14:55:03.7791856Z', + lastSeen: '2018-08-02T14:55:03.7791856Z', + osPlatform: 'Windows1', + version: '1709', + osProcessor: 'x64', + lastIpAddress: '172.17.230.209', + lastExternalIpAddress: '167.220.196.71', + osBuild: 18209, + healthStatus: 'Active', + rbacGroupId: '140', + rbacGroupName: 'The-A-Team', + riskScore: 'Low', + exposureLevel: 'Medium', + aadDeviceId: '80fe8ff8-2624-418e-9591-41f0491218f9', + machineTags: ['test tag 1', 'test tag 2'], + onboardingstatus: 'foo', + ipAddresses: [ + { ipAddress: '1.1.1.1', macAddress: '23:a2:5t', type: '', operationalStatus: '' }, + ], + osArchitecture: '', + + ...overrides, + }; +}; + +const createMicrosoftMachineActionMock = ( + overrides: Partial = {} +): MicrosoftDefenderEndpointMachineAction => { + return { + id: '5382f7ea-7557-4ab7-9782-d50480024a4e', + type: 'Isolate', + scope: 'Selective', + requestor: 'Analyst@TestPrd.onmicrosoft.com', + requestorComment: 'test for docs', + requestSource: '', + status: 'Succeeded', + machineId: '1-2-3', + computerDnsName: 'desktop-test', + creationDateTimeUtc: '2019-01-02T14:39:38.2262283Z', + lastUpdateDateTimeUtc: '2019-01-02T14:40:44.6596267Z', + externalID: 'abc', + commands: ['RunScript'], + cancellationRequestor: '', + cancellationComment: '', + cancellationDateTimeUtc: '', + title: '', + + ...overrides, + }; +}; + +const createMicrosoftGetActionsApiResponseMock = + (): MicrosoftDefenderEndpointGetActionsResponse => { + return { + '@odata.context': 'some-context', + '@odata.count': 1, + total: 1, + page: 1, + pageSize: 0, + value: [createMicrosoftMachineActionMock()], + }; + }; + +export const microsoftDefenderMock = { + createConstructorOptions: createMsDefenderClientConstructorOptionsMock, + createMachineAction: createMicrosoftMachineActionMock, + createMachine: createMicrosoftMachineMock, + createGetActionsApiResponse: createMicrosoftGetActionsApiResponseMock, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts new file mode 100644 index 0000000000000..a01132ad6989b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts @@ -0,0 +1,292 @@ +/* + * 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 { MicrosoftDefenderEndpointActionsClient } from './ms_defender_endpoint_actions_client'; +import type { ProcessPendingActionsMethodOptions, ResponseActionsClient } from '../../../../..'; +import { getActionDetailsById as _getActionDetailsById } from '../../../../action_details_by_id'; +import type { MicrosoftDefenderActionsClientOptionsMock } from './mocks'; +import { microsoftDefenderMock } from './mocks'; +import { ResponseActionsNotSupportedError } from '../../../errors'; +import type { NormalizedExternalConnectorClientMock } from '../../../mocks'; +import { responseActionsClientMock } from '../../../mocks'; +import type { + LogsEndpointActionResponse, + MicrosoftDefenderEndpointActionRequestCommonMeta, +} from '../../../../../../../../common/endpoint/types'; +import { EndpointActionGenerator } from '../../../../../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { applyEsClientSearchMock } from '../../../../../../mocks/utils.mock'; +import { ENDPOINT_ACTIONS_INDEX } from '../../../../../../../../common/endpoint/constants'; +import type { + MicrosoftDefenderEndpointGetActionsResponse, + MicrosoftDefenderEndpointMachineAction, +} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; +import { MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants'; + +jest.mock('../../../../action_details_by_id', () => { + const originalMod = jest.requireActual('../../../../action_details_by_id'); + + return { + ...originalMod, + getActionDetailsById: jest.fn(originalMod.getActionDetailsById), + }; +}); + +const getActionDetailsByIdMock = _getActionDetailsById as jest.Mock; + +describe('MS Defender response actions client', () => { + let clientConstructorOptionsMock: MicrosoftDefenderActionsClientOptionsMock; + let connectorActionsMock: NormalizedExternalConnectorClientMock; + let msClientMock: ResponseActionsClient; + + beforeEach(() => { + clientConstructorOptionsMock = microsoftDefenderMock.createConstructorOptions(); + connectorActionsMock = + clientConstructorOptionsMock.connectorActions as NormalizedExternalConnectorClientMock; + msClientMock = new MicrosoftDefenderEndpointActionsClient(clientConstructorOptionsMock); + }); + + const supporteResponseActionClassMethods: Record = { + upload: false, + scan: false, + execute: false, + getFile: false, + getFileDownload: false, + getFileInfo: false, + killProcess: false, + runningProcesses: false, + runscript: false, + suspendProcess: false, + isolate: true, + release: true, + processPendingActions: true, + }; + + it.each( + Object.entries(supporteResponseActionClassMethods).reduce((acc, [key, value]) => { + if (!value) { + acc.push(key as keyof ResponseActionsClient); + } + return acc; + }, [] as Array) + )('should throw error for %s', async (methodName) => { + // @ts-expect-error Purposely passing in empty object for options + await expect(msClientMock[methodName]({})).rejects.toBeInstanceOf( + ResponseActionsNotSupportedError + ); + }); + + it('should error if multiple agent ids are received', async () => { + await expect(msClientMock.isolate({ endpoint_ids: ['a', 'b'] })).rejects.toMatchObject({ + message: `[body.endpoint_ids]: Multiple agents IDs not currently supported for Microsoft Defender for Endpoint`, + statusCode: 400, + }); + }); + + it('should update cases', async () => { + await msClientMock.isolate( + responseActionsClientMock.createIsolateOptions({ case_ids: ['case-1'] }) + ); + + expect(clientConstructorOptionsMock.casesClient?.attachments.bulkCreate).toHaveBeenCalled(); + }); + + describe.each>([ + 'isolate', + 'release', + ])('#%s()', (responseActionMethod) => { + it(`should send ${responseActionMethod} request to Microsoft with expected comment`, async () => { + await msClientMock[responseActionMethod](responseActionsClientMock.createIsolateOptions()); + + expect(connectorActionsMock.execute).toHaveBeenCalledWith({ + params: { + subAction: responseActionMethod === 'isolate' ? 'isolateHost' : 'releaseHost', + subActionParams: { + comment: expect.stringMatching( + /Action triggered from Elastic Security by user \[foo\] for action \[.* \(action id: .*\)\]: test comment/ + ), + id: '1-2-3', + }, + }, + }); + }); + + it('should write action request doc. to endpoint index', async () => { + await msClientMock[responseActionMethod](responseActionsClientMock.createIsolateOptions()); + + expect(clientConstructorOptionsMock.esClient.index).toHaveBeenCalledWith( + { + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { + command: responseActionMethod === 'isolate' ? 'isolate' : 'unisolate', + comment: 'test comment', + hosts: { + '1-2-3': { + name: 'mymachine1.contoso.com', + }, + }, + parameters: undefined, + }, + expiration: expect.any(String), + input_type: 'microsoft_defender_endpoint', + type: 'INPUT_ACTION', + }, + agent: { + id: ['1-2-3'], + }, + meta: { + machineActionId: '5382f7ea-7557-4ab7-9782-d50480024a4e', + }, + user: { + id: 'foo', + }, + }, + index: '.logs-endpoint.actions-default', + refresh: 'wait_for', + }, + { meta: true } + ); + }); + + it('should return action details', async () => { + await expect( + msClientMock[responseActionMethod](responseActionsClientMock.createIsolateOptions()) + ).resolves.toEqual( + expect.objectContaining({ + id: expect.any(String), + command: expect.any(String), + isCompleted: false, + }) + ); + expect(getActionDetailsByIdMock).toHaveBeenCalled(); + }); + + it('should update cases', async () => { + await msClientMock[responseActionMethod]( + responseActionsClientMock.createIsolateOptions({ + case_ids: ['case-1'], + }) + ); + + expect(clientConstructorOptionsMock.casesClient?.attachments.bulkCreate).toHaveBeenCalled(); + }); + }); + + describe('#processPendingActions()', () => { + let abortController: AbortController; + let processPendingActionsOptions: ProcessPendingActionsMethodOptions; + + beforeEach(() => { + abortController = new AbortController(); + processPendingActionsOptions = { + abortSignal: abortController.signal, + addToQueue: jest.fn(), + }; + }); + + describe('for Isolate and Release', () => { + let msMachineActionsApiResponse: MicrosoftDefenderEndpointGetActionsResponse; + + beforeEach(() => { + const generator = new EndpointActionGenerator('seed'); + + const actionRequestsSearchResponse = generator.toEsSearchResponse([ + generator.generateActionEsHit< + undefined, + {}, + MicrosoftDefenderEndpointActionRequestCommonMeta + >({ + agent: { id: 'agent-uuid-1' }, + EndpointActions: { + data: { command: 'isolate' }, + input_type: 'microsoft_defender_endpoint', + }, + meta: { machineActionId: '5382f7ea-7557-4ab7-9782-d50480024a4e' }, + }), + ]); + + applyEsClientSearchMock({ + esClientMock: clientConstructorOptionsMock.esClient, + index: ENDPOINT_ACTIONS_INDEX, + response: actionRequestsSearchResponse, + pitUsage: true, + }); + + msMachineActionsApiResponse = microsoftDefenderMock.createGetActionsApiResponse(); + responseActionsClientMock.setConnectorActionsClientExecuteResponse( + connectorActionsMock, + MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS, + msMachineActionsApiResponse + ); + }); + + it('should generate action response docs for completed actions', async () => { + await msClientMock.processPendingActions(processPendingActionsOptions); + + expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({ + '@timestamp': expect.any(String), + EndpointActions: { + action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + completed_at: expect.any(String), + data: { command: 'isolate' }, + input_type: 'microsoft_defender_endpoint', + started_at: expect.any(String), + }, + agent: { id: 'agent-uuid-1' }, + error: undefined, + meta: undefined, + }); + }); + + it.each(['Pending', 'InProgress'])( + 'should NOT generate action responses if action in MS Defender as a status of %s', + async (machineActionStatus) => { + msMachineActionsApiResponse.value[0].status = machineActionStatus; + await msClientMock.processPendingActions(processPendingActionsOptions); + + expect(processPendingActionsOptions.addToQueue).not.toHaveBeenCalled(); + } + ); + + it.each` + msStatusValue | responseState + ${'Failed'} | ${'failure'} + ${'TimeOut'} | ${'failure'} + ${'Cancelled'} | ${'failure'} + ${'Succeeded'} | ${'success'} + `( + 'should generate $responseState action response if MS machine action status is $msStatusValue', + async ({ msStatusValue, responseState }) => { + msMachineActionsApiResponse.value[0].status = msStatusValue; + const expectedResult: LogsEndpointActionResponse = { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + completed_at: expect.any(String), + data: { command: 'isolate' }, + input_type: 'microsoft_defender_endpoint', + started_at: expect.any(String), + }, + agent: { id: 'agent-uuid-1' }, + error: undefined, + meta: undefined, + }; + if (responseState === 'failure') { + expectedResult.error = { + message: expect.any(String), + }; + } + await msClientMock.processPendingActions(processPendingActionsOptions); + + expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith(expectedResult); + } + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts new file mode 100644 index 0000000000000..59477ffa610bc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts @@ -0,0 +1,524 @@ +/* + * 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 { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { + MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID, + MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION, +} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants'; +import type { + MicrosoftDefenderEndpointGetActionsParams, + MicrosoftDefenderEndpointGetActionsResponse, +} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; +import { + type MicrosoftDefenderEndpointAgentDetailsParams, + type MicrosoftDefenderEndpointIsolateHostParams, + type MicrosoftDefenderEndpointMachine, + type MicrosoftDefenderEndpointMachineAction, +} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; +import { groupBy } from 'lodash'; +import type { + IsolationRouteRequestBody, + UnisolationRouteRequestBody, +} from '../../../../../../../../common/api/endpoint'; +import type { + ActionDetails, + EndpointActionDataParameterTypes, + EndpointActionResponseDataOutput, + LogsEndpointAction, + LogsEndpointActionResponse, + MicrosoftDefenderEndpointActionRequestCommonMeta, +} from '../../../../../../../../common/endpoint/types'; +import type { + ResponseActionAgentType, + ResponseActionsApiCommandNames, +} from '../../../../../../../../common/endpoint/service/response_actions/constants'; +import type { NormalizedExternalConnectorClient } from '../../../lib/normalized_external_connector_client'; +import type { + ResponseActionsClientPendingAction, + ResponseActionsClientValidateRequestResponse, + ResponseActionsClientWriteActionRequestToEndpointIndexOptions, +} from '../../../lib/base_response_actions_client'; +import { + ResponseActionsClientImpl, + type ResponseActionsClientOptions, +} from '../../../lib/base_response_actions_client'; +import { stringify } from '../../../../../../utils/stringify'; +import { ResponseActionsClientError } from '../../../errors'; +import type { + CommonResponseActionMethodOptions, + ProcessPendingActionsMethodOptions, +} from '../../../lib/types'; + +export type MicrosoftDefenderActionsClientOptions = ResponseActionsClientOptions & { + connectorActions: NormalizedExternalConnectorClient; +}; + +export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClientImpl { + protected readonly agentType: ResponseActionAgentType = 'microsoft_defender_endpoint'; + private readonly connectorActionsClient: NormalizedExternalConnectorClient; + + constructor({ connectorActions, ...options }: MicrosoftDefenderActionsClientOptions) { + super(options); + this.connectorActionsClient = connectorActions; + connectorActions.setup(MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID); + } + + protected async handleResponseActionCreation< + TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TMeta extends {} = {} + >( + actionRequestOptions: ResponseActionsClientWriteActionRequestToEndpointIndexOptions< + TParameters, + TOutputContent, + TMeta + > + ): Promise<{ + actionEsDoc: LogsEndpointAction; + actionDetails: ActionDetails; + }> { + const actionRequestDoc = await this.writeActionRequestToEndpointIndex< + TParameters, + TOutputContent, + TMeta + >(actionRequestOptions); + + await this.updateCases({ + command: actionRequestOptions.command, + caseIds: actionRequestOptions.case_ids, + alertIds: actionRequestOptions.alert_ids, + actionId: actionRequestDoc.EndpointActions.action_id, + hosts: actionRequestOptions.endpoint_ids.map((agentId) => { + return { + hostId: agentId, + hostname: actionRequestDoc.EndpointActions.data.hosts?.[agentId].name ?? '', + }; + }), + comment: actionRequestOptions.comment, + }); + + return { + actionEsDoc: actionRequestDoc, + actionDetails: await this.fetchActionDetails>( + actionRequestDoc.EndpointActions.action_id + ), + }; + } + + /** + * Sends actions to Ms Defender for Endpoint directly (via Connector) + * @private + */ + private async sendAction< + TResponse = unknown, + TParams extends Record = Record + >( + actionType: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION, + actionParams: TParams + ): Promise> { + const executeOptions: Parameters[0] = { + params: { + subAction: actionType, + subActionParams: actionParams, + }, + }; + + this.log.debug( + () => + `calling connector actions 'execute()' for Microsoft Defender for Endpoint with:\n${stringify( + executeOptions + )}` + ); + + const actionSendResponse = await this.connectorActionsClient.execute(executeOptions); + + if (actionSendResponse.status === 'error') { + this.log.error(stringify(actionSendResponse)); + + throw new ResponseActionsClientError( + `Attempt to send [${actionType}] to Microsoft Defender for Endpoint failed: ${ + actionSendResponse.serviceMessage || actionSendResponse.message + }`, + 500, + actionSendResponse + ); + } + + this.log.debug(() => `Response:\n${stringify(actionSendResponse)}`); + + return actionSendResponse as ActionTypeExecutorResult; + } + + protected async writeActionRequestToEndpointIndex< + TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes, + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TMeta extends {} = MicrosoftDefenderEndpointActionRequestCommonMeta + >( + actionRequest: ResponseActionsClientWriteActionRequestToEndpointIndexOptions< + TParameters, + TOutputContent, + TMeta + > + ): Promise> { + const agentId = actionRequest.endpoint_ids[0]; + const agentDetails = await this.getAgentDetails(agentId); + + const doc = await super.writeActionRequestToEndpointIndex({ + ...actionRequest, + hosts: { + [agentId]: { name: agentDetails.computerDnsName }, + }, + }); + + return doc; + } + + /** Gets agent details directly from MS Defender for Endpoint */ + private async getAgentDetails(agentId: string): Promise { + const cachedEntry = this.cache.get(agentId); + + if (cachedEntry) { + this.log.debug( + `Found cached agent details for UUID [${agentId}]:\n${stringify(cachedEntry)}` + ); + return cachedEntry; + } + + let msDefenderEndpointGetMachineDetailsApiResponse: + | MicrosoftDefenderEndpointMachine + | undefined; + + try { + const agentDetailsResponse = await this.sendAction< + MicrosoftDefenderEndpointMachine, + MicrosoftDefenderEndpointAgentDetailsParams + >(MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_DETAILS, { id: agentId }); + + msDefenderEndpointGetMachineDetailsApiResponse = agentDetailsResponse.data; + } catch (err) { + throw new ResponseActionsClientError( + `Error while attempting to retrieve Microsoft Defender for Endpoint host with agent id [${agentId}]: ${err.message}`, + 500, + err + ); + } + + if (!msDefenderEndpointGetMachineDetailsApiResponse) { + throw new ResponseActionsClientError( + `Microsoft Defender for Endpoint agent id [${agentId}] not found`, + 404 + ); + } + + this.cache.set(agentId, msDefenderEndpointGetMachineDetailsApiResponse); + + return msDefenderEndpointGetMachineDetailsApiResponse; + } + + protected async validateRequest( + payload: ResponseActionsClientWriteActionRequestToEndpointIndexOptions + ): Promise { + // TODO: support multiple agents + if (payload.endpoint_ids.length > 1) { + return { + isValid: false, + error: new ResponseActionsClientError( + `[body.endpoint_ids]: Multiple agents IDs not currently supported for Microsoft Defender for Endpoint`, + 400 + ), + }; + } + + return super.validateRequest(payload); + } + + async isolate( + actionRequest: IsolationRouteRequestBody, + options: CommonResponseActionMethodOptions = {} + ): Promise { + const reqIndexOptions: ResponseActionsClientWriteActionRequestToEndpointIndexOptions< + undefined, + {}, + MicrosoftDefenderEndpointActionRequestCommonMeta + > = { + ...actionRequest, + ...this.getMethodOptions(options), + command: 'isolate', + }; + + if (!reqIndexOptions.error) { + let error = (await this.validateRequest(reqIndexOptions)).error; + + if (!error) { + try { + const msActionResponse = await this.sendAction< + MicrosoftDefenderEndpointMachineAction, + MicrosoftDefenderEndpointIsolateHostParams + >(MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.ISOLATE_HOST, { + id: actionRequest.endpoint_ids[0], + comment: this.buildExternalComment(reqIndexOptions), + }); + + if (msActionResponse?.data?.id) { + reqIndexOptions.meta = { machineActionId: msActionResponse.data.id }; + } else { + throw new ResponseActionsClientError( + `Isolate request was sent to Microsoft Defender, but Machine Action Id was not provided!` + ); + } + } catch (err) { + error = err; + } + } + + reqIndexOptions.error = error?.message; + + if (!this.options.isAutomated && error) { + throw error; + } + } + + const { actionDetails } = await this.handleResponseActionCreation(reqIndexOptions); + + return actionDetails; + } + + async release( + actionRequest: UnisolationRouteRequestBody, + options: CommonResponseActionMethodOptions = {} + ): Promise { + const reqIndexOptions: ResponseActionsClientWriteActionRequestToEndpointIndexOptions< + undefined, + {}, + MicrosoftDefenderEndpointActionRequestCommonMeta + > = { + ...actionRequest, + ...this.getMethodOptions(options), + command: 'unisolate', + }; + + if (!reqIndexOptions.error) { + let error = (await this.validateRequest(reqIndexOptions)).error; + + if (!error) { + try { + const msActionResponse = await this.sendAction< + MicrosoftDefenderEndpointMachineAction, + MicrosoftDefenderEndpointIsolateHostParams + >(MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.RELEASE_HOST, { + id: actionRequest.endpoint_ids[0], + comment: this.buildExternalComment(reqIndexOptions), + }); + + if (msActionResponse?.data?.id) { + reqIndexOptions.meta = { machineActionId: msActionResponse.data.id }; + } else { + throw new ResponseActionsClientError( + `Un-Isolate request was sent to Microsoft Defender, but Machine Action Id was not provided!` + ); + } + } catch (err) { + error = err; + } + } + + reqIndexOptions.error = error?.message; + + if (!this.options.isAutomated && error) { + throw error; + } + } + + const { actionDetails } = await this.handleResponseActionCreation(reqIndexOptions); + + return actionDetails; + } + + async processPendingActions({ + abortSignal, + addToQueue, + }: ProcessPendingActionsMethodOptions): Promise { + if (abortSignal.aborted) { + return; + } + + const addResponsesToQueueIfAny = (responseList: LogsEndpointActionResponse[]): void => { + if (responseList.length > 0) { + addToQueue(...responseList); + + this.sendActionResponseTelemetry(responseList); + } + }; + + for await (const pendingActions of this.fetchAllPendingActions< + EndpointActionDataParameterTypes, + EndpointActionResponseDataOutput, + MicrosoftDefenderEndpointActionRequestCommonMeta + >()) { + if (abortSignal.aborted) { + return; + } + + const pendingActionsByType = groupBy(pendingActions, 'action.EndpointActions.data.command'); + + for (const [actionType, typePendingActions] of Object.entries(pendingActionsByType)) { + if (abortSignal.aborted) { + return; + } + + switch (actionType as ResponseActionsApiCommandNames) { + case 'isolate': + case 'unisolate': + addResponsesToQueueIfAny( + await this.checkPendingIsolateReleaseActions( + typePendingActions as Array< + ResponseActionsClientPendingAction< + undefined, + {}, + MicrosoftDefenderEndpointActionRequestCommonMeta + > + > + ) + ); + } + } + } + } + + private async checkPendingIsolateReleaseActions( + actionRequests: Array< + ResponseActionsClientPendingAction< + undefined, + {}, + MicrosoftDefenderEndpointActionRequestCommonMeta + > + > + ): Promise { + const completedResponses: LogsEndpointActionResponse[] = []; + const warnings: string[] = []; + const actionsByMachineId: Record< + string, + Array> + > = {}; + const machineActionIds: string[] = []; + const msApiOptions: MicrosoftDefenderEndpointGetActionsParams = { + id: machineActionIds, + pageSize: 1000, + }; + + for (const { action } of actionRequests) { + const command = action.EndpointActions.data.command; + const machineActionId = action.meta?.machineActionId; + + if (!machineActionId) { + warnings.push( + `${command} response action ID [${action.EndpointActions.action_id}] is missing Microsoft Defender for Endpoint machine action id, thus unable to check on it's status. Forcing it to complete as failure.` + ); + + completedResponses.push( + this.buildActionResponseEsDoc({ + actionId: action.EndpointActions.action_id, + agentId: Array.isArray(action.agent.id) ? action.agent.id[0] : action.agent.id, + data: { command }, + error: { + message: `Unable to very if action completed. Microsoft Defender machine action id ('meta.machineActionId') missing on action request document!`, + }, + }) + ); + } else { + if (!actionsByMachineId[machineActionId]) { + actionsByMachineId[machineActionId] = []; + } + + actionsByMachineId[machineActionId].push(action); + machineActionIds.push(machineActionId); + } + } + + const { data: machineActions } = + await this.sendAction( + MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS, + msApiOptions + ); + + if (machineActions?.value) { + for (const machineAction of machineActions.value) { + const { isPending, isError, message } = this.calculateMachineActionState(machineAction); + + if (!isPending) { + const pendingActionRequests = actionsByMachineId[machineAction.id] ?? []; + + for (const actionRequest of pendingActionRequests) { + completedResponses.push( + this.buildActionResponseEsDoc({ + actionId: actionRequest.EndpointActions.action_id, + agentId: Array.isArray(actionRequest.agent.id) + ? actionRequest.agent.id[0] + : actionRequest.agent.id, + data: { command: actionRequest.EndpointActions.data.command }, + error: isError ? { message } : undefined, + }) + ); + } + } + } + } + + this.log.debug( + () => + `${completedResponses.length} action responses generated:\n${stringify(completedResponses)}` + ); + + if (warnings.length > 0) { + this.log.warn(warnings.join('\n')); + } + + return completedResponses; + } + + private calculateMachineActionState(machineAction: MicrosoftDefenderEndpointMachineAction): { + isPending: boolean; + isError: boolean; + message: string; + } { + let isPending = true; + let isError = false; + let message = ''; + + switch (machineAction.status) { + case 'Failed': + case 'TimeOut': + isPending = false; + isError = true; + message = `Response action ${machineAction.status} (Microsoft Defender for Endpoint machine action ID: ${machineAction.id})`; + break; + + case 'Cancelled': + isPending = false; + isError = true; + message = `Response action was canceled by [${ + machineAction.cancellationRequestor + }] (Microsoft Defender for Endpoint machine action ID: ${machineAction.id})${ + machineAction.cancellationComment ? `: ${machineAction.cancellationComment}` : '' + }`; + break; + + case 'Succeeded': + isPending = false; + isError = false; + break; + + default: + // covers 'Pending' | 'InProgress' + isPending = true; + isError = false; + } + + return { isPending, isError, message }; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts index 00b4774d9489c..1a02f780cce03 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types'; @@ -57,6 +59,9 @@ export interface ResponseActionsClientOptionsMock extends ResponseActionsClientO casesClient?: CasesClientMock; } +export type NormalizedExternalConnectorClientMock = + DeeplyMockedKeys; + const createResponseActionClientMock = (): jest.Mocked => { return { suspendProcess: jest.fn().mockReturnValue(Promise.resolve()), @@ -305,7 +310,7 @@ const createConnectorActionsClientMock = ({ const createNormalizedExternalConnectorClientMock = ( connectorActionsClientMock: ActionsClientMock = createConnectorActionsClientMock() -): DeeplyMockedKeys => { +): NormalizedExternalConnectorClientMock => { const normalizedClient = new NormalizedExternalConnectorClient( connectorActionsClientMock, loggingSystemMock.createLogger() @@ -314,7 +319,31 @@ const createNormalizedExternalConnectorClientMock = ( jest.spyOn(normalizedClient, 'execute'); jest.spyOn(normalizedClient, 'setup'); - return normalizedClient as DeeplyMockedKeys; + return normalizedClient as NormalizedExternalConnectorClientMock; +}; + +const setConnectorActionsClientExecuteResponseMock = ( + connectorActionsClient: ActionsClientMock | NormalizedExternalConnectorClientMock, + subAction: string, + response: any +): void => { + const executeMockFn = (connectorActionsClient.execute as jest.Mock).getMockImplementation(); + + (connectorActionsClient.execute as jest.Mock).mockImplementation(async (options) => { + if (options.params.subAction === subAction) { + return responseActionsClientMock.createConnectorActionExecuteResponse({ + data: response, + }); + } + + if (executeMockFn) { + return executeMockFn(options); + } + + return responseActionsClientMock.createConnectorActionExecuteResponse({ + data: {}, + }); + }); }; export const responseActionsClientMock = Object.freeze({ @@ -341,4 +370,5 @@ export const responseActionsClientMock = Object.freeze({ /** Create a mock connector instance */ createConnector: createConnectorMock, createConnectorActionExecuteResponse: createConnectorActionExecuteResponseMock, + setConnectorActionsClientExecuteResponse: setConnectorActionsClientExecuteResponseMock, });