From 4c6abdebdfe5940abc82503519db748eaac18da6 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:12:41 -0500 Subject: [PATCH] [Security Solution][Endpoint] Display of isolation state for Microsoft Defender agents in alert details and response console (#206317) ## Summary ### Connector Changes - Added support for sort field to the Machine Actions Microsoft Defender API method ### Security Solution - Add error handling to the calls made to a Connector's `.execute()` and throws a more details error message - Added logic to the Agent Status client for MS Defender to calculate the agent's Isolated status by querying for Machine Actions - Note: Due to API rate limits, which I believe may be associated with the current Microsoft Defender test environment we are using, the agent status in kibana (ex. Alert flyout, console) may flip to `Unenrolled` periodically --- .../microsoft_defender_endpoint/schema.ts | 2 + .../microsoft_defender_endpoint.test.ts | 13 +-- .../microsoft_defender_endpoint.ts | 27 ++++-- ...rmalized_external_connector_client.test.ts | 37 +++++++- .../normalized_external_connector_client.ts | 40 ++++++--- .../microsoft/defender/endpoint/mocks.ts | 43 ++++----- .../services/actions/clients/mocks.ts | 12 ++- ...ender_endpoint_agent_status_client.test.ts | 77 +++++++++++----- ...t_defender_endpoint_agent_status_client.ts | 87 ++++++++++++++++--- 9 files changed, 257 insertions(+), 81 deletions(-) diff --git a/x-pack/platform/plugins/shared/stack_connectors/common/microsoft_defender_endpoint/schema.ts b/x-pack/platform/plugins/shared/stack_connectors/common/microsoft_defender_endpoint/schema.ts index a6063340211fc..0daf547de21fa 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/common/microsoft_defender_endpoint/schema.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/common/microsoft_defender_endpoint/schema.ts @@ -203,6 +203,8 @@ export const GetActionsParamsSchema = schema.object({ ), page: schema.maybe(schema.number({ min: 1, defaultValue: 1 })), pageSize: schema.maybe(schema.number({ min: 1, max: 1000, defaultValue: 20 })), + sortField: schema.maybe(schema.string({ minLength: 1 })), + sortDirection: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), }); // ---------------------------------- diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.test.ts index 38be24e6aa224..24c84e62169a7 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.test.ts @@ -187,10 +187,13 @@ describe('Microsoft Defender for Endpoint Connector', () => { }); it.each` - title | options | expectedParams - ${'single value filters'} | ${{ id: '123', status: 'Succeeded', machineId: 'abc', page: 2 }} | ${{ $count: true, $filter: 'id eq 123 AND status eq Succeeded AND machineId eq abc', $skip: 20, $top: 20 }} - ${'multiple value filters'} | ${{ id: ['123', '321'], type: ['Isolate', 'Unisolate'], page: 1, pageSize: 100 }} | ${{ $count: true, $filter: "id in ('123','321') AND type in ('Isolate','Unisolate')", $top: 100 }} - ${'page and page size'} | ${{ id: ['123', '321'], type: ['Isolate', 'Unisolate'], page: 3, pageSize: 100 }} | ${{ $count: true, $filter: "id in ('123','321') AND type in ('Isolate','Unisolate')", $skip: 200, $top: 100 }} + title | options | expectedParams + ${'single value filters'} | ${{ id: '123', status: 'Succeeded', machineId: 'abc', page: 2 }} | ${{ $count: true, $filter: "id eq '123' AND status eq 'Succeeded' AND machineId eq 'abc'", $skip: 20, $top: 20 }} + ${'multiple value filters'} | ${{ id: ['123', '321'], type: ['Isolate', 'Unisolate'], page: 1, pageSize: 100 }} | ${{ $count: true, $filter: "id in ('123','321') AND type in ('Isolate','Unisolate')", $top: 100 }} + ${'page and page size'} | ${{ id: ['123', '321'], type: ['Isolate', 'Unisolate'], page: 3, pageSize: 100 }} | ${{ $count: true, $filter: "id in ('123','321') AND type in ('Isolate','Unisolate')", $skip: 200, $top: 100 }} + ${'with sortDirection but no sortField'} | ${{ id: '123', sortDirection: 'asc' }} | ${{ $count: true, $filter: "id eq '123'", $top: 20 }} + ${'with sortField and no sortDirection (desc is default)'} | ${{ id: '123', sortField: 'type' }} | ${{ $count: true, $filter: "id eq '123'", $top: 20, $orderby: 'type desc' }} + ${'with sortField and sortDirection'} | ${{ id: '123', sortField: 'type', sortDirection: 'asc' }} | ${{ $count: true, $filter: "id eq '123'", $top: 20, $orderby: 'type asc' }} `( 'should correctly build the oData URL params: $title', async ({ options, expectedParams }) => { @@ -226,7 +229,7 @@ describe('Microsoft Defender for Endpoint Connector', () => { expect(connectorMock.instanceMock.request).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://api.mock__microsoft.com/api/machines', - params: { $count: true, $filter: 'id eq 1-2-3', $top: 20 }, + params: { $count: true, $filter: "id eq '1-2-3'", $top: 20 }, }), connectorMock.usageCollector ); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.ts index 203b5be802a50..d06112631cf45 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.ts @@ -132,7 +132,7 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector< const responseBody = JSON.stringify(error.response?.data ?? {}); if (responseBody) { - return `${message}\nURL called: ${error.response?.config?.url}\nResponse body: ${responseBody}`; + return `${message}\nURL called:[${error.response?.config?.method}] ${error.response?.config?.url}\nResponse body: ${responseBody}`; } return message; @@ -153,10 +153,14 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector< filter = {}, page = 1, pageSize = 20, + sortField = '', + sortDirection = 'desc', }: { filter: Record; - page: number; - pageSize: number; + page?: number; + pageSize?: number; + sortField?: string; + sortDirection?: string; }): Partial { const oDataQueryOptions: Partial = { $count: true, @@ -170,6 +174,10 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector< oDataQueryOptions.$skip = page * pageSize - pageSize; } + if (sortField) { + oDataQueryOptions.$orderby = `${sortField} ${sortDirection}`; + } + const filterEntries = Object.entries(filter); if (filterEntries.length > 0) { @@ -185,7 +193,7 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector< oDataQueryOptions.$filter += `${key} ${isArrayValue ? 'in' : 'eq'} ${ isArrayValue ? '(' + value.map((valueString) => `'${valueString}'`).join(',') + ')' - : value + : `'${value}'` }`; } } @@ -313,7 +321,13 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector< } public async getActions( - { page = 1, pageSize = 20, ...filter }: MicrosoftDefenderEndpointGetActionsParams, + { + page = 1, + pageSize = 20, + sortField, + sortDirection = 'desc', + ...filter + }: MicrosoftDefenderEndpointGetActionsParams, connectorUsageCollector: ConnectorUsageCollector ): Promise { // API Reference: https://learn.microsoft.com/en-us/defender-endpoint/api/get-machineactions-collection @@ -323,7 +337,7 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector< { url: `${this.urls.machineActions}`, method: 'GET', - params: this.buildODataUrlParams({ filter, page, pageSize }), + params: this.buildODataUrlParams({ filter, page, pageSize, sortField, sortDirection }), }, connectorUsageCollector ); @@ -342,4 +356,5 @@ interface BuildODataUrlParamsResponse { $top: number; $skip: number; $count: boolean; + $orderby: string; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts index 4190224c592c1..c539ac4913c2d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts @@ -11,7 +11,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import type { Logger } from '@kbn/logging'; import type { NormalizedExternalConnectorClientExecuteOptions } from './normalized_external_connector_client'; import { NormalizedExternalConnectorClient } from './normalized_external_connector_client'; -import { ResponseActionsConnectorNotConfiguredError } from '../errors'; +import { ResponseActionsClientError, ResponseActionsConnectorNotConfiguredError } from '../errors'; import type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server'; import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock'; @@ -139,6 +139,22 @@ describe('`NormalizedExternalConnectorClient` class', () => { params: executeInputOptions.params, }); }); + + it('should throw a ResponseActionClientError', async () => { + actionPluginConnectorClient.execute.mockImplementation(async () => { + throw new Error('oh oh'); + }); + + const testInstance = new NormalizedExternalConnectorClient( + actionPluginConnectorClient, + logger + ); + testInstance.setup('foo'); + + await expect(testInstance.execute(executeInputOptions)).rejects.toThrow( + ResponseActionsClientError + ); + }); }); describe('with IUnsecuredActionsClient', () => { @@ -150,6 +166,9 @@ describe('`NormalizedExternalConnectorClient` class', () => { (actionPluginConnectorClient.getAll as jest.Mock).mockResolvedValue([ responseActionsClientMock.createConnector({ actionTypeId: 'foo' }), ]); + (actionPluginConnectorClient.execute as jest.Mock).mockResolvedValue( + responseActionsClientMock.createConnectorActionExecuteResponse() + ); }); it('should call Action Plugin client `.execute()` with expected arguments', async () => { @@ -181,5 +200,21 @@ describe('`NormalizedExternalConnectorClient` class', () => { ], }); }); + + it('should throw a ResponseActionClientError', async () => { + (actionPluginConnectorClient.execute as jest.Mock).mockImplementation(async () => { + throw new Error('oh oh'); + }); + + const testInstance = new NormalizedExternalConnectorClient( + actionPluginConnectorClient, + logger + ); + testInstance.setup('foo'); + + await expect(testInstance.execute(executeInputOptions)).rejects.toThrow( + ResponseActionsClientError + ); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts index 43c07f0050b80..36106180044a8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts @@ -115,22 +115,38 @@ export class NormalizedExternalConnectorClient { ActionTypeExecutorResult > { this.ensureSetupDone(); - const { id: connectorId } = await this.getConnectorInstance(); + const { + id: connectorId, + name: connectorName, + actionTypeId: connectorTypeId, + } = await this.getConnectorInstance(); + + const catchAndThrow = (err: Error) => { + throw new ResponseActionsClientError( + `Attempt to execute [${params.subAction}] with connector [Name: ${connectorName} | Type: ${connectorTypeId} | ID: ${connectorId})] failed with : ${err.message}`, + 500, + err + ); + }; if (this.isUnsecuredActionsClient(this.connectorsClient)) { - return this.connectorsClient.execute({ - requesterId: 'background_task', - id: connectorId, - spaceId, - params, - relatedSavedObjects: this.options?.relatedSavedObjects, - }) as Promise>; + return this.connectorsClient + .execute({ + requesterId: 'background_task', + id: connectorId, + spaceId, + params, + relatedSavedObjects: this.options?.relatedSavedObjects, + }) + .catch(catchAndThrow) as Promise>; } - return this.connectorsClient.execute({ - actionId: connectorId, - params, - }) as Promise>; + return this.connectorsClient + .execute({ + actionId: connectorId, + params, + }) + .catch(catchAndThrow) as Promise>; } protected async getAll(spaceId: string = 'default'): ReturnType { 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 index 5c08982b9ade1..5baf18f9200d3 100644 --- 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 @@ -153,29 +153,32 @@ const createMicrosoftMachineActionMock = ( }; }; -const createMicrosoftGetActionsApiResponseMock = - (): MicrosoftDefenderEndpointGetActionsResponse => { - return { - '@odata.context': 'some-context', - '@odata.count': 1, - total: 1, - page: 1, - pageSize: 0, - value: [createMicrosoftMachineActionMock()], - }; +const createMicrosoftGetActionsApiResponseMock = ( + overrides: Partial = {} +): MicrosoftDefenderEndpointGetActionsResponse => { + return { + '@odata.context': 'some-context', + '@odata.count': 1, + total: 1, + page: 1, + pageSize: 0, + value: [createMicrosoftMachineActionMock(overrides)], }; +}; -const createMicrosoftGetMachineListApiResponseMock = - (): MicrosoftDefenderEndpointAgentListResponse => { - return { - '@odata.context': 'some-context', - '@odata.count': 1, - total: 1, - page: 1, - pageSize: 0, - value: [createMicrosoftMachineMock()], - }; +const createMicrosoftGetMachineListApiResponseMock = ( + /** Any overrides to the 1 machine action that is included in the mock response */ + machineActionOverrides: Partial = {} +): MicrosoftDefenderEndpointAgentListResponse => { + return { + '@odata.context': 'some-context', + '@odata.count': 1, + total: 1, + page: 1, + pageSize: 0, + value: [createMicrosoftMachineMock(machineActionOverrides)], }; +}; export const microsoftDefenderMock = { createConstructorOptions: createMsDefenderClientConstructorOptionsMock, 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 1a02f780cce03..97973b0638d7a 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 @@ -301,10 +301,12 @@ const createConnectorActionsClientMock = ({ } = {}): ActionsClientMock => { const client = actionsClientMock.create(); - (client.getAll as jest.Mock).mockImplementation(async () => { + client.getAll.mockImplementation(async () => { return getAllResponse ?? []; }); + client.execute.mockImplementation(async () => createConnectorActionExecuteResponseMock()); + return client; }; @@ -325,14 +327,20 @@ const createNormalizedExternalConnectorClientMock = ( const setConnectorActionsClientExecuteResponseMock = ( connectorActionsClient: ActionsClientMock | NormalizedExternalConnectorClientMock, subAction: string, + /** + * The response to be returned. If this value is a function, it will be called with the + * arguments passed to `.execute()` and should then return the response + */ response: any ): void => { const executeMockFn = (connectorActionsClient.execute as jest.Mock).getMockImplementation(); (connectorActionsClient.execute as jest.Mock).mockImplementation(async (options) => { if (options.params.subAction === subAction) { + const responseData = typeof response === 'function' ? response(options) : response; + return responseActionsClientMock.createConnectorActionExecuteResponse({ - data: response, + data: responseData, }); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.test.ts index a2c3006249e6c..3f682a4ac7dab 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.test.ts @@ -12,6 +12,8 @@ import { microsoftDefenderMock } from '../../../actions/clients/microsoft/defend import type { AgentStatusClientOptions } from '../lib/base_agent_status_client'; import { HostStatus } from '../../../../../../common/endpoint/types'; import { responseActionsClientMock } from '../../../actions/clients/mocks'; +import { MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants'; +import type { ActionsClientMock } from '@kbn/actions-plugin/server/mocks'; jest.mock('../../../actions/pending_actions_summary', () => { const realModule = jest.requireActual('../../../actions/pending_actions_summary'); @@ -94,7 +96,7 @@ describe('Microsoft Defender Agent Status client', () => { agentId: '1-2-3', agentType: 'microsoft_defender_endpoint', found: true, - isolated: false, + isolated: true, lastSeen: '2018-08-02T14:55:03.7791856Z', pendingActions: { isolate: 1, @@ -105,7 +107,7 @@ describe('Microsoft Defender Agent Status client', () => { agentId: 'foo', agentType: 'microsoft_defender_endpoint', found: false, - isolated: false, + isolated: true, // << This is only true because of the way the default mock is setup. The important value for this test data `found: false` lastSeen: '', pendingActions: {}, status: 'unenrolled', @@ -124,25 +126,12 @@ describe('Microsoft Defender Agent Status client', () => { `( 'should correctly map MS machine healthStatus of $msHealthStatus to agent status $expectedAgentStatus', async ({ msHealthStatus, expectedAgentStatus }) => { - const priorExecuteMock = ( - clientConstructorOptions.connectorActionsClient?.execute as jest.Mock - ).getMockImplementation(); - (clientConstructorOptions.connectorActionsClient?.execute as jest.Mock).mockImplementation( - async (options) => { - if (options.params.subAction === 'getAgentList') { - const machineListResponse = - microsoftDefenderMock.createMicrosoftGetMachineListApiResponse(); - machineListResponse.value[0].healthStatus = msHealthStatus; - - return responseActionsClientMock.createConnectorActionExecuteResponse({ - data: machineListResponse, - }); - } - - if (priorExecuteMock) { - return priorExecuteMock(options); - } - } + responseActionsClientMock.setConnectorActionsClientExecuteResponse( + clientConstructorOptions.connectorActionsClient! as ActionsClientMock, + MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_LIST, + microsoftDefenderMock.createMicrosoftGetMachineListApiResponse({ + healthStatus: msHealthStatus, + }) ); await expect(msAgentStatusClientMock.getAgentStatuses(['1-2-3'])).resolves.toEqual({ @@ -152,4 +141,50 @@ describe('Microsoft Defender Agent Status client', () => { }); } ); + + it('should retrieve the last successful isolate/release action from MS', async () => { + await msAgentStatusClientMock.getAgentStatuses(['1-2-3']); + + expect(clientConstructorOptions.connectorActionsClient?.execute).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + subAction: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS, + subActionParams: { + status: 'Succeeded', + type: ['Isolate', 'Unisolate'], + machineId: '1-2-3', + pageSize: 1, + sortField: 'lastUpdateDateTimeUtc', + sortDirection: 'desc', + }, + }, + }) + ); + }); + + it.each` + title | msMachineActionsResponse | expectedIsolatedValue + ${'last success action was Isolate'} | ${microsoftDefenderMock.createGetActionsApiResponse({ type: 'Isolate' })} | ${true} + ${'last success action was Unisolate'} | ${microsoftDefenderMock.createGetActionsApiResponse({ type: 'Unisolate' })} | ${false} + ${'no isolation records are found'} | ${{ value: [] }} | ${false} + ${'when ms API throws an error'} | ${new Error('foo')} | ${false} + `( + `should display isolated:$expectedIsolatedValue when $title`, + async ({ msMachineActionsResponse, expectedIsolatedValue }) => { + responseActionsClientMock.setConnectorActionsClientExecuteResponse( + clientConstructorOptions.connectorActionsClient! as ActionsClientMock, + MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS, + () => { + if (msMachineActionsResponse instanceof Error) { + throw msMachineActionsResponse; + } + return msMachineActionsResponse; + } + ); + + await expect(msAgentStatusClientMock.getAgentStatuses(['1-2-3'])).resolves.toEqual({ + '1-2-3': expect.objectContaining({ isolated: expectedIsolatedValue }), + }); + } + ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.ts index 6f4820290aa85..e102fe3d46f64 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/agent/clients/microsoft_defender_endpoint/microsoft_defender_endpoint_agent_status_client.ts @@ -12,8 +12,12 @@ import { import { keyBy } from 'lodash'; import type { MicrosoftDefenderEndpointAgentListResponse, + MicrosoftDefenderEndpointGetActionsParams, + MicrosoftDefenderEndpointGetActionsResponse, MicrosoftDefenderEndpointMachine, } from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types'; +import pMap from 'p-map'; +import { stringify } from '../../../../utils/stringify'; import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants'; import { AgentStatusClientError } from '../errors'; import { getPendingActionsSummary, NormalizedExternalConnectorClient } from '../../..'; @@ -66,22 +70,79 @@ export class MicrosoftDefenderEndpointAgentStatusClient extends AgentStatusClien } } + protected async calculateHostIsolatedState(agentIds: string[]): Promise> { + const response: Record = {}; + const errors: string[] = []; + + await pMap( + agentIds, + async (agentId) => { + response[agentId] = false; + + try { + // Microsoft's does not seem to have a public API that enables us to get the Isolation state for a machine. To + // get around this, we query the list of machine actions for each host and look at the last successful + // Isolate or Unisolate action to determine if host is isolated or not. + const { data: hostLastSuccessfulMachineAction } = await this.connectorActions.execute< + MicrosoftDefenderEndpointGetActionsResponse, + MicrosoftDefenderEndpointGetActionsParams + >({ + params: { + subAction: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS, + subActionParams: { + status: 'Succeeded', + type: ['Isolate', 'Unisolate'], + machineId: agentId, + pageSize: 1, + sortField: 'lastUpdateDateTimeUtc', + sortDirection: 'desc', + }, + }, + }); + + if (hostLastSuccessfulMachineAction?.value?.[0].type === 'Isolate') { + response[agentId] = true; + } + } catch (err) { + errors.push(err.message); + } + }, + { concurrency: 2 } + ); + + if (errors.length > 0) { + this.log.error( + `Attempt to calculate isolate state for Microsoft Defender hosts generated the following errors:\n${errors.join( + '\n' + )}` + ); + } + + this.log.debug(() => `Microsoft agents isolated state:\n${stringify(response)}`); + + return response; + } + public async getAgentStatuses(agentIds: string[]): Promise { const esClient = this.options.esClient; const metadataService = this.options.endpointService.getEndpointMetadataService(); try { - const [{ data: msMachineListResponse }, allPendingActions] = await Promise.all([ - this.connectorActions.execute({ - params: { - subAction: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_LIST, - subActionParams: { id: agentIds }, - }, - }), - - // Fetch pending actions summary - getPendingActionsSummary(esClient, metadataService, this.log, agentIds), - ]); + const [{ data: msMachineListResponse }, agentIsolationState, allPendingActions] = + await Promise.all([ + this.connectorActions.execute({ + params: { + subAction: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_LIST, + subActionParams: { id: agentIds }, + }, + }), + + // Calculate host's current isolation state + this.calculateHostIsolatedState(agentIds), + + // Fetch pending actions summary + getPendingActionsSummary(esClient, metadataService, this.log, agentIds), + ]); const machinesById = keyBy(msMachineListResponse?.value ?? [], 'id'); const pendingActionsByAgentId = keyBy(allPendingActions, 'agent_id'); @@ -94,9 +155,7 @@ export class MicrosoftDefenderEndpointAgentStatusClient extends AgentStatusClien agentId, agentType: this.agentType, found: !!thisMachine, - // Unfortunately, it does not look like MS Defender has a way to determine - // if a host is isolated or not via API, so we just set this to false - isolated: false, + isolated: agentIsolationState[agentId] ?? false, lastSeen: thisMachine?.lastSeen ?? '', status: this.getAgentStatusFromMachineHealthStatus(thisMachine?.healthStatus), pendingActions: thisAgentPendingActions?.pending_actions ?? {},