[SecuritySolutions][Endpoint] Microsoft defender for Endpoint response actions API (#205097)

## Summary

- Adds response actions client/APIs for isolate and release actions for
Microsoft Defender for Endpoint
- The feature is behind a feature flag
`responseActionsMSDefenderEndpointEnabled`
This commit is contained in:
Ash 2025-01-09 17:59:56 +01:00 committed by GitHub
parent 737cf96809
commit b25c9984bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1290 additions and 57 deletions

View file

@ -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.

View file

@ -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.

View file

@ -142,8 +142,16 @@ export const Comment = z.string();
export type Parameters = z.infer<typeof Parameters>;
export const Parameters = z.object({});
/**
* The host agent type (optional). Defaults to endpoint.
*/
export type AgentTypes = z.infer<typeof AgentTypes>;
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;

View file

@ -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

View file

@ -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<Record<ResponseActionAgentT
endpoint: 'elastic',
sentinel_one: 'Elastic@123',
crowdstrike: 'tbd..',
microsoft_defender_endpoint: 'tbd..',
});
/**
@ -206,6 +212,8 @@ export const RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELDS: Readonly<
'sentinel_one.agent.agent.id',
],
crowdstrike: ['device.id'],
// FIXME:PT temporary change. Tests for MS defender will be in PR https://github.com/elastic/kibana/pull/205012
microsoft_defender_endpoint: ['some.id'],
});
export const SUPPORTED_AGENT_ID_ALERT_FIELDS: Readonly<string[]> = Object.values(

View file

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

View file

@ -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';
/**

View file

@ -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;
}

View file

@ -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<keyof ExperimentalFeatures>;

View file

@ -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.

View file

@ -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.

View file

@ -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();

View file

@ -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];

View file

@ -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();
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 } }));
}
);
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<ResponseActionAgentType, 'endpoint'>
>
)('should set `isSupported` to `false` for [%s] if feature flag is disabled', (agentType) => {

View file

@ -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',

View file

@ -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),

View file

@ -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',
]);

View file

@ -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,

View file

@ -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),

View file

@ -1277,4 +1277,102 @@ describe('Response actions', () => {
expect(httpResponseMock.ok).toHaveBeenCalled();
});
});
describe('and agent type if Microsoft Defender', () => {
let testSetup: HttpApiTestSetupMock;
let httpRequestMock: ReturnType<HttpApiTestSetupMock['createRequestMock']>;
let httpHandlerContextMock: HttpApiTestSetupMock['httpHandlerContextMock'];
let httpResponseMock: HttpApiTestSetupMock['httpResponseMock'];
let callHandler: () => ReturnType<RequestHandler>;
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<ActionsApiRequestHandlerContext>);
// 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,
});
});
});
});

View file

@ -412,7 +412,9 @@ function isThirdPartyFeatureDisabled(
return (
(agentType === 'sentinel_one' && !experimentalFeatures.responseActionsSentinelOneV1Enabled) ||
(agentType === 'crowdstrike' &&
!experimentalFeatures.responseActionsCrowdstrikeManualHostIsolationEnabled)
!experimentalFeatures.responseActionsCrowdstrikeManualHostIsolationEnabled) ||
(agentType === 'microsoft_defender_endpoint' &&
!experimentalFeatures.responseActionsMSDefenderEndpointEnabled)
);
}

View file

@ -29,51 +29,61 @@ const COMMANDS_WITH_ACCESS_TO_FILES: CommandsWithFileAccess = deepFreeze<Command
endpoint: true,
sentinel_one: true,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
execute: {
endpoint: true,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
'running-processes': {
endpoint: false,
sentinel_one: true,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
upload: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
scan: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
isolate: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
unisolate: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
'kill-process': {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
'suspend-process': {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
runscript: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
microsoft_defender_endpoint: false,
},
});

View file

@ -7,6 +7,7 @@
/* eslint-disable max-classes-per-file */
import type { ResponseActionsClient } from '../..';
import type { ResponseActionsApiCommandNames } from '../../../../../common/endpoint/service/response_actions/constants';
import { stringify } from '../../../utils/stringify';
import { CustomHttpRequestError } from '../../../../utils/custom_http_request_error';
@ -31,7 +32,7 @@ export class ResponseActionsClientError extends CustomHttpRequestError {
export class ResponseActionsNotSupportedError extends ResponseActionsClientError {
constructor(
responseAction?: ResponseActionsApiCommandNames,
responseAction?: ResponseActionsApiCommandNames | keyof ResponseActionsClient,
statusCode: number = 405,
meta?: unknown
) {

View file

@ -14,6 +14,7 @@ import { UnsupportedResponseActionsAgentTypeError } from './errors';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import type { CrowdstrikeActionsClientOptions } from './crowdstrike/crowdstrike_actions_client';
import { CrowdstrikeActionsClient } from './crowdstrike/crowdstrike_actions_client';
import { MicrosoftDefenderEndpointActionsClient } from './microsoft/defender/endpoint/ms_defender_endpoint_actions_client';
export type GetResponseActionsClientConstructorOptions = ResponseActionsClientOptions &
SentinelOneActionsClientOptions &
@ -37,6 +38,8 @@ export const getResponseActionsClient = (
return new SentinelOneActionsClient(constructorOptions);
case 'crowdstrike':
return new CrowdstrikeActionsClient(constructorOptions);
case 'microsoft_defender_endpoint':
return new MicrosoftDefenderEndpointActionsClient(constructorOptions);
default:
throw new UnsupportedResponseActionsAgentTypeError(
`Agent type [${agentType}] does not support response actions`

View file

@ -112,6 +112,8 @@ describe('ResponseActionsClientImpl base class', () => {
'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<TOutputContent>(options);
}
public fetchAllPendingActions(): AsyncIterable<ResponseActionsClientPendingAction[]> {
return super.fetchAllPendingActions();
public fetchAllPendingActions<
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes,
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TMeta extends {} = {}
>(): AsyncIterable<
Array<ResponseActionsClientPendingAction<TParameters, TOutputContent, TMeta>>
> {
return super.fetchAllPendingActions<TParameters, TOutputContent, TMeta>();
}
}

View file

@ -654,7 +654,13 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
}
}
protected fetchAllPendingActions(): AsyncIterable<ResponseActionsClientPendingAction[]> {
protected fetchAllPendingActions<
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes,
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TMeta extends {} = {}
>(): AsyncIterable<
Array<ResponseActionsClientPendingAction<TParameters, TOutputContent, TMeta>>
> {
const esClient = this.options.esClient;
const query: QueryDslQueryContainer = {
bool: {
@ -861,10 +867,10 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
actionId: string,
fileId: string
): Promise<GetFileDownloadMethodResponse> {
throw new ResponseActionsClientError(`Method getFileDownload() not implemented`, 501);
throw new ResponseActionsNotSupportedError('getFileDownload');
}
public async getFileInfo(actionId: string, fileId: string): Promise<UploadedFileInfo> {
throw new ResponseActionsClientError(`Method getFileInfo() not implemented`, 501);
throw new ResponseActionsNotSupportedError('getFileInfo');
}
}

View file

@ -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<typeof client.execute>[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> = {}
): 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> = {}
): 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,
};

View file

@ -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<keyof ResponseActionsClient, boolean> = {
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<keyof ResponseActionsClient>)
)('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<Extract<keyof ResponseActionsClient, 'isolate' | 'release'>>([
'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<MicrosoftDefenderEndpointMachineAction['status']>(['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);
}
);
});
});
});

View file

@ -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<TParameters, TOutputContent, TMeta>;
actionDetails: ActionDetails<TOutputContent, TParameters>;
}> {
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<ActionDetails<TOutputContent, TParameters>>(
actionRequestDoc.EndpointActions.action_id
),
};
}
/**
* Sends actions to Ms Defender for Endpoint directly (via Connector)
* @private
*/
private async sendAction<
TResponse = unknown,
TParams extends Record<string, unknown> = Record<string, unknown>
>(
actionType: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION,
actionParams: TParams
): Promise<ActionTypeExecutorResult<TResponse>> {
const executeOptions: Parameters<typeof this.connectorActionsClient.execute>[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<TResponse>;
}
protected async writeActionRequestToEndpointIndex<
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes,
TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
TMeta extends {} = MicrosoftDefenderEndpointActionRequestCommonMeta
>(
actionRequest: ResponseActionsClientWriteActionRequestToEndpointIndexOptions<
TParameters,
TOutputContent,
TMeta
>
): Promise<LogsEndpointAction<TParameters, TOutputContent, TMeta>> {
const agentId = actionRequest.endpoint_ids[0];
const agentDetails = await this.getAgentDetails(agentId);
const doc = await super.writeActionRequestToEndpointIndex<TParameters, TOutputContent, TMeta>({
...actionRequest,
hosts: {
[agentId]: { name: agentDetails.computerDnsName },
},
});
return doc;
}
/** Gets agent details directly from MS Defender for Endpoint */
private async getAgentDetails(agentId: string): Promise<MicrosoftDefenderEndpointMachine> {
const cachedEntry = this.cache.get<MicrosoftDefenderEndpointMachine>(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<ResponseActionsClientValidateRequestResponse> {
// 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<ActionDetails> {
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<ActionDetails> {
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<void> {
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<LogsEndpointActionResponse[]> {
const completedResponses: LogsEndpointActionResponse[] = [];
const warnings: string[] = [];
const actionsByMachineId: Record<
string,
Array<LogsEndpointAction<undefined, {}, MicrosoftDefenderEndpointActionRequestCommonMeta>>
> = {};
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<MicrosoftDefenderEndpointGetActionsResponse>(
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 };
}
}

View file

@ -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<NormalizedExternalConnectorClient>;
const createResponseActionClientMock = (): jest.Mocked<ResponseActionsClient> => {
return {
suspendProcess: jest.fn().mockReturnValue(Promise.resolve()),
@ -305,7 +310,7 @@ const createConnectorActionsClientMock = ({
const createNormalizedExternalConnectorClientMock = (
connectorActionsClientMock: ActionsClientMock = createConnectorActionsClientMock()
): DeeplyMockedKeys<NormalizedExternalConnectorClient> => {
): 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<NormalizedExternalConnectorClient>;
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,
});