Skip to content

Commit

Permalink
[Security Solution][Endpoint] Use a feature flag to use the new pendi…
Browse files Browse the repository at this point in the history
…ng actions logic (elastic#117219) (elastic#117497)

* use a feature flag to use the new pending actions logic

refs elastic/issues/116715

* switch off pending actions for endpoints when feature flag is disabled

review suggestions

* update/add tests to use FF

* correctly override the FF

`parseExperimentalConfigValue` method sets feature flag key values to `true` if passed as arg

Co-authored-by: Kibana Machine <[email protected]>

Co-authored-by: Ashokaditya <[email protected]>
  • Loading branch information
kibanamachine and ashokaditya authored Nov 4, 2021
1 parent e7761d0 commit 9fc68bb
Show file tree
Hide file tree
Showing 5 changed files with 337 additions and 7 deletions.
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

0 comments on commit 9fc68bb

Please sign in to comment.