Skip to content

Commit

Permalink
[8.15][Endpoint][Security Solution] Add an agent status client (elast…
Browse files Browse the repository at this point in the history
…ic#180513)

## Summary

Adds an agent status service and client for fetching endpoint and third
party agent statuses. This PR is a smaller chunk of
elastic/pull/178625.

With `responseActionsSentinelOneV2Enabled` enabled, we use the new agent
status client to get agent status and pending actions.

- [x] Updates code that deals with sentinel one agent status
- [x] pending actions badge for sentinel one alerts and response console
- [x] adds a new feature flag `responseActionsSentinelOneV2Enabled`. 


### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
  • Loading branch information
ashokaditya authored Apr 18, 2024
1 parent dfa001a commit 0956f53
Show file tree
Hide file tree
Showing 29 changed files with 765 additions and 244 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('Agent status api route schema', () => {
agentIds: '1',
agentType: 'foo',
})
).toThrow(/\[agentType\]: types that failed validation/);
).toThrow(/\[agentType]: types that failed validation/);
});

it.each([
Expand All @@ -37,7 +37,7 @@ describe('Agent status api route schema', () => {
],
])('should error if %s are used for `agentIds`', (_, validateOptions) => {
expect(() => EndpointAgentStatusRequestSchema.query.validate(validateOptions)).toThrow(
/\[agentIds\]:/
/\[agentIds]:/
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const ACTION_AGENT_FILE_DOWNLOAD_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{
export const ACTION_STATE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/state`;

/** Endpoint Agent Routes */
export const ENDPOINT_AGENT_STATUS_ROUTE = `/internal${BASE_ENDPOINT_ROUTE}/agent_status`;
export const AGENT_STATUS_ROUTE = `/internal${BASE_ENDPOINT_ROUTE}/agent_status`;

export const failedFleetActionErrorCode = '424';

Expand Down
30 changes: 17 additions & 13 deletions x-pack/plugins/security_solution/common/endpoint/types/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,26 @@

import type { HostStatus } from '.';
import type {
ResponseActionsApiCommandNames,
ResponseActionAgentType,
ResponseActionsApiCommandNames,
} from '../service/response_actions/constants';

export interface AgentStatusInfo {
id: string;
agentType: ResponseActionAgentType;
found: boolean;
isolated: boolean;
isPendingUninstall: boolean;
isUninstalled: boolean;
lastSeen: string; // ISO date
pendingActions: Record<ResponseActionsApiCommandNames, number>;
status: HostStatus;
export interface AgentStatusRecords {
[agentId: string]: {
agentId: string;
agentType: ResponseActionAgentType;
found: boolean;
isolated: boolean;
lastSeen: string; // ISO date
pendingActions: Record<ResponseActionsApiCommandNames | string, number>;
status: HostStatus;
};
}

export interface AgentStatusApiResponse {
data: Record<string, AgentStatusInfo>;
// TODO: 8.15 remove when `agentStatusClientEnabled` is enabled/removed
export interface AgentStatusInfo {
[agentId: string]: AgentStatusRecords[string] & {
isPendingUninstall: boolean;
isUninstalled: boolean;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export const allowedExperimentalValues = Object.freeze({
*/
responseActionsSentinelOneV2Enabled: false,

/**
* 8.15
* Enables use of agent status service to get agent status information
* for endpoint and third-party agents.
*/
agentStatusClientEnabled: false,

/**
* Enables top charts on Alerts Page
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import styled from 'styled-components';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getAgentStatusText } from '../../../common/components/endpoint/agent_status_text';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../management/pages/endpoint_hosts/view/host_constants';
import { useGetSentinelOneAgentStatus } from './use_sentinelone_host_isolation';
import { useAgentStatusHook } from './use_sentinelone_host_isolation';
import {
ISOLATED_LABEL,
ISOLATING_LABEL,
Expand All @@ -33,11 +33,13 @@ const EuiFlexGroupStyled = styled(EuiFlexGroup)`

export const SentinelOneAgentStatus = React.memo(
({ agentId, 'data-test-subj': dataTestSubj }: { agentId: string; 'data-test-subj'?: string }) => {
const useAgentStatus = useAgentStatusHook();

const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);

const { data, isLoading, isFetched } = useGetSentinelOneAgentStatus([agentId], {
const { data, isLoading, isFetched } = useAgentStatus([agentId], 'sentinel_one', {
enabled: sentinelOneManualHostActionsEnabled,
});
const agentStatus = data?.[`${agentId}`];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,93 +8,106 @@
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useHostIsolationAction } from './use_host_isolation_action';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGetSentinelOneAgentStatus } from './use_sentinelone_host_isolation';
import {
useAgentStatusHook,
useGetAgentStatus,
useGetSentinelOneAgentStatus,
} from './use_sentinelone_host_isolation';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';

jest.mock('./use_sentinelone_host_isolation');
jest.mock('../../../common/hooks/use_experimental_features');

const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
const useGetSentinelOneAgentStatusMock = useGetSentinelOneAgentStatus as jest.Mock;
const useGetAgentStatusMock = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;

describe('useHostIsolationAction', () => {
const createReactQueryWrapper = () => {
const queryClient = new QueryClient();
const wrapper: React.FC = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
describe.each([
['useGetSentinelOneAgentStatus', useGetSentinelOneAgentStatusMock],
['useGetAgentStatus', useGetAgentStatusMock],
])('works with %s hook', (name, hook) => {
const createReactQueryWrapper = () => {
const queryClient = new QueryClient();
const wrapper: React.FC = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};

const render = (isSentinelAlert: boolean = true) =>
renderHook(
() =>
useHostIsolationAction({
closePopover: jest.fn(),
detailsData: isSentinelAlert
? [
{
category: 'event',
field: 'event.module',
values: ['sentinel_one'],
originalValue: ['sentinel_one'],
isObjectArray: false,
},
{
category: 'observer',
field: 'observer.serial_number',
values: ['some-agent-id'],
originalValue: ['some-agent-id'],
isObjectArray: false,
},
]
: [
{
category: 'agent',
field: 'agent.id',
values: ['some-agent-id'],
originalValue: ['some-agent-id'],
isObjectArray: false,
},
],
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
}),
{
wrapper: createReactQueryWrapper(),
}
);
const render = (isSentinelAlert: boolean = true) =>
renderHook(
() =>
useHostIsolationAction({
closePopover: jest.fn(),
detailsData: isSentinelAlert
? [
{
category: 'event',
field: 'event.module',
values: ['sentinel_one'],
originalValue: ['sentinel_one'],
isObjectArray: false,
},
{
category: 'observer',
field: 'observer.serial_number',
values: ['some-agent-id'],
originalValue: ['some-agent-id'],
isObjectArray: false,
},
]
: [
{
category: 'agent',
field: 'agent.id',
values: ['some-agent-id'],
originalValue: ['some-agent-id'],
isObjectArray: false,
},
],
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
}),
{
wrapper: createReactQueryWrapper(),
}
);

beforeEach(() => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
});
beforeEach(() => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
useAgentStatusHookMock.mockImplementation(() => hook);
});

afterEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});

it('`useGetSentinelOneAgentStatusMock` is invoked as `enabled` when SentinelOne alert and FF enabled', () => {
render();
it(`${name} is invoked as 'enabled' when SentinelOne alert and FF enabled`, () => {
render();

expect(useGetSentinelOneAgentStatusMock).toHaveBeenCalledWith(['some-agent-id'], {
enabled: true,
expect(hook).toHaveBeenCalledWith(['some-agent-id'], 'sentinel_one', {
enabled: true,
});
});
});

it('`useGetSentinelOneAgentStatusMock` is invoked as `disabled` when SentinelOne alert and FF disabled', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
render();
it(`${name} is invoked as 'disabled' when SentinelOne alert and FF disabled`, () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
render();

expect(useGetSentinelOneAgentStatusMock).toHaveBeenCalledWith(['some-agent-id'], {
enabled: false,
expect(hook).toHaveBeenCalledWith(['some-agent-id'], 'sentinel_one', {
enabled: false,
});
});
});

it('`useGetSentinelOneAgentStatusMock` is invoked as `disabled` when non-SentinelOne alert', () => {
render(false);
it(`${name} is invoked as 'disabled' when non-SentinelOne alert`, () => {
render(false);

expect(useGetSentinelOneAgentStatusMock).toHaveBeenCalledWith([''], {
enabled: false,
expect(hook).toHaveBeenCalledWith([''], 'sentinel_one', {
enabled: false,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,23 @@
* 2.0.
*/
import { useCallback, useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useKibana } from '../../../common/lib/kibana/kibana_react';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import {
getSentinelOneAgentId,
isAlertFromSentinelOneEvent,
} from '../../../common/utils/sentinelone_alert_check';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { isIsolationSupported } from '../../../../common/endpoint/service/host_isolation/utils';
import type { AgentStatusInfo } from '../../../../common/endpoint/types';
import { HostStatus } from '../../../../common/endpoint/types';
import { isAlertFromEndpointEvent } from '../../../common/utils/endpoint_alert_check';
import { useEndpointHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status';
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { getFieldValue } from './helpers';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { useGetSentinelOneAgentStatus } from './use_sentinelone_host_isolation';
import { useAgentStatusHook } from './use_sentinelone_host_isolation';

interface UseHostIsolationActionProps {
closePopover: () => void;
Expand All @@ -35,11 +36,15 @@ export const useHostIsolationAction = ({
isHostIsolationPanelOpen,
onAddIsolationStatusClick,
}: UseHostIsolationActionProps): AlertTableContextMenuItem[] => {
const useAgentStatus = useAgentStatusHook();

const hasActionsAllPrivileges = useKibana().services.application?.capabilities?.actions?.save;

const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);

const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const { canIsolateHost, canUnIsolateHost } = useUserPrivileges().endpointPrivileges;

const isEndpointAlert = useMemo(
Expand Down Expand Up @@ -79,9 +84,13 @@ export const useHostIsolationAction = ({
agentType: sentinelOneAgentId ? 'sentinel_one' : 'endpoint',
});

const { data: sentinelOneAgentData } = useGetSentinelOneAgentStatus([sentinelOneAgentId || ''], {
enabled: !!sentinelOneAgentId && sentinelOneManualHostActionsEnabled,
});
const { data: sentinelOneAgentData } = useAgentStatus(
[sentinelOneAgentId || ''],
'sentinel_one',
{
enabled: !!sentinelOneAgentId && sentinelOneManualHostActionsEnabled,
}
);
const sentinelOneAgentStatus = sentinelOneAgentData?.[`${sentinelOneAgentId}`];

const isHostIsolated = useMemo(() => {
Expand Down Expand Up @@ -131,16 +140,26 @@ export const useHostIsolationAction = ({

const isIsolationActionDisabled = useMemo(() => {
if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert) {
return (
!sentinelOneAgentStatus ||
sentinelOneAgentStatus?.isUninstalled ||
sentinelOneAgentStatus?.isPendingUninstall
);
// 8.15 use FF for computing if action is enabled
if (agentStatusClientEnabled) {
return sentinelOneAgentStatus?.status === HostStatus.UNENROLLED;
}

// else use the old way
if (!sentinelOneAgentStatus) {
return true;
}

const { isUninstalled, isPendingUninstall } =
sentinelOneAgentStatus as AgentStatusInfo[string];

return isUninstalled || isPendingUninstall;
}

return agentStatus === HostStatus.UNENROLLED;
}, [
agentStatus,
agentStatusClientEnabled,
isSentinelOneAlert,
sentinelOneAgentStatus,
sentinelOneManualHostActionsEnabled,
Expand Down
Loading

0 comments on commit 0956f53

Please sign in to comment.