Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Endpoint] Use a feature flag to use the new pending actions logic #117219

Merged
merged 7 commits into from
Nov 4, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const allowedExperimentalValues = Object.freeze({
uebaEnabled: false,
disableIsolationUIPendingStatuses: false,
riskyHostsEnabled: false,
pendingActionResponsesWithAck: true,
});

type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ describe('Endpoint Action Status', () => {
logFactory: loggingSystemMock.create(),
service: endpointAppContextService,
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental),
experimentalFeatures: {
...parseExperimentalConfigValue(createMockConfig().enableExperimental),
pendingActionResponsesWithAck: true,
},
});

getPendingStatus = async (reqParams?: any): Promise<jest.Mocked<KibanaResponseFactory>> => {
Expand Down Expand Up @@ -451,4 +454,321 @@ describe('Endpoint Action Status', () => {
});
});
});

describe('response (when pendingActionResponsesWithAck is FALSE)', () => {
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: MockResponse[],
endpointResponses?: MockEndpointResponse[]
) => void;

beforeEach(() => {
const esClientMock = elasticsearchServiceMock.createScopedClusterClient();
const routerMock = httpServiceMock.createRouter();
endpointAppContextService = new EndpointAppContextService();
endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract());
endpointAppContextService.start(createMockEndpointAppContextServiceStartContract());

registerActionStatusRoutes(routerMock, {
logFactory: loggingSystemMock.create(),
service: endpointAppContextService,
config: () => Promise.resolve(createMockConfig()),
experimentalFeatures: {
...parseExperimentalConfigValue(createMockConfig().enableExperimental),
pendingActionResponsesWithAck: false,
},
});

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[],
endpointResponses?: MockEndpointResponse[]
) => {
esClientMock.asCurrentUser.search = jest.fn().mockImplementation((req) => {
const size = req.size ? req.size : 10;
const items: any[] =
req.index === '.fleet-actions'
? actions.splice(0, size)
: req.index === '.logs-endpoint.action.responses' && !!endpointResponses
? endpointResponses
: responses.splice(0, size);

if (items.length > 0) {
return Promise.resolve(mockSearchResult(items.map((x) => x.build())));
} else {
return Promise.resolve(mockSearchResult());
}
});
};
});

afterEach(() => {
endpointAppContextService.stop();
});

it('should include total counts for large (more than a page) action counts', async () => {
const mockID = 'XYZABC-000';
const actions = [];
for (let i = 0; i < 1400; i++) {
// putting more than a single page of results in
actions.push(aMockAction().withAgent(mockID));
}
havingActionsAndResponses(actions, []);

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(
0
);
});
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(
0
);
});
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(
0
);
expect(
(response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate
).toEqual(0);
});
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(
0
);
});

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)]
);
(endpointAppContextService.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]),
});
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(
0
);
});
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)]
);
(endpointAppContextService.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]),
});
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: 0,
},
});
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({
agent_id: agentTwo,
pending_actions: {
isolate: 0,
},
});
expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({
agent_id: agentThree,
pending_actions: {
isolate: 0,
},
});
});

describe('with endpoint response index', () => {
it('should include a total count of a pending action response', async () => {
const mockAgentId = 'XYZABC-000';
const actionIds = ['action_id_0', 'action_id_1'];
havingActionsAndResponses(
[
aMockAction().withAgent(mockAgentId).withAction('isolate').withID(actionIds[0]),
aMockAction().withAgent(mockAgentId).withAction('isolate').withID(actionIds[1]),
],
[
aMockResponse(actionIds[0], mockAgentId, true),
aMockResponse(actionIds[1], mockAgentId, true),
]
);
(endpointAppContextService.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]),
});
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(0);
});

it('should show multiple pending action responses, and their counts', async () => {
const mockAgentID = 'XYZABC-000';
const actionIds = ['ack_0', 'ack_1', 'ack_2', 'ack_3', 'ack_4'];
havingActionsAndResponses(
[
aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionIds[0]),
aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionIds[1]),
aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionIds[2]),
aMockAction().withAgent(mockAgentID).withAction('unisolate').withID(actionIds[3]),
aMockAction().withAgent(mockAgentID).withAction('unisolate').withID(actionIds[4]),
],
[
aMockResponse(actionIds[0], mockAgentID, true),
aMockResponse(actionIds[1], mockAgentID, true),
aMockResponse(actionIds[2], mockAgentID, true),
aMockResponse(actionIds[3], mockAgentID, true),
aMockResponse(actionIds[4], mockAgentID, true),
]
);
(endpointAppContextService.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
findHostMetadataForFleetAgents: jest.fn().mockResolvedValue([]),
});
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(0);
expect(
(response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate
).toEqual(0);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export const actionStatusRequestHandler = function (
const response = await getPendingActionCounts(
esClient,
endpointContext.service.getEndpointMetadataService(),
agentIDs
agentIDs,
endpointContext.experimentalFeatures.pendingActionResponsesWithAck
);

return res.ok({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ export const getPendingActionCounts = async (
esClient: ElasticsearchClient,
metadataService: EndpointMetadataService,
/** The Fleet Agent IDs to be checked */
agentIDs: string[]
agentIDs: string[],
isPendingActionResponsesWithAckEnabled: boolean
): Promise<EndpointPendingActions[]> => {
// retrieve the unexpired actions for the given hosts
const recentActions = await esClient
Expand Down Expand Up @@ -254,11 +255,17 @@ export const getPendingActionCounts = async (
pending_actions: pendingActions
.map((a) => a.data.command)
.reduce((acc, cur) => {
if (cur in acc) {
acc[cur] += 1;
if (!isPendingActionResponsesWithAckEnabled) {
acc[cur] = 0; // set pending counts to 0 when FF is disabled
} else {
acc[cur] = 1;
// else do the usual counting
if (cur in acc) {
acc[cur] += 1;
} else {
acc[cur] = 1;
}
}

return acc;
}, {} as EndpointPendingActions['pending_actions']),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ export const getHostEndpoint = async (
? getPendingActionCounts(
esClient.asInternalUser,
endpointContext.service.getEndpointMetadataService(),
[fleetAgentId]
[fleetAgentId],
endpointContext.experimentalFeatures.pendingActionResponsesWithAck
)
.then((results) => {
return results[0].pending_actions;
Expand Down