[Security Solution][Endpoint] Fix the endpoint pending actions status and popover to include totals for all actions (#136966)

* update pending badge logic

fixes elastic/security-team/issues/4356

* remove command/todo

* Rework logic in EndpointHostIsolationStatus to ensure that multiple pending of the same type, then still show isolating/releasing

* Fix for when there are no pending isolation but there are others

* Fix pending action api service so that it only waits for a metadta update for isolate/release

* Fix tests

* add additional test

Co-authored-by: Paul Tavares <paul.tavares@elastic.co>
This commit is contained in:
Ashokaditya 2022-07-27 21:59:40 +02:00 committed by GitHub
parent 78da900fd4
commit ccabbc735b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 305 additions and 103 deletions

View file

@ -236,7 +236,7 @@ export interface ResponseActionApiResponse<TOutput extends object = object> {
export interface EndpointPendingActions {
agent_id: string;
pending_actions: {
/** Number of actions pending for each type. The `key` could be one of the `ISOLATION_ACTIONS` values. */
/** Number of actions pending for each type. The `key` could be one of the `RESPONSE_ACTION_COMMANDS` values. */
[key: string]: number;
};
}

View file

@ -0,0 +1,100 @@
/*
* 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 React, { memo } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { EndpointHostIsolationStatusProps } from './host_isolation';
export const AgentPendingActionStatusBadge = memo<
{ 'data-test-subj'?: string } & Pick<EndpointHostIsolationStatusProps, 'pendingActions'>
>(({ 'data-test-subj': dataTestSubj, pendingActions }) => {
return (
<EuiBadge color="hollow" data-test-subj={dataTestSubj}>
<EuiToolTip
display="block"
anchorClassName="eui-textTruncate"
content={
<div style={{ width: 150 }} data-test-subj={`${dataTestSubj}-tooltipContent`}>
<div>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingActions"
defaultMessage="Pending actions:"
/>
</div>
{!!pendingActions.pendingIsolate && (
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingIsolate"
defaultMessage="Isolate"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingActions.pendingIsolate}</EuiFlexItem>
</EuiFlexGroup>
)}
{!!pendingActions.pendingUnIsolate && (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingUnIsolate"
defaultMessage="Release"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingActions.pendingUnIsolate}</EuiFlexItem>
</EuiFlexGroup>
)}
{!!pendingActions.pendingKillProcess && (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingKillProcess"
defaultMessage="Kill process"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingActions.pendingKillProcess}</EuiFlexItem>
</EuiFlexGroup>
)}
{!!pendingActions.pendingSuspendProcess && (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingSuspendProcess"
defaultMessage="Suspend process"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingActions.pendingSuspendProcess}</EuiFlexItem>
</EuiFlexGroup>
)}
{!!pendingActions.pendingRunningProcesses && (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingRunningProcesses"
defaultMessage="Processes"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingActions.pendingRunningProcesses}</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
}
>
<EuiTextColor color="subdued" data-test-subj={`${dataTestSubj}-pending`}>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.multiplePendingActions"
defaultMessage="{count} {count, plural, one {action} other {actions}} pending"
values={{
count: Object.values(pendingActions).reduce((prev, curr) => prev + curr, 0),
}}
/>
</EuiTextColor>
</EuiToolTip>
</EuiBadge>
);
});
AgentPendingActionStatusBadge.displayName = 'AgentPendingActionStatusBadge';

View file

@ -26,8 +26,7 @@ describe('when using the EndpointHostIsolationStatus component', () => {
{...{
'data-test-subj': 'test',
isIsolated: false,
pendingUnIsolate: 0,
pendingIsolate: 0,
pendingActions: {},
...renderProps,
}}
/>
@ -45,9 +44,64 @@ describe('when using the EndpointHostIsolationStatus component', () => {
});
it.each([
['Isolating', { pendingIsolate: 2 }],
['Releasing', { pendingUnIsolate: 2 }],
['4 actions pending', { isIsolated: true, pendingUnIsolate: 2, pendingIsolate: 2 }],
[
'Isolating',
{
pendingActions: {
pendingIsolate: 1,
},
},
],
[
'Releasing',
{
pendingActions: {
pendingUnIsolate: 1,
},
},
],
[
// Because they are both of the same type and there are no other types,
// the status should be `isolating`
'Isolating',
{
pendingActions: {
pendingIsolate: 2,
},
},
],
[
// Because they are both of the same type and there are no other types,
// the status should be `Releasing`
'Releasing',
{
pendingActions: {
pendingUnIsolate: 2,
},
},
],
[
'10 actions pending',
{
isIsolated: true,
pendingActions: {
pendingIsolate: 2,
pendingUnIsolate: 2,
pendingKillProcess: 2,
pendingSuspendProcess: 2,
pendingRunningProcesses: 2,
},
},
],
[
'1 action pending',
{
isIsolated: true,
pendingActions: {
pendingKillProcess: 1,
},
},
],
])('should show %s}', (expectedLabel, componentProps) => {
const { getByTestId } = render(componentProps);
expect(getByTestId('test').textContent).toBe(expectedLabel);
@ -61,15 +115,22 @@ describe('when using the EndpointHostIsolationStatus component', () => {
});
it('should render `null` if not isolated', () => {
const renderResult = render({ pendingIsolate: 10, pendingUnIsolate: 20 });
const renderResult = render({
pendingActions: {
pendingIsolate: 10,
pendingUnIsolate: 20,
},
});
expect(renderResult.container.textContent).toBe('');
});
it('should show `Isolated` when no pending actions and isolated', () => {
const { getByTestId } = render({
isIsolated: true,
pendingIsolate: 10,
pendingUnIsolate: 20,
pendingActions: {
pendingIsolate: 10,
pendingUnIsolate: 20,
},
});
expect(getByTestId('test').textContent).toBe('Isolated');
});

View file

@ -6,17 +6,23 @@
*/
import React, { memo, useMemo, useRef, useEffect } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiToolTip } from '@elastic/eui';
import { EuiBadge, EuiTextColor } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useTestIdGenerator } from '../../../../management/hooks/use_test_id_generator';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
import { AgentPendingActionStatusBadge } from '../agent_pending_action_status_badge';
export interface EndpointHostIsolationStatusProps {
isIsolated: boolean;
/** the count of pending isolate actions */
pendingIsolate?: number;
/** the count of pending unisolate actions */
pendingUnIsolate?: number;
pendingActions: {
/** the count of pending isolate actions */
pendingIsolate?: number;
/** the count of pending unisolate actions */
pendingUnIsolate?: number;
pendingKillProcess?: number;
pendingSuspendProcess?: number;
pendingRunningProcesses?: number;
};
'data-test-subj'?: string;
}
@ -26,15 +32,50 @@ export interface EndpointHostIsolationStatusProps {
* (`null` is returned)
*/
export const EndpointHostIsolationStatus = memo<EndpointHostIsolationStatusProps>(
({ isIsolated, pendingIsolate = 0, pendingUnIsolate = 0, 'data-test-subj': dataTestSubj }) => {
({ isIsolated, pendingActions, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const isPendingStatusDisabled = useIsExperimentalFeatureEnabled(
'disableIsolationUIPendingStatuses'
);
const {
pendingIsolate = 0,
pendingUnIsolate = 0,
pendingKillProcess = 0,
pendingSuspendProcess = 0,
pendingRunningProcesses = 0,
} = pendingActions;
const wasReleasing = useRef<boolean>(false);
const wasIsolating = useRef<boolean>(false);
const totalPending = useMemo(
() =>
pendingIsolate +
pendingUnIsolate +
pendingKillProcess +
pendingSuspendProcess +
pendingRunningProcesses,
[
pendingIsolate,
pendingKillProcess,
pendingRunningProcesses,
pendingSuspendProcess,
pendingUnIsolate,
]
);
const hasMultipleActionTypesPending = useMemo<boolean>(() => {
return (
Object.values(pendingActions).reduce((countOfTypes, pendingActionCount) => {
if (pendingActionCount > 0) {
return countOfTypes + 1;
}
return countOfTypes;
}, 0) > 1
);
}, [pendingActions]);
useEffect(() => {
wasReleasing.current = pendingIsolate === 0 && pendingUnIsolate > 0;
wasIsolating.current = pendingIsolate > 0 && pendingUnIsolate === 0;
@ -58,7 +99,7 @@ export const EndpointHostIsolationStatus = memo<EndpointHostIsolationStatusProps
}
// If nothing is pending
if (!(pendingIsolate || pendingUnIsolate)) {
if (totalPending === 0) {
// and host is either releasing and or currently released, then render nothing
if ((!wasIsolating.current && wasReleasing.current) || !isIsolated) {
return null;
@ -76,55 +117,22 @@ export const EndpointHostIsolationStatus = memo<EndpointHostIsolationStatusProps
}
}
// If there are multiple types of pending isolation actions, then show count of actions with tooltip that displays breakdown
if (pendingIsolate && pendingUnIsolate) {
// If there are different types of action pending
// --OR--
// the only type of actions pending is NOT isolate/release,
// then show a summary with tooltip
if (hasMultipleActionTypesPending || (!pendingIsolate && !pendingUnIsolate)) {
return (
<EuiBadge color="hollow" data-test-subj={dataTestSubj}>
<EuiToolTip
display="block"
anchorClassName="eui-textTruncate"
content={
<div data-test-subj={getTestId('tooltipContent')}>
<div>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingActions"
defaultMessage="Pending actions:"
/>
</div>
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingIsolate"
defaultMessage="Isolate"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingIsolate}</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.tooltipPendingUnIsolate"
defaultMessage="Release"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pendingUnIsolate}</EuiFlexItem>
</EuiFlexGroup>
</div>
}
>
<EuiTextColor color="subdued" data-test-subj={getTestId('pending')}>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolationStatus.multiplePendingActions"
defaultMessage="{count} actions pending"
values={{ count: pendingIsolate + pendingUnIsolate }}
/>
</EuiTextColor>
</EuiToolTip>
</EuiBadge>
<AgentPendingActionStatusBadge
data-test-subj={dataTestSubj}
pendingActions={pendingActions}
/>
);
}
// Show 'pending [un]isolate' depending on what's pending
// show pending isolation badge if a single type of isolation action has pending numbers.
// We don't care about the count here because if there were more than 1 of the same type
// (ex. 3 isolate... 0 release), then the action status displayed is still the same - "isolating".
return (
<EuiBadge color="hollow" data-test-subj={dataTestSubj}>
<EuiTextColor color="subdued" data-test-subj={getTestId('pending')}>
@ -143,12 +151,15 @@ export const EndpointHostIsolationStatus = memo<EndpointHostIsolationStatusProps
</EuiBadge>
);
}, [
isPendingStatusDisabled,
totalPending,
hasMultipleActionTypesPending,
pendingIsolate,
pendingUnIsolate,
dataTestSubj,
getTestId,
isIsolated,
isPendingStatusDisabled,
pendingIsolate,
pendingUnIsolate,
pendingActions,
]);
}
);

View file

@ -23,6 +23,7 @@ describe('When using the EndpointAgentAndIsolationStatus component', () => {
renderProps = {
status: HostStatus.HEALTHY,
'data-test-subj': 'test',
pendingActions: {},
};
render = () => {

View file

@ -21,7 +21,7 @@ const EuiFlexGroupStyled = styled(EuiFlexGroup)`
`;
export interface EndpointAgentAndIsolationStatusProps
extends Pick<EndpointHostIsolationStatusProps, 'pendingIsolate' | 'pendingUnIsolate'> {
extends Pick<EndpointHostIsolationStatusProps, 'pendingActions'> {
status: HostStatus;
/**
* If defined with a boolean, then the isolation status will be shown along with the agent status.
@ -33,7 +33,7 @@ export interface EndpointAgentAndIsolationStatusProps
}
export const EndpointAgentAndIsolationStatus = memo<EndpointAgentAndIsolationStatusProps>(
({ status, isIsolated, pendingIsolate, pendingUnIsolate, 'data-test-subj': dataTestSubj }) => {
({ status, isIsolated, pendingActions, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
return (
<EuiFlexGroupStyled
@ -50,8 +50,7 @@ export const EndpointAgentAndIsolationStatus = memo<EndpointAgentAndIsolationSta
<EndpointHostIsolationStatus
data-test-subj={getTestId('isolationStatus')}
isIsolated={isIsolated}
pendingIsolate={pendingIsolate}
pendingUnIsolate={pendingUnIsolate}
pendingActions={pendingActions}
/>
</EuiFlexItem>
)}

View file

@ -32,20 +32,18 @@ export const HeaderEndpointInfo = memo<HeaderEndpointInfoProps>(({ endpointId })
refetchInterval: 10000,
});
const pendingIsolationActions = useMemo<
Pick<Required<EndpointHostIsolationStatusProps>, 'pendingIsolate' | 'pendingUnIsolate'>
const pendingActionRequests = useMemo<
Pick<Required<EndpointHostIsolationStatusProps>, 'pendingActions'>
>(() => {
if (endpointPendingActions?.data.length) {
const pendingActions = endpointPendingActions.data[0].pending_actions;
return {
pendingIsolate: pendingActions.isolate ?? 0,
pendingUnIsolate: pendingActions.unisolate ?? 0,
};
}
const pendingActions = endpointPendingActions?.data?.[0].pending_actions;
return {
pendingIsolate: 0,
pendingUnIsolate: 0,
pendingActions: {
pendingIsolate: pendingActions?.isolate ?? 0,
pendingUnIsolate: pendingActions?.unisolate ?? 0,
pendingKillProcess: pendingActions?.['kill-process'] ?? 0,
pendingSuspendProcess: pendingActions?.['suspend-process'] ?? 0,
pendingRunningProcesses: pendingActions?.['running-processes'] ?? 0,
},
};
}, [endpointPendingActions?.data]);
@ -75,7 +73,7 @@ export const HeaderEndpointInfo = memo<HeaderEndpointInfoProps>(({ endpointId })
<EndpointAgentAndIsolationStatus
status={endpointDetails.host_status}
isIsolated={endpointDetails.metadata.Endpoint.state?.isolation}
{...pendingIsolationActions}
{...pendingActionRequests}
data-test-subj="responderHeaderEndpointAgentIsolationStatus"
/>
</EuiFlexItem>

View file

@ -54,7 +54,10 @@ export const EndpointStatusActionResult = memo<
});
const pendingIsolationActions = useMemo<
Pick<Required<EndpointHostIsolationStatusProps>, 'pendingIsolate' | 'pendingUnIsolate'>
Pick<
Required<EndpointHostIsolationStatusProps['pendingActions']>,
'pendingIsolate' | 'pendingUnIsolate'
>
>(() => {
if (endpointPendingActions?.data.length) {
const pendingActions = endpointPendingActions.data[0].pending_actions;

View file

@ -287,6 +287,9 @@ export const getEndpointHostIsolationStatusPropsCallback: (
return (endpoint: HostMetadata) => {
let pendingIsolate = 0;
let pendingUnIsolate = 0;
let pendingKillProcess = 0;
let pendingSuspendProcess = 0;
let pendingRunningProcesses = 0;
if (isLoadedResourceState(pendingActionsState)) {
const endpointPendingActions = pendingActionsState.data.get(endpoint.elastic.agent.id);
@ -294,13 +297,21 @@ export const getEndpointHostIsolationStatusPropsCallback: (
if (endpointPendingActions) {
pendingIsolate = endpointPendingActions?.isolate ?? 0;
pendingUnIsolate = endpointPendingActions?.unisolate ?? 0;
pendingKillProcess = endpointPendingActions?.['kill-process'] ?? 0;
pendingSuspendProcess = endpointPendingActions?.['suspend-process'] ?? 0;
pendingRunningProcesses = endpointPendingActions?.['running-processes'] ?? 0;
}
}
return {
isIsolated: isEndpointHostIsolated(endpoint),
pendingIsolate,
pendingUnIsolate,
pendingActions: {
pendingIsolate,
pendingUnIsolate,
pendingKillProcess,
pendingSuspendProcess,
pendingRunningProcesses,
},
};
};
}

View file

@ -82,8 +82,13 @@ export const EndpointOverview = React.memo<Props>(({ contextID, data }) => {
<AgentStatus hostStatus={data.elasticAgentStatus} />
<EndpointHostIsolationStatus
isIsolated={Boolean(data.isolation)}
pendingIsolate={data.pendingActions?.isolate ?? 0}
pendingUnIsolate={data.pendingActions?.unisolate ?? 0}
pendingActions={{
pendingIsolate: data.pendingActions?.isolate ?? 0,
pendingUnIsolate: data.pendingActions?.unisolate ?? 0,
pendingKillProcess: data.pendingActions?.['kill-process'] ?? 0,
pendingSuspendProcess: data.pendingActions?.['suspend-process'] ?? 0,
pendingRunningProcesses: data.pendingActions?.['running-processes'] ?? 0,
}}
/>
</>
) : (

View file

@ -46,8 +46,10 @@ export const AgentStatuses = React.memo(
<EuiFlexItem grow={false}>
<EndpointHostIsolationStatus
isIsolated={isIsolated}
pendingIsolate={pendingIsolation}
pendingUnIsolate={pendingUnisolation}
pendingActions={{
pendingIsolate: pendingIsolation,
pendingUnIsolate: pendingUnisolation,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -196,15 +196,16 @@ export const getPendingActionCounts = async (
);
const pending: EndpointPendingActions[] = [];
for (const agentId of agentIDs) {
const agentResponses = responses[agentId];
// get response actionIds for responses with ACKs
// get response actionIds for responses with ACKs from the fleet agent
const ackResponseActionIdList: string[] = agentResponses
.filter(hasAckInResponse)
.map((response) => response.action_id);
// actions Ids that are indexed in new response index
// actions Ids that are indexed in new endpoint response index
const indexedActionIds = await hasEndpointResponseDoc({
agentId,
actionIds: ackResponseActionIdList,
@ -350,20 +351,30 @@ const fetchActionResponses = async (
);
for (const actionResponse of actionResponses) {
const lastEndpointMetadataEventTimestamp = endpointLastEventCreated[actionResponse.agent_id];
const actionCompletedAtTimestamp = new Date(actionResponse.completed_at);
// If enough time has lapsed in checking for updated Endpoint metadata doc so that we don't keep
// checking it forever.
// It uses the `@timestamp` in order to ensure we are looking at times that were set by the server
const enoughTimeHasLapsed =
Date.now() - new Date(actionResponse['@timestamp']).getTime() >
PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME;
const actionCommand = actionResponse.action_data.command;
if (
!lastEndpointMetadataEventTimestamp ||
enoughTimeHasLapsed ||
lastEndpointMetadataEventTimestamp > actionCompletedAtTimestamp
) {
// We only (possibly) withhold fleet action responses for `isolate` and `unisolate`.
// All others should just return the responses and not wait until a metadata
// document update is received.
if (actionCommand === 'unisolate' || actionCommand === 'isolate') {
const lastEndpointMetadataEventTimestamp = endpointLastEventCreated[actionResponse.agent_id];
const actionCompletedAtTimestamp = new Date(actionResponse.completed_at);
// If enough time has lapsed in checking for updated Endpoint metadata doc so that we don't keep
// checking it forever.
// It uses the `@timestamp` in order to ensure we are looking at times that were set by the server
const enoughTimeHasLapsed =
Date.now() - new Date(actionResponse['@timestamp']).getTime() >
PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME;
if (
!lastEndpointMetadataEventTimestamp ||
enoughTimeHasLapsed ||
lastEndpointMetadataEventTimestamp > actionCompletedAtTimestamp
) {
actionResponsesByAgentId[actionResponse.agent_id].push(actionResponse);
}
} else {
actionResponsesByAgentId[actionResponse.agent_id].push(actionResponse);
}
}