[Security Solution] [OLM] Endpoint pending actions API (#101269)

This commit is contained in:
Dan Panzarella 2021-06-03 14:51:45 -04:00 committed by GitHub
parent e4f74471ec
commit 747b80b58f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 592 additions and 21 deletions

View file

@ -36,3 +36,4 @@ export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`;
/** Endpoint Actions Log Routes */
export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`;
export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`;

View file

@ -28,3 +28,12 @@ export const EndpointActionLogRequestSchema = {
agent_id: schema.string(),
}),
};
export const ActionStatusRequestSchema = {
query: schema.object({
agent_ids: schema.oneOf([
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }),
schema.string({ minLength: 1 }),
]),
}),
};

View file

@ -10,6 +10,11 @@ import { HostIsolationRequestSchema } from '../schema/actions';
export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';
export interface EndpointActionData {
command: ISOLATION_ACTIONS;
comment?: string;
}
export interface EndpointAction {
action_id: string;
'@timestamp': string;
@ -18,10 +23,7 @@ export interface EndpointAction {
input_type: 'endpoint';
agents: string[];
user_id: string;
data: {
command: ISOLATION_ACTIONS;
comment?: string;
};
data: EndpointActionData;
}
export interface EndpointActionResponse {
@ -32,11 +34,8 @@ export interface EndpointActionResponse {
agent_id: string;
started_at: string;
completed_at: string;
error: string;
action_data: {
command: ISOLATION_ACTIONS;
comment?: string;
};
error?: string;
action_data: EndpointActionData;
}
export type HostIsolationRequestBody = TypeOf<typeof HostIsolationRequestSchema.body>;
@ -44,3 +43,10 @@ export type HostIsolationRequestBody = TypeOf<typeof HostIsolationRequestSchema.
export interface HostIsolationResponse {
action: string;
}
export interface PendingActionsResponse {
agent_id: string;
pending_actions: {
[key: string]: number;
};
}

View file

@ -5,5 +5,21 @@
* 2.0.
*/
import { SecuritySolutionPluginRouter } from '../../../types';
import { EndpointAppContext } from '../../types';
import { registerHostIsolationRoutes } from './isolation';
import { registerActionStatusRoutes } from './status';
import { registerActionAuditLogRoutes } from './audit_log';
export * from './isolation';
export * from './audit_log';
// wrap route registration
export function registerActionRoutes(
router: SecuritySolutionPluginRouter,
endpointContext: EndpointAppContext
) {
registerHostIsolationRoutes(router, endpointContext);
registerActionStatusRoutes(router, endpointContext);
registerActionAuditLogRoutes(router, endpointContext);
}

View file

@ -94,7 +94,6 @@ describe('Host Isolation', () => {
},
])
);
endpointAppContextService.start({ ...startContract, packageService: mockPackageService });
licenseEmitter = new Subject();
licenseService = new LicenseService();
licenseService.start(licenseEmitter);

View file

@ -117,7 +117,7 @@ export const isolationRequestHandler = function (
const actionID = uuid.v4();
let result;
try {
result = await esClient.index({
result = await esClient.index<EndpointAction>({
index: AGENT_ACTIONS_INDEX,
body: {
action_id: actionID,
@ -126,12 +126,12 @@ export const isolationRequestHandler = function (
type: 'INPUT_ACTION',
input_type: 'endpoint',
agents: agentIDs,
user_id: user?.username,
user_id: user!.username,
data: {
command: isolate ? 'isolate' : 'unisolate',
comment: req.body.comment,
comment: req.body.comment ?? undefined,
},
} as EndpointAction,
},
});
} catch (e) {
return res.customError({

View file

@ -0,0 +1,140 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable max-classes-per-file */
/* eslint-disable @typescript-eslint/no-useless-constructor */
import { ApiResponse } from '@elastic/elasticsearch';
import moment from 'moment';
import uuid from 'uuid';
import {
EndpointAction,
EndpointActionResponse,
ISOLATION_ACTIONS,
} from '../../../../common/endpoint/types';
export const mockSearchResult = (results: any = []): ApiResponse<any> => {
return {
body: {
hits: {
hits: results.map((a: any) => ({
_source: a,
})),
},
},
statusCode: 200,
headers: {},
warnings: [],
meta: {} as any,
};
};
export class MockAction {
private actionID: string = uuid.v4();
private ts: moment.Moment = moment();
private user: string = '';
private agents: string[] = [];
private command: ISOLATION_ACTIONS = 'isolate';
private comment?: string;
constructor() {}
public build(): EndpointAction {
return {
action_id: this.actionID,
'@timestamp': this.ts.toISOString(),
expiration: this.ts.add(2, 'weeks').toISOString(),
type: 'INPUT_ACTION',
input_type: 'endpoint',
agents: this.agents,
user_id: this.user,
data: {
command: this.command,
comment: this.comment,
},
};
}
public fromUser(u: string) {
this.user = u;
return this;
}
public withAgents(a: string[]) {
this.agents = a;
return this;
}
public withAgent(a: string) {
this.agents = [a];
return this;
}
public withComment(c: string) {
this.comment = c;
return this;
}
public withAction(a: ISOLATION_ACTIONS) {
this.command = a;
return this;
}
public atTime(m: moment.Moment | Date) {
if (m instanceof Date) {
this.ts = moment(m);
} else {
this.ts = m;
}
return this;
}
public withID(id: string) {
this.actionID = id;
return this;
}
}
export const aMockAction = (): MockAction => {
return new MockAction();
};
export class MockResponse {
private actionID: string = uuid.v4();
private ts: moment.Moment = moment();
private started: moment.Moment = moment();
private completed: moment.Moment = moment();
private agent: string = '';
private command: ISOLATION_ACTIONS = 'isolate';
private comment?: string;
private error?: string;
constructor() {}
public build(): EndpointActionResponse {
return {
'@timestamp': this.ts.toISOString(),
action_id: this.actionID,
agent_id: this.agent,
started_at: this.started.toISOString(),
completed_at: this.completed.toISOString(),
error: this.error,
action_data: {
command: this.command,
comment: this.comment,
},
};
}
public forAction(id: string) {
this.actionID = id;
return this;
}
public forAgent(id: string) {
this.agent = id;
return this;
}
}
export const aMockResponse = (actionID: string, agentID: string): MockResponse => {
return new MockResponse().forAction(actionID).forAgent(agentID);
};

View file

@ -0,0 +1,276 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { KibanaResponseFactory, RequestHandler, RouteConfig } from 'kibana/server';
import {
elasticsearchServiceMock,
httpServerMock,
httpServiceMock,
loggingSystemMock,
savedObjectsClientMock,
} from 'src/core/server/mocks';
import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import { parseExperimentalConfigValue } from '../../../../common/experimental_features';
import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__';
import { EndpointAppContextService } from '../../endpoint_app_context_services';
import {
createMockEndpointAppContextServiceStartContract,
createRouteHandlerContext,
} from '../../mocks';
import { registerActionStatusRoutes } from './status';
import uuid from 'uuid';
import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks';
describe('Endpoint Action Status', () => {
describe('schema', () => {
it('should require at least 1 agent ID', () => {
expect(() => {
ActionStatusRequestSchema.query.validate({}); // no agent_ids provided
}).toThrow();
});
it('should accept a single agent ID', () => {
expect(() => {
ActionStatusRequestSchema.query.validate({ agent_ids: uuid.v4() });
}).not.toThrow();
});
it('should accept multiple agent IDs', () => {
expect(() => {
ActionStatusRequestSchema.query.validate({ agent_ids: [uuid.v4(), uuid.v4()] });
}).not.toThrow();
});
it('should limit the maximum number of agent IDs', () => {
const tooManyCooks = new Array(200).fill(uuid.v4()); // all the same ID string
expect(() => {
ActionStatusRequestSchema.query.validate({ agent_ids: tooManyCooks });
}).toThrow();
});
});
describe('response', () => {
let endpointAppContextService: EndpointAppContextService;
// convenience for calling the route and handler for action status
let getPendingStatus: (reqParams?: any) => Promise<jest.Mocked<KibanaResponseFactory>>;
// convenience for injecting mock responses for actions index and responses
let havingActionsAndResponses: (actions: MockAction[], responses: any[]) => void;
beforeEach(() => {
const esClientMock = elasticsearchServiceMock.createScopedClusterClient();
const routerMock = httpServiceMock.createRouter();
endpointAppContextService = new EndpointAppContextService();
endpointAppContextService.start(createMockEndpointAppContextServiceStartContract());
registerActionStatusRoutes(routerMock, {
logFactory: loggingSystemMock.create(),
service: endpointAppContextService,
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
});
getPendingStatus = async (reqParams?: any): Promise<jest.Mocked<KibanaResponseFactory>> => {
const req = httpServerMock.createKibanaRequest(reqParams);
const mockResponse = httpServerMock.createResponseFactory();
const [, routeHandler]: [
RouteConfig<any, any, any, any>,
RequestHandler<any, any, any, any>
] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(ACTION_STATUS_ROUTE))!;
await routeHandler(
createRouteHandlerContext(esClientMock, savedObjectsClientMock.create()),
req,
mockResponse
);
return mockResponse;
};
havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => {
esClientMock.asCurrentUser.search = jest
.fn()
.mockImplementationOnce(() =>
Promise.resolve(mockSearchResult(actions.map((a) => a.build())))
)
.mockImplementationOnce(() =>
Promise.resolve(mockSearchResult(responses.map((r) => r.build())))
);
};
});
afterEach(() => {
endpointAppContextService.stop();
});
it('should include agent IDs in the output, even if they have no actions', async () => {
const mockID = 'XYZABC-000';
havingActionsAndResponses([], []);
const response = await getPendingStatus({
query: {
agent_ids: [mockID],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID);
});
it('should respond with a valid pending action', async () => {
const mockID = 'XYZABC-000';
havingActionsAndResponses([aMockAction().withAgent(mockID)], []);
const response = await getPendingStatus({
query: {
agent_ids: [mockID],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID);
});
it('should include a total count of a pending action', async () => {
const mockID = 'XYZABC-000';
havingActionsAndResponses(
[
aMockAction().withAgent(mockID).withAction('isolate'),
aMockAction().withAgent(mockID).withAction('isolate'),
],
[]
);
const response = await getPendingStatus({
query: {
agent_ids: [mockID],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual(
2
);
});
it('should show multiple pending actions, and their counts', async () => {
const mockID = 'XYZABC-000';
havingActionsAndResponses(
[
aMockAction().withAgent(mockID).withAction('isolate'),
aMockAction().withAgent(mockID).withAction('isolate'),
aMockAction().withAgent(mockID).withAction('isolate'),
aMockAction().withAgent(mockID).withAction('unisolate'),
aMockAction().withAgent(mockID).withAction('unisolate'),
],
[]
);
const response = await getPendingStatus({
query: {
agent_ids: [mockID],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual(
3
);
expect(
(response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate
).toEqual(2);
});
it('should calculate correct pending counts from grouped/bulked actions', async () => {
const mockID = 'XYZABC-000';
havingActionsAndResponses(
[
aMockAction()
.withAgents([mockID, 'IRRELEVANT-OTHER-AGENT', 'ANOTHER-POSSIBLE-AGENT'])
.withAction('isolate'),
aMockAction().withAgents([mockID, 'YET-ANOTHER-AGENT-ID']).withAction('isolate'),
aMockAction().withAgents(['YET-ANOTHER-AGENT-ID']).withAction('isolate'), // one WITHOUT our agent-under-test
],
[]
);
const response = await getPendingStatus({
query: {
agent_ids: [mockID],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual(
2
);
});
it('should exclude actions that have responses from the pending count', async () => {
const mockAgentID = 'XYZABC-000';
const actionID = 'some-known-actionid';
havingActionsAndResponses(
[
aMockAction().withAgent(mockAgentID).withAction('isolate'),
aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionID),
],
[aMockResponse(actionID, mockAgentID)]
);
const response = await getPendingStatus({
query: {
agent_ids: [mockAgentID],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockAgentID);
expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual(
1
);
});
it('should have accurate counts for multiple agents, bulk actions, and responses', async () => {
const agentOne = 'XYZABC-000';
const agentTwo = 'DEADBEEF';
const agentThree = 'IDIDIDID';
const actionTwoID = 'ID-TWO';
havingActionsAndResponses(
[
aMockAction().withAgents([agentOne, agentTwo, agentThree]).withAction('isolate'),
aMockAction()
.withAgents([agentTwo, agentThree])
.withAction('isolate')
.withID(actionTwoID),
aMockAction().withAgents([agentThree]).withAction('isolate'),
],
[aMockResponse(actionTwoID, agentThree)]
);
const response = await getPendingStatus({
query: {
agent_ids: [agentOne, agentTwo, agentThree],
},
});
expect(response.ok).toBeCalled();
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(3);
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({
agent_id: agentOne,
pending_actions: {
isolate: 1,
},
});
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({
agent_id: agentTwo,
pending_actions: {
isolate: 2,
},
});
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({
agent_id: agentThree,
pending_actions: {
isolate: 2, // present in all three actions, but second one has a response, therefore not pending
},
});
});
});
});

View file

@ -0,0 +1,128 @@
/*
* 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 { RequestHandler } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import {
EndpointAction,
EndpointActionResponse,
PendingActionsResponse,
} from '../../../../common/endpoint/types';
import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common';
import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import {
SecuritySolutionPluginRouter,
SecuritySolutionRequestHandlerContext,
} from '../../../types';
import { EndpointAppContext } from '../../types';
/**
* Registers routes for checking status of endpoints based on pending actions
*/
export function registerActionStatusRoutes(
router: SecuritySolutionPluginRouter,
endpointContext: EndpointAppContext
) {
router.get(
{
path: ACTION_STATUS_ROUTE,
validate: ActionStatusRequestSchema,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
actionStatusRequestHandler(endpointContext)
);
}
export const actionStatusRequestHandler = function (
endpointContext: EndpointAppContext
): RequestHandler<
unknown,
TypeOf<typeof ActionStatusRequestSchema.query>,
unknown,
SecuritySolutionRequestHandlerContext
> {
return async (context, req, res) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
const agentIDs: string[] = Array.isArray(req.query.agent_ids)
? [...new Set(req.query.agent_ids)]
: [req.query.agent_ids];
// retrieve the unexpired actions for the given hosts
const recentActionResults = await esClient.search<EndpointAction>(
{
index: AGENT_ACTIONS_INDEX,
body: {
query: {
bool: {
filter: [
{ term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children
{ term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions
{ range: { expiration: { gte: 'now' } } }, // that have not expired yet
{ terms: { agents: agentIDs } }, // for the requested agent IDs
],
},
},
},
},
{
ignore: [404],
}
);
const pendingActions =
recentActionResults.body?.hits?.hits?.map((a): EndpointAction => a._source!) || [];
// retrieve any responses to those action IDs from these agents
const actionIDs = pendingActions.map((a) => a.action_id);
const responseResults = await esClient.search<EndpointActionResponse>(
{
index: '.fleet-actions-results',
body: {
query: {
bool: {
filter: [
{ terms: { action_id: actionIDs } }, // get results for these actions
{ terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for
],
},
},
},
},
{
ignore: [404],
}
);
const actionResponses = responseResults.body?.hits?.hits?.map((a) => a._source!) || [];
// respond with action-count per agent
const response = agentIDs.map((aid) => {
const responseIDsFromAgent = actionResponses
.filter((r) => r.agent_id === aid)
.map((r) => r.action_id);
return {
agent_id: aid,
pending_actions: pendingActions
.filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id))
.map((a) => a.data.command)
.reduce((acc, cur) => {
if (cur in acc) {
acc[cur] += 1;
} else {
acc[cur] = 1;
}
return acc;
}, {} as PendingActionsResponse['pending_actions']),
} as PendingActionsResponse;
});
return res.ok({
body: {
data: response,
},
});
};
};

View file

@ -75,10 +75,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata';
import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency';
import { registerResolverRoutes } from './endpoint/routes/resolver';
import { registerPolicyRoutes } from './endpoint/routes/policy';
import {
registerHostIsolationRoutes,
registerActionAuditLogRoutes,
} from './endpoint/routes/actions';
import { registerActionRoutes } from './endpoint/routes/actions';
import { EndpointArtifactClient, ManifestManager } from './endpoint/services';
import { EndpointAppContextService } from './endpoint/endpoint_app_context_services';
import { EndpointAppContext } from './endpoint/types';
@ -293,8 +290,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
registerResolverRoutes(router);
registerPolicyRoutes(router, endpointContext);
registerTrustedAppsRoutes(router, endpointContext);
registerHostIsolationRoutes(router, endpointContext);
registerActionAuditLogRoutes(router, endpointContext);
registerActionRoutes(router, endpointContext);
const referenceRuleTypes = [
REFERENCE_RULE_ALERT_TYPE_ID,