Skip to content

Commit

Permalink
[Security Solution][Endpoint] Display of isolation state for Microsof…
Browse files Browse the repository at this point in the history
…t Defender agents in alert details and response console (elastic#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
  • Loading branch information
paul-tavares authored Jan 15, 2025
1 parent 8345223 commit 4c6abde
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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')])),
});

// ----------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -153,10 +153,14 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
filter = {},
page = 1,
pageSize = 20,
sortField = '',
sortDirection = 'desc',
}: {
filter: Record<string, string | string[]>;
page: number;
pageSize: number;
page?: number;
pageSize?: number;
sortField?: string;
sortDirection?: string;
}): Partial<BuildODataUrlParamsResponse> {
const oDataQueryOptions: Partial<BuildODataUrlParamsResponse> = {
$count: true,
Expand All @@ -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) {
Expand All @@ -185,7 +193,7 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
oDataQueryOptions.$filter += `${key} ${isArrayValue ? 'in' : 'eq'} ${
isArrayValue
? '(' + value.map((valueString) => `'${valueString}'`).join(',') + ')'
: value
: `'${value}'`
}`;
}
}
Expand Down Expand Up @@ -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<MicrosoftDefenderEndpointGetActionsResponse> {
// API Reference: https://learn.microsoft.com/en-us/defender-endpoint/api/get-machineactions-collection
Expand All @@ -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
);
Expand All @@ -342,4 +356,5 @@ interface BuildODataUrlParamsResponse {
$top: number;
$skip: number;
$count: boolean;
$orderby: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,38 @@ export class NormalizedExternalConnectorClient {
ActionTypeExecutorResult<TResponse>
> {
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<ActionTypeExecutorResult<TResponse>>;
return this.connectorsClient
.execute({
requesterId: 'background_task',
id: connectorId,
spaceId,
params,
relatedSavedObjects: this.options?.relatedSavedObjects,
})
.catch(catchAndThrow) as Promise<ActionTypeExecutorResult<TResponse>>;
}

return this.connectorsClient.execute({
actionId: connectorId,
params,
}) as Promise<ActionTypeExecutorResult<TResponse>>;
return this.connectorsClient
.execute({
actionId: connectorId,
params,
})
.catch(catchAndThrow) as Promise<ActionTypeExecutorResult<TResponse>>;
}

protected async getAll(spaceId: string = 'default'): ReturnType<ActionsClient['getAll']> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MicrosoftDefenderEndpointMachineAction> = {}
): 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<MicrosoftDefenderEndpointMachine> = {}
): MicrosoftDefenderEndpointAgentListResponse => {
return {
'@odata.context': 'some-context',
'@odata.count': 1,
total: 1,
page: 1,
pageSize: 0,
value: [createMicrosoftMachineMock(machineActionOverrides)],
};
};

export const microsoftDefenderMock = {
createConstructorOptions: createMsDefenderClientConstructorOptionsMock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -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,
});
}

Expand Down
Loading

0 comments on commit 4c6abde

Please sign in to comment.