Skip to content

Commit 1d71d42

Browse files
[Security Solution][Endpoint] Host Isolation API changes (#113621)
* Use the new data stream (if exists) to write action request to and then the fleet index. Else do as usual. fixes elastic/security-team/issues/1704 * fix legacy tests * add relevant additional tests * remove duplicate test * update tests * cleanup review changes refs elastic/security-team/issues/1704 * fix lint * Use correct mapping keys when writing to index * write record on new index when action request fails to write to `.fleet-actions` review comments * better error message review comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
1 parent 3d75154 commit 1d71d42

File tree

6 files changed

+326
-101
lines changed

6 files changed

+326
-101
lines changed

x-pack/plugins/security_solution/common/endpoint/constants.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
* 2.0.
66
*/
77

8-
export const ENDPOINT_ACTIONS_INDEX = '.logs-endpoint.actions-default';
9-
export const ENDPOINT_ACTION_RESPONSES_INDEX = '.logs-endpoint.action.responses-default';
8+
/** endpoint data streams that are used for host isolation */
9+
/** for index patterns `.logs-endpoint.actions-* and .logs-endpoint.action.responses-*`*/
10+
export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions';
11+
export const ENDPOINT_ACTIONS_INDEX = `${ENDPOINT_ACTIONS_DS}-default`;
12+
export const ENDPOINT_ACTION_RESPONSES_DS = '.logs-endpoint.action.responses';
13+
export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTIONS_DS}-default`;
1014

1115
export const eventsIndexPattern = 'logs-endpoint.events.*';
1216
export const alertsIndexPattern = 'logs-endpoint.alerts-*';

x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts

Lines changed: 5 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,51 +8,7 @@
88
import { DeepPartial } from 'utility-types';
99
import { merge } from 'lodash';
1010
import { BaseDataGenerator } from './base_data_generator';
11-
import { EndpointActionData, ISOLATION_ACTIONS } from '../types';
12-
13-
interface EcsError {
14-
code: string;
15-
id: string;
16-
message: string;
17-
stack_trace: string;
18-
type: string;
19-
}
20-
21-
interface EndpointActionFields {
22-
action_id: string;
23-
data: EndpointActionData;
24-
}
25-
26-
interface ActionRequestFields {
27-
expiration: string;
28-
type: 'INPUT_ACTION';
29-
input_type: 'endpoint';
30-
}
31-
32-
interface ActionResponseFields {
33-
completed_at: string;
34-
started_at: string;
35-
}
36-
export interface LogsEndpointAction {
37-
'@timestamp': string;
38-
agent: {
39-
id: string | string[];
40-
};
41-
EndpointAction: EndpointActionFields & ActionRequestFields;
42-
error?: EcsError;
43-
user: {
44-
id: string;
45-
};
46-
}
47-
48-
export interface LogsEndpointActionResponse {
49-
'@timestamp': string;
50-
agent: {
51-
id: string | string[];
52-
};
53-
EndpointAction: EndpointActionFields & ActionResponseFields;
54-
error?: EcsError;
55-
}
11+
import { ISOLATION_ACTIONS, LogsEndpointAction, LogsEndpointActionResponse } from '../types';
5612

5713
const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate'];
5814

@@ -66,7 +22,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
6622
agent: {
6723
id: [this.randomUUID()],
6824
},
69-
EndpointAction: {
25+
EndpointActions: {
7026
action_id: this.randomUUID(),
7127
expiration: this.randomFutureDate(timeStamp),
7228
type: 'INPUT_ACTION',
@@ -86,11 +42,11 @@ export class EndpointActionGenerator extends BaseDataGenerator {
8642
}
8743

8844
generateIsolateAction(overrides: DeepPartial<LogsEndpointAction> = {}): LogsEndpointAction {
89-
return merge(this.generate({ EndpointAction: { data: { command: 'isolate' } } }), overrides);
45+
return merge(this.generate({ EndpointActions: { data: { command: 'isolate' } } }), overrides);
9046
}
9147

9248
generateUnIsolateAction(overrides: DeepPartial<LogsEndpointAction> = {}): LogsEndpointAction {
93-
return merge(this.generate({ EndpointAction: { data: { command: 'unisolate' } } }), overrides);
49+
return merge(this.generate({ EndpointActions: { data: { command: 'unisolate' } } }), overrides);
9450
}
9551

9652
/** Generates an endpoint action response */
@@ -105,7 +61,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
10561
agent: {
10662
id: this.randomUUID(),
10763
},
108-
EndpointAction: {
64+
EndpointActions: {
10965
action_id: this.randomUUID(),
11066
completed_at: timeStamp.toISOString(),
11167
data: {

x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_actions.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,8 @@
77

88
import { Client } from '@elastic/elasticsearch';
99
import { DeleteByQueryResponse } from '@elastic/elasticsearch/api/types';
10-
import { HostMetadata } from '../types';
11-
import {
12-
EndpointActionGenerator,
13-
LogsEndpointAction,
14-
LogsEndpointActionResponse,
15-
} from '../data_generators/endpoint_action_generator';
10+
import { HostMetadata, LogsEndpointAction, LogsEndpointActionResponse } from '../types';
11+
import { EndpointActionGenerator } from '../data_generators/endpoint_action_generator';
1612
import { wrapErrorAndRejectPromise } from './utils';
1713
import { ENDPOINT_ACTIONS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX } from '../constants';
1814

@@ -49,7 +45,7 @@ export const indexEndpointActionsForHost = async (
4945
for (let i = 0; i < total; i++) {
5046
// create an action
5147
const action = endpointActionGenerator.generate({
52-
EndpointAction: {
48+
EndpointActions: {
5349
data: { comment: 'data generator: this host is same as bad' },
5450
},
5551
});
@@ -66,9 +62,9 @@ export const indexEndpointActionsForHost = async (
6662
// Create an action response for the above
6763
const actionResponse = endpointActionGenerator.generateResponse({
6864
agent: { id: agentId },
69-
EndpointAction: {
70-
action_id: action.EndpointAction.action_id,
71-
data: action.EndpointAction.data,
65+
EndpointActions: {
66+
action_id: action.EndpointActions.action_id,
67+
data: action.EndpointActions.data,
7268
},
7369
});
7470

@@ -174,7 +170,7 @@ export const deleteIndexedEndpointActions = async (
174170
{
175171
terms: {
176172
action_id: indexedData.endpointActions.map(
177-
(action) => action.EndpointAction.action_id
173+
(action) => action.EndpointActions.action_id
178174
),
179175
},
180176
},
@@ -200,7 +196,7 @@ export const deleteIndexedEndpointActions = async (
200196
{
201197
terms: {
202198
action_id: indexedData.endpointActionResponses.map(
203-
(action) => action.EndpointAction.action_id
199+
(action) => action.EndpointActions.action_id
204200
),
205201
},
206202
},

x-pack/plugins/security_solution/common/endpoint/types/actions.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,50 @@ import { ActionStatusRequestSchema, HostIsolationRequestSchema } from '../schema
1010

1111
export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';
1212

13+
interface EcsError {
14+
code?: string;
15+
id?: string;
16+
message: string;
17+
stack_trace?: string;
18+
type?: string;
19+
}
20+
21+
interface EndpointActionFields {
22+
action_id: string;
23+
data: EndpointActionData;
24+
}
25+
26+
interface ActionRequestFields {
27+
expiration: string;
28+
type: 'INPUT_ACTION';
29+
input_type: 'endpoint';
30+
}
31+
32+
interface ActionResponseFields {
33+
completed_at: string;
34+
started_at: string;
35+
}
36+
export interface LogsEndpointAction {
37+
'@timestamp': string;
38+
agent: {
39+
id: string | string[];
40+
};
41+
EndpointActions: EndpointActionFields & ActionRequestFields;
42+
error?: EcsError;
43+
user: {
44+
id: string;
45+
};
46+
}
47+
48+
export interface LogsEndpointActionResponse {
49+
'@timestamp': string;
50+
agent: {
51+
id: string | string[];
52+
};
53+
EndpointActions: EndpointActionFields & ActionResponseFields;
54+
error?: EcsError;
55+
}
56+
1357
export interface EndpointActionData {
1458
command: ISOLATION_ACTIONS;
1559
comment?: string;

x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,18 @@ import {
3434
ISOLATE_HOST_ROUTE,
3535
UNISOLATE_HOST_ROUTE,
3636
metadataTransformPrefix,
37+
ENDPOINT_ACTIONS_INDEX,
3738
} from '../../../../common/endpoint/constants';
3839
import {
3940
EndpointAction,
4041
HostIsolationRequestBody,
4142
HostIsolationResponse,
4243
HostMetadata,
44+
LogsEndpointAction,
4345
} from '../../../../common/endpoint/types';
4446
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
4547
import { legacyMetadataSearchResponse } from '../metadata/support/test_support';
46-
import { ElasticsearchAssetType } from '../../../../../fleet/common';
48+
import { AGENT_ACTIONS_INDEX, ElasticsearchAssetType } from '../../../../../fleet/common';
4749
import { CasesClientMock } from '../../../../../cases/server/client/mocks';
4850

4951
interface CallRouteInterface {
@@ -109,7 +111,8 @@ describe('Host Isolation', () => {
109111

110112
let callRoute: (
111113
routePrefix: string,
112-
opts: CallRouteInterface
114+
opts: CallRouteInterface,
115+
indexExists?: { endpointDsExists: boolean }
113116
) => Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>>;
114117
const superUser = {
115118
username: 'superuser',
@@ -175,22 +178,42 @@ describe('Host Isolation', () => {
175178
// it returns the requestContext mock used in the call, to assert internal calls (e.g. the indexed document)
176179
callRoute = async (
177180
routePrefix: string,
178-
{ body, idxResponse, searchResponse, mockUser, license }: CallRouteInterface
181+
{ body, idxResponse, searchResponse, mockUser, license }: CallRouteInterface,
182+
indexExists?: { endpointDsExists: boolean }
179183
): Promise<jest.Mocked<SecuritySolutionRequestHandlerContext>> => {
180184
const asUser = mockUser ? mockUser : superUser;
181185
(startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce(
182186
() => asUser
183187
);
188+
184189
const ctx = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient);
185-
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
186-
ctx.core.elasticsearch.client.asCurrentUser.index = jest
190+
// mock _index_template
191+
ctx.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = jest
187192
.fn()
188-
.mockImplementationOnce(() => Promise.resolve(withIdxResp));
189-
ctx.core.elasticsearch.client.asCurrentUser.search = jest
193+
.mockImplementationOnce(() => {
194+
if (indexExists) {
195+
return Promise.resolve({
196+
body: true,
197+
statusCode: 200,
198+
});
199+
}
200+
return Promise.resolve({
201+
body: false,
202+
statusCode: 404,
203+
});
204+
});
205+
const withIdxResp = idxResponse ? idxResponse : { statusCode: 201 };
206+
const mockIndexResponse = jest.fn().mockImplementation(() => Promise.resolve(withIdxResp));
207+
const mockSearchResponse = jest
190208
.fn()
191209
.mockImplementation(() =>
192210
Promise.resolve({ body: legacyMetadataSearchResponse(searchResponse) })
193211
);
212+
if (indexExists) {
213+
ctx.core.elasticsearch.client.asInternalUser.index = mockIndexResponse;
214+
}
215+
ctx.core.elasticsearch.client.asCurrentUser.index = mockIndexResponse;
216+
ctx.core.elasticsearch.client.asCurrentUser.search = mockSearchResponse;
194217
const withLicense = license ? license : Platinum;
195218
licenseEmitter.next(withLicense);
196219
const mockRequest = httpServerMock.createKibanaRequest({ body });
@@ -288,11 +311,6 @@ describe('Host Isolation', () => {
288311
).mock.calls[0][0].body;
289312
expect(actionDoc.timeout).toEqual(300);
290313
});
291-
292-
it('succeeds when just an endpoint ID is provided', async () => {
293-
await callRoute(ISOLATE_HOST_ROUTE, { body: { endpoint_ids: ['XYZ'] } });
294-
expect(mockResponse.ok).toBeCalled();
295-
});
296314
it('sends the action to the correct agent when endpoint ID is given', async () => {
297315
const doc = docGen.generateHostMetadata();
298316
const AgentID = doc.elastic.agent.id;
@@ -326,6 +344,74 @@ describe('Host Isolation', () => {
326344
expect(actionDoc.data.command).toEqual('unisolate');
327345
});
328346

347+
describe('With endpoint data streams', () => {
348+
it('handles unisolation', async () => {
349+
const ctx = await callRoute(
350+
UNISOLATE_HOST_ROUTE,
351+
{
352+
body: { endpoint_ids: ['XYZ'] },
353+
},
354+
{ endpointDsExists: true }
355+
);
356+
const actionDocs: [
357+
{ index: string; body: LogsEndpointAction },
358+
{ index: string; body: EndpointAction }
359+
] = [
360+
(ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0],
361+
(ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0],
362+
];
363+
364+
expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX);
365+
expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX);
366+
expect(actionDocs[0].body.EndpointActions.data.command).toEqual('unisolate');
367+
expect(actionDocs[1].body.data.command).toEqual('unisolate');
368+
});
369+
370+
it('handles isolation', async () => {
371+
const ctx = await callRoute(
372+
ISOLATE_HOST_ROUTE,
373+
{
374+
body: { endpoint_ids: ['XYZ'] },
375+
},
376+
{ endpointDsExists: true }
377+
);
378+
const actionDocs: [
379+
{ index: string; body: LogsEndpointAction },
380+
{ index: string; body: EndpointAction }
381+
] = [
382+
(ctx.core.elasticsearch.client.asCurrentUser.index as jest.Mock).mock.calls[0][0],
383+
(ctx.core.elasticsearch.client.asInternalUser.index as jest.Mock).mock.calls[1][0],
384+
];
385+
386+
expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX);
387+
expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX);
388+
expect(actionDocs[0].body.EndpointActions.data.command).toEqual('isolate');
389+
expect(actionDocs[1].body.data.command).toEqual('isolate');
390+
});
391+
392+
it('handles errors', async () => {
393+
const ErrMessage = 'Uh oh!';
394+
await callRoute(
395+
UNISOLATE_HOST_ROUTE,
396+
{
397+
body: { endpoint_ids: ['XYZ'] },
398+
idxResponse: {
399+
statusCode: 500,
400+
body: {
401+
result: ErrMessage,
402+
},
403+
},
404+
},
405+
{ endpointDsExists: true }
406+
);
407+
408+
expect(mockResponse.ok).not.toBeCalled();
409+
const response = mockResponse.customError.mock.calls[0][0];
410+
expect(response.statusCode).toEqual(500);
411+
expect((response.body as Error).message).toEqual(ErrMessage);
412+
});
413+
});
414+
329415
describe('License Level', () => {
330416
it('allows platinum license levels to isolate hosts', async () => {
331417
await callRoute(ISOLATE_HOST_ROUTE, {

0 commit comments

Comments
 (0)