Skip to content

Commit

Permalink
[Security Solution][Endpoint] Agent status api support for Microsoft …
Browse files Browse the repository at this point in the history
…Defender for Endpoint hosts (elastic#205817)

## Summary

### Stack Connectors changes

- Added new method to the Microsoft Defender for Endpoint connector to
retrieve list of Machines

### Security Solution

- Added support for retrieving the status of Microsoft Defender agents
  • Loading branch information
paul-tavares authored Jan 10, 2025
1 parent 221f1b1 commit d891807
Show file tree
Hide file tree
Showing 14 changed files with 498 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID = '.microsoft_defender_end
export enum MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION {
TEST_CONNECTOR = 'testConnector',
GET_AGENT_DETAILS = 'getAgentDetails',
GET_AGENT_LIST = 'getAgentList',
ISOLATE_HOST = 'isolateHost',
RELEASE_HOST = 'releaseHost',
GET_ACTIONS = 'getActions',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,105 @@ export const AgentDetailsParamsSchema = schema.object({
id: schema.string({ minLength: 1 }),
});

const MachineHealthStatusSchema = schema.oneOf([
schema.literal('Active'),
schema.literal('Inactive'),
schema.literal('ImpairedCommunication'),
schema.literal('NoSensorData'),
schema.literal('NoSensorDataImpairedCommunication'),
schema.literal('Unknown'),
]);

export const AgentListParamsSchema = schema.object({
computerDnsName: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
id: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
version: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
deviceValue: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
aaDeviceId: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
machineTags: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
lastSeen: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
exposureLevel: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
onboardingStatus: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
lastIpAddress: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
healthStatus: schema.maybe(
schema.oneOf([
MachineHealthStatusSchema,
schema.arrayOf(MachineHealthStatusSchema, { minSize: 1 }),
])
),
osPlatform: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
riskScore: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),
rbacGroupId: schema.maybe(
schema.oneOf([
schema.string({ minLength: 1 }),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
])
),

page: schema.maybe(schema.number({ min: 1, defaultValue: 1 })),
pageSize: schema.maybe(schema.number({ min: 1, max: 1000, defaultValue: 20 })),
});

export const IsolateHostParamsSchema = schema.object({
id: schema.string({ minLength: 1 }),
comment: schema.string({ minLength: 1 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
TestConnectorParamsSchema,
AgentDetailsParamsSchema,
GetActionsParamsSchema,
AgentListParamsSchema,
} from './schema';

export type MicrosoftDefenderEndpointConfig = TypeOf<typeof MicrosoftDefenderEndpointConfigSchema>;
Expand All @@ -35,6 +36,18 @@ export interface MicrosoftDefenderEndpointTestConnector {

export type MicrosoftDefenderEndpointAgentDetailsParams = TypeOf<typeof AgentDetailsParamsSchema>;

export type MicrosoftDefenderEndpointAgentListParams = TypeOf<typeof AgentListParamsSchema>;

export interface MicrosoftDefenderEndpointAgentListResponse {
'@odata.context': string;
'@odata.count'?: number;
/** If value is `-1`, then API did not provide a total count */
total: number;
page: number;
pageSize: number;
value: MicrosoftDefenderEndpointMachine[];
}

export type MicrosoftDefenderEndpointGetActionsParams = TypeOf<typeof GetActionsParamsSchema>;

export interface MicrosoftDefenderEndpointGetActionsResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,31 @@ describe('Microsoft Defender for Endpoint Connector', () => {
}
);
});

describe('#getAgentList()', () => {
it('should return expected response', async () => {
await expect(
connectorMock.instanceMock.getAgentList({ id: '1-2-3' }, connectorMock.usageCollector)
).resolves.toEqual({
'@odata.context': 'https://api-us3.securitycenter.microsoft.com/api/$metadata#Machines',
'@odata.count': 1,
page: 1,
pageSize: 20,
total: 1,
value: [expect.any(Object)],
});
});

it('should call Microsoft API with expected query params', async () => {
await connectorMock.instanceMock.getAgentList({ id: '1-2-3' }, connectorMock.usageCollector);

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 },
}),
connectorMock.usageCollector
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MicrosoftDefenderEndpointDoNotValidateResponseSchema,
GetActionsParamsSchema,
AgentDetailsParamsSchema,
AgentListParamsSchema,
} from '../../../common/microsoft_defender_endpoint/schema';
import {
MicrosoftDefenderEndpointAgentDetailsParams,
Expand All @@ -32,6 +33,8 @@ import {
MicrosoftDefenderEndpointTestConnector,
MicrosoftDefenderEndpointGetActionsParams,
MicrosoftDefenderEndpointGetActionsResponse,
MicrosoftDefenderEndpointAgentListParams,
MicrosoftDefenderEndpointAgentListResponse,
} from '../../../common/microsoft_defender_endpoint/types';

export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
Expand Down Expand Up @@ -70,6 +73,11 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
method: 'getAgentDetails',
schema: AgentDetailsParamsSchema,
});
this.registerSubAction({
name: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_LIST,
method: 'getAgentList',
schema: AgentListParamsSchema,
});

this.registerSubAction({
name: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.ISOLATE_HOST,
Expand Down Expand Up @@ -243,6 +251,30 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
);
}

public async getAgentList(
{ page = 1, pageSize = 20, ...filter }: MicrosoftDefenderEndpointAgentListParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<MicrosoftDefenderEndpointAgentListResponse> {
// API Reference: https://learn.microsoft.com/en-us/defender-endpoint/api/get-machines
// OData usage reference: https://learn.microsoft.com/en-us/defender-endpoint/api/exposed-apis-odata-samples

const response = await this.fetchFromMicrosoft<MicrosoftDefenderEndpointAgentListResponse>(
{
url: `${this.urls.machines}`,
method: 'GET',
params: this.buildODataUrlParams({ filter, page, pageSize }),
},
connectorUsageCollector
);

return {
...response,
page,
pageSize,
total: response['@odata.count'] ?? -1,
};
}

public async isolateHost(
{ id, comment }: MicrosoftDefenderEndpointIsolateHostParams,
connectorUsageCollector: ConnectorUsageCollector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ const createMicrosoftDefenderConnectorMock = (): CreateMicrosoftDefenderConnecto
'@odata.count': 1,
value: [createMicrosoftMachineAction()],
}),

// Machine List
[`${apiUrl}/api/machines`]: () =>
createAxiosResponseMock({
'@odata.context': 'https://api-us3.securitycenter.microsoft.com/api/$metadata#Machines',
'@odata.count': 1,
value: [createMicrosoftMachineMock()],
}),
};

instanceMock.request.mockImplementation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ describe('Agent Status API route handler', () => {
});

it.each`
agentType | featureFlag
${'sentinel_one'} | ${'responseActionsSentinelOneV1Enabled'}
${'crowdstrike'} | ${'responseActionsCrowdstrikeManualHostIsolationEnabled'}
agentType | featureFlag
${'sentinel_one'} | ${'responseActionsSentinelOneV1Enabled'}
${'crowdstrike'} | ${'responseActionsCrowdstrikeManualHostIsolationEnabled'}
${'microsoft_defender_endpoint'} | ${'responseActionsMSDefenderEndpointEnabled'}
`(
'should error if the $agentType feature flag ($featureFlag) is turned off',
async ({
Expand Down Expand Up @@ -102,6 +103,10 @@ describe('Agent Status API route handler', () => {

it.each(RESPONSE_ACTION_AGENT_TYPE)('should accept agent type of %s', async (agentType) => {
httpRequestMock.query.agentType = agentType;
apiTestSetup.endpointAppContextMock.experimentalFeatures = {
...apiTestSetup.endpointAppContextMock.experimentalFeatures,
responseActionsMSDefenderEndpointEnabled: true,
};
await apiTestSetup
.getRegisteredVersionedRoute('get', AGENT_STATUS_ROUTE, '1')
.routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export const getAgentStatusRouteHandler = (
(agentType === 'sentinel_one' &&
!endpointContext.experimentalFeatures.responseActionsSentinelOneV1Enabled) ||
(agentType === 'crowdstrike' &&
!endpointContext.experimentalFeatures.responseActionsCrowdstrikeManualHostIsolationEnabled)
!endpointContext.experimentalFeatures
.responseActionsCrowdstrikeManualHostIsolationEnabled) ||
(agentType === 'microsoft_defender_endpoint' &&
!endpointContext.experimentalFeatures.responseActionsMSDefenderEndpointEnabled)
) {
return errorHandler(
logger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION,
} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants';
import type {
MicrosoftDefenderEndpointAgentListResponse,
MicrosoftDefenderEndpointGetActionsResponse,
MicrosoftDefenderEndpointMachine,
MicrosoftDefenderEndpointMachineAction,
Expand Down Expand Up @@ -59,6 +60,11 @@ const createMsConnectorActionsClientMock = (): ActionsClientMock => {
data: createMicrosoftMachineMock(),
});

case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_AGENT_LIST:
return responseActionsClientMock.createConnectorActionExecuteResponse({
data: createMicrosoftGetMachineListApiResponseMock(),
});

case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.ISOLATE_HOST:
return responseActionsClientMock.createConnectorActionExecuteResponse({
data: createMicrosoftMachineActionMock({ type: 'Isolate' }),
Expand Down Expand Up @@ -159,9 +165,23 @@ const createMicrosoftGetActionsApiResponseMock =
};
};

const createMicrosoftGetMachineListApiResponseMock =
(): MicrosoftDefenderEndpointAgentListResponse => {
return {
'@odata.context': 'some-context',
'@odata.count': 1,
total: 1,
page: 1,
pageSize: 0,
value: [createMicrosoftMachineMock()],
};
};

export const microsoftDefenderMock = {
createConstructorOptions: createMsDefenderClientConstructorOptionsMock,
createMsConnectorActionsClient: createMsConnectorActionsClientMock,
createMachineAction: createMicrosoftMachineActionMock,
createMachine: createMicrosoftMachineMock,
createGetActionsApiResponse: createMicrosoftGetActionsApiResponseMock,
createMicrosoftGetMachineListApiResponse: createMicrosoftGetMachineListApiResponseMock,
};
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ export const getPendingActionsSummary = async (
setActionAsPending(unExpiredAction.command);
} else if (
unExpiredAction.wasSuccessful &&
(unExpiredAction.command === 'isolate' || unExpiredAction.command === 'unisolate')
(unExpiredAction.command === 'isolate' || unExpiredAction.command === 'unisolate') &&
unExpiredAction.agentType === 'endpoint'
) {
// For Elastic Defend (endpoint):
// For Isolate and Un-Isolate, we want to ensure that the isolation status being reported in the
// endpoint metadata was received after the action was completed. This is to ensure that the
// isolation status being reported in the UI remains as accurate as possible.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* 2.0.
*/

import { MicrosoftDefenderEndpointAgentStatusClient } from './microsoft_defender_endpoint';
import { CrowdstrikeAgentStatusClient } from './crowdstrike/crowdstrike_agent_status_client';
import { SentinelOneAgentStatusClient } from './sentinel_one/sentinel_one_agent_status_client';
import type { AgentStatusClientInterface } from './lib/types';
Expand All @@ -30,6 +31,8 @@ export const getAgentStatusClient = (
return new SentinelOneAgentStatusClient(constructorOptions);
case 'crowdstrike':
return new CrowdstrikeAgentStatusClient(constructorOptions);
case 'microsoft_defender_endpoint':
return new MicrosoftDefenderEndpointAgentStatusClient(constructorOptions);
default:
throw new UnsupportedAgentTypeError(
`Agent type [${agentType}] does not support agent status`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export * from './microsoft_defender_endpoint_agent_status_client';
Loading

0 comments on commit d891807

Please sign in to comment.