[EDR Workflows] Complete Crowdstrike actions (#186522)

This commit is contained in:
Tomasz Ciecierski 2024-06-25 18:44:27 +02:00 committed by GitHub
parent 606c695866
commit 7ce69fc52c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 186 additions and 105 deletions

View file

@ -13,9 +13,13 @@ import { ResponseActionsNotSupportedError } from '../errors';
import type { CrowdstrikeActionsClientOptionsMock } from './mocks';
import { CrowdstrikeMock } from './mocks';
import { ENDPOINT_ACTIONS_INDEX } from '../../../../../../common/endpoint/constants';
import {
ENDPOINT_ACTION_RESPONSES_INDEX,
ENDPOINT_ACTIONS_INDEX,
} from '../../../../../../common/endpoint/constants';
import { SUB_ACTION } from '@kbn/stack-connectors-plugin/common/crowdstrike/constants';
import type { NormalizedExternalConnectorClient } from '../../..';
jest.mock('../../action_details_by_id', () => {
const originalMod = jest.requireActual('../../action_details_by_id');
@ -75,6 +79,48 @@ describe('CrowdstrikeActionsClient class', () => {
});
});
it('should save response with error in case of actionResponse containing errors', async () => {
// mock execute of CS action to return error
const actionResponse = {
data: {
errors: [{ message: 'error message' }],
},
};
(connectorActionsMock.execute as jest.Mock).mockResolvedValueOnce(actionResponse);
await crowdstrikeActionsClient.isolate(
createCrowdstrikeIsolationOptions({ actionId: '123-345-567' })
);
expect(classConstructorOptions.esClient.index.mock.calls[1][0]).toEqual({
document: {
'@timestamp': expect.any(String),
agent: { id: ['1-2-3'] },
EndpointActions: {
action_id: expect.any(String),
completed_at: expect.any(String),
started_at: expect.any(String),
data: {
command: 'isolate',
comment: 'test comment',
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
},
},
input_type: 'crowdstrike',
},
error: {
code: '500',
message: 'Crowdstrike action failed: error message',
},
meta: undefined,
},
index: ENDPOINT_ACTION_RESPONSES_INDEX,
refresh: 'wait_for',
});
});
describe(`#isolate()`, () => {
it('should send action to Crowdstrike', async () => {
await crowdstrikeActionsClient.isolate(
@ -99,40 +145,61 @@ describe('CrowdstrikeActionsClient class', () => {
it('should write action request to endpoint indexes', async () => {
await crowdstrikeActionsClient.isolate(createCrowdstrikeIsolationOptions());
// we do not write response to es yet
expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(1);
expect(classConstructorOptions.esClient.index).toHaveBeenNthCalledWith(
1,
{
document: {
'@timestamp': expect.any(String),
EndpointActions: {
action_id: expect.any(String),
data: {
command: 'isolate',
comment: 'test comment',
parameters: undefined,
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(2);
expect(classConstructorOptions.esClient.index.mock.calls[0][0]).toEqual({
document: {
'@timestamp': expect.any(String),
EndpointActions: {
action_id: expect.any(String),
data: {
command: 'isolate',
comment: 'test comment',
parameters: undefined,
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
},
expiration: expect.any(String),
input_type: 'crowdstrike',
type: 'INPUT_ACTION',
},
agent: { id: ['1-2-3'] },
meta: {
hostName: 'Crowdstrike-1460',
},
user: { id: 'foo' },
expiration: expect.any(String),
input_type: 'crowdstrike',
type: 'INPUT_ACTION',
},
index: ENDPOINT_ACTIONS_INDEX,
refresh: 'wait_for',
agent: { id: ['1-2-3'] },
meta: {
hostName: 'Crowdstrike-1460',
},
user: { id: 'foo' },
},
{ meta: true }
);
index: ENDPOINT_ACTIONS_INDEX,
refresh: 'wait_for',
});
expect(classConstructorOptions.esClient.index.mock.calls[1][0]).toEqual({
document: {
'@timestamp': expect.any(String),
agent: { id: ['1-2-3'] },
EndpointActions: {
action_id: expect.any(String),
completed_at: expect.any(String),
started_at: expect.any(String),
data: {
command: 'isolate',
comment: 'test comment',
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
parameters: undefined,
},
},
input_type: 'crowdstrike',
error: undefined,
meta: undefined,
},
},
index: ENDPOINT_ACTION_RESPONSES_INDEX,
refresh: 'wait_for',
});
});
it('should return action details', async () => {
@ -174,40 +241,61 @@ describe('CrowdstrikeActionsClient class', () => {
it('should write action request to endpoint indexes', async () => {
await crowdstrikeActionsClient.release(createCrowdstrikeIsolationOptions());
// we do not write response to es yet
expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(1);
expect(classConstructorOptions.esClient.index).toHaveBeenNthCalledWith(
1,
{
document: {
'@timestamp': expect.any(String),
EndpointActions: {
action_id: expect.any(String),
data: {
command: 'unisolate',
comment: 'test comment',
parameters: undefined,
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(2);
expect(classConstructorOptions.esClient.index.mock.calls[0][0]).toEqual({
document: {
'@timestamp': expect.any(String),
EndpointActions: {
action_id: expect.any(String),
data: {
command: 'unisolate',
comment: 'test comment',
parameters: undefined,
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
},
expiration: expect.any(String),
input_type: 'crowdstrike',
type: 'INPUT_ACTION',
},
agent: { id: ['1-2-3'] },
meta: {
hostName: 'Crowdstrike-1460',
},
user: { id: 'foo' },
expiration: expect.any(String),
input_type: 'crowdstrike',
type: 'INPUT_ACTION',
},
index: ENDPOINT_ACTIONS_INDEX,
refresh: 'wait_for',
agent: { id: ['1-2-3'] },
meta: {
hostName: 'Crowdstrike-1460',
},
user: { id: 'foo' },
},
{ meta: true }
);
index: ENDPOINT_ACTIONS_INDEX,
refresh: 'wait_for',
});
expect(classConstructorOptions.esClient.index.mock.calls[1][0]).toEqual({
document: {
'@timestamp': expect.any(String),
agent: { id: ['1-2-3'] },
EndpointActions: {
action_id: expect.any(String),
completed_at: expect.any(String),
started_at: expect.any(String),
data: {
command: 'unisolate',
comment: 'test comment',
hosts: {
'1-2-3': {
name: 'Crowdstrike-1460',
},
},
parameters: undefined,
},
input_type: 'crowdstrike',
},
error: undefined,
meta: undefined,
},
index: ENDPOINT_ACTION_RESPONSES_INDEX,
refresh: 'wait_for',
});
});
it('should return action details', async () => {

View file

@ -11,6 +11,7 @@ import {
CROWDSTRIKE_CONNECTOR_ID,
} from '@kbn/stack-connectors-plugin/common/crowdstrike/constants';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type { CrowdstrikeBaseApiResponse } from '@kbn/stack-connectors-plugin/common/crowdstrike/types';
import type { CrowdstrikeActionRequestCommonMeta } from '../../../../../../common/endpoint/types/crowdstrike';
import type {
CommonResponseActionMethodOptions,
@ -159,45 +160,6 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
}
}
// TODO TC: uncomment when working on agent status support
// private async getAgentDetails(
// id: string
// ): Promise<CrowdstrikeGetAgentsResponse['resources'][number]> {
// const executeOptions: NormalizedExternalConnectorClientExecuteOptions<
// CrowdstrikeGetAgentsParams,
// SUB_ACTION
// > = {
// params: {
// subAction: SUB_ACTION.GET_AGENT_DETAILS,
// subActionParams: {
// ids: [id],
// },
// },
// };
// let crowdstrikeApiResponse: CrowdstrikeGetAgentsResponse | undefined;
// try {
// const response = await this.connectorActionsClient.execute(executeOptions);
// this.log.debug(`Response for Crowdstrike agent id [${id}] returned:\n${stringify(response)}`);
// crowdstrikeApiResponse = response.data;
// } catch (err) {
// throw new ResponseActionsClientError(
// `Error while attempting to retrieve Crowdstrike host with agent id [${id}]`,
// 500,
// err
// );
// }
// if (!crowdstrikeApiResponse || !crowdstrikeApiResponse.resources[0]) {
// throw new ResponseActionsClientError(`Crowdstrike agent id [${id}] not found`, 404);
// }
// return crowdstrikeApiResponse.resources[0];
// }
protected async validateRequest(
payload: ResponseActionsClientWriteActionRequestToEndpointIndexOptions
): Promise<ResponseActionsClientValidateRequestResponse> {
@ -224,17 +186,16 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
...this.getMethodOptions(options),
command: 'isolate',
};
let actionResponse: ActionTypeExecutorResult<CrowdstrikeBaseApiResponse> | undefined;
if (!reqIndexOptions.error) {
let error = (await this.validateRequest(reqIndexOptions)).error;
const actionCommentMessage = ELASTIC_RESPONSE_ACTION_MESSAGE(
this.options.username,
reqIndexOptions.actionId
);
if (!error) {
try {
await this.sendAction(SUB_ACTION.HOST_ACTIONS, {
actionResponse = (await this.sendAction(SUB_ACTION.HOST_ACTIONS, {
ids: actionRequest.endpoint_ids,
actionParameters: {
comment: reqIndexOptions.comment
@ -242,7 +203,7 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
: actionCommentMessage,
},
command: 'contain',
});
})) as ActionTypeExecutorResult<CrowdstrikeBaseApiResponse>;
} catch (err) {
error = err;
}
@ -257,6 +218,11 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
const actionRequestDoc = await this.writeActionRequestToEndpointIndex(reqIndexOptions);
// Ensure actionResponse is assigned before using it
if (actionResponse) {
await this.completeCrowdstrikeAction(actionResponse, actionRequestDoc);
}
await this.updateCases({
command: reqIndexOptions.command,
caseIds: reqIndexOptions.case_ids,
@ -284,6 +250,7 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
command: 'unisolate',
};
let actionResponse: ActionTypeExecutorResult<CrowdstrikeBaseApiResponse> | undefined;
if (!reqIndexOptions.error) {
let error = (await this.validateRequest(reqIndexOptions)).error;
const actionCommentMessage = ELASTIC_RESPONSE_ACTION_MESSAGE(
@ -292,13 +259,13 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
);
if (!error) {
try {
await this.sendAction(SUB_ACTION.HOST_ACTIONS, {
actionResponse = (await this.sendAction(SUB_ACTION.HOST_ACTIONS, {
ids: actionRequest.endpoint_ids,
command: 'lift_containment',
comment: reqIndexOptions.comment
? `${actionCommentMessage}: ${reqIndexOptions.comment}`
: actionCommentMessage,
});
})) as ActionTypeExecutorResult<CrowdstrikeBaseApiResponse>;
} catch (err) {
error = err;
}
@ -313,6 +280,11 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
const actionRequestDoc = await this.writeActionRequestToEndpointIndex(reqIndexOptions);
// Ensure actionResponse is assigned before using it
if (actionResponse) {
await this.completeCrowdstrikeAction(actionResponse, actionRequestDoc);
}
await this.updateCases({
command: reqIndexOptions.command,
caseIds: reqIndexOptions.case_ids,
@ -330,6 +302,27 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
return this.fetchActionDetails(actionRequestDoc.EndpointActions.action_id);
}
private async completeCrowdstrikeAction(
actionResponse: ActionTypeExecutorResult<CrowdstrikeBaseApiResponse> | undefined,
doc: LogsEndpointAction
): Promise<void> {
const options = {
actionId: doc.EndpointActions.action_id,
agentId: doc.agent.id,
data: doc.EndpointActions.data,
...(actionResponse?.data?.errors?.length
? {
error: {
code: '500',
message: `Crowdstrike action failed: ${actionResponse.data.errors[0].message}`,
},
}
: {}),
};
await this.writeActionResponseToEndpointIndex(options);
}
async processPendingActions({
abortSignal,
addToQueue,

View file

@ -135,7 +135,7 @@ export class CrowdstrikeAgentStatusClient extends AgentStatusClient {
const agentStatuses = await this.getAgentStatusFromConnectorAction(agentIds);
return agentIds.reduce<AgentStatusRecords>((acc, agentId) => {
const agentInfo = mostRecentAgentInfosByAgentId[agentId].crowdstrike;
const agentInfo = mostRecentAgentInfosByAgentId[agentId]?.crowdstrike;
const agentStatus = agentStatuses[agentId];
const pendingActions = allPendingActions.find(