[Security Solution][Endpoint] Refactor UI response actions code (#184543)

## Summary

This PR does a major refactor of Response Actions functionality usage
outside of the `management` section of the code base. The impact
(although should be transparent from a user's standpoint) is mostly to
the Alert Details "Take Action" menu and specifically to the
"Isolate/Release" and "Respond" menu actions and the UI's it displays
when clicked. The changes can be summarized as:

- Centralized (moved) all code associated with Response Actions under
one of the following three directories:
    - `public/common/component/endpoint`
    - `public/common/hooks/endpoint`
    - `public/common/lib/endpoint`
    - Most changed files in this PR were a result of this activity
- Deleted several utilities that were used to determine the Alert's host
support for Response actions and replaced with a single `hook`
(`useAlertResponseActionsSupport()`)
- The "Isolate/Release" Take Action menu item now behaves similar to the
"Respond" menu option (on Alerts) in that:
    - Its only NOT displayed if the user is not authorized to use it
- It will show up as disabled while we are attempting to determine
support for response actions on the alert's host
    - Tooltips will be displayed when options is disabled
This commit is contained in:
Paul Tavares 2024-06-18 08:21:48 -04:00 committed by GitHub
parent 7528264531
commit f820f78807
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 2596 additions and 2737 deletions

11
.github/CODEOWNERS vendored
View file

@ -1513,6 +1513,8 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/
/x-pack/plugins/stack_connectors/public/connector_types/sentinelone @elastic/security-defend-workflows
/x-pack/plugins/stack_connectors/server/connector_types/sentinelone @elastic/security-defend-workflows
/x-pack/plugins/stack_connectors/common/sentinelone @elastic/security-defend-workflows
/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike @elastic/security-defend-workflows
/x-pack/plugins/stack_connectors/common/crowdstrike @elastic/security-defend-workflows
## Security Solution shared OAS schemas
/x-pack/plugins/security_solution/common/api/model @elastic/security-detection-rule-management @elastic/security-detection-engine
@ -1596,12 +1598,14 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/
## Security Solution sub teams - security-defend-workflows
/x-pack/plugins/security_solution/public/management/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/common/lib/endpoint*/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/common/components/agents/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/common/lib/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/common/components/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/common/hooks/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/common/mock/endpoint @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/common/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/server/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/common/api/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/server/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/server/lists_integration/endpoint/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/server/lib/license/ @elastic/security-defend-workflows
/x-pack/plugins/security_solution/server/fleet_integration/ @elastic/security-defend-workflows
@ -1636,6 +1640,7 @@ x-pack/plugins/security_solution/common/api/entity_analytics @elastic/security-e
x-pack/test/security_solution_api_integration/test_suites/genai @elastic/security-generative-ai
# Security Defend Workflows - OSQuery Ownership
x-pack/plugins/osquery @elastic/security-defend-workflows
/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_response_actions @elastic/security-defend-workflows
/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions @elastic/security-defend-workflows
/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions @elastic/security-defend-workflows

View file

@ -1,84 +0,0 @@
/*
* 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 { isVersionSupported, isOsSupported, isIsolationSupported } from './utils';
describe('Host Isolation utils isVersionSupported', () => {
// NOTE: the `7.15.0.8295.0` and the text current versions are invalid.
test.each`
currentVersion | minVersionRequired | expected
${'8.14.0'} | ${'7.13.0'} | ${true}
${'7.14.0'} | ${'7.13.0'} | ${true}
${'7.14.1'} | ${'7.14.0'} | ${true}
${'8.14.0'} | ${'9.14.0'} | ${false}
${'7.13.0'} | ${'7.14.0'} | ${false}
${'7.14.0'} | ${'7.14.1'} | ${false}
${'7.14.0'} | ${'7.14.0'} | ${true}
${'7.14.0-SNAPSHOT'} | ${'7.14.0'} | ${true}
${'7.14.0-SNAPSHOT-beta'} | ${'7.14.0'} | ${true}
${'7.14.0-alpha'} | ${'7.14.0'} | ${true}
${'8.0.0-SNAPSHOT'} | ${'7.14.0'} | ${true}
${'8.0.0'} | ${'7.14.0'} | ${true}
${'7.15.0.8295.0'} | ${'7.14.0'} | ${false}
${'NOT_SEMVER'} | ${'7.14.0'} | ${false}
`(
'should validate that version $a is compatible($expected) to $b',
({ currentVersion, minVersionRequired, expected }) => {
expect(
isVersionSupported({
currentVersion,
minVersionRequired,
})
).toEqual(expected);
}
);
});
describe('Host Isolation utils isOsSupported', () => {
test.each`
currentOs | supportedOss | expected
${'linux'} | ${{ macos: true, linux: true }} | ${true}
${'linux'} | ${{ macos: true, windows: true }} | ${false}
`(
'should validate that os $a is compatible($expected) to $b',
({ currentOs, supportedOss, expected }) => {
expect(
isOsSupported({
currentOs,
supportedOss,
})
).toEqual(expected);
}
);
});
describe('Host Isolation utils isIsolationSupported', () => {
test.each`
osName | version | capabilities | expected
${'windows'} | ${'7.14.0'} | ${[]} | ${true}
${'linux'} | ${'7.13.0'} | ${['isolation']} | ${false}
${'linux'} | ${'7.14.0'} | ${['isolation']} | ${false}
${'macos'} | ${'7.13.0'} | ${['isolation']} | ${false}
${'linux'} | ${'7.13.0'} | ${['isolation']} | ${false}
${'windows'} | ${'7.15.0'} | ${[]} | ${false}
${'macos'} | ${'7.15.0'} | ${[]} | ${false}
${'linux'} | ${'7.15.0'} | ${['isolation']} | ${true}
${'macos'} | ${'7.15.0'} | ${['isolation']} | ${true}
${'linux'} | ${'7.16.0'} | ${['isolation']} | ${true}
`(
'should validate that os $a, version $b, and capabilities $c supports hostIsolation($expected)',
({ osName, version, capabilities, expected }) => {
expect(
isIsolationSupported({
osName,
version,
capabilities,
})
).toEqual(expected);
}
);
});

View file

@ -1,83 +0,0 @@
/*
* 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 semverLte from 'semver/functions/lte';
import type { ImmutableArray } from '../../types';
const minSupportedVersion = '7.14.0';
const minCapabilitiesVersion = '7.15.0';
const supportedOssMap = {
macos: true,
windows: true,
};
const isolationCapability = 'isolation';
function parseSemver(semver: string) {
return semver.includes('-') ? semver.substring(0, semver.indexOf('-')) : semver;
}
export const isVersionSupported = ({
currentVersion,
minVersionRequired = minSupportedVersion,
}: {
currentVersion: string;
minVersionRequired?: string;
}) => {
// `parseSemver()` will throw if the version provided is not a valid semver value.
// If that happens, then just return false from this function
try {
const parsedCurrentVersion = parseSemver(currentVersion);
return semverLte(minVersionRequired, parsedCurrentVersion);
} catch (e) {
// If running in the browser, log to console
if (window && window.console) {
window.console.warn(
`SecuritySolution: isVersionSupported(): Unable to determine if current version [${currentVersion}] meets minimum version [${minVersionRequired}]. Error: ${e.message}`
);
}
return false;
}
};
export const isOsSupported = ({
currentOs,
supportedOss = supportedOssMap,
}: {
currentOs: string;
supportedOss?: { [os: string]: boolean };
}) => !!supportedOss[currentOs];
function isCapabilitiesSupported(semver: string): boolean {
const parsedVersion = parseSemver(semver);
// capabilities is only available from 7.15+
return semverLte(minCapabilitiesVersion, parsedVersion);
}
function isIsolationSupportedCapabilities(capabilities: ImmutableArray<string> = []): boolean {
return capabilities.includes(isolationCapability);
}
// capabilities isn't introduced until 7.15 so check the OS for support
function isIsolationSupportedOS(osName: string): boolean {
const normalizedOs = osName.toLowerCase();
return isOsSupported({ currentOs: normalizedOs });
}
export const isIsolationSupported = ({
osName,
version,
capabilities,
}: {
osName: string;
version: string;
capabilities?: ImmutableArray<string>;
}): boolean => {
if (!version || !isVersionSupported({ currentVersion: version })) return false;
return isCapabilitiesSupported(version)
? isIsolationSupportedCapabilities(capabilities)
: isIsolationSupportedOS(osName);
};

View file

@ -174,3 +174,14 @@ export const RESPONSE_ACTIONS_ZIP_PASSCODE: Readonly<Record<ResponseActionAgentT
sentinel_one: 'Elastic@123',
crowdstrike: 'tbd..',
});
/**
* Map of Agent Type to alert field that holds the Agent ID for that agent type
*/
export const RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD: Readonly<
Record<ResponseActionAgentType, string>
> = Object.freeze({
endpoint: 'agent.id',
sentinel_one: 'observer.serial_number',
crowdstrike: 'crowdstrike.event.DeviceId',
});

View file

@ -10,6 +10,7 @@ import { useDispatch } from 'react-redux';
import type { CaseViewRefreshPropInterface } from '@kbn/cases-plugin/common';
import { CaseMetricsFeature } from '@kbn/cases-plugin/common';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { CaseDetailsRefreshContext } from '../../common/components/endpoint';
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys';
import { useTourContext } from '../../common/components/guided_onboarding_tour';
@ -26,7 +27,6 @@ import { APP_ID, CASES_PATH, SecurityPageName } from '../../../common/constants'
import { timelineActions } from '../../timelines/store';
import { useSourcererDataView } from '../../sourcerer/containers';
import { SourcererScopeName } from '../../sourcerer/store/model';
import { CaseDetailsRefreshContext } from '../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { getEndpointDetailsPath } from '../../management/common/routing';
import { SpyRoute } from '../../common/utils/route/spy_routes';

View file

@ -8,11 +8,11 @@
import React, { memo, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiToolTip } from '@elastic/eui';
import type { EndpointPendingActions } from '../../../../../common/endpoint/types';
import type { ResponseActionsApiCommandNames } from '../../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP } from '../../../../../common/endpoint/service/response_actions/constants';
import type { EndpointPendingActions } from '../../../../../../common/endpoint/types';
import type { ResponseActionsApiCommandNames } from '../../../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP } from '../../../../../../common/endpoint/service/response_actions/constants';
import { ISOLATED_LABEL, ISOLATING_LABEL, RELEASING_LABEL } from './endpoint/endpoint_agent_status';
import { useTestIdGenerator } from '../../../../management/hooks/use_test_id_generator';
import { useTestIdGenerator } from '../../../../../management/hooks/use_test_id_generator';
const TOOLTIP_CONTENT_STYLES: React.CSSProperties = Object.freeze({ width: 150 });

View file

@ -11,17 +11,17 @@ import { AgentStatus } from './agent_status';
import {
useAgentStatusHook,
useGetAgentStatus,
} from '../../../../management/hooks/agents/use_get_agent_status';
} from '../../../../../management/hooks/agents/use_get_agent_status';
import {
RESPONSE_ACTION_AGENT_TYPE,
type ResponseActionAgentType,
} from '../../../../../common/endpoint/service/response_actions/constants';
import type { AppContextTestRender } from '../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../mock/endpoint';
import { HostStatus } from '../../../../../common/endpoint/types';
} from '../../../../../../common/endpoint/service/response_actions/constants';
import type { AppContextTestRender } from '../../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../../mock/endpoint';
import { HostStatus } from '../../../../../../common/endpoint/types';
jest.mock('../../../hooks/use_experimental_features');
jest.mock('../../../../management/hooks/agents/use_get_agent_status');
jest.mock('../../../../hooks/use_experimental_features');
jest.mock('../../../../../management/hooks/agents/use_get_agent_status');
const getAgentStatusMock = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;

View file

@ -8,12 +8,12 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import type { EndpointPendingActions } from '../../../../../common/endpoint/types';
import { useAgentStatusHook } from '../../../../management/hooks/agents/use_get_agent_status';
import { useTestIdGenerator } from '../../../../management/hooks/use_test_id_generator';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../../management/pages/endpoint_hosts/view/host_constants';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import type { EndpointPendingActions } from '../../../../../../common/endpoint/types';
import { useAgentStatusHook } from '../../../../../management/hooks/agents/use_get_agent_status';
import { useTestIdGenerator } from '../../../../../management/hooks/use_test_id_generator';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../../../management/pages/endpoint_hosts/view/host_constants';
import { useIsExperimentalFeatureEnabled } from '../../../../hooks/use_experimental_features';
import { getAgentStatusText } from '../agent_status_text';
import { AgentResponseActionsStatus } from './agent_response_action_status';
export enum SENTINEL_ONE_NETWORK_STATUS {

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import type { AppContextTestRender } from '../../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../../mock/endpoint';
import type { AppContextTestRender } from '../../../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../../../mock/endpoint';
import type {
EndpointAgentStatusByIdProps,
EndpointAgentStatusProps,
@ -15,18 +15,18 @@ import { EndpointAgentStatus, EndpointAgentStatusById } from './endpoint_agent_s
import type {
EndpointPendingActions,
HostInfoInterface,
} from '../../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../../common/endpoint/types';
} from '../../../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../../../common/endpoint/types';
import React from 'react';
import { EndpointActionGenerator } from '../../../../../../common/endpoint/data_generators/endpoint_action_generator';
import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data';
import { composeHttpHandlerMocks } from '../../../../mock/endpoint/http_handler_mock_factory';
import type { EndpointMetadataHttpMocksInterface } from '../../../../../management/pages/endpoint_hosts/mocks';
import { endpointMetadataHttpMocks } from '../../../../../management/pages/endpoint_hosts/mocks';
import type { ResponseActionsHttpMocksInterface } from '../../../../../management/mocks/response_actions_http_mocks';
import { responseActionsHttpMocks } from '../../../../../management/mocks/response_actions_http_mocks';
import { EndpointActionGenerator } from '../../../../../../../common/endpoint/data_generators/endpoint_action_generator';
import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data';
import { composeHttpHandlerMocks } from '../../../../../mock/endpoint/http_handler_mock_factory';
import type { EndpointMetadataHttpMocksInterface } from '../../../../../../management/pages/endpoint_hosts/mocks';
import { endpointMetadataHttpMocks } from '../../../../../../management/pages/endpoint_hosts/mocks';
import type { ResponseActionsHttpMocksInterface } from '../../../../../../management/mocks/response_actions_http_mocks';
import { responseActionsHttpMocks } from '../../../../../../management/mocks/response_actions_http_mocks';
import { waitFor, within, fireEvent } from '@testing-library/react';
import { getEmptyValue } from '../../../empty_value';
import { getEmptyValue } from '../../../../empty_value';
import { clone, set } from 'lodash';
type AgentStatusApiMocksInterface = EndpointMetadataHttpMocksInterface &

View file

@ -9,14 +9,14 @@ import React, { memo, useMemo } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { DEFAULT_POLL_INTERVAL } from '../../../../../management/common/constants';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../../../management/pages/endpoint_hosts/view/host_constants';
import { getEmptyValue } from '../../../empty_value';
import { DEFAULT_POLL_INTERVAL } from '../../../../../../management/common/constants';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../../../../management/pages/endpoint_hosts/view/host_constants';
import { getEmptyValue } from '../../../../empty_value';
import { useGetEndpointPendingActionsSummary } from '../../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary';
import { useTestIdGenerator } from '../../../../../management/hooks/use_test_id_generator';
import type { EndpointPendingActions, HostInfo } from '../../../../../../common/endpoint/types';
import { useGetEndpointDetails } from '../../../../../management/hooks';
import { useGetEndpointPendingActionsSummary } from '../../../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary';
import { useTestIdGenerator } from '../../../../../../management/hooks/use_test_id_generator';
import type { EndpointPendingActions, HostInfo } from '../../../../../../../common/endpoint/types';
import { useGetEndpointDetails } from '../../../../../../management/hooks';
import { getAgentStatusText } from '../../agent_status_text';
import { AgentResponseActionsStatus } from '../agent_response_action_status';

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import type { HostStatus } from '../../../../common/endpoint/types';
import type { HostStatus } from '../../../../../common/endpoint/types';
export const getAgentStatusText = (hostStatus: HostStatus) => {
return i18n.translate('xpack.securitySolution.endpoint.list.hostStatusValue', {

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { useResponderActionItem } from './use_responder_action_item';
export * from '../from_alerts/__mocks__';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './use_host_isolation_action';

View file

@ -0,0 +1,23 @@
/*
* 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 type { AlertTableContextMenuItem } from '../../../../../../detections/components/alerts_table/types';
import { ISOLATE_HOST } from '../translations';
const useHostIsolationActionMock = (): AlertTableContextMenuItem[] => {
return [
{
key: 'isolate-host-action-item',
'data-test-subj': 'isolate-host-action-item',
disabled: false,
onClick: jest.fn(),
name: ISOLATE_HOST,
},
];
};
export { useHostIsolationActionMock as useHostIsolationAction };

View file

@ -8,8 +8,10 @@
import React from 'react';
import { renderReactTestingLibraryWithI18n as render } from '@kbn/test-jest-helpers';
import { HostIsolationPanel } from '.';
import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__';
import { useKibana as mockUseKibana } from '../../../../lib/kibana/__mocks__';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { endpointAlertDataMock } from '../../../../mock/endpoint';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
const queryClient = new QueryClient({
logger: {
@ -19,36 +21,40 @@ const queryClient = new QueryClient({
},
});
jest.mock('../../../../experimental_features_service');
const useKibanaMock = mockUseKibana as jest.Mock;
jest.mock('../../../common/lib/kibana');
jest.mock('../../../../lib/kibana');
describe('HostIsolationPanel', () => {
const renderWithContext = (Element: React.ReactElement) =>
render(<QueryClientProvider client={queryClient}>{Element}</QueryClientProvider>);
let cancelCallback: () => void;
let details: TimelineEventsDetailsItem[];
beforeEach(() => {
useKibanaMock.mockReturnValue({
...mockUseKibana(),
services: { ...mockUseKibana().services, notifications: { toasts: jest.fn() } },
});
});
const details = [
{
category: 'observer',
field: 'observer.serial_number',
values: ['expectedSentinelOneAgentId'],
originalValue: ['expectedSentinelOneAgentId'],
isObjectArray: false,
},
{
category: 'crowdstrike',
field: 'crowdstrike.event.DeviceId',
values: ['expectedCrowdstrikeAgentId'],
originalValue: ['expectedCrowdstrikeAgentId'],
isObjectArray: false,
},
];
const cancelCallback = jest.fn();
cancelCallback = jest.fn();
details = endpointAlertDataMock.generateEndpointAlertDetailsItemData();
});
it('should render warning callout if alert data host does not support response actions', () => {
const { getByTestId } = renderWithContext(
<HostIsolationPanel
details={[]}
cancelCallback={cancelCallback}
isolateAction="isolateHost"
/>
);
expect(getByTestId('unsupportedAlertHost')).toHaveTextContent(
"The alert's host () does not support host isolation response actions."
);
});
it('renders IsolateHost when isolateAction is "isolateHost"', () => {
const { getByText } = renderWithContext(

View file

@ -0,0 +1,74 @@
/*
* 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, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut } from '@elastic/eui';
import { getAlertDetailsFieldValue } from '../../../../lib/endpoint/utils/get_event_details_field_values';
import { useCasesFromAlerts } from '../../../../../detections/containers/detection_engine/alerts/use_cases_from_alerts';
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
import { IsolateHost } from './isolate';
import { UnisolateHost } from './unisolate';
import { useAlertResponseActionsSupport } from '../../../../hooks/endpoint/use_alert_response_actions_support';
export const HostIsolationPanel = React.memo(
({
details,
cancelCallback,
successCallback,
isolateAction,
}: {
details: TimelineEventsDetailsItem[] | null;
cancelCallback: () => void;
successCallback?: () => void;
isolateAction: string;
}) => {
const {
isSupported: alertHostSupportsResponseActions,
details: { agentId, agentType, hostName },
} = useAlertResponseActionsSupport(details);
const alertId = useMemo(
() => getAlertDetailsFieldValue({ category: '_id', field: '_id' }, details),
[details]
);
const { casesInfo } = useCasesFromAlerts({ alertId });
const formProps: React.ComponentProps<typeof IsolateHost> &
React.ComponentProps<typeof UnisolateHost> = useMemo(() => {
return {
endpointId: agentId,
hostName,
casesInfo,
agentType,
cancelCallback,
successCallback,
};
}, [agentId, agentType, cancelCallback, casesInfo, hostName, successCallback]);
if (!alertHostSupportsResponseActions) {
return (
<EuiCallOut color="warning" data-test-subj="unsupportedAlertHost">
<FormattedMessage
id="xpack.securitySolution.detections.hostIsolation.alertHostNotSupported"
defaultMessage="The alert's host ({hostName}) does not support host isolation response actions."
values={{ hostName }}
/>
</EuiCallOut>
);
}
return isolateAction === 'isolateHost' ? (
<IsolateHost {...formProps} />
) : (
<UnisolateHost {...formProps} />
);
}
);
HostIsolationPanel.displayName = 'HostIsolationContent';

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export * from './host_isolation_panel';
export * from './use_host_isolation_action';
export * from './translations';

View file

@ -8,15 +8,12 @@
import React, { useMemo, useState, useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { useHostIsolation } from './use_host_isolation';
import { CASES_ASSOCIATED_WITH_ALERT, RETURN_TO_ALERT_DETAILS } from './translations';
import type { EndpointIsolatedFormProps } from '../../../common/components/endpoint/host_isolation';
import {
EndpointIsolateForm,
ActionCompletionReturnButton,
} from '../../../common/components/endpoint/host_isolation';
import type { CasesFromAlertsResponse } from '../../containers/detection_engine/alerts/types';
import type { EndpointIsolatedFormProps } from '..';
import { EndpointIsolateForm, ActionCompletionReturnButton } from '..';
import type { CasesFromAlertsResponse } from '../../../../../detections/containers/detection_engine/alerts/types';
export const IsolateHost = React.memo(
({

View file

@ -8,15 +8,12 @@
import React, { useMemo, useState, useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { CASES_ASSOCIATED_WITH_ALERT, RETURN_TO_ALERT_DETAILS } from './translations';
import type { EndpointIsolatedFormProps } from '../../../common/components/endpoint/host_isolation';
import {
EndpointUnisolateForm,
ActionCompletionReturnButton,
} from '../../../common/components/endpoint/host_isolation';
import { useHostUnisolation } from '../../containers/detection_engine/alerts/use_host_unisolation';
import type { CasesFromAlertsResponse } from '../../containers/detection_engine/alerts/types';
import type { EndpointIsolatedFormProps } from '..';
import { EndpointUnisolateForm, ActionCompletionReturnButton } from '..';
import { useHostUnisolation } from './use_host_unisolation';
import type { CasesFromAlertsResponse } from '../../../../../detections/containers/detection_engine/alerts/types';
export const UnisolateHost = React.memo(
({

View file

@ -6,10 +6,10 @@
*/
import { useCallback, useState } from 'react';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { HOST_ISOLATION_FAILURE } from './translations';
import { createHostIsolation } from './api';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { useAppToasts } from '../../../../hooks/use_app_toasts';
import { HOST_ISOLATION_FAILURE } from '../../../../../detections/containers/detection_engine/alerts/translations';
import { createHostIsolation } from '../../../../../detections/containers/detection_engine/alerts/api';
interface HostIsolationStatus {
loading: boolean;

View file

@ -0,0 +1,144 @@
/*
* 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 type { FC, PropsWithChildren } from 'react';
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useHostIsolationAction } from './use_host_isolation_action';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
useAgentStatusHook,
useGetAgentStatus,
useGetSentinelOneAgentStatus,
} from '../../../../../management/hooks/agents/use_get_agent_status';
import { useIsExperimentalFeatureEnabled } from '../../../../hooks/use_experimental_features';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { ExperimentalFeaturesService as ExperimentalFeaturesServiceMock } from '../../../../experimental_features_service';
import { endpointAlertDataMock } from '../../../../mock/endpoint';
jest.mock('../../../../../management/hooks/agents/use_get_agent_status');
jest.mock('../../../../hooks/use_experimental_features');
jest.mock('../../../../experimental_features_service');
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 setFeatureFlags = (isEnabled: boolean = true): void => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(isEnabled);
(ExperimentalFeaturesServiceMock.get as jest.Mock).mockReturnValue({
responseActionsSentinelOneV1Enabled: isEnabled,
responseActionsCrowdstrikeManualHostIsolationEnabled: isEnabled,
});
};
const createReactQueryWrapper = () => {
const queryClient = new QueryClient();
const wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
it('should NOT return the menu item for Events', () => {
useAgentStatusHookMock.mockImplementation(() => {
return jest.fn(() => {
return { data: {} };
});
});
setFeatureFlags(true);
const { result } = renderHook(
() => {
return useHostIsolationAction({
closePopover: jest.fn(),
detailsData: endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo', {
'kibana.alert.rule.uuid': undefined,
}),
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
});
},
{ wrapper: createReactQueryWrapper() }
);
expect(result.current).toHaveLength(0);
});
// FIXME:PT refactor describe below - its not actually testing the component! Tests seem to be for `useAgentStatusHook()`
describe.each([
['useGetSentinelOneAgentStatus', useGetSentinelOneAgentStatusMock],
['useGetAgentStatus', useGetAgentStatusMock],
])('works with %s hook', (name, hook) => {
const render = (agentTypeAlert: ResponseActionAgentType) =>
renderHook(
() =>
useHostIsolationAction({
closePopover: jest.fn(),
detailsData:
endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentTypeAlert),
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
}),
{
wrapper: createReactQueryWrapper(),
}
);
beforeEach(() => {
useAgentStatusHookMock.mockImplementation(() => hook);
setFeatureFlags(true);
});
afterEach(() => {
jest.clearAllMocks();
(ExperimentalFeaturesServiceMock.get as jest.Mock).mockReset();
});
it(`${name} is invoked as 'enabled' when SentinelOne alert and FF enabled`, () => {
render('sentinel_one');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'sentinel_one', {
enabled: true,
});
});
it(`${name} is invoked as 'enabled' when Crowdstrike alert and FF enabled`, () => {
render('crowdstrike');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'crowdstrike', {
enabled: true,
});
});
it(`${name} is invoked as 'disabled' when SentinelOne alert and FF disabled`, () => {
setFeatureFlags(false);
render('sentinel_one');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'sentinel_one', {
enabled: false,
});
});
it(`${name} is invoked as 'disabled' when Crowdstrike alert and FF disabled`, () => {
setFeatureFlags(false);
render('crowdstrike');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'crowdstrike', {
enabled: false,
});
});
it(`${name} is invoked as 'disabled' when endpoint alert`, () => {
render('endpoint');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'endpoint', {
enabled: false,
});
});
});
});

View file

@ -0,0 +1,175 @@
/*
* 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 { useCallback, useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import {
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
LOADING_ENDPOINT_DATA_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from '../../responder';
import { useAlertResponseActionsSupport } from '../../../../hooks/endpoint/use_alert_response_actions_support';
import { useIsExperimentalFeatureEnabled } from '../../../../hooks/use_experimental_features';
import type { AgentStatusInfo } from '../../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../../common/endpoint/types';
import { useEndpointHostIsolationStatus } from './use_host_isolation_status';
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { useUserPrivileges } from '../../../user_privileges';
import type { AlertTableContextMenuItem } from '../../../../../detections/components/alerts_table/types';
import { useAgentStatusHook } from '../../../../../management/hooks/agents/use_get_agent_status';
interface UseHostIsolationActionProps {
closePopover: () => void;
detailsData: TimelineEventsDetailsItem[] | null;
isHostIsolationPanelOpen: boolean;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
}
export const useHostIsolationAction = ({
closePopover,
detailsData,
isHostIsolationPanelOpen,
onAddIsolationStatusClick,
}: UseHostIsolationActionProps): AlertTableContextMenuItem[] => {
const {
isSupported: hostSupportsResponseActions,
isAlert,
unsupportedReason,
details: {
agentType,
agentId,
agentSupport: { isolate: isolationSupported },
},
} = useAlertResponseActionsSupport(detailsData);
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const useAgentStatus = useAgentStatusHook();
const { canIsolateHost, canUnIsolateHost } = useUserPrivileges().endpointPrivileges;
const isEndpointAgent = useMemo(() => {
return agentType === 'endpoint';
}, [agentType]);
const {
loading: loadingHostIsolationStatus,
isIsolated,
agentStatus,
capabilities,
} = useEndpointHostIsolationStatus({
agentId,
agentType,
});
const { data: externalAgentData } = useAgentStatus([agentId], agentType, {
enabled: hostSupportsResponseActions && !isEndpointAgent,
});
const externalAgentStatus = externalAgentData?.[agentId];
const isHostIsolated = useMemo((): boolean => {
if (!isEndpointAgent) {
return Boolean(externalAgentStatus?.isolated);
}
return isIsolated;
}, [isEndpointAgent, isIsolated, externalAgentStatus?.isolated]);
const doesHostSupportIsolation = useMemo(() => {
// With Elastic Defend Endpoint, we check that the actual `endpoint` agent on
// this host reported that capability
if (agentType === 'endpoint') {
return capabilities.includes('isolation');
}
return Boolean(externalAgentStatus?.found && isolationSupported);
}, [agentType, externalAgentStatus?.found, isolationSupported, capabilities]);
const isolateHostHandler = useCallback(() => {
closePopover();
if (!isHostIsolated) {
onAddIsolationStatusClick('isolateHost');
} else {
onAddIsolationStatusClick('unisolateHost');
}
}, [closePopover, isHostIsolated, onAddIsolationStatusClick]);
const isHostAgentUnEnrolled = useMemo<boolean>(() => {
if (!hostSupportsResponseActions) {
return true;
}
if (isEndpointAgent) {
return agentStatus === HostStatus.UNENROLLED;
}
// NON-Endpoint agent types
// 8.15 use FF for computing if action is enabled
if (agentStatusClientEnabled) {
return externalAgentStatus?.status === HostStatus.UNENROLLED;
}
// else use the old way
if (!externalAgentStatus) {
return true;
}
const { isUninstalled, isPendingUninstall } = externalAgentStatus as AgentStatusInfo[string];
return isUninstalled || isPendingUninstall;
}, [
hostSupportsResponseActions,
isEndpointAgent,
agentStatusClientEnabled,
externalAgentStatus,
agentStatus,
]);
return useMemo<AlertTableContextMenuItem[]>(() => {
// If not an Alert OR user has no Authz, then don't show the menu item at all
if (!isAlert || (isHostIsolated && !canUnIsolateHost) || !canIsolateHost) {
return [];
}
const menuItem: AlertTableContextMenuItem = {
key: 'isolate-host-action-item',
'data-test-subj': 'isolate-host-action-item',
disabled: isHostAgentUnEnrolled || isHostIsolationPanelOpen,
onClick: isolateHostHandler,
name: isHostIsolated ? UNISOLATE_HOST : ISOLATE_HOST,
};
// Determine if menu item should be disabled
if (!doesHostSupportIsolation) {
menuItem.disabled = true;
// If we were able to calculate the agentType and we have a reason why the host is does not
// support response actions, then show that as the tooltip. Else, just show the normal "enroll" message
menuItem.toolTipContent =
agentType && unsupportedReason ? unsupportedReason : NOT_FROM_ENDPOINT_HOST_TOOLTIP;
} else if (isEndpointAgent && loadingHostIsolationStatus) {
menuItem.disabled = true;
menuItem.toolTipContent = LOADING_ENDPOINT_DATA_TOOLTIP;
} else if (isHostAgentUnEnrolled) {
menuItem.disabled = true;
menuItem.toolTipContent = isEndpointAgent
? HOST_ENDPOINT_UNENROLLED_TOOLTIP
: NOT_FROM_ENDPOINT_HOST_TOOLTIP;
}
return [menuItem];
}, [
isAlert,
isHostIsolated,
canUnIsolateHost,
canIsolateHost,
isHostAgentUnEnrolled,
isHostIsolationPanelOpen,
isolateHostHandler,
doesHostSupportIsolation,
isEndpointAgent,
loadingHostIsolationStatus,
agentType,
unsupportedReason,
]);
};

View file

@ -7,11 +7,11 @@
import { isEmpty } from 'lodash';
import { useEffect, useState } from 'react';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { getHostMetadata } from './api';
import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions';
import { isEndpointHostIsolated } from '../../../../common/utils/validators';
import { HostStatus } from '../../../../../common/endpoint/types';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { getHostMetadata } from '../../../../../detections/containers/detection_engine/alerts/api';
import { fetchPendingActionsByAgentId } from '../../../../lib/endpoint/endpoint_pending_actions';
import { isEndpointHostIsolated } from '../../../../utils/validators';
import { HostStatus } from '../../../../../../common/endpoint/types';
interface HostIsolationStatusResponse {
loading: boolean;
@ -22,8 +22,9 @@ interface HostIsolationStatusResponse {
pendingUnisolation: number;
}
/*
* Retrieves the current isolation status of a host and the agent/host status */
/**
* Retrieves the current isolation status of a host and the agent/host status
*/
export const useEndpointHostIsolationStatus = ({
agentId,
agentType,
@ -79,16 +80,15 @@ export const useEndpointHostIsolationStatus = ({
}
} catch (error) {
// silently catch non-user initiated error
return;
}
if (isMounted) {
setLoading(false);
}
};
if (!isEmpty(agentId) && agentType === 'endpoint') {
fetchData();
fetchData().finally(() => {
if (isMounted) {
setLoading(false);
}
});
}
return () => {
// updates to show component is unmounted

View file

@ -6,10 +6,10 @@
*/
import { useCallback, useState } from 'react';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { HOST_ISOLATION_FAILURE } from './translations';
import { createHostUnIsolation } from './api';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { useAppToasts } from '../../../../hooks/use_app_toasts';
import { HOST_ISOLATION_FAILURE } from '../../../../../detections/containers/detection_engine/alerts/translations';
import { createHostUnIsolation } from '../../../../../detections/containers/detection_engine/alerts/api';
interface HostUnisolationStatus {
loading: boolean;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './endpoint_host_isolation_cases_context';

View file

@ -9,3 +9,5 @@ export * from './isolate_success';
export * from './isolate_form';
export * from './unisolate_form';
export * from './action_completion_return_button';
export * from './from_alerts';
export * from './from_cases';

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export * from './host_isolation';
export * from './responder';
export * from './link_to_app';
export * from './route_capture';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from '../from_alerts/__mocks__';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './use_responder_action_data';
export * from './use_responder_action_item';

View file

@ -0,0 +1,24 @@
/*
* 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 type { ResponderActionData, UseWithResponderActionDataFromAlertProps } from '../../..';
const useWithResponderActionDataFromAlertMock = (
options: UseWithResponderActionDataFromAlertProps
): ResponderActionData => {
return {
handleResponseActionsClick: jest.fn(() => {
if (options.onClick) {
options.onClick();
}
}),
isDisabled: false,
tooltip: null,
};
};
export { useWithResponderActionDataFromAlertMock as useWithResponderActionDataFromAlert };

View file

@ -0,0 +1,24 @@
/*
* 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 type { AlertTableContextMenuItem } from '../../../../../../detections/components/alerts_table/types';
const useResponderActionItemMock = (): AlertTableContextMenuItem[] => {
return [
{
key: 'endpointResponseActions-action-item',
'data-test-subj': 'endpointResponseActions-action-item',
disabled: false,
toolTipContent: undefined,
size: 's',
onClick: jest.fn(),
name: 'Respond',
},
];
};
export { useResponderActionItemMock as useResponderActionItem };

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export * from './responder_action_button';
export * from './use_responder_action_item';
export * from './use_responder_action_data';
export * from './translations';

View file

@ -8,18 +8,15 @@
import { EuiButton, EuiToolTip } from '@elastic/eui';
import React, { memo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
type ResponderContextMenuItemProps,
useResponderActionData,
} from './use_responder_action_data';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { useResponderActionData } from './use_responder_action_data';
import { useUserPrivileges } from '../../../user_privileges';
export const ResponderActionButton = memo<ResponderContextMenuItemProps>(
({ agentType, endpointId, onClick }) => {
export const ResponderActionButton = memo<{ agentId: string; agentType: ResponseActionAgentType }>(
({ agentType, agentId }) => {
const { handleResponseActionsClick, isDisabled, tooltip } = useResponderActionData({
agentType,
endpointId,
onClick,
agentId,
});
const endpointPrivileges = useUserPrivileges().endpointPrivileges;

View file

@ -6,8 +6,6 @@
*/
import { i18n } from '@kbn/i18n';
import { CROWDSTRIKE_AGENT_ID_FIELD } from '../../../common/utils/crowdstrike_alert_check';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../common/utils/sentinelone_alert_check';
export const NOT_FROM_ENDPOINT_HOST_TOOLTIP = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.notSupportedTooltip',
@ -27,22 +25,3 @@ export const METADATA_API_ERROR_TOOLTIP = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.generalMetadataErrorTooltip',
{ defaultMessage: 'Failed to retrieve Endpoint metadata' }
);
export const SENTINEL_ONE_AGENT_ID_PROPERTY_MISSING = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.missingSentinelOneAgentId',
{
defaultMessage: 'Event data missing SentinelOne agent identifier ({field})',
values: {
field: SENTINEL_ONE_AGENT_ID_FIELD,
},
}
);
export const CROWDSTRIKE_AGENT_ID_PROPERTY_MISSING = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.missingCrowdstrikeAgentId',
{
defaultMessage: 'Event data missing Crowdstrike agent identifier ({field})',
values: {
field: CROWDSTRIKE_AGENT_ID_FIELD,
},
}
);

View file

@ -0,0 +1,283 @@
/*
* 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 type {
UseWithResponderActionDataFromAlertProps,
ResponderActionData,
UseResponderActionDataProps,
} from './use_responder_action_data';
import {
useResponderActionData,
useWithResponderActionDataFromAlert,
} from './use_responder_action_data';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import {
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
LOADING_ENDPOINT_DATA_TOOLTIP,
METADATA_API_ERROR_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from './translations';
import type { AppContextTestRender } from '../../../../mock/endpoint';
import { createAppRootMockRenderer, endpointAlertDataMock } from '../../../../mock/endpoint';
import { HOST_METADATA_LIST_ROUTE } from '../../../../../../common/endpoint/constants';
import { endpointMetadataHttpMocks } from '../../../../../management/pages/endpoint_hosts/mocks';
import type { RenderHookResult } from '@testing-library/react-hooks/src/types';
import { createHttpFetchError } from '@kbn/core-http-browser-mocks';
import { HostStatus } from '../../../../../../common/endpoint/types';
import {
RESPONSE_ACTION_AGENT_TYPE,
RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD,
} from '../../../../../../common/endpoint/service/response_actions/constants';
import { getAgentTypeName } from '../../../../translations';
import { ALERT_EVENT_DATA_MISSING_AGENT_ID_FIELD } from '../../../../hooks/endpoint/use_alert_response_actions_support';
describe('use responder action data hooks', () => {
let appContextMock: AppContextTestRender;
let onClickMock: UseWithResponderActionDataFromAlertProps['onClick'];
const getExpectedResponderActionData = (
overrides: Partial<ResponderActionData> = {}
): ResponderActionData => {
return {
isDisabled: false,
tooltip: undefined,
handleResponseActionsClick: expect.any(Function),
...overrides,
};
};
beforeEach(() => {
appContextMock = createAppRootMockRenderer();
onClickMock = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('useWithResponderActionDataFromAlert() hook', () => {
let renderHook: () => RenderHookResult<
UseWithResponderActionDataFromAlertProps,
ResponderActionData
>;
let alertDetailItemData: TimelineEventsDetailsItem[];
beforeEach(() => {
renderHook = () => {
return appContextMock.renderHook<
UseWithResponderActionDataFromAlertProps,
ResponderActionData
>(() =>
useWithResponderActionDataFromAlert({
eventData: alertDetailItemData,
onClick: onClickMock,
})
);
};
});
describe('Common behaviours', () => {
it('should show action as disabled if agent does not support response actions', () => {
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo');
expect(renderHook().result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: NOT_FROM_ENDPOINT_HOST_TOOLTIP,
})
);
});
it('should call `onClick()` function prop when is pass to the hook', () => {
alertDetailItemData = endpointAlertDataMock.generateSentinelOneAlertDetailsItemData();
const { result } = renderHook();
result.current.handleResponseActionsClick();
expect(onClickMock).toHaveBeenCalled();
});
it('should NOT call `onClick` if the action is disabled', () => {
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo');
const { result } = renderHook();
result.current.handleResponseActionsClick();
expect(onClickMock).not.toHaveBeenCalled();
});
});
describe('and agentType is NOT Endpoint', () => {
beforeEach(() => {
alertDetailItemData = endpointAlertDataMock.generateSentinelOneAlertDetailsItemData();
});
it('should show action when agentType is supported', () => {
expect(renderHook().result.current).toEqual(getExpectedResponderActionData());
});
it('should NOT call the endpoint host metadata api', () => {
renderHook();
const wasMetadataApiCalled = appContextMock.coreStart.http.get.mock.calls.some(([path]) => {
return (path as unknown as string).includes(HOST_METADATA_LIST_ROUTE);
});
expect(wasMetadataApiCalled).toBe(false);
});
it.each([...RESPONSE_ACTION_AGENT_TYPE])(
'should show action disabled with tooltip for %s if agent id field is missing',
(agentType) => {
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(
agentType,
{
[RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD[agentType]]: undefined,
}
);
expect(renderHook().result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: ALERT_EVENT_DATA_MISSING_AGENT_ID_FIELD(
getAgentTypeName(agentType),
RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD[agentType]
),
})
);
}
);
});
describe('and agentType IS Endpoint', () => {
let metadataApiMocks: ReturnType<typeof endpointMetadataHttpMocks>;
beforeEach(() => {
alertDetailItemData = endpointAlertDataMock.generateEndpointAlertDetailsItemData();
metadataApiMocks = endpointMetadataHttpMocks(appContextMock.coreStart.http);
});
it('should show action disabled with tooltip while retrieving host metadata', () => {
expect(renderHook().result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: LOADING_ENDPOINT_DATA_TOOLTIP,
})
);
});
it('should show action enabled if host metadata was retrieved and host is enrolled', async () => {
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current.isDisabled);
expect(result.current).toEqual(getExpectedResponderActionData());
});
it('should show action disabled if host was not found', async () => {
metadataApiMocks.responseProvider.metadataDetails.mockImplementation(() => {
throw createHttpFetchError('Not found', undefined, undefined, undefined, {
statusCode: 404,
});
});
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current.tooltip);
expect(result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: NOT_FROM_ENDPOINT_HOST_TOOLTIP,
})
);
});
it('should show action as disabled with tooltip when host is found, but has a status of unenrolled', async () => {
const hostMetadata = {
...metadataApiMocks.responseProvider.metadataDetails(),
host_status: HostStatus.UNENROLLED,
};
metadataApiMocks.responseProvider.metadataDetails.mockReturnValue(hostMetadata);
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current.tooltip);
expect(result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: HOST_ENDPOINT_UNENROLLED_TOOLTIP,
})
);
});
it('should show action disabled if a metadata API error was encountered', async () => {
metadataApiMocks.responseProvider.metadataDetails.mockImplementation(() => {
throw createHttpFetchError('Server error', undefined, undefined, undefined, {
statusCode: 500,
});
});
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current.tooltip);
expect(result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: METADATA_API_ERROR_TOOLTIP,
})
);
});
});
});
describe('useResponderActionData() hook', () => {
let hookProps: UseResponderActionDataProps;
let renderHook: () => RenderHookResult<UseResponderActionDataProps, ResponderActionData>;
beforeEach(() => {
endpointMetadataHttpMocks(appContextMock.coreStart.http);
hookProps = {
agentId: 'agent-123',
agentType: 'endpoint',
onClick: onClickMock,
};
renderHook = () => {
return appContextMock.renderHook<UseResponderActionDataProps, ResponderActionData>(() =>
useResponderActionData(hookProps)
);
};
});
it('should show action enabled when agentType is Endpoint and host is enabled', async () => {
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current.isDisabled);
expect(result.current).toEqual(getExpectedResponderActionData());
});
it('should show action disabled if agent type is not Endpoint', () => {
hookProps.agentType = 'crowdstrike';
expect(renderHook().result.current).toEqual(
getExpectedResponderActionData({
isDisabled: true,
tooltip: NOT_FROM_ENDPOINT_HOST_TOOLTIP,
})
);
});
it('should call `onClick` prop when action is enabled', async () => {
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current.isDisabled);
result.current.handleResponseActionsClick();
expect(onClickMock).toHaveBeenCalled();
});
it('should not call `onCLick` prop when action is disabled', () => {
hookProps.agentType = 'sentinel_one';
const { result } = renderHook();
result.current.handleResponseActionsClick();
expect(onClickMock).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,271 @@
/*
* 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 type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useAlertResponseActionsSupport } from '../../../../hooks/endpoint/use_alert_response_actions_support';
import type {
EndpointCapabilities,
ResponseActionAgentType,
} from '../../../../../../common/endpoint/service/response_actions/constants';
import { useGetEndpointDetails, useWithShowResponder } from '../../../../../management/hooks';
import { HostStatus } from '../../../../../../common/endpoint/types';
import {
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
LOADING_ENDPOINT_DATA_TOOLTIP,
METADATA_API_ERROR_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from './translations';
export interface UseWithResponderActionDataFromAlertProps {
eventData: TimelineEventsDetailsItem[] | null;
onClick?: () => void;
}
export interface ResponderActionData {
handleResponseActionsClick: () => void;
isDisabled: boolean;
tooltip: ReactNode;
}
/**
* This hook is used to get the data needed to show the context menu items for the responder
* actions using Alert data.
*
* NOTE: If wanting to get teh same type of response but don't have Alert
* data, use `useResponderActionData()` instead
*
* @param onClick the callback to handle the click event
* @param eventData the event data, exists only when agentType !== 'endpoint'
* @returns an object with the data needed to show the context menu item
*/
export const useWithResponderActionDataFromAlert = ({
eventData = [],
onClick,
}: UseWithResponderActionDataFromAlertProps): ResponderActionData => {
const {
isSupported: hostSupportsResponseActions,
unsupportedReason,
details: { agentType, agentId, platform, hostName },
} = useAlertResponseActionsSupport(eventData);
const isEndpointHost = agentType === 'endpoint';
const endpointHostData = useResponderDataForEndpointHost(
agentId,
hostSupportsResponseActions && isEndpointHost
);
const showResponseActionsConsole = useWithShowResponder();
const [isDisabled, tooltip]: [disabled: boolean, tooltip: ReactNode] = useMemo(() => {
if (!hostSupportsResponseActions) {
return [
true,
agentType && unsupportedReason ? unsupportedReason : NOT_FROM_ENDPOINT_HOST_TOOLTIP,
];
}
if (isEndpointHost) {
return [endpointHostData.isDisabled, endpointHostData.tooltip];
}
return [false, undefined];
}, [
hostSupportsResponseActions,
isEndpointHost,
agentType,
unsupportedReason,
endpointHostData.isDisabled,
endpointHostData.tooltip,
]);
const handleResponseActionsClick = useCallback(() => {
if (!isDisabled) {
showResponseActionsConsole({
agentId,
agentType,
hostName,
platform,
capabilities: isEndpointHost ? endpointHostData.capabilities : [],
});
if (onClick) {
onClick();
}
}
}, [
isDisabled,
showResponseActionsConsole,
agentId,
agentType,
hostName,
platform,
isEndpointHost,
endpointHostData.capabilities,
onClick,
]);
return {
handleResponseActionsClick,
isDisabled,
tooltip,
};
};
type ResponderDataForEndpointHost = Omit<ResponderActionData, 'handleResponseActionsClick'> & {
capabilities: EndpointCapabilities[];
hostName: string;
platform: string;
};
/**
* Hook to specifically for the responder data for Elastic Defend endpoints
* @param endpointAgentId
* @param enabled
*
* @private
*/
const useResponderDataForEndpointHost = (
endpointAgentId: string,
enabled: boolean = true
): ResponderDataForEndpointHost => {
// FIXME:PT is this the correct API to call? or should we call the agent status api instead
const {
data: endpointHostInfo,
isFetching,
error,
} = useGetEndpointDetails(endpointAgentId, {
enabled,
});
return useMemo<ResponderDataForEndpointHost>(() => {
const response: ResponderDataForEndpointHost = {
isDisabled: false,
tooltip: undefined,
capabilities: [],
hostName: '',
platform: '',
};
if (!enabled) {
response.isDisabled = true;
return response;
}
if (isFetching) {
response.isDisabled = true;
response.tooltip = LOADING_ENDPOINT_DATA_TOOLTIP;
return response;
}
// if we got an error, and it's a 404, it means the endpoint is not from the endpoint host
if (error && error.body?.statusCode === 404) {
response.isDisabled = true;
response.tooltip = NOT_FROM_ENDPOINT_HOST_TOOLTIP;
return response;
}
// if we got an error and,
// it's a 400 with unenrolled in the error message (alerts can exist for endpoint that are no longer around)
// or,
// the Host status is `unenrolled`
if (
(error && error.body?.statusCode === 400 && error.body?.message.includes('unenrolled')) ||
endpointHostInfo?.host_status === HostStatus.UNENROLLED
) {
response.isDisabled = true;
response.tooltip = HOST_ENDPOINT_UNENROLLED_TOOLTIP;
return response;
}
// return general error tooltip
if (error) {
response.isDisabled = true;
response.tooltip = METADATA_API_ERROR_TOOLTIP;
}
response.capabilities = (endpointHostInfo?.metadata.Endpoint.capabilities ??
[]) as EndpointCapabilities[];
response.hostName = endpointHostInfo?.metadata.host.name ?? '';
response.platform = endpointHostInfo?.metadata.host.os.name.toLowerCase() ?? '';
return response;
}, [
enabled,
isFetching,
error,
endpointHostInfo?.host_status,
endpointHostInfo?.metadata.Endpoint.capabilities,
endpointHostInfo?.metadata.host.name,
endpointHostInfo?.metadata.host.os.name,
]);
};
export interface UseResponderActionDataProps {
agentId: string;
agentType: ResponseActionAgentType;
onClick?: () => void;
}
/**
* Returns the data necessary to render a Responder action item (ex. menu item) when only the
* `agentId` and `agentType` is available (ex. when showing the `Respond` button on the Host
* details page of SIEM
* @param onClick
* @param agentId
* @param agentType
*/
export const useResponderActionData = ({
onClick,
agentId,
agentType,
}: UseResponderActionDataProps): ResponderActionData => {
const isEndpointHost = agentType === 'endpoint';
const showResponseActionsConsole = useWithShowResponder();
const { tooltip, isDisabled, capabilities, hostName, platform } = useResponderDataForEndpointHost(
agentId,
isEndpointHost
);
// TODO:PT add support for other agent types once we add the `Respond` button to the Host details page in SIEM
const handleResponseActionsClick = useCallback(() => {
if (!isDisabled) {
showResponseActionsConsole({
agentId,
agentType,
hostName,
platform,
capabilities: isEndpointHost ? capabilities : [],
});
if (onClick) {
onClick();
}
}
}, [
isDisabled,
showResponseActionsConsole,
agentId,
agentType,
hostName,
platform,
isEndpointHost,
capabilities,
onClick,
]);
return useMemo(() => {
return {
isDisabled: isEndpointHost ? isDisabled : true,
tooltip: isEndpointHost ? tooltip : NOT_FROM_ENDPOINT_HOST_TOOLTIP,
handleResponseActionsClick,
};
}, [handleResponseActionsClick, isDisabled, isEndpointHost, tooltip]);
};

View file

@ -0,0 +1,61 @@
/*
* 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 { useResponderActionItem } from './use_responder_action_item';
import { useUserPrivileges as _useUserPrivileges } from '../../../user_privileges';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { AppContextTestRender } from '../../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../../mock/endpoint';
import { endpointAlertDataMock } from '../../../../mock/endpoint/endpoint_alert_data_mock';
jest.mock('../../../user_privileges');
jest.mock('./use_responder_action_data');
const useUserPrivilegesMock = _useUserPrivileges as jest.Mock;
describe('useResponderActionItem', () => {
let alertDetailItemData: TimelineEventsDetailsItem[];
let renderHook: () => ReturnType<AppContextTestRender['renderHook']>;
beforeEach(() => {
const appContextMock = createAppRootMockRenderer();
// This is on purpose - an alert for an unsupported agent type. The menu item should always be
// visible as long as the user has authz to it. In this case it will be disabled.
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo');
renderHook = () =>
appContextMock.renderHook(() => useResponderActionItem(alertDetailItemData, () => {}));
useUserPrivilegesMock.mockReturnValue({
endpointPrivileges: { loading: false, canAccessResponseConsole: true },
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return Respond action menu item if user has Authz', () => {
expect(renderHook().result.current).toHaveLength(1);
});
it('should NOT return the Respond action menu item if user is not Authorized', () => {
useUserPrivilegesMock.mockReturnValue({
endpointPrivileges: { loading: false, canAccessResponseConsole: false },
});
expect(renderHook().result.current).toHaveLength(0);
});
it('should NOT return the Respond action menu item for Events', () => {
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo', {
'kibana.alert.rule.uuid': undefined,
});
expect(renderHook().result.current).toHaveLength(0);
});
});

View file

@ -8,14 +8,10 @@
import React, { useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { isAlertFromCrowdstrikeEvent } from '../../../common/utils/crowdstrike_alert_check';
import { isAlertFromSentinelOneEvent } from '../../../common/utils/sentinelone_alert_check';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { isTimelineEventItemAnAlert } from '../../../common/utils/endpoint_alert_check';
import { getFieldValue } from '../host_isolation/helpers';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { useResponderActionData } from './use_responder_action_data';
import { useAlertResponseActionsSupport } from '../../../../hooks/endpoint/use_alert_response_actions_support';
import { useUserPrivileges } from '../../../user_privileges';
import type { AlertTableContextMenuItem } from '../../../../../detections/components/alerts_table/types';
import { useWithResponderActionDataFromAlert } from './use_responder_action_data';
export const useResponderActionItem = (
eventDetailsData: TimelineEventsDetailsItem[] | null,
@ -23,36 +19,10 @@ export const useResponderActionItem = (
): AlertTableContextMenuItem[] => {
const { loading: isAuthzLoading, canAccessResponseConsole } =
useUserPrivileges().endpointPrivileges;
const isAlert = useMemo(() => {
return isTimelineEventItemAnAlert(eventDetailsData || []);
}, [eventDetailsData]);
const endpointId: string = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, eventDetailsData),
[eventDetailsData]
);
const agentType: ResponseActionAgentType = useMemo(() => {
if (!eventDetailsData) {
return 'endpoint';
}
if (isAlertFromSentinelOneEvent({ data: eventDetailsData })) {
return 'sentinel_one';
}
if (isAlertFromCrowdstrikeEvent({ data: eventDetailsData })) {
return 'crowdstrike';
}
return 'endpoint';
}, [eventDetailsData]);
const { handleResponseActionsClick, isDisabled, tooltip } = useResponderActionData({
endpointId,
const { isAlert } = useAlertResponseActionsSupport(eventDetailsData);
const { handleResponseActionsClick, isDisabled, tooltip } = useWithResponderActionDataFromAlert({
onClick,
agentType,
eventData: agentType !== 'endpoint' ? eventDetailsData : null,
eventData: eventDetailsData,
});
return useMemo(() => {

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './from_alerts';

View file

@ -5,73 +5,41 @@
* 2.0.
*/
import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check';
import {
isAlertFromSentinelOneEvent,
SENTINEL_ONE_AGENT_ID_FIELD,
} from '../../utils/sentinelone_alert_check';
import {
CROWDSTRIKE_AGENT_ID_FIELD,
isAlertFromCrowdstrikeEvent,
} from '../../utils/crowdstrike_alert_check';
import { renderHook } from '@testing-library/react-hooks';
import type { UseSummaryRowsProps } from './get_alert_summary_rows';
import { useSummaryRows } from './get_alert_summary_rows';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
jest.mock('../../utils/endpoint_alert_check');
jest.mock('../../utils/sentinelone_alert_check');
jest.mock('../../utils/crowdstrike_alert_check');
jest.mock('../../hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn(),
}));
import { createAppRootMockRenderer, endpointAlertDataMock } from '../../mock/endpoint';
import type { RenderHookResult } from '@testing-library/react-hooks/src/types';
import type { AlertSummaryRow } from './helpers';
describe('useSummaryRows', () => {
const mockData: TimelineEventsDetailsItem[] = [
{
category: 'event',
field: 'event.category',
originalValue: ['process'],
values: ['process'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
originalValue: 'rule-uuid',
values: ['rule-uuid'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.name',
originalValue: 'test-host',
values: ['text-host'],
isObjectArray: false,
},
];
const mockBrowserFields = {};
const mockScopeId = 'scope-id';
const mockEventId = 'event-id';
const mockInvestigationFields: string[] = [];
let hookProps: UseSummaryRowsProps;
let renderHook: () => RenderHookResult<UseSummaryRowsProps, AlertSummaryRow[]>;
beforeEach(() => {
jest.clearAllMocks();
(isAlertFromEndpointEvent as jest.Mock).mockReturnValue(true);
(isAlertFromCrowdstrikeEvent as jest.Mock).mockReturnValue(false);
const appContextMock = createAppRootMockRenderer();
appContextMock.setExperimentalFlag({
responseActionsSentinelOneV1Enabled: true,
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
});
hookProps = {
data: endpointAlertDataMock.generateEndpointAlertDetailsItemData(),
browserFields: {},
scopeId: 'scope-id',
eventId: 'event-id',
investigationFields: [],
};
renderHook = () => {
return appContextMock.renderHook<UseSummaryRowsProps, AlertSummaryRow[]>(() =>
useSummaryRows(hookProps)
);
};
});
it('returns summary rows for default event categories', () => {
const { result } = renderHook(() =>
useSummaryRows({
data: mockData,
browserFields: mockBrowserFields,
scopeId: mockScopeId,
eventId: mockEventId,
investigationFields: mockInvestigationFields,
})
);
const { result } = renderHook();
expect(result.current).toEqual(
expect.arrayContaining([
@ -81,17 +49,7 @@ describe('useSummaryRows', () => {
});
it('excludes fields not related to the event source', () => {
(isAlertFromEndpointEvent as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() =>
useSummaryRows({
data: mockData,
browserFields: mockBrowserFields,
scopeId: mockScopeId,
eventId: mockEventId,
investigationFields: mockInvestigationFields,
})
);
const { result } = renderHook();
expect(result.current).not.toEqual(
expect.arrayContaining([
@ -104,70 +62,32 @@ describe('useSummaryRows', () => {
});
it('includes sentinel_one agent status field', () => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
(isAlertFromSentinelOneEvent as jest.Mock).mockReturnValue(true);
const sentinelOneData: TimelineEventsDetailsItem[] = [
...mockData,
{
category: 'host',
field: SENTINEL_ONE_AGENT_ID_FIELD,
originalValue: 'sentinelone-agent-id',
values: ['sentinelone-agent-id'],
isObjectArray: false,
},
];
const { result } = renderHook(() =>
useSummaryRows({
data: sentinelOneData,
browserFields: mockBrowserFields,
scopeId: mockScopeId,
eventId: mockEventId,
investigationFields: mockInvestigationFields,
})
);
hookProps.data = endpointAlertDataMock.generateSentinelOneAlertDetailsItemData();
const { result } = renderHook();
expect(result.current).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Agent status',
description: expect.objectContaining({ values: ['sentinelone-agent-id'] }),
description: expect.objectContaining({
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
}),
}),
])
);
});
it('includes crowdstrike agent status field', () => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
(isAlertFromCrowdstrikeEvent as jest.Mock).mockReturnValue(true);
const crowdstrikeData: TimelineEventsDetailsItem[] = [
...mockData,
{
category: 'host',
field: CROWDSTRIKE_AGENT_ID_FIELD,
originalValue: 'crowdstrike-agent-id',
values: ['crowdstrike-agent-id'],
isObjectArray: false,
},
];
const { result } = renderHook(() =>
useSummaryRows({
data: crowdstrikeData,
browserFields: mockBrowserFields,
scopeId: mockScopeId,
eventId: mockEventId,
investigationFields: mockInvestigationFields,
})
);
hookProps.data = endpointAlertDataMock.generateCrowdStrikeAlertDetailsItemData();
const { result } = renderHook();
expect(result.current).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Agent status',
description: expect.objectContaining({ values: ['crowdstrike-agent-id'] }),
description: expect.objectContaining({
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
}),
}),
])
);

View file

@ -9,6 +9,9 @@ import { find, isEmpty, uniqBy } from 'lodash/fp';
import { ALERT_RULE_PARAMETERS, ALERT_RULE_TYPE } from '@kbn/rule-data-utils';
import { EventCode, EventCategory } from '@kbn/securitysolution-ecs';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../common/endpoint/service/response_actions/constants';
import { isResponseActionsAlertAgentIdField } from '../../lib/endpoint';
import { useAlertResponseActionsSupport } from '../../hooks/endpoint/use_alert_response_actions_support';
import * as i18n from './translations';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import {
@ -33,17 +36,6 @@ import { getEnrichedFieldInfo } from './helpers';
import type { EventSummaryField, EnrichedFieldInfo } from './types';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check';
import {
SENTINEL_ONE_AGENT_ID_FIELD,
isAlertFromSentinelOneEvent,
} from '../../utils/sentinelone_alert_check';
import {
CROWDSTRIKE_AGENT_ID_FIELD,
isAlertFromCrowdstrikeEvent,
} from '../../utils/crowdstrike_alert_check';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
const THRESHOLD_TERMS_FIELD = `${ALERT_THRESHOLD_RESULT}.terms.field`;
const THRESHOLD_TERMS_VALUE = `${ALERT_THRESHOLD_RESULT}.terms.value`;
const THRESHOLD_CARDINALITY_FIELD = `${ALERT_THRESHOLD_RESULT}.cardinality.field`;
@ -56,12 +48,12 @@ const alwaysDisplayedFields: EventSummaryField[] = [
// ENDPOINT-related field //
{ id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS },
{
id: SENTINEL_ONE_AGENT_ID_FIELD,
id: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one,
overrideField: AGENT_STATUS_FIELD_NAME,
label: i18n.AGENT_STATUS,
},
{
id: CROWDSTRIKE_AGENT_ID_FIELD,
id: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike,
overrideField: AGENT_STATUS_FIELD_NAME,
label: i18n.AGENT_STATUS,
},
@ -307,6 +299,16 @@ export function getEventCategoriesFromData(data: TimelineEventsDetailsItem[]): E
return { primaryEventCategory, allEventCategories };
}
export interface UseSummaryRowsProps {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
scopeId: string;
eventId: string;
investigationFields?: string[];
isDraggable?: boolean;
isReadOnly?: boolean;
}
export const useSummaryRows = ({
data,
browserFields,
@ -315,21 +317,9 @@ export const useSummaryRows = ({
isDraggable = false,
isReadOnly = false,
investigationFields,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
scopeId: string;
eventId: string;
investigationFields?: string[];
isDraggable?: boolean;
isReadOnly?: boolean;
}) => {
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const crowdstrikeManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
}: UseSummaryRowsProps): AlertSummaryRow[] => {
const responseActionsSupport = useAlertResponseActionsSupport(data);
return useMemo(() => {
const eventCategories = getEventCategoriesFromData(data);
@ -381,22 +371,14 @@ export const useSummaryRows = ({
isReadOnly,
};
if (field.id === 'agent.id' && !isAlertFromEndpointEvent({ data })) {
return acc;
}
// If the field is one used by a supported Response Actions agentType,
// and the alert's host supports response actions
// but the alert field is not the one that the agentType on the alert host uses,
// then exit and return accumulator
if (
field.id === SENTINEL_ONE_AGENT_ID_FIELD &&
sentinelOneManualHostActionsEnabled &&
!isAlertFromSentinelOneEvent({ data })
) {
return acc;
}
if (
field.id === CROWDSTRIKE_AGENT_ID_FIELD &&
crowdstrikeManualHostActionsEnabled &&
!isAlertFromCrowdstrikeEvent({ data })
isResponseActionsAlertAgentIdField(field.id) &&
responseActionsSupport.isSupported &&
responseActionsSupport.details.agentIdField !== field.id
) {
return acc;
}
@ -429,15 +411,15 @@ export const useSummaryRows = ({
}, [])
: [];
}, [
browserFields,
data,
investigationFields,
scopeId,
browserFields,
eventId,
isDraggable,
scopeId,
isReadOnly,
investigationFields,
sentinelOneManualHostActionsEnabled,
crowdstrikeManualHostActionsEnabled,
responseActionsSupport.details.agentIdField,
responseActionsSupport.isSupported,
]);
};

View file

@ -23,8 +23,7 @@ import {
AGENT_STATUS_FIELD_NAME,
QUARANTINED_PATH_FIELD_NAME,
} from '../../../timelines/components/timeline/body/renderers/constants';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../utils/sentinelone_alert_check';
import { CROWDSTRIKE_AGENT_ID_FIELD } from '../../utils/crowdstrike_alert_check';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../common/endpoint/service/response_actions/constants';
/**
* Defines the behavior of the search input that appears above the table of data
@ -183,8 +182,8 @@ export function getEnrichedFieldInfo({
export const FIELDS_WITHOUT_ACTIONS: { [field: string]: boolean } = {
[AGENT_STATUS_FIELD_NAME]: true,
[QUARANTINED_PATH_FIELD_NAME]: true,
[SENTINEL_ONE_AGENT_ID_FIELD]: true,
[CROWDSTRIKE_AGENT_ID_FIELD]: true,
[RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one]: true,
[RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike]: true,
};
/**

View file

@ -0,0 +1,35 @@
/*
* 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 type { AlertResponseActionsSupport } from '../use_alert_response_actions_support';
import {
RESPONSE_ACTION_API_COMMANDS_NAMES,
RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD,
} from '../../../../../common/endpoint/service/response_actions/constants';
const useAlertResponseActionsSupportMock = (): AlertResponseActionsSupport => {
return {
isSupported: true,
unsupportedReason: undefined,
isAlert: true,
details: {
agentId: '123',
agentType: 'endpoint',
agentIdField: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.endpoint,
hostName: 'host-a',
platform: 'linux',
agentSupport: RESPONSE_ACTION_API_COMMANDS_NAMES.reduce<
AlertResponseActionsSupport['details']['agentSupport']
>((acc, responseActionName) => {
acc[responseActionName] = true;
return acc;
}, {} as AlertResponseActionsSupport['details']['agentSupport']),
},
};
};
export { useAlertResponseActionsSupportMock as useAlertResponseActionsSupport };

View file

@ -0,0 +1,207 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { AppContextTestRender } from '../../mock/endpoint';
import { createAppRootMockRenderer, endpointAlertDataMock } from '../../mock/endpoint';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import {
RESPONSE_ACTION_AGENT_TYPE,
RESPONSE_ACTION_API_COMMANDS_NAMES,
RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD,
} from '../../../../common/endpoint/service/response_actions/constants';
import type { AlertResponseActionsSupport } from './use_alert_response_actions_support';
import {
ALERT_EVENT_DATA_MISSING_AGENT_ID_FIELD,
RESPONSE_ACTIONS_ONLY_SUPPORTED_ON_ALERTS,
useAlertResponseActionsSupport,
} from './use_alert_response_actions_support';
import { isAgentTypeAndActionSupported } from '../../lib/endpoint';
import type { DeepPartial } from 'utility-types';
import { merge } from 'lodash';
describe('When using `useAlertResponseActionsSupport()` hook', () => {
let appContextMock: AppContextTestRender;
let alertDetailItemData: TimelineEventsDetailsItem[];
let renderHook: () => ReturnType<AppContextTestRender['renderHook']>;
const getExpectedResult = (
overrides: DeepPartial<AlertResponseActionsSupport> = {},
options: Partial<{
/* If true, then all properties in `agentSupport` will be false */
noAgentSupport: boolean;
}> = {}
): AlertResponseActionsSupport => {
const agentType = overrides.details?.agentType ?? 'endpoint';
return merge(
{
isAlert: true,
isSupported: true,
unsupportedReason: undefined,
details: {
agentId: 'abfe4a35-d5b4-42a0-a539-bd054c791769',
agentIdField: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD[agentType],
agentSupport: RESPONSE_ACTION_API_COMMANDS_NAMES.reduce((acc, commandName) => {
acc[commandName] = options.noAgentSupport
? false
: isAgentTypeAndActionSupported(agentType, commandName);
return acc;
}, {} as AlertResponseActionsSupport['details']['agentSupport']),
agentType,
hostName: 'elastic-host-win',
platform: 'windows',
},
},
overrides
);
};
beforeEach(() => {
appContextMock = createAppRootMockRenderer();
// Enable feature flags by default
appContextMock.setExperimentalFlag({
responseActionsSentinelOneV1Enabled: true,
responseActionsSentinelOneGetFileEnabled: true,
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
});
alertDetailItemData = endpointAlertDataMock.generateEndpointAlertDetailsItemData();
renderHook = () =>
appContextMock.renderHook(() => useAlertResponseActionsSupport(alertDetailItemData));
});
it.each(RESPONSE_ACTION_AGENT_TYPE)(
'should return expected response for agentType: `%s`',
(agentType) => {
alertDetailItemData =
endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType);
const { result } = renderHook();
expect(result.current).toEqual(getExpectedResult({ details: { agentType } }));
}
);
it('should set `isSupported` to `false` if no alert details item data is provided', () => {
alertDetailItemData = [];
expect(renderHook().result.current).toEqual(
getExpectedResult(
{
isAlert: false,
isSupported: false,
unsupportedReason: RESPONSE_ACTIONS_ONLY_SUPPORTED_ON_ALERTS,
details: {
agentId: '',
agentIdField: '',
hostName: '',
platform: '',
},
},
{ noAgentSupport: true }
)
);
});
it('should set `isSupported` to `false` for if not an Alert', () => {
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(
'sentinel_one',
{ 'kibana.alert.rule.uuid': undefined }
);
expect(renderHook().result.current).toEqual(
getExpectedResult({
isAlert: false,
isSupported: false,
unsupportedReason: RESPONSE_ACTIONS_ONLY_SUPPORTED_ON_ALERTS,
details: {
agentType: 'sentinel_one',
agentIdField: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one,
},
})
);
});
it('should set `isSupported` to `false` if unable to get agent id', () => {
alertDetailItemData = endpointAlertDataMock.generateEndpointAlertDetailsItemData({
[RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.endpoint]: undefined,
});
expect(renderHook().result.current).toEqual(
getExpectedResult({
isSupported: false,
unsupportedReason: ALERT_EVENT_DATA_MISSING_AGENT_ID_FIELD('Elastic Defend', 'agent.id'),
details: { agentId: '' },
})
);
});
it('should set `isSupported` to `false` if unable to determine agent type', () => {
alertDetailItemData = endpointAlertDataMock.generateCrowdStrikeAlertDetailsItemData({
'event.module': undefined,
});
expect(renderHook().result.current).toEqual(
getExpectedResult(
{
isSupported: false,
details: {
agentId: '',
agentIdField: '',
},
},
{ noAgentSupport: true }
)
);
});
it('should default `details.agentType` to `endpoint` for non-supported hosts', () => {
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo');
expect(renderHook().result.current).toEqual(
getExpectedResult(
{
isSupported: false,
details: {
agentId: '',
agentIdField: '',
},
},
{ noAgentSupport: true }
)
);
});
it.each(
RESPONSE_ACTION_AGENT_TYPE.filter((agentType) => agentType !== 'endpoint') as Array<
Exclude<ResponseActionAgentType, 'endpoint'>
>
)('should set `isSupported` to `false` for [%s] if feature flag is disabled', (agentType) => {
switch (agentType) {
case 'sentinel_one':
appContextMock.setExperimentalFlag({ responseActionsSentinelOneV1Enabled: false });
break;
case 'crowdstrike':
appContextMock.setExperimentalFlag({
responseActionsCrowdstrikeManualHostIsolationEnabled: false,
});
break;
default:
throw new Error(`Unknown agent type: ${agentType}`);
}
alertDetailItemData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType);
expect(renderHook().result.current).toEqual(
getExpectedResult({
isSupported: false,
details: { agentType },
})
);
});
});

View file

@ -0,0 +1,241 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useMemo } from 'react';
import { find, some } from 'lodash/fp';
import { i18n } from '@kbn/i18n';
import { getAlertDetailsFieldValue } from '../../lib/endpoint/utils/get_event_details_field_values';
import { isAgentTypeAndActionSupported } from '../../lib/endpoint';
import type {
ResponseActionAgentType,
ResponseActionsApiCommandNames,
} from '../../../../common/endpoint/service/response_actions/constants';
import {
RESPONSE_ACTION_API_COMMANDS_NAMES,
RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD,
} from '../../../../common/endpoint/service/response_actions/constants';
import { getAgentTypeName } from '../../translations';
export const ALERT_EVENT_DATA_MISSING_AGENT_ID_FIELD = (
agentTypeName: string,
missingField: string
): string => {
return i18n.translate(
'xpack.securitySolution.useAlertResponseActionsSupport.missingAgentIdField',
{
defaultMessage:
'Alert event data missing {agentTypeName} agent identifier field ({missingField})',
values: {
missingField,
agentTypeName,
},
}
);
};
export const RESPONSE_ACTIONS_ONLY_SUPPORTED_ON_ALERTS = i18n.translate(
'xpack.securitySolution.useAlertResponseActionsSupport.notAnAlert',
{ defaultMessage: 'Response actions are only supported for Alerts (not events)' }
);
export interface AlertResponseActionsSupport {
/** Does the host/agent for the given alert have support for response actions */
isSupported: boolean;
/** A i18n'd string value indicating the reason why the host does is unsupported */
unsupportedReason: string | undefined;
/**
* If the Event Data provide was for a SIEM alert (generated as a result of a Rule run) or
* just an event.
*/
isAlert: boolean;
/**
* Full details around support for response actions.
* NOTE That some data may not be blank if `isSupported` is `false`
*/
details: {
/** Defaults to `endpoint` when unable to determine agent type */
agentType: ResponseActionAgentType;
/** Agent ID could be an empty string if `isSupported` is `false` */
agentId: string;
/** Host name could be an empty string if `isSupported` is `false` */
hostName: string;
/** The OS platform - normally the ECS value from `host.os.family. could be an empty string if `isSupported` is `false` */
platform: string;
/**
* A map with the response actions supported by this alert's agent type. This is only what is
* supported, not what the user has privileges to execute.
*/
agentSupport: AlertAgentActionsSupported;
/** The field that was/is used to store the agent ID in the ES document */
agentIdField: string;
};
}
type AlertAgentActionsSupported = Record<ResponseActionsApiCommandNames, boolean>;
/**
* Determines the level of support that an alert's host has for Response Actions.
* This hook already checks feature flags to determine the level of support that we have available
*/
export const useAlertResponseActionsSupport = (
eventData: TimelineEventsDetailsItem[] | null = []
): AlertResponseActionsSupport => {
const isAlert = useMemo(() => {
return some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, eventData);
}, [eventData]);
const agentType: ResponseActionAgentType | undefined = useMemo(() => {
if ((find({ field: 'agent.type' }, eventData)?.values ?? []).includes('endpoint')) {
return 'endpoint';
}
const eventModuleValues = find({ field: 'event.module' }, eventData)?.values ?? [];
if (eventModuleValues.includes('sentinel_one')) {
return 'sentinel_one';
}
if (eventModuleValues.includes('crowdstrike')) {
return 'crowdstrike';
}
return undefined;
}, [eventData]);
const isFeatureEnabled: boolean = useMemo(() => {
return agentType ? isAgentTypeAndActionSupported(agentType) : false;
}, [agentType]);
const agentId: string = useMemo(() => {
if (!agentType) {
return '';
}
if (agentType === 'endpoint') {
return getAlertDetailsFieldValue({ category: 'agent', field: 'agent.id' }, eventData);
}
if (agentType === 'sentinel_one') {
return getAlertDetailsFieldValue(
{ category: 'observer', field: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one },
eventData
);
}
if (agentType === 'crowdstrike') {
return getAlertDetailsFieldValue(
{ category: 'crowdstrike', field: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike },
eventData
);
}
return '';
}, [agentType, eventData]);
const doesHostSupportResponseActions = useMemo(() => {
return Boolean(isFeatureEnabled && isAlert && agentId && agentType);
}, [agentId, agentType, isAlert, isFeatureEnabled]);
const agentIdField = useMemo(() => {
if (agentType) {
return RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD[agentType];
}
return '';
}, [agentType]);
const supportedActions = useMemo(() => {
return RESPONSE_ACTION_API_COMMANDS_NAMES.reduce<AlertAgentActionsSupported>(
(acc, responseActionName) => {
acc[responseActionName] = false;
if (agentType && isFeatureEnabled) {
acc[responseActionName] = isAgentTypeAndActionSupported(
agentType,
responseActionName,
'manual'
);
}
return acc;
},
{} as AlertAgentActionsSupported
);
}, [agentType, isFeatureEnabled]);
const hostName = useMemo(() => {
// TODO:PT need to check if crowdstrike event has `host.name`
if (agentType === 'crowdstrike') {
return getAlertDetailsFieldValue(
{ category: 'crowdstrike', field: 'crowdstrike.event.HostName' },
eventData
);
}
return getAlertDetailsFieldValue({ category: 'host', field: 'host.name' }, eventData);
}, [agentType, eventData]);
const platform = useMemo(() => {
// TODO:PT need to check if crowdstrike event has `host.os.family`
if (agentType === 'crowdstrike') {
return getAlertDetailsFieldValue(
{ category: 'crowdstrike', field: 'crowdstrike.event.Platform' },
eventData
);
}
return getAlertDetailsFieldValue({ category: 'host', field: 'host.os.family' }, eventData);
}, [agentType, eventData]);
const unsupportedReason = useMemo(() => {
if (!doesHostSupportResponseActions) {
if (!isAlert) {
return RESPONSE_ACTIONS_ONLY_SUPPORTED_ON_ALERTS;
}
if (!agentType) {
// No message is provided for this condition because the
// return from this hook will always default to `endpoint`
return;
}
if (!agentId) {
return ALERT_EVENT_DATA_MISSING_AGENT_ID_FIELD(getAgentTypeName(agentType), agentIdField);
}
}
}, [agentId, agentIdField, agentType, doesHostSupportResponseActions, isAlert]);
return useMemo<AlertResponseActionsSupport>(() => {
return {
isSupported: doesHostSupportResponseActions,
unsupportedReason,
isAlert,
details: {
agentType: agentType || 'endpoint',
agentId,
hostName,
platform,
agentIdField,
agentSupport: supportedActions,
},
};
}, [
agentId,
agentIdField,
agentType,
doesHostSupportResponseActions,
hostName,
isAlert,
platform,
supportedActions,
unsupportedReason,
]);
};

View file

@ -5,16 +5,16 @@
* 2.0.
*/
import { KibanaServices } from '../kibana';
import { KibanaServices } from '../../kibana';
import { coreMock } from '@kbn/core/public/mocks';
import { isolateHost, unIsolateHost } from '.';
import { hostIsolationRequestBodyMock } from './mocks';
import {
ISOLATE_HOST_ROUTE_V2,
UNISOLATE_HOST_ROUTE_V2,
} from '../../../../common/endpoint/constants';
} from '../../../../../common/endpoint/constants';
jest.mock('../kibana');
jest.mock('../../kibana');
describe('When using Host Isolation library', () => {
const mockKibanaServices = KibanaServices.get as jest.Mock;

View file

@ -8,12 +8,14 @@
import type {
HostIsolationRequestBody,
ResponseActionApiResponse,
} from '../../../../common/endpoint/types';
import { KibanaServices } from '../kibana';
} from '../../../../../common/endpoint/types';
import { KibanaServices } from '../../kibana';
import {
ISOLATE_HOST_ROUTE_V2,
UNISOLATE_HOST_ROUTE_V2,
} from '../../../../common/endpoint/constants';
} from '../../../../../common/endpoint/constants';
// FIXME:PT refactor usage of these and use common hooks
/** Isolates a Host running either elastic endpoint or fleet agent */
export const isolateHost = async (

View file

@ -8,13 +8,13 @@
import type {
HostIsolationRequestBody,
HostIsolationResponse,
} from '../../../../common/endpoint/types';
import type { ResponseProvidersInterface } from '../../mock/endpoint/http_handler_mock_factory';
import { httpHandlerMockFactory } from '../../mock/endpoint/http_handler_mock_factory';
} from '../../../../../common/endpoint/types';
import type { ResponseProvidersInterface } from '../../../mock/endpoint/http_handler_mock_factory';
import { httpHandlerMockFactory } from '../../../mock/endpoint/http_handler_mock_factory';
import {
ISOLATE_HOST_ROUTE_V2,
UNISOLATE_HOST_ROUTE_V2,
} from '../../../../common/endpoint/constants';
} from '../../../../../common/endpoint/constants';
export const hostIsolationRequestBodyMock = (): HostIsolationRequestBody => {
return {

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import { KibanaServices } from '../kibana';
import { KibanaServices } from '../../kibana';
import { coreMock } from '@kbn/core/public/mocks';
import { fetchPendingActionsByAgentId } from './endpoint_pending_actions';
import { pendingActionsHttpMock, pendingActionsResponseMock } from './mocks';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import { ACTION_STATUS_ROUTE } from '../../../../../common/endpoint/constants';
jest.mock('../kibana');
jest.mock('../../kibana');
describe('when using endpoint pending actions api service', () => {
let coreHttp: ReturnType<typeof coreMock.createStart>['http'];

View file

@ -8,9 +8,11 @@
import type {
PendingActionsRequestQuery,
PendingActionsResponse,
} from '../../../../common/endpoint/types';
import { KibanaServices } from '../kibana';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
} from '../../../../../common/endpoint/types';
import { KibanaServices } from '../../kibana';
import { ACTION_STATUS_ROUTE } from '../../../../../common/endpoint/constants';
// FIXME:PT refactor these to use common hooks
/**
* Retrieve a list of pending actions against the given Fleet Agent Ids provided on input

View file

@ -8,10 +8,10 @@
import type {
PendingActionsRequestQuery,
PendingActionsResponse,
} from '../../../../common/endpoint/types';
import type { ResponseProvidersInterface } from '../../mock/endpoint/http_handler_mock_factory';
import { httpHandlerMockFactory } from '../../mock/endpoint/http_handler_mock_factory';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
} from '../../../../../common/endpoint/types';
import type { ResponseProvidersInterface } from '../../../mock/endpoint/http_handler_mock_factory';
import { httpHandlerMockFactory } from '../../../mock/endpoint/http_handler_mock_factory';
import { ACTION_STATUS_ROUTE } from '../../../../../common/endpoint/constants';
export const pendingActionsResponseMock = (): PendingActionsResponse => ({
data: [

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export * from './utils';
export * from './endpoint_isolation';
export * from './endpoint_pending_actions';

View file

@ -4,11 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { find } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
export const getFieldValues = (
/**
* Gets the array of values for a given field in an Alert Details data
*
* @param category
* @param field
* @param data
*/
const getEventDetailsFieldValues = (
{
category,
field,
@ -17,7 +24,7 @@ export const getFieldValues = (
field: string;
},
data: TimelineEventsDetailsItem[] | null
) => {
): string[] => {
const categoryCompat =
category === 'signal' ? 'kibana' : category === 'kibana' ? 'signal' : category;
const fieldCompat =
@ -28,11 +35,20 @@ export const getFieldValues = (
: field;
return (
find({ category, field }, data)?.values ??
find({ category: categoryCompat, field: fieldCompat }, data)?.values
find({ category: categoryCompat, field: fieldCompat }, data)?.values ??
[]
);
};
export const getFieldValue = (
/**
* Gets a single value for a given Alert Details data field. If the field has multiple values,
* the first one will be returned.
*
* @param category
* @param field
* @param data
*/
export const getAlertDetailsFieldValue = (
{
category,
field,
@ -41,7 +57,7 @@ export const getFieldValue = (
field: string;
},
data: TimelineEventsDetailsItem[] | null
) => {
const currentField = getFieldValues({ category, field }, data);
): string => {
const currentField = getEventDetailsFieldValues({ category, field }, data);
return currentField && currentField.length > 0 ? currentField[0] : '';
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './is_agent_type_and_action_supported';
export * from './is_response_actions_alert_agent_id_field';

View file

@ -0,0 +1,82 @@
/*
* 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 type {
ResponseActionAgentType,
ResponseActionsApiCommandNames,
ResponseActionType,
} from '../../../../../common/endpoint/service/response_actions/constants';
import { isAgentTypeAndActionSupported } from './is_agent_type_and_action_supported';
import { ExperimentalFeaturesService } from '../../../experimental_features_service';
import type { ExperimentalFeatures } from '../../../../../common';
import { allowedExperimentalValues } from '../../../../../common';
jest.mock('../../../experimental_features_service');
describe('isAgentTypeAndActionSupported() util', () => {
const enableFeatures = (overrides: Partial<ExperimentalFeatures> = {}): void => {
(ExperimentalFeaturesService.get as jest.Mock).mockReturnValue({
...allowedExperimentalValues,
responseActionsSentinelOneGetFileEnabled: true,
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
...overrides,
});
};
const disableS1GetFileFeature = () => {
enableFeatures({ responseActionsSentinelOneGetFileEnabled: false });
};
const resetFeatures = (): void => {
(ExperimentalFeaturesService.get as jest.Mock).mockReturnValue({
...allowedExperimentalValues,
});
};
beforeEach(() => {
enableFeatures();
});
afterEach(() => {
resetFeatures();
});
it.each`
agentType | actionName | actionType | expectedValue | runSetup
${'endpoint'} | ${undefined} | ${undefined} | ${true} | ${undefined}
${'endpoint'} | ${'isolate'} | ${'manual'} | ${true} | ${undefined}
${'endpoint'} | ${'isolate'} | ${'automated'} | ${true} | ${undefined}
${'sentinel_one'} | ${undefined} | ${undefined} | ${true} | ${undefined}
${'sentinel_one'} | ${'isolate'} | ${'manual'} | ${true} | ${undefined}
${'sentinel_one'} | ${'get-file'} | ${'manual'} | ${true} | ${undefined}
${'sentinel_one'} | ${'get-file'} | ${undefined} | ${false} | ${disableS1GetFileFeature}
${'crowdstrike'} | ${undefined} | ${undefined} | ${true} | ${undefined}
${'crowdstrike'} | ${'isolate'} | ${'manual'} | ${true} | ${undefined}
${'crowdstrike'} | ${'isolate'} | ${undefined} | ${false} | ${resetFeatures}
`(
'should return `$expectedValue` for $agentType $actionName ($actionType)',
({
agentType,
actionName,
actionType,
expectedValue,
runSetup,
}: {
agentType: ResponseActionAgentType;
actionName?: ResponseActionsApiCommandNames;
actionType?: ResponseActionType;
runSetup?: () => void;
expectedValue: boolean;
}) => {
if (runSetup) {
runSetup();
}
expect(isAgentTypeAndActionSupported(agentType, actionName, actionType)).toBe(expectedValue);
}
);
});

View file

@ -0,0 +1,60 @@
/*
* 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 type {
ResponseActionAgentType,
ResponseActionsApiCommandNames,
ResponseActionType,
} from '../../../../../common/endpoint/service/response_actions/constants';
import { isActionSupportedByAgentType } from '../../../../../common/endpoint/service/response_actions/is_response_action_supported';
import { ExperimentalFeaturesService } from '../../../experimental_features_service';
/**
* Checks if a given Agent type is supported (aka: is feature flag enabled) and optionally
* also checks if a given response action is implemented for that agent type.
*/
export const isAgentTypeAndActionSupported = (
agentType: ResponseActionAgentType,
actionName?: ResponseActionsApiCommandNames,
actionType: ResponseActionType = 'manual'
): boolean => {
const features = ExperimentalFeaturesService.get();
const isSentinelOneV1Enabled = features.responseActionsSentinelOneV1Enabled;
const isSentinelOneGetFileEnabled = features.responseActionsSentinelOneGetFileEnabled;
const isCrowdstrikeHostIsolationEnabled =
features.responseActionsCrowdstrikeManualHostIsolationEnabled;
const isAgentTypeSupported =
agentType === 'endpoint' ||
(agentType === 'sentinel_one' && isSentinelOneV1Enabled) ||
(agentType === 'crowdstrike' && isCrowdstrikeHostIsolationEnabled);
let isActionNameSupported: boolean =
!actionName || isActionSupportedByAgentType(agentType, actionName, actionType);
// if response action is supported, then do specific response action FF checks
if (isAgentTypeSupported && isActionNameSupported && actionName) {
switch (agentType) {
case 'sentinel_one':
switch (actionName) {
case 'get-file':
if (!isSentinelOneGetFileEnabled) {
isActionNameSupported = false;
}
break;
}
break;
case 'crowdstrike':
// Placeholder for future individual response actions FF checks
break;
}
}
return Boolean(isAgentTypeSupported && isActionNameSupported);
};

View file

@ -0,0 +1,19 @@
/*
* 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 { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../../common/endpoint/service/response_actions/constants';
const SUPPORTED_ALERT_FIELDS: Readonly<string[]> = Object.values(
RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD
);
/**
* Checks to see if a given alert field (ex. `agent.id`) is used by Agents that have support for response actions.
*/
export const isResponseActionsAlertAgentIdField = (field: string): boolean => {
return SUPPORTED_ALERT_FIELDS.includes(field);
};

View file

@ -0,0 +1,248 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../common/endpoint/service/response_actions/constants';
/**
* Provide overrides for data `fields`. If a field is set to `undefined`, then it will be removed
* from the array. If an override field name is not currently in the array, it will be added.
*/
interface AlertDetailsItemDataOverrides {
[field: string]: Partial<TimelineEventsDetailsItem> | undefined;
}
/**
* Will update (mutate) the data passed in with the override data defined
* @param data
* @param overrides
*/
const setAlertDetailsItemDataOverrides = (
data: TimelineEventsDetailsItem[],
overrides: AlertDetailsItemDataOverrides
): TimelineEventsDetailsItem[] => {
if (Object.keys(overrides).length > 0) {
const definedFields: string[] = [];
const deleteIndexes: number[] = [];
// Override current fields' values
data.forEach((item, index) => {
definedFields.push(item.field);
if (item.field in overrides) {
// If value is undefined, then mark item for deletion
if (!overrides[item.field]) {
deleteIndexes.unshift(index);
} else {
Object.assign(item, overrides[item.field]);
}
}
});
// Delete any items from the array
if (deleteIndexes.length > 0) {
for (const index of deleteIndexes) {
data.splice(index, 1);
}
}
// Add any new fields to the data
Object.entries(overrides).forEach(([field, fieldData]) => {
if (!definedFields.includes(field)) {
data.push({
category: 'unknown',
field: 'unknonwn',
values: [],
originalValue: [],
isObjectArray: false,
...fieldData,
});
}
});
}
return data;
};
/** @private */
const generateEndpointAlertDetailsItemDataMock = (
overrides: AlertDetailsItemDataOverrides = {}
): TimelineEventsDetailsItem[] => {
const data = [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
values: ['b69d086c-325a-4f46-b17b-fb6d227006ba'],
originalValue: ['b69d086c-325a-4f46-b17b-fb6d227006ba'],
isObjectArray: false,
},
{
category: 'agent',
field: 'agent.type',
values: ['endpoint'],
originalValue: ['endpoint'],
isObjectArray: false,
},
{
category: 'agent',
field: 'agent.id',
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
originalValue: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
isObjectArray: false,
},
{
category: 'event',
field: 'event.module',
values: ['endpoint'],
originalValue: ['endpoint'],
isObjectArray: false,
},
{
category: 'event',
field: 'event.category',
originalValue: ['process'],
values: ['process'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.name',
values: ['elastic-host-win'],
originalValue: ['windows-native'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.os.family',
values: ['windows'],
originalValue: ['windows'],
isObjectArray: false,
},
];
setAlertDetailsItemDataOverrides(data, overrides);
return data;
};
/** @private */
const generateSentinelOneAlertDetailsItemDataMock = (
overrides: AlertDetailsItemDataOverrides = {}
): TimelineEventsDetailsItem[] => {
const data = generateEndpointAlertDetailsItemDataMock(overrides);
data.forEach((itemData) => {
switch (itemData.field) {
case 'event.module':
itemData.values = ['sentinel_one'];
itemData.originalValue = ['sentinel_one'];
break;
case 'agent.type':
itemData.values = ['filebeat'];
itemData.originalValue = ['filebeat'];
break;
}
});
data.push({
category: 'observer',
field: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one,
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
originalValue: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
isObjectArray: false,
});
setAlertDetailsItemDataOverrides(data, overrides);
return data;
};
/** @private */
const generateCrowdStrikeAlertDetailsItemDataMock = (
overrides: AlertDetailsItemDataOverrides = {}
): TimelineEventsDetailsItem[] => {
const data = generateEndpointAlertDetailsItemDataMock();
data.forEach((itemData) => {
switch (itemData.field) {
case 'event.module':
itemData.values = ['crowdstrike'];
itemData.originalValue = ['crowdstrike'];
break;
case 'agent.type':
itemData.values = ['filebeat'];
itemData.originalValue = ['filebeat'];
break;
}
});
data.push(
{
category: 'crowdstrike',
field: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike,
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
originalValue: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
isObjectArray: false,
},
{
category: 'crowdstrike',
field: 'crowdstrike.event.HostName',
values: ['elastic-host-win'],
originalValue: ['windows-native'],
isObjectArray: false,
},
{
category: 'crowdstrike',
field: 'crowdstrike.event.Platform',
values: ['windows'],
originalValue: ['windows'],
isObjectArray: false,
}
);
setAlertDetailsItemDataOverrides(data, overrides);
return data;
};
/**
* Will return alert details item data for a known agent type or if unknown agent type is
* pass, then data will be for `filebeat`
* @param agentType
* @param overrides
*/
const generateAlertDetailsItemDataForAgentTypeMock = (
agentType?: ResponseActionAgentType | string,
overrides: AlertDetailsItemDataOverrides = {}
): TimelineEventsDetailsItem[] => {
const unSupportedAgentType = agentType ?? 'filebeat';
switch (agentType) {
case 'endpoint':
return generateEndpointAlertDetailsItemDataMock(overrides);
case 'sentinel_one':
return generateSentinelOneAlertDetailsItemDataMock(overrides);
case 'crowdstrike':
return generateCrowdStrikeAlertDetailsItemDataMock(overrides);
default:
return generateEndpointAlertDetailsItemDataMock({
'agent.type': { values: [unSupportedAgentType], originalValue: [unSupportedAgentType] },
'event.module': { values: [unSupportedAgentType], originalValue: [unSupportedAgentType] },
...overrides,
});
}
};
export const endpointAlertDataMock = Object.freeze({
generateEndpointAlertDetailsItemData: generateEndpointAlertDetailsItemDataMock,
generateSentinelOneAlertDetailsItemData: generateSentinelOneAlertDetailsItemDataMock,
generateCrowdStrikeAlertDetailsItemData: generateCrowdStrikeAlertDetailsItemDataMock,
generateAlertDetailsItemDataForAgentType: generateAlertDetailsItemDataForAgentTypeMock,
});

View file

@ -7,3 +7,5 @@
export * from './dependencies_start_mock';
export * from './app_context_render';
export * from './endpoint_alert_data_mock';
export * from './http_handler_mock_factory';

View file

@ -101,7 +101,7 @@ export const UNSAVED_TIMELINE_SAVE_PROMPT_TITLE = i18n.translate(
}
);
export const getAgentTypeName = (agentType: ResponseActionAgentType) => {
export const getAgentTypeName = (agentType: ResponseActionAgentType): string => {
switch (agentType) {
case 'endpoint':
return 'Elastic Defend';

View file

@ -1,69 +0,0 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import {
isAlertFromCrowdstrikeAlert,
isAlertFromCrowdstrikeEvent,
} from './crowdstrike_alert_check';
describe('crowdstrike_alert_check', () => {
describe('isAlertFromCrowdstrikeEvent', () => {
it('returns false if data is not a timeline event alert', () => {
const data: TimelineEventsDetailsItem[] = [];
expect(isAlertFromCrowdstrikeEvent({ data })).toBe(false);
});
it('returns false if data is a timeline event alert but not from Crowdstrike', () => {
const data = [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
},
] as unknown as TimelineEventsDetailsItem[];
expect(isAlertFromCrowdstrikeEvent({ data })).toBe(false);
});
it('returns true if data is a Crowdstrike timeline event alert', () => {
const data = [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
},
{
field: 'event.module',
values: ['crowdstrike'],
},
] as unknown as TimelineEventsDetailsItem[];
expect(isAlertFromCrowdstrikeEvent({ data })).toBe(true);
});
});
describe('isAlertFromCrowdstrikeAlert', () => {
it('returns false if ecsData is null', () => {
expect(isAlertFromCrowdstrikeAlert({ ecsData: null })).toBe(false);
});
it('returns false if ecsData is not a Crowdstrike alert', () => {
const ecsData = {
'kibana.alert.original_event.module': ['other'],
'kibana.alert.original_event.dataset': ['other'],
} as unknown as Ecs;
expect(isAlertFromCrowdstrikeAlert({ ecsData })).toBe(false);
});
it('returns true if ecsData is a Crowdstrike alert', () => {
const ecsData = {
'kibana.alert.original_event.module': ['crowdstrike'],
'kibana.alert.original_event.dataset': ['alert'],
} as unknown as Ecs;
expect(isAlertFromCrowdstrikeAlert({ ecsData })).toBe(true);
});
});
});

View file

@ -1,69 +0,0 @@
/*
* 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 { find, getOr, some } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { getFieldValue } from '../../detections/components/host_isolation/helpers';
/**
* Check to see if a timeline event item is an Alert (vs an event)
* @param timelineEventItem
*/
export const isTimelineEventItemAnAlert = (
timelineEventItem: TimelineEventsDetailsItem[]
): boolean => {
return some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, timelineEventItem);
};
export const CROWDSTRIKE_AGENT_ID_FIELD = 'crowdstrike.event.DeviceId';
export const getCrowdstrikeAgentId = (
data: TimelineEventsDetailsItem[] | null
): string | undefined => {
return (
getFieldValue({ category: 'crowdstrike', field: CROWDSTRIKE_AGENT_ID_FIELD }, data) || undefined
);
};
/**
* Checks to see if the given set of Timeline event detail items includes data that indicates its
* an endpoint Alert. Note that it will NOT match on Events - only alerts
* @param data
*/
export const isAlertFromCrowdstrikeEvent = ({
data,
}: {
data: TimelineEventsDetailsItem[];
}): boolean => {
if (!isTimelineEventItemAnAlert(data)) {
return false;
}
const findEndpointAlert = find({ field: 'event.module' }, data)?.values;
return findEndpointAlert ? findEndpointAlert[0] === 'crowdstrike' : false;
};
/**
* Checks to see if the given alert was generated out of the Crowdstrike Alerts dataset, coming from
* crowdstrike Fleet integration
* @param ecsData
*/
export const isAlertFromCrowdstrikeAlert = ({
ecsData,
}: {
ecsData: Ecs | null | undefined;
}): boolean => {
if (ecsData == null) {
return false;
}
const eventModules = getOr([], 'kibana.alert.original_event.module', ecsData);
const kinds = getOr([], 'kibana.alert.original_event.dataset', ecsData);
return eventModules.includes('crowdstrike') && kinds.includes('alert');
};

View file

@ -1,85 +0,0 @@
/*
* 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 _ from 'lodash';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { generateMockDetailItemData } from '../mock';
import { isAlertFromEndpointAlert, isAlertFromEndpointEvent } from './endpoint_alert_check';
describe('isAlertFromEndpointEvent', () => {
let mockDetailItemData: ReturnType<typeof generateMockDetailItemData>;
beforeEach(() => {
mockDetailItemData = generateMockDetailItemData();
// Remove the filebeat agent type from the mock
_.remove(mockDetailItemData, { field: 'agent.type' });
mockDetailItemData.push(
// Must be an Alert
{
field: 'kibana.alert.rule.uuid',
category: 'kibana',
originalValue: 'endpoint',
values: ['endpoint'],
isObjectArray: false,
},
// Must be from an endpoint agent
{
field: 'agent.type',
originalValue: 'endpoint',
values: ['endpoint'],
isObjectArray: false,
}
);
});
it('should return true if detections data comes from an endpoint rule', () => {
expect(isAlertFromEndpointEvent({ data: mockDetailItemData })).toBe(true);
});
it('should return false if it is not an Alert (ex. maybe an event)', () => {
_.remove(mockDetailItemData, { field: 'kibana.alert.rule.uuid' });
expect(isAlertFromEndpointEvent({ data: mockDetailItemData })).toBeFalsy();
});
it('should return false if it is not an endpoint agent', () => {
_.remove(mockDetailItemData, { field: 'agent.type' });
expect(isAlertFromEndpointEvent({ data: mockDetailItemData })).toBeFalsy();
});
});
describe('isAlertFromEndpointAlert', () => {
it('should return true if detections data comes from an endpoint rule', () => {
const mockEcsData = {
_id: 'mockId',
'kibana.alert.original_event.module': ['endpoint'],
'kibana.alert.original_event.kind': ['alert'],
} as Ecs;
expect(isAlertFromEndpointAlert({ ecsData: mockEcsData })).toBe(true);
});
it('should return false if ecsData is undefined', () => {
expect(isAlertFromEndpointAlert({ ecsData: undefined })).toBeFalsy();
});
it('should return false if it is not an Alert', () => {
const mockEcsData = {
_id: 'mockId',
'kibana.alert.original_event.module': ['endpoint'],
} as Ecs;
expect(isAlertFromEndpointAlert({ ecsData: mockEcsData })).toBeFalsy();
});
it('should return false if it is not an endpoint module', () => {
const mockEcsData = {
_id: 'mockId',
'kibana.alert.original_event.kind': ['alert'],
} as Ecs;
expect(isAlertFromEndpointAlert({ ecsData: mockEcsData })).toBeFalsy();
});
});

View file

@ -1,53 +0,0 @@
/*
* 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 { find, getOr, some } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
/**
* Check to see if a timeline event item is an Alert (vs an event)
* @param timelineEventItem
*/
export const isTimelineEventItemAnAlert = (
timelineEventItem: TimelineEventsDetailsItem[]
): boolean => {
return some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, timelineEventItem);
};
/**
* Checks to see if the given set of Timeline event detail items includes data that indicates its
* an endpoint Alert. Note that it will NOT match on Events - only alerts
* @param data
*/
export const isAlertFromEndpointEvent = ({
data,
}: {
data: TimelineEventsDetailsItem[];
}): boolean => {
if (!isTimelineEventItemAnAlert(data)) {
return false;
}
const findEndpointAlert = find({ field: 'agent.type' }, data)?.values;
return findEndpointAlert ? findEndpointAlert[0] === 'endpoint' : false;
};
export const isAlertFromEndpointAlert = ({
ecsData,
}: {
ecsData: Ecs | null | undefined;
}): boolean => {
if (ecsData == null) {
return false;
}
const eventModules = getOr([], 'kibana.alert.original_event.module', ecsData);
const kinds = getOr([], 'kibana.alert.original_event.kind', ecsData);
return eventModules.includes('endpoint') && kinds.includes('alert');
};

View file

@ -1,64 +0,0 @@
/*
* 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 { find, getOr, some } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { getFieldValue } from '../../detections/components/host_isolation/helpers';
/**
* Check to see if a timeline event item is an Alert (vs an event)
* @param timelineEventItem
*/
export const isTimelineEventItemAnAlert = (
timelineEventItem: TimelineEventsDetailsItem[]
): boolean => {
return some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, timelineEventItem);
};
export const SENTINEL_ONE_AGENT_ID_FIELD = 'observer.serial_number';
export const getSentinelOneAgentId = (data: TimelineEventsDetailsItem[] | null) =>
getFieldValue({ category: 'observer', field: SENTINEL_ONE_AGENT_ID_FIELD }, data) || undefined;
/**
* Checks to see if the given set of Timeline event detail items includes data that indicates its
* an endpoint Alert. Note that it will NOT match on Events - only alerts
* @param data
*/
export const isAlertFromSentinelOneEvent = ({
data,
}: {
data: TimelineEventsDetailsItem[];
}): boolean => {
if (!isTimelineEventItemAnAlert(data)) {
return false;
}
const findEndpointAlert = find({ field: 'event.module' }, data)?.values;
return findEndpointAlert ? findEndpointAlert[0] === 'sentinel_one' : false;
};
/**
* Checks to see if the given alert was generated out of the SentinelOne Alerts dataset, coming from
* sentinel_one Fleet integration
* @param ecsData
*/
export const isAlertFromSentinelOneAlert = ({
ecsData,
}: {
ecsData: Ecs | null | undefined;
}): boolean => {
if (ecsData == null) {
return false;
}
const eventModules = getOr([], 'kibana.alert.original_event.module', ecsData);
const kinds = getOr([], 'kibana.alert.original_event.dataset', ecsData);
return eventModules.includes('sentinel_one') && kinds.includes('alert');
};

View file

@ -11,7 +11,7 @@ import { EuiButtonIcon, EuiContextMenu, EuiPopover, EuiToolTip } from '@elastic/
import { indexOf } from 'lodash';
import { useSelector } from 'react-redux';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { get } from 'lodash/fp';
import { get, getOr } from 'lodash/fp';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { TableId } from '@kbn/securitysolution-data-table';
import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback';
@ -46,7 +46,6 @@ import type {
import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/timeline/body/translations';
import { useEventFilterAction } from './use_event_filter_action';
import { useAddToCaseActions } from './use_add_to_case_actions';
import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check';
import type { Rule } from '../../../../detection_engine/rule_management/logic/types';
import type { AlertTableContextMenuItem } from '../types';
import { useAlertTagsActions } from './use_alert_tags_actions';
@ -115,6 +114,13 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]);
const isAgentEndpoint = useMemo(() => ecsRowData.agent?.type?.includes('endpoint'), [ecsRowData]);
const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]);
const isAlertSourceEndpoint = useMemo(() => {
const eventModules = getOr([], 'kibana.alert.original_event.module', ecsRowData);
const kinds = getOr([], 'kibana.alert.original_event.kind', ecsRowData);
return eventModules.includes('endpoint') && kinds.includes('alert');
}, [ecsRowData]);
const scopeIdAllowsAddEndpointEventFilter = useMemo(
() => scopeId === TableId.hostsPageEvents || scopeId === TableId.usersPageEvents,
[scopeId]
@ -200,7 +206,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
}, [closePopover, onAddEventFilterClick]);
const { exceptionActionItems } = useAlertExceptionActions({
isEndpointAlert: isAlertFromEndpointAlert({ ecsData: ecsRowData }),
isEndpointAlert: isAlertSourceEndpoint,
onAddExceptionTypeClick: handleOnAddExceptionTypeClick,
});
const { eventFilterActionItems } = useEventFilterAction({

View file

@ -1,120 +0,0 @@
/*
* 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 { getSentinelOneAgentId } from '../../../common/utils/sentinelone_alert_check';
import { getCrowdstrikeAgentId } from '../../../common/utils/crowdstrike_alert_check';
import { getExternalEdrAgentInfo } from './get_external_edr_agent_info';
jest.mock('../../../common/utils/sentinelone_alert_check');
jest.mock('../../../common/utils/crowdstrike_alert_check');
describe('getExternalEdrAgentInfo', () => {
const mockEventData = [
{
category: 'event',
field: 'event.module',
values: ['sentinel_one'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.name',
values: ['test-host'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.os.name',
values: ['Windows'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.os.family',
values: ['windows'],
isObjectArray: false,
},
{
category: 'host',
field: 'host.os.version',
values: ['10'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.last_detected',
values: ['2023-05-01T12:34:56Z'],
isObjectArray: false,
},
{
category: 'crowdstrike',
field: 'crowdstrike.event.HostName',
values: ['test-crowdstrike-host'],
isObjectArray: false,
},
{
category: 'crowdstrike',
field: 'crowdstrike.event.Platform',
values: ['linux'],
isObjectArray: false,
},
];
beforeEach(() => {
(getSentinelOneAgentId as jest.Mock).mockReturnValue('sentinel-one-agent-id');
(getCrowdstrikeAgentId as jest.Mock).mockReturnValue('crowdstrike-agent-id');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return correct info for sentinel_one agent type', () => {
const result = getExternalEdrAgentInfo(mockEventData, 'sentinel_one');
expect(result).toEqual({
agent: {
id: 'sentinel-one-agent-id',
type: 'sentinel_one',
},
host: {
name: 'test-host',
os: {
name: 'Windows',
family: 'windows',
version: '10',
},
},
lastCheckin: '2023-05-01T12:34:56Z',
});
});
it('should return correct info for crowdstrike agent type', () => {
const result = getExternalEdrAgentInfo(mockEventData, 'crowdstrike');
expect(result).toEqual({
agent: {
id: 'crowdstrike-agent-id',
type: 'crowdstrike',
},
host: {
name: 'test-crowdstrike-host',
os: {
name: '',
family: 'linux',
version: '',
},
},
lastCheckin: '2023-05-01T12:34:56Z',
});
});
it('should throw an error for unsupported agent type', () => {
expect(() => {
// @ts-expect-error testing purpose
getExternalEdrAgentInfo(mockEventData, 'unsupported_agent_type');
}).toThrow('Unsupported agent type: unsupported_agent_type');
});
});

View file

@ -1,70 +0,0 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import type { ThirdPartyAgentInfo } from '../../../../common/types';
import { getSentinelOneAgentId } from '../../../common/utils/sentinelone_alert_check';
import { getFieldValue } from '../host_isolation/helpers';
import { getCrowdstrikeAgentId } from '../../../common/utils/crowdstrike_alert_check';
export const getExternalEdrAgentInfo = (
eventData: TimelineEventsDetailsItem[],
agentType: ResponseActionAgentType
): ThirdPartyAgentInfo => {
switch (agentType) {
case 'sentinel_one':
return {
agent: {
id: getSentinelOneAgentId(eventData) || '',
type: getFieldValue(
{ category: 'event', field: 'event.module' },
eventData
) as ResponseActionAgentType,
},
host: {
name: getFieldValue({ category: 'host', field: 'host.name' }, eventData),
os: {
name: getFieldValue({ category: 'host', field: 'host.os.name' }, eventData),
family: getFieldValue({ category: 'host', field: 'host.os.family' }, eventData),
version: getFieldValue({ category: 'host', field: 'host.os.version' }, eventData),
},
},
lastCheckin: getFieldValue(
{ category: 'kibana', field: 'kibana.alert.last_detected' },
eventData
),
};
case 'crowdstrike':
return {
agent: {
id: getCrowdstrikeAgentId(eventData) || '',
type: agentType,
},
host: {
name: getFieldValue(
{ category: 'crowdstrike', field: 'crowdstrike.event.HostName' },
eventData
),
os: {
name: '',
family: getFieldValue(
{ category: 'crowdstrike', field: 'crowdstrike.event.Platform' },
eventData
),
version: '',
},
},
lastCheckin: getFieldValue(
{ category: 'kibana', field: 'kibana.alert.last_detected' },
eventData
),
};
default:
throw new Error(`Unsupported agent type: ${agentType}`);
}
};

View file

@ -1,233 +0,0 @@
/*
* 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 { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useResponderActionData } from './use_responder_action_data';
import { renderHook } from '@testing-library/react-hooks';
import { useGetEndpointDetails } from '../../../management/hooks';
import { HostStatus } from '../../../../common/endpoint/types';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { HOST_ENDPOINT_UNENROLLED_TOOLTIP } from './translations';
jest.mock('../../../common/hooks/use_experimental_features');
jest.mock('../../../management/hooks', () => ({
useGetEndpointDetails: (jest.fn() as jest.Mock).mockImplementation(() => ({ enabled: false })),
useWithShowResponder: jest.fn(),
}));
const useGetEndpointDetailsMock = useGetEndpointDetails as jest.Mock;
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
describe('#useResponderActionData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return `responder` menu item as `disabled` if agentType is not `endpoint` and feature flag is enabled', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'some-agent-type-id',
// @ts-expect-error this is for testing purpose
agentType: 'some_agent_type',
eventData: [],
})
);
expect(result.current.isDisabled).toEqual(true);
});
describe('when agentType is `endpoint`', () => {
it.each(Object.values(HostStatus).filter((status) => status !== 'unenrolled'))(
'should return `responder` menu item as `enabled `if agentType is `endpoint` when endpoint is %s',
(hostStatus) => {
useGetEndpointDetailsMock.mockReturnValue({
data: {
host_status: hostStatus,
},
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'endpoint-id',
agentType: 'endpoint',
})
);
expect(result.current.isDisabled).toEqual(false);
}
);
it('should return responder menu item `disabled` if agentType is `endpoint` when endpoint is `unenrolled`', () => {
useGetEndpointDetailsMock.mockReturnValue({
data: {
host_status: 'unenrolled',
},
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'endpoint-id',
agentType: 'endpoint',
})
);
expect(result.current.isDisabled).toEqual(true);
});
it('should return responder menu item `disabled` if agentType is `endpoint` when endpoint data has error', () => {
useGetEndpointDetailsMock.mockReturnValue({
data: {
host_status: 'online',
},
isFetching: false,
error: new Error('uh oh!'),
});
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'endpoint-id',
agentType: 'endpoint',
})
);
expect(result.current.isDisabled).toEqual(true);
});
it('should return responder menu item `disabled` if agentType is `endpoint` and endpoint data is fetching', () => {
useGetEndpointDetailsMock.mockReturnValue({
data: undefined,
isFetching: true,
error: undefined,
});
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'endpoint-id',
agentType: 'endpoint',
})
);
expect(result.current.isDisabled).toEqual(true);
});
it('should return responder menu item `disabled` when agentType is `endpoint` but no endpoint id is provided', () => {
const { result } = renderHook(() =>
useResponderActionData({
endpointId: '',
agentType: 'endpoint',
})
);
expect(result.current.isDisabled).toEqual(true);
expect(result.current.tooltip).toEqual(HOST_ENDPOINT_UNENROLLED_TOOLTIP);
});
});
describe('when agentType is `sentinel_one`', () => {
const createEventDataMock = (): TimelineEventsDetailsItem[] => {
return [
{
category: 'observer',
field: 'observer.serial_number',
values: ['c06d63d9-9fa2-046d-e91e-dc94cf6695d8'],
originalValue: ['c06d63d9-9fa2-046d-e91e-dc94cf6695d8'],
isObjectArray: false,
},
];
};
it('should return `responder` menu item as `disabled` if agentType is `sentinel_one` and feature flag is disabled', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'sentinel-one-id',
agentType: 'sentinel_one',
eventData: createEventDataMock(),
})
);
expect(result.current.isDisabled).toEqual(true);
});
it('should return responder menu item as disabled with tooltip if agent id property is missing from event data', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'sentinel-one-id',
agentType: 'sentinel_one',
eventData: [],
})
);
expect(result.current.isDisabled).toEqual(true);
expect(result.current.tooltip).toEqual(
'Event data missing SentinelOne agent identifier (observer.serial_number)'
);
});
it('should return `responder` menu item as `enabled `if agentType is `sentinel_one` and feature flag is enabled', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'sentinel-one-id',
agentType: 'sentinel_one',
eventData: createEventDataMock(),
})
);
expect(result.current.isDisabled).toEqual(false);
});
});
describe('when agentType is `crowdstrike`', () => {
const createEventDataMock = (): TimelineEventsDetailsItem[] => {
return [
{
category: 'crowdstrike',
field: 'crowdstrike.event.DeviceId',
values: ['mockedAgentId'],
originalValue: ['mockedAgentId'],
isObjectArray: false,
},
];
};
it('should return `responder` menu item as `disabled` if agentType is `crowdstrike` and feature flag is disabled', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'crowdstrike-id',
agentType: 'crowdstrike',
eventData: createEventDataMock(),
})
);
expect(result.current.isDisabled).toEqual(true);
});
it('should return responder menu item as disabled with tooltip if agent id property is missing from event data', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'crowdstrike-id',
agentType: 'crowdstrike',
eventData: [],
})
);
expect(result.current.isDisabled).toEqual(true);
expect(result.current.tooltip).toEqual(
'Event data missing Crowdstrike agent identifier (crowdstrike.event.DeviceId)'
);
});
it('should return `responder` menu item as `enabled `if agentType is `crowdstrike` and feature flag is enabled', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
const { result } = renderHook(() =>
useResponderActionData({
endpointId: 'crowdstrike-id',
agentType: 'crowdstrike',
eventData: createEventDataMock(),
})
);
expect(result.current.isDisabled).toEqual(false);
});
});
});

View file

@ -1,175 +0,0 @@
/*
* 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 type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { getExternalEdrAgentInfo } from './get_external_edr_agent_info';
import { getCrowdstrikeAgentId } from '../../../common/utils/crowdstrike_alert_check';
import type { Platform } from '../../../management/components/endpoint_responder/components/header_info/platforms';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getSentinelOneAgentId } from '../../../common/utils/sentinelone_alert_check';
import type {
ResponseActionAgentType,
EndpointCapabilities,
} from '../../../../common/endpoint/service/response_actions/constants';
import { useGetEndpointDetails, useWithShowResponder } from '../../../management/hooks';
import { HostStatus } from '../../../../common/endpoint/types';
import {
CROWDSTRIKE_AGENT_ID_PROPERTY_MISSING,
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
LOADING_ENDPOINT_DATA_TOOLTIP,
METADATA_API_ERROR_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
SENTINEL_ONE_AGENT_ID_PROPERTY_MISSING,
} from './translations';
export interface ResponderContextMenuItemProps {
endpointId: string;
onClick?: () => void;
agentType: ResponseActionAgentType;
eventData?: TimelineEventsDetailsItem[] | null;
}
/**
* This hook is used to get the data needed to show the context menu items for the responder
* actions.
* @param endpointId the id of the endpoint
* @param onClick the callback to handle the click event
* @param agentType the type of agent, defaults to 'endpoint'
* @param eventData the event data, exists only when agentType !== 'endpoint'
* @returns an object with the data needed to show the context menu item
*/
export const useResponderActionData = ({
endpointId,
onClick,
agentType,
eventData,
}: ResponderContextMenuItemProps): {
handleResponseActionsClick: () => void;
isDisabled: boolean;
tooltip: ReactNode;
} => {
const isEndpointHost = agentType === 'endpoint';
const showResponseActionsConsole = useWithShowResponder();
const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled(
'responseActionsSentinelOneV1Enabled'
);
const responseActionsCrowdstrikeManualHostIsolationEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
const {
data: hostInfo,
isFetching,
error,
} = useGetEndpointDetails(endpointId, { enabled: Boolean(endpointId && isEndpointHost) });
const [isDisabled, tooltip]: [disabled: boolean, tooltip: ReactNode] = useMemo(() => {
// v8.13 disabled for third-party agent alerts if the feature flag is not enabled
if (!isEndpointHost) {
switch (agentType) {
case 'sentinel_one':
// Disable it if feature flag is disabled
if (!isSentinelOneV1Enabled) {
return [true, undefined];
}
// Event must have the property that identifies the agent id
if (!getSentinelOneAgentId(eventData ?? null)) {
return [true, SENTINEL_ONE_AGENT_ID_PROPERTY_MISSING];
}
return [false, undefined];
case 'crowdstrike':
// Disable it if feature flag is disabled
if (!responseActionsCrowdstrikeManualHostIsolationEnabled) {
return [true, undefined];
}
// Event must have the property that identifies the agent id
if (!getCrowdstrikeAgentId(eventData ?? null)) {
return [true, CROWDSTRIKE_AGENT_ID_PROPERTY_MISSING];
}
return [false, undefined];
default:
return [true, undefined];
}
}
if (!endpointId) {
return [true, HOST_ENDPOINT_UNENROLLED_TOOLTIP];
}
// Still loading host info
if (isFetching) {
return [true, LOADING_ENDPOINT_DATA_TOOLTIP];
}
// if we got an error, and it's a 404, it means the endpoint is not from the endpoint host
if (error && error.body?.statusCode === 404) {
return [true, NOT_FROM_ENDPOINT_HOST_TOOLTIP];
}
// if we got an error and,
// it's a 400 with unenrolled in the error message (alerts can exist for endpoint that are no longer around)
// or,
// the Host status is `unenrolled`
if (
(error && error.body?.statusCode === 400 && error.body?.message.includes('unenrolled')) ||
hostInfo?.host_status === HostStatus.UNENROLLED
) {
return [true, HOST_ENDPOINT_UNENROLLED_TOOLTIP];
}
// return general error tooltip
if (error) {
return [true, METADATA_API_ERROR_TOOLTIP];
}
return [false, undefined];
}, [
isEndpointHost,
endpointId,
isFetching,
error,
hostInfo?.host_status,
agentType,
isSentinelOneV1Enabled,
eventData,
responseActionsCrowdstrikeManualHostIsolationEnabled,
]);
const handleResponseActionsClick = useCallback(() => {
if (!isEndpointHost && eventData != null) {
const agentInfoFromAlert = getExternalEdrAgentInfo(eventData, agentType);
showResponseActionsConsole({
agentId: agentInfoFromAlert.agent.id,
agentType,
capabilities: ['isolation'],
hostName: agentInfoFromAlert.host.name,
platform: agentInfoFromAlert.host.os.family,
});
}
if (isEndpointHost && hostInfo) {
showResponseActionsConsole({
agentId: hostInfo.metadata.agent.id,
agentType,
capabilities: (hostInfo.metadata.Endpoint.capabilities as EndpointCapabilities[]) ?? [],
hostName: hostInfo.metadata.host.name,
platform: hostInfo.metadata.host.os.name.toLowerCase() as Platform,
});
}
if (onClick) onClick();
}, [isEndpointHost, hostInfo, onClick, eventData, showResponseActionsConsole, agentType]);
return {
handleResponseActionsClick,
isDisabled,
tooltip,
};
};

View file

@ -1,144 +0,0 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useResponderActionItem } from './use_responder_action_item';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { isTimelineEventItemAnAlert } from '../../../common/utils/endpoint_alert_check';
import { getFieldValue } from '../host_isolation/helpers';
import { isAlertFromCrowdstrikeEvent } from '../../../common/utils/crowdstrike_alert_check';
import { isAlertFromSentinelOneEvent } from '../../../common/utils/sentinelone_alert_check';
import { useResponderActionData } from './use_responder_action_data';
jest.mock('../../../common/components/user_privileges');
jest.mock('../../../common/utils/endpoint_alert_check');
jest.mock('../host_isolation/helpers');
jest.mock('../../../common/utils/crowdstrike_alert_check');
jest.mock('../../../common/utils/sentinelone_alert_check');
jest.mock('./use_responder_action_data');
describe('useResponderActionItem', () => {
const mockUseUserPrivileges = useUserPrivileges as jest.Mock;
const mockIsTimelineEventItemAnAlert = isTimelineEventItemAnAlert as jest.Mock;
const mockGetFieldValue = getFieldValue as jest.Mock;
const mockIsAlertFromCrowdstrikeEvent = isAlertFromCrowdstrikeEvent as jest.Mock;
const mockIsAlertFromSentinelOneEvent = isAlertFromSentinelOneEvent as jest.Mock;
const mockUseResponderActionData = useResponderActionData as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockUseResponderActionData.mockImplementation(() => ({
handleResponseActionsClick: jest.fn(),
isDisabled: false,
tooltip: 'Tooltip text',
}));
});
it('should return an empty array if user privileges are loading', () => {
mockUseUserPrivileges.mockReturnValue({
endpointPrivileges: {
loading: true,
canAccessResponseConsole: false,
},
});
const { result } = renderHook(() => useResponderActionItem(null, jest.fn()));
expect(result.current).toEqual([]);
});
it('should return an empty array if user cannot access response console', () => {
mockUseUserPrivileges.mockReturnValue({
endpointPrivileges: {
loading: false,
canAccessResponseConsole: false,
},
});
const { result } = renderHook(() => useResponderActionItem(null, jest.fn()));
expect(result.current).toEqual([]);
});
it('should return an empty array if the event is not an alert', () => {
mockUseUserPrivileges.mockReturnValue({
endpointPrivileges: {
loading: false,
canAccessResponseConsole: true,
},
});
mockIsTimelineEventItemAnAlert.mockReturnValue(false);
const { result } = renderHook(() => useResponderActionItem(null, jest.fn()));
expect(result.current).toEqual([]);
});
it('should return the response action item if all conditions are met for a generic endpoint', () => {
mockUseUserPrivileges.mockReturnValue({
endpointPrivileges: {
loading: false,
canAccessResponseConsole: true,
},
});
mockIsTimelineEventItemAnAlert.mockReturnValue(true);
mockGetFieldValue.mockReturnValue('endpoint-id');
mockIsAlertFromCrowdstrikeEvent.mockReturnValue(false);
mockIsAlertFromSentinelOneEvent.mockReturnValue(false);
renderHook(() => useResponderActionItem([], jest.fn()));
expect(mockUseResponderActionData).toHaveBeenCalledWith({
agentType: 'endpoint',
endpointId: 'endpoint-id',
eventData: null,
onClick: expect.any(Function),
});
});
it('should return the response action item if all conditions are met for a Crowdstrike event', () => {
mockUseUserPrivileges.mockReturnValue({
endpointPrivileges: {
loading: false,
canAccessResponseConsole: true,
},
});
mockIsTimelineEventItemAnAlert.mockReturnValue(true);
mockGetFieldValue.mockReturnValue('crowdstrike-id');
mockIsAlertFromCrowdstrikeEvent.mockReturnValue(true);
mockIsAlertFromSentinelOneEvent.mockReturnValue(false);
renderHook(() => useResponderActionItem([], jest.fn()));
expect(mockUseResponderActionData).toHaveBeenCalledWith({
agentType: 'crowdstrike',
endpointId: 'crowdstrike-id',
eventData: [],
onClick: expect.any(Function),
});
});
it('should return the response action item if all conditions are met for a SentinelOne event', () => {
mockUseUserPrivileges.mockReturnValue({
endpointPrivileges: {
loading: false,
canAccessResponseConsole: true,
},
});
mockIsTimelineEventItemAnAlert.mockReturnValue(true);
mockGetFieldValue.mockReturnValue('sentinelone-id');
mockIsAlertFromCrowdstrikeEvent.mockReturnValue(false);
mockIsAlertFromSentinelOneEvent.mockReturnValue(true);
renderHook(() => useResponderActionItem([], jest.fn()));
expect(mockUseResponderActionData).toHaveBeenCalledWith({
agentType: 'sentinel_one',
endpointId: 'sentinelone-id',
eventData: [],
onClick: expect.any(Function),
});
});
});

View file

@ -1,94 +0,0 @@
/*
* 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, { useMemo } from 'react';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { getSentinelOneAgentId } from '../../../common/utils/sentinelone_alert_check';
import { getCrowdstrikeAgentId } from '../../../common/utils/crowdstrike_alert_check';
import { useCasesFromAlerts } from '../../containers/detection_engine/alerts/use_cases_from_alerts';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { getFieldValue } from './helpers';
import { IsolateHost } from './isolate';
import { UnisolateHost } from './unisolate';
export const HostIsolationPanel = React.memo(
({
details,
cancelCallback,
successCallback,
isolateAction,
}: {
details: TimelineEventsDetailsItem[] | null;
cancelCallback: () => void;
successCallback?: () => void;
isolateAction: string;
}) => {
const elasticAgentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, details),
[details]
);
const sentinelOneAgentId = useMemo(() => getSentinelOneAgentId(details), [details]);
const crowdstrikeAgentId = useMemo(() => getCrowdstrikeAgentId(details), [details]);
const alertId = useMemo(
() => getFieldValue({ category: '_id', field: '_id' }, details),
[details]
);
const { casesInfo } = useCasesFromAlerts({ alertId });
const agentType: ResponseActionAgentType = useMemo(() => {
if (sentinelOneAgentId) {
return 'sentinel_one';
} else if (crowdstrikeAgentId) {
return 'crowdstrike';
} else {
return 'endpoint';
}
}, [sentinelOneAgentId, crowdstrikeAgentId]);
const endpointId = useMemo(
() => sentinelOneAgentId ?? crowdstrikeAgentId ?? elasticAgentId,
[elasticAgentId, sentinelOneAgentId, crowdstrikeAgentId]
);
const hostName = useMemo(() => {
switch (agentType) {
case 'crowdstrike':
return getFieldValue(
{ category: 'crowdstrike', field: 'crowdstrike.event.HostName' },
details
);
default:
return getFieldValue({ category: 'host', field: 'host.name' }, details);
}
}, [agentType, details]);
return isolateAction === 'isolateHost' ? (
<IsolateHost
endpointId={endpointId}
hostName={hostName}
casesInfo={casesInfo}
agentType={agentType}
cancelCallback={cancelCallback}
successCallback={successCallback}
/>
) : (
<UnisolateHost
endpointId={endpointId}
hostName={hostName}
casesInfo={casesInfo}
agentType={agentType}
cancelCallback={cancelCallback}
successCallback={successCallback}
/>
);
}
);
HostIsolationPanel.displayName = 'HostIsolationContent';

View file

@ -1,163 +0,0 @@
/*
* 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 type { FC, PropsWithChildren } from 'react';
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useHostIsolationAction } from './use_host_isolation_action';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
useAgentStatusHook,
useGetAgentStatus,
useGetSentinelOneAgentStatus,
} from '../../../management/hooks/agents/use_get_agent_status';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
jest.mock('../../../management/hooks/agents/use_get_agent_status');
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', () => {
describe.each([
['useGetSentinelOneAgentStatus', useGetSentinelOneAgentStatusMock],
['useGetAgentStatus', useGetAgentStatusMock],
])('works with %s hook', (name, hook) => {
const createReactQueryWrapper = () => {
const queryClient = new QueryClient();
const wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const render = (agentTypeAlert: ResponseActionAgentType) =>
renderHook(
() =>
useHostIsolationAction({
closePopover: jest.fn(),
detailsData:
agentTypeAlert === 'sentinel_one'
? [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
isObjectArray: false,
values: ['ruleId'],
originalValue: ['ruleId'],
},
{
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,
},
]
: agentTypeAlert === 'crowdstrike'
? [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
isObjectArray: false,
values: ['ruleId'],
originalValue: ['ruleId'],
},
{
category: 'event',
field: 'event.module',
values: ['crowdstrike'],
originalValue: ['crowdstrike'],
isObjectArray: false,
},
{
category: 'crowdstrike',
field: 'crowdstrike.event.DeviceId',
values: ['expectedCrowdstrikeAgentId'],
originalValue: ['expectedCrowdstrikeAgentId'],
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);
useAgentStatusHookMock.mockImplementation(() => hook);
});
afterEach(() => {
jest.clearAllMocks();
});
it(`${name} is invoked as 'enabled' when SentinelOne alert and FF enabled`, () => {
render('sentinel_one');
expect(hook).toHaveBeenCalledWith(['some-agent-id'], 'sentinel_one', {
enabled: true,
});
});
it(`${name} is invoked as 'enabled' when Crowdstrike alert and FF enabled`, () => {
render('crowdstrike');
expect(hook).toHaveBeenCalledWith(['expectedCrowdstrikeAgentId'], 'crowdstrike', {
enabled: true,
});
});
it(`${name} is invoked as 'disabled' when SentinelOne alert and FF disabled`, () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
render('sentinel_one');
expect(hook).toHaveBeenCalledWith(['some-agent-id'], 'sentinel_one', {
enabled: false,
});
});
it(`${name} is invoked as 'disabled' when Crowdstrike alert and FF disabled`, () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
render('crowdstrike');
expect(hook).toHaveBeenCalledWith(['expectedCrowdstrikeAgentId'], 'crowdstrike', {
enabled: false,
});
});
it(`${name} is invoked as 'disabled' when endpoint alert`, () => {
render('endpoint');
expect(hook).toHaveBeenCalledWith([''], 'endpoint', {
enabled: false,
});
});
});
});

View file

@ -1,272 +0,0 @@
/*
* 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 { 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 {
getCrowdstrikeAgentId,
isAlertFromCrowdstrikeEvent,
} from '../../../common/utils/crowdstrike_alert_check';
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 { useAgentStatusHook } from '../../../management/hooks/agents/use_get_agent_status';
interface UseHostIsolationActionProps {
closePopover: () => void;
detailsData: TimelineEventsDetailsItem[] | null;
isHostIsolationPanelOpen: boolean;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
}
export const useHostIsolationAction = ({
closePopover,
detailsData,
isHostIsolationPanelOpen,
onAddIsolationStatusClick,
}: UseHostIsolationActionProps): AlertTableContextMenuItem[] => {
const useAgentStatus = useAgentStatusHook();
const hasActionsAllPrivileges = useKibana().services.application?.capabilities?.actions?.save;
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const crowdstrikeManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
const { canIsolateHost, canUnIsolateHost } = useUserPrivileges().endpointPrivileges;
const isEndpointAlert = useMemo(
() => isAlertFromEndpointEvent({ data: detailsData || [] }),
[detailsData]
);
const isSentinelOneAlert = useMemo(
() => isAlertFromSentinelOneEvent({ data: detailsData || [] }),
[detailsData]
);
const isCrowdstrikeAlert = useMemo(
() => isAlertFromCrowdstrikeEvent({ data: detailsData || [] }),
[detailsData]
);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData),
[detailsData]
);
const sentinelOneAgentId = useMemo(() => getSentinelOneAgentId(detailsData), [detailsData]);
const crowdstrikeAgentId = useMemo(() => getCrowdstrikeAgentId(detailsData), [detailsData]);
const externalAgentId = sentinelOneAgentId ?? crowdstrikeAgentId ?? '';
const hostOsFamily = useMemo(
() => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData),
[detailsData]
);
const agentVersion = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.version' }, detailsData),
[detailsData]
);
const agentType = useMemo(() => {
if (isSentinelOneAlert) {
return 'sentinel_one';
}
if (isCrowdstrikeAlert) {
return 'crowdstrike';
}
return 'endpoint';
}, [isCrowdstrikeAlert, isSentinelOneAlert]);
const {
loading: loadingHostIsolationStatus,
isIsolated,
agentStatus,
capabilities,
} = useEndpointHostIsolationStatus({
agentId,
agentType,
});
const { data: externalAgentData } = useAgentStatus([externalAgentId], agentType, {
enabled:
(!!sentinelOneAgentId && sentinelOneManualHostActionsEnabled) ||
(!!crowdstrikeAgentId && crowdstrikeManualHostActionsEnabled),
});
const externalAgentStatus = externalAgentData?.[externalAgentId];
const isHostIsolated = useMemo(() => {
if (
(sentinelOneManualHostActionsEnabled && isSentinelOneAlert) ||
(crowdstrikeManualHostActionsEnabled && isCrowdstrikeAlert)
) {
return externalAgentStatus?.isolated;
}
return isIsolated;
}, [
isIsolated,
isSentinelOneAlert,
isCrowdstrikeAlert,
externalAgentStatus?.isolated,
sentinelOneManualHostActionsEnabled,
crowdstrikeManualHostActionsEnabled,
]);
const doesHostSupportIsolation = useMemo(() => {
if (isEndpointAlert) {
return isIsolationSupported({
osName: hostOsFamily,
version: agentVersion,
capabilities,
});
}
if (
(externalAgentStatus && sentinelOneManualHostActionsEnabled && isSentinelOneAlert) ||
(externalAgentStatus && crowdstrikeManualHostActionsEnabled && isCrowdstrikeAlert)
) {
return externalAgentStatus.status === 'healthy';
}
return false;
}, [
isEndpointAlert,
sentinelOneManualHostActionsEnabled,
isSentinelOneAlert,
externalAgentStatus,
crowdstrikeManualHostActionsEnabled,
isCrowdstrikeAlert,
hostOsFamily,
agentVersion,
capabilities,
]);
const isolateHostHandler = useCallback(() => {
closePopover();
if (!isHostIsolated) {
onAddIsolationStatusClick('isolateHost');
} else {
onAddIsolationStatusClick('unisolateHost');
}
}, [closePopover, isHostIsolated, onAddIsolationStatusClick]);
const isIsolationActionDisabled = useMemo(() => {
if (
(sentinelOneManualHostActionsEnabled && isSentinelOneAlert) ||
(crowdstrikeManualHostActionsEnabled && isCrowdstrikeAlert)
) {
// 8.15 use FF for computing if action is enabled
if (agentStatusClientEnabled) {
return externalAgentStatus?.status === HostStatus.UNENROLLED;
}
// else use the old way
if (!externalAgentStatus) {
return true;
}
const { isUninstalled, isPendingUninstall } = externalAgentStatus as AgentStatusInfo[string];
return isUninstalled || isPendingUninstall;
}
return agentStatus === HostStatus.UNENROLLED;
}, [
agentStatus,
agentStatusClientEnabled,
isSentinelOneAlert,
externalAgentStatus,
sentinelOneManualHostActionsEnabled,
crowdstrikeManualHostActionsEnabled,
isCrowdstrikeAlert,
]);
const menuItems = useMemo(
() => [
{
key: 'isolate-host-action-item',
'data-test-subj': 'isolate-host-action-item',
disabled: isIsolationActionDisabled,
onClick: isolateHostHandler,
name: isHostIsolated ? UNISOLATE_HOST : ISOLATE_HOST,
},
],
[isHostIsolated, isolateHostHandler, isIsolationActionDisabled]
);
return useMemo(() => {
if (isHostIsolationPanelOpen) {
return [];
}
if (
isSentinelOneAlert &&
sentinelOneManualHostActionsEnabled &&
sentinelOneAgentId &&
externalAgentStatus &&
hasActionsAllPrivileges
) {
return menuItems;
}
if (
isCrowdstrikeAlert &&
crowdstrikeManualHostActionsEnabled &&
crowdstrikeAgentId &&
externalAgentStatus &&
hasActionsAllPrivileges
) {
return menuItems;
}
if (
isEndpointAlert &&
doesHostSupportIsolation &&
!loadingHostIsolationStatus &&
(canIsolateHost || (isHostIsolated && !canUnIsolateHost))
) {
return menuItems;
}
return [];
}, [
canIsolateHost,
canUnIsolateHost,
doesHostSupportIsolation,
hasActionsAllPrivileges,
isEndpointAlert,
isHostIsolated,
isHostIsolationPanelOpen,
isSentinelOneAlert,
loadingHostIsolationStatus,
menuItems,
externalAgentStatus,
sentinelOneAgentId,
sentinelOneManualHostActionsEnabled,
crowdstrikeAgentId,
isCrowdstrikeAlert,
crowdstrikeManualHostActionsEnabled,
]);
};

View file

@ -22,26 +22,15 @@ import { useHttp, useKibana } from '../../../common/lib/kibana';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import {
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
LOADING_ENDPOINT_DATA_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from '../endpoint_responder/translations';
import { endpointMetadataHttpMocks } from '../../../management/pages/endpoint_hosts/mocks';
import type { HttpSetup } from '@kbn/core/public';
import {
isAlertFromEndpointAlert,
isAlertFromEndpointEvent,
} from '../../../common/utils/endpoint_alert_check';
import { getUserPrivilegesMockDefaultValue } from '../../../common/components/user_privileges/__mocks__';
import { allCasesPermissions } from '../../../cases_test_utils';
import { HostStatus } from '../../../../common/endpoint/types';
import { ENDPOINT_CAPABILITIES } from '../../../../common/endpoint/service/response_actions/constants';
import {
ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
} from '../../../common/components/toolbar/bulk_actions/translations';
jest.mock('../../../common/components/endpoint/host_isolation');
jest.mock('../../../common/components/endpoint/responder');
jest.mock('../../../common/components/user_privileges');
jest.mock('../user_info', () => ({
@ -66,34 +55,18 @@ jest.mock('../../../common/hooks/use_license', () => ({
useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true, isEnterprise: () => false }),
}));
jest.mock('../../../common/utils/endpoint_alert_check', () => {
const realEndpointAlertCheckUtils = jest.requireActual(
'../../../common/utils/endpoint_alert_check'
);
return {
isTimelineEventItemAnAlert: realEndpointAlertCheckUtils.isTimelineEventItemAnAlert,
isAlertFromEndpointAlert: jest.fn().mockReturnValue(true),
isAlertFromEndpointEvent: jest.fn().mockReturnValue(true),
};
});
jest.mock('../../../../common/endpoint/service/host_isolation/utils', () => {
return {
isIsolationSupported: jest.fn().mockReturnValue(true),
};
});
jest.mock('../../containers/detection_engine/alerts/use_host_isolation_status', () => {
return {
useEndpointHostIsolationStatus: jest.fn().mockReturnValue({
loading: false,
isIsolated: false,
agentStatus: 'healthy',
}),
};
});
jest.mock('../../../common/components/user_privileges');
jest.mock(
'../../../common/components/endpoint/host_isolation/from_alerts/use_host_isolation_status',
() => {
return {
useEndpointHostIsolationStatus: jest.fn().mockReturnValue({
loading: false,
isIsolated: false,
agentStatus: 'healthy',
}),
};
}
);
describe('take action dropdown', () => {
let defaultProps: TakeActionDropdownProps;
@ -292,15 +265,15 @@ describe('take action dropdown', () => {
}
};
const setAlertDetailsDataMockToEndpointAgent = () => {
const setAgentTypeOnAlertDetailsDataMock = (agentType: string = 'endpoint') => {
if (defaultProps.detailsData) {
defaultProps.detailsData = defaultProps.detailsData.map((obj) => {
if (obj.field === 'agent.type') {
return {
category: 'agent',
field: 'agent.type',
values: ['endpoint'],
originalValue: ['endpoint'],
values: [agentType],
originalValue: [agentType],
};
}
if (obj.field === 'agent.id') {
@ -335,9 +308,32 @@ describe('take action dropdown', () => {
}
};
describe('should correctly enable/disable the "Add Endpoint event filter" button', () => {
let wrapper: ReactWrapper;
let wrapper: ReactWrapper;
const render = (): ReactWrapper => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
return wrapper;
};
it('should include the Isolate/Release action', () => {
render();
expect(wrapper.exists('[data-test-subj="isolate-host-action-item"]')).toBe(true);
});
it('should include the Responder action', () => {
render();
expect(wrapper.exists('[data-test-subj="endpointResponseActions-action-item"]')).toBe(true);
});
describe('should correctly enable/disable the "Add Endpoint event filter" button', () => {
beforeEach(() => {
setTypeOnEcsDataWithAgentType();
setAlertDetailsDataMockToEvent();
@ -348,12 +344,7 @@ describe('take action dropdown', () => {
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canWriteEventFilters: true },
});
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
render();
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').last().getDOMNode()
@ -366,206 +357,20 @@ describe('take action dropdown', () => {
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canWriteEventFilters: false },
});
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
render();
await waitFor(() => {
expect(wrapper.exists('[data-test-subj="add-event-filter-menu-item"]')).toBeFalsy();
});
});
test('should hide the "Add Endpoint event filter" button if provided no event from endpoint', async () => {
setAgentTypeOnAlertDetailsDataMock('filebeat');
setTypeOnEcsDataWithAgentType('filebeat');
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
render();
await waitFor(() => {
expect(wrapper.exists('[data-test-subj="add-event-filter-menu-item"]')).toBeFalsy();
});
});
});
describe('should correctly enable/disable the "Isolate Host" button', () => {
let wrapper: ReactWrapper;
const render = (): ReactWrapper => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
return wrapper;
};
const isolateHostButtonExists = (): ReturnType<typeof wrapper.exists> => {
return wrapper.exists('[data-test-subj="isolate-host-action-item"]');
};
beforeEach(() => {
setTypeOnEcsDataWithAgentType();
});
it('should show Isolate host button if user has "Host isolation" privileges set to all', async () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canIsolateHost: true },
});
render();
await waitFor(() => {
expect(isolateHostButtonExists()).toBeTruthy();
});
});
it('should hide Isolate host button if user has "Host isolation" privileges set to none', () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canIsolateHost: false },
});
render();
expect(isolateHostButtonExists()).toBeFalsy();
});
});
describe('should correctly enable/disable the "Respond" button', () => {
let wrapper: ReactWrapper;
let apiMocks: ReturnType<typeof endpointMetadataHttpMocks>;
const render = (): ReactWrapper => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
return wrapper;
};
const findLaunchResponderButton = (): ReturnType<typeof wrapper.find> => {
return wrapper.find('[data-test-subj="endpointResponseActions-action-item"]');
};
beforeAll(() => {
// Un-Mock endpoint alert check hooks
const actualChecks = jest.requireActual('../../../common/utils/endpoint_alert_check');
(isAlertFromEndpointEvent as jest.Mock).mockImplementation(
actualChecks.isAlertFromEndpointEvent
);
(isAlertFromEndpointAlert as jest.Mock).mockImplementation(
actualChecks.isAlertFromEndpointAlert
);
});
afterAll(() => {
// Set the mock modules back to what they were
(isAlertFromEndpointEvent as jest.Mock).mockImplementation(() => true);
(isAlertFromEndpointAlert as jest.Mock).mockImplementation(() => true);
});
beforeEach(() => {
setTypeOnEcsDataWithAgentType();
apiMocks = endpointMetadataHttpMocks(mockStartServicesMock.http as jest.Mocked<HttpSetup>);
});
it('should not display the button if user is not allowed to write event filters', async () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canWriteEventFilters: false },
});
render();
expect(findLaunchResponderButton()).toHaveLength(0);
});
it('should not display the button for Events', async () => {
setAlertDetailsDataMockToEvent();
render();
expect(findLaunchResponderButton()).toHaveLength(0);
});
it('should enable button for non endpoint event type when defend integration present', async () => {
setTypeOnEcsDataWithAgentType('filebeat');
if (defaultProps.detailsData) {
defaultProps.detailsData = generateAlertDetailsDataMock() as TimelineEventsDetailsItem[];
}
render();
expect(findLaunchResponderButton().first().prop('disabled')).toBe(true);
expect(findLaunchResponderButton().first().prop('toolTipContent')).toEqual(
LOADING_ENDPOINT_DATA_TOOLTIP
);
await waitFor(() => {
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalled();
wrapper.update();
expect(findLaunchResponderButton().first().prop('disabled')).toBe(false);
expect(findLaunchResponderButton().first().prop('toolTipContent')).toEqual(undefined);
});
});
it('should disable the button for non endpoint event type when defend integration not present', async () => {
setAlertDetailsDataMockToEndpointAgent();
apiMocks.responseProvider.metadataDetails.mockImplementation(() => {
const error: Error & { body?: { statusCode: number } } = new Error();
error.body = { statusCode: 404 };
throw error;
});
render();
await waitFor(() => {
expect(apiMocks.responseProvider.metadataDetails).toThrow();
wrapper.update();
expect(findLaunchResponderButton().first().prop('disabled')).toBe(true);
expect(findLaunchResponderButton().first().prop('toolTipContent')).toEqual(
NOT_FROM_ENDPOINT_HOST_TOOLTIP
);
});
});
it('should disable the button if host status is unenrolled', async () => {
setAlertDetailsDataMockToEndpointAgent();
const getApiResponse = apiMocks.responseProvider.metadataDetails.getMockImplementation();
apiMocks.responseProvider.metadataDetails.mockImplementation(() => {
if (getApiResponse) {
return {
...getApiResponse(),
metadata: {
...getApiResponse().metadata,
Endpoint: {
...getApiResponse().metadata.Endpoint,
capabilities: [...ENDPOINT_CAPABILITIES],
},
},
host_status: HostStatus.UNENROLLED,
};
}
throw new Error('some error');
});
render();
await waitFor(() => {
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalled();
wrapper.update();
expect(findLaunchResponderButton().first().prop('disabled')).toBe(true);
expect(findLaunchResponderButton().first().prop('toolTipContent')).toEqual(
HOST_ENDPOINT_UNENROLLED_TOOLTIP
);
});
});
});
});
});

View file

@ -11,23 +11,21 @@ import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-typ
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { TableId } from '@kbn/securitysolution-data-table';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { getAlertDetailsFieldValue } from '../../../common/lib/endpoint/utils/get_event_details_field_values';
import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../../common/components/guided_onboarding_tour/tour_config';
import { isActiveTimeline } from '../../../helpers';
import { useResponderActionItem } from '../endpoint_responder';
import { TAKE_ACTION } from '../alerts_table/additional_filters_action/translations';
import { useAlertExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions';
import { useAlertsActions } from '../alerts_table/timeline_actions/use_alerts_actions';
import { useInvestigateInTimeline } from '../alerts_table/timeline_actions/use_investigate_in_timeline';
import { useEventFilterAction } from '../alerts_table/timeline_actions/use_event_filter_action';
import { useHostIsolationAction } from '../host_isolation/use_host_isolation_action';
import { getFieldValue } from '../host_isolation/helpers';
import { useResponderActionItem } from '../../../common/components/endpoint/responder';
import { useHostIsolationAction } from '../../../common/components/endpoint/host_isolation';
import type { Status } from '../../../../common/api/detection_engine';
import { isAlertFromEndpointAlert } from '../../../common/utils/endpoint_alert_check';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions';
import { useKibana } from '../../../common/lib/kibana';
@ -97,7 +95,10 @@ export const TakeActionDropdown = React.memo(
].reduce<ActionsData>(
(acc, curr) => ({
...acc,
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
[curr.name]: getAlertDetailsFieldValue(
{ category: curr.category, field: curr.field },
detailsData
),
}),
{} as ActionsData
),
@ -107,11 +108,10 @@ export const TakeActionDropdown = React.memo(
const isEvent = actionsData.eventKind === 'event';
const isAgentEndpoint = useMemo(() => ecsData?.agent?.type?.includes('endpoint'), [ecsData]);
const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData),
const osqueryAgentId = useMemo(
() => getAlertDetailsFieldValue({ category: 'agent', field: 'agent.id' }, detailsData),
[detailsData]
);
@ -157,7 +157,7 @@ export const TakeActionDropdown = React.memo(
);
const { exceptionActionItems } = useAlertExceptionActions({
isEndpointAlert: isAlertFromEndpointAlert({ ecsData }),
isEndpointAlert: Boolean(isAgentEndpoint),
onAddExceptionTypeClick: handleOnAddExceptionTypeClick,
});
@ -208,13 +208,13 @@ export const TakeActionDropdown = React.memo(
});
const osqueryAvailable = osquery?.isOsqueryAvailable({
agentId,
agentId: osqueryAgentId,
});
const handleOnOsqueryClick = useCallback(() => {
onOsqueryClick(agentId);
onOsqueryClick(osqueryAgentId);
setIsPopoverOpen(false);
}, [onOsqueryClick, setIsPopoverOpen, agentId]);
}, [onOsqueryClick, setIsPopoverOpen, osqueryAgentId]);
const osqueryActionItem = useMemo(
() =>

View file

@ -30,7 +30,7 @@ import type {
CheckSignalIndex,
UpdateAlertStatusByIdsProps,
} from './types';
import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isolation';
import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint/endpoint_isolation';
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
/**

View file

@ -77,7 +77,7 @@ import { useSourcererDataView } from '../../../../sourcerer/containers';
import { EmptyPrompt } from '../../../../common/components/empty_prompt';
import { AlertCountByRuleByStatus } from '../../../../common/components/alert_count_by_status';
import { useLicense } from '../../../../common/hooks/use_license';
import { ResponderActionButton } from '../../../../detections/components/endpoint_responder/responder_action_button';
import { ResponderActionButton } from '../../../../common/components/endpoint/responder';
import { useRefetchOverviewPageRiskScore } from '../../../../entity_analytics/api/hooks/use_refetch_overview_page_risk_score';
const ES_HOST_FIELD = 'host.name';
@ -226,7 +226,7 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
rightSideItems={[
hostOverview.endpoint?.hostInfo?.metadata.elastic.agent.id && (
<ResponderActionButton
endpointId={hostOverview.endpoint?.hostInfo?.metadata.elastic.agent.id}
agentId={hostOverview.endpoint?.hostInfo?.metadata.elastic.agent.id}
agentType="endpoint"
/>
),

View file

@ -10,10 +10,12 @@ import React, { useCallback } from 'react';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { DocumentDetailsRightPanelKey } from '../shared/constants/panel_keys';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { EndpointIsolateSuccess } from '../../../common/components/endpoint/host_isolation';
import {
EndpointIsolateSuccess,
HostIsolationPanel,
} from '../../../common/components/endpoint/host_isolation';
import { useHostIsolationTools } from '../../../timelines/components/side_panel/event_details/use_host_isolation_tools';
import { useIsolateHostPanelContext } from './context';
import { HostIsolationPanel } from '../../../detections/components/host_isolation';
import { FlyoutBody } from '../../shared/components/flyout_body';
/**

View file

@ -6,128 +6,95 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import type { IsolateHostPanelContext } from './context';
import { useIsolateHostPanelContext } from './context';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { PanelHeader } from './header';
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
import { isAlertFromSentinelOneEvent } from '../../../common/utils/sentinelone_alert_check';
import { isAlertFromCrowdstrikeEvent } from '../../../common/utils/crowdstrike_alert_check';
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer, endpointAlertDataMock } from '../../../common/mock/endpoint';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../common/endpoint/service/response_actions/constants';
import { ISOLATE_HOST, UNISOLATE_HOST } from '../../../common/components/endpoint/host_isolation';
import { TECHNICAL_PREVIEW } from '../../../common/translations';
jest.mock('../../../common/hooks/use_experimental_features');
jest.mock('../../../common/utils/sentinelone_alert_check');
jest.mock('../../../common/utils/crowdstrike_alert_check');
jest.mock('./context');
const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock;
const mockIsAlertFromSentinelOneEvent = isAlertFromSentinelOneEvent as jest.Mock;
const mockIsAlertFromCrowdstrike = isAlertFromCrowdstrikeEvent as jest.Mock;
describe('Isolation Flyout PanelHeader', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
const renderPanelHeader = () =>
render(
<IntlProvider locale="en">
<PanelHeader />
</IntlProvider>
);
describe('<PanelHeader />', () => {
beforeEach(() => {
mockUseIsExperimentalFeatureEnabled.mockReturnValue(false);
mockIsAlertFromSentinelOneEvent.mockReturnValue(false);
mockIsAlertFromCrowdstrike.mockReturnValue(false);
});
it.each([
{
const setUseIsolateHostPanelContext = (data: Partial<IsolateHostPanelContext> = {}) => {
const panelContextMock: IsolateHostPanelContext = {
eventId: 'some-even-1',
indexName: 'some-index-name',
scopeId: 'some-scope-id',
dataFormattedForFieldBrowser: endpointAlertDataMock.generateEndpointAlertDetailsItemData(),
isolateAction: 'isolateHost',
title: 'Isolate host',
},
{
isolateAction: 'unisolateHost',
title: 'Release host',
},
])('should display release host message', ({ isolateAction, title }) => {
(useIsolateHostPanelContext as jest.Mock).mockReturnValue({ isolateAction });
...data,
};
const { getByTestId } = renderPanelHeader();
(useIsolateHostPanelContext as jest.Mock).mockReturnValue(panelContextMock);
};
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent(title);
beforeEach(() => {
const appContextMock = createAppRootMockRenderer();
appContextMock.setExperimentalFlag({
responseActionsSentinelOneV1Enabled: true,
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
});
render = () => appContextMock.render(<PanelHeader />);
setUseIsolateHostPanelContext({
isolateAction: 'isolateHost',
dataFormattedForFieldBrowser: endpointAlertDataMock.generateEndpointAlertDetailsItemData(),
});
});
it.each([
{
action: 'isolateHost',
alertCheck: mockIsAlertFromSentinelOneEvent,
description: 'SentinelOne',
},
{
action: 'unisolateHost',
alertCheck: mockIsAlertFromSentinelOneEvent,
description: 'SentinelOne',
},
{
action: 'isolateHost',
alertCheck: mockIsAlertFromCrowdstrike,
description: 'Crowdstrike',
},
{
action: 'unisolateHost',
alertCheck: mockIsAlertFromCrowdstrike,
description: 'Crowdstrike',
},
])(
'should display beta badge on $description alerts for %s host message',
({ action, alertCheck }) => {
(useIsolateHostPanelContext as jest.Mock).mockReturnValue({
afterEach(() => {
jest.clearAllMocks();
});
const testConditions: Array<{
action: IsolateHostPanelContext['isolateAction'];
agentType: ResponseActionAgentType;
title: string;
// if `expectedBadgeText` is `undefined`, then it validates that the badge is not displayed
expectedBadgeText: string | undefined;
}> = [];
for (const agentType of RESPONSE_ACTION_AGENT_TYPE) {
(['isolateHost', 'unisolateHost'] as Array<IsolateHostPanelContext['isolateAction']>).forEach(
(action) => {
testConditions.push({
action,
agentType,
title: action === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST,
expectedBadgeText:
agentType === 'crowdstrike' || agentType === 'sentinel_one'
? TECHNICAL_PREVIEW
: undefined,
});
}
);
}
it.each(testConditions)(
'should display correct flyout header title for $action on agentType $agentType',
({ action, agentType, title, expectedBadgeText }) => {
setUseIsolateHostPanelContext({
isolateAction: action,
dataFormattedForFieldBrowser:
endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentType),
});
mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
alertCheck.mockReturnValue(true);
const { getByTestId, queryByTestId } = render();
const { getByTestId } = renderPanelHeader();
expect(getByTestId('flyoutHostIsolationHeaderTitle')).toHaveTextContent(title);
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent(TECHNICAL_PREVIEW);
}
);
it.each([
{
action: 'isolateHost',
alertCheck: mockIsAlertFromSentinelOneEvent,
description: 'SentinelOne',
},
{
action: 'unisolateHost',
alertCheck: mockIsAlertFromSentinelOneEvent,
description: 'SentinelOne',
},
{
action: 'isolateHost',
alertCheck: mockIsAlertFromCrowdstrike,
description: 'Crowdstrike',
},
{
action: 'unisolateHost',
alertCheck: mockIsAlertFromCrowdstrike,
description: 'Crowdstrike',
},
])(
'should not display beta badge on $description alerts for %s host message',
({ action, alertCheck }) => {
(useIsolateHostPanelContext as jest.Mock).mockReturnValue({
isolateAction: action,
});
mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
alertCheck.mockReturnValue(false);
const { getByTestId } = renderPanelHeader();
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).not.toHaveTextContent(TECHNICAL_PREVIEW);
if (expectedBadgeText) {
expect(getByTestId('flyoutHostIsolationHeaderBadge')).toHaveTextContent(expectedBadgeText);
} else {
expect(queryByTestId('flyoutHostIsolationHeaderBadge')).toBeNull();
}
}
);
});

View file

@ -7,51 +7,40 @@
import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import type { FC } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { isAlertFromCrowdstrikeEvent } from '../../../common/utils/crowdstrike_alert_check';
import React, { useMemo } from 'react';
import { useAlertResponseActionsSupport } from '../../../common/hooks/endpoint/use_alert_response_actions_support';
import { TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_TOOLTIP } from '../../../common/translations';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { isAlertFromSentinelOneEvent } from '../../../common/utils/sentinelone_alert_check';
import { useIsolateHostPanelContext } from './context';
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
import { FlyoutHeader } from '../../shared/components/flyout_header';
import { ISOLATE_HOST, UNISOLATE_HOST } from '../../../common/components/endpoint';
/**
* Document details expandable right section header for the isolate host panel
*/
export const PanelHeader: FC = () => {
const { isolateAction, dataFormattedForFieldBrowser: data } = useIsolateHostPanelContext();
const isSentinelOneAlert = isAlertFromSentinelOneEvent({ data });
const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled(
'responseActionsSentinelOneV1Enabled'
);
const {
isSupported: supportsResponseActions,
details: { agentType },
} = useAlertResponseActionsSupport(data);
const showTechPreviewBadge: boolean = useMemo(() => {
return supportsResponseActions && (agentType === 'sentinel_one' || agentType === 'crowdstrike');
}, [agentType, supportsResponseActions]);
const isAlertFromCrowdstrikeAlert = isAlertFromCrowdstrikeEvent({ data });
const responseActionsCrowdstrikeManualHostIsolationEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
const showAsTechPreview =
(isSentinelOneV1Enabled && isSentinelOneAlert) ||
(responseActionsCrowdstrikeManualHostIsolationEnabled && isAlertFromCrowdstrikeAlert);
const title = (
<EuiFlexGroup responsive gutterSize="s">
<EuiFlexItem grow={false}>
{isolateAction === 'isolateHost' ? (
<FormattedMessage
id="xpack.securitySolution.flyout.isolateHost.isolateTitle"
defaultMessage="Isolate host"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.flyout.isolateHost.releaseTitle"
defaultMessage="Release host"
/>
)}
<EuiFlexItem grow={false} data-test-subj="flyoutHostIsolationHeaderTitle">
{isolateAction === 'isolateHost' ? <>{ISOLATE_HOST}</> : <>{UNISOLATE_HOST}</>}
</EuiFlexItem>
{showAsTechPreview && (
{showTechPreviewBadge && (
<EuiFlexItem grow={false}>
<EuiBetaBadge label={TECHNICAL_PREVIEW} tooltipContent={TECHNICAL_PREVIEW_TOOLTIP} />
<EuiBetaBadge
data-test-subj="flyoutHostIsolationHeaderBadge"
label={TECHNICAL_PREVIEW}
tooltipContent={TECHNICAL_PREVIEW_TOOLTIP}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -11,12 +11,10 @@ import { EuiFlexItem, EuiLink } from '@elastic/eui';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../common/utils/sentinelone_alert_check';
import {
AgentStatus,
EndpointAgentStatusById,
} from '../../../../common/components/agents/agent_status';
import { CROWDSTRIKE_AGENT_ID_FIELD } from '../../../../common/utils/crowdstrike_alert_check';
} from '../../../../common/components/endpoint/agents/agent_status';
import { useRightPanelContext } from '../context';
import {
AGENT_STATUS_FIELD_NAME,
@ -32,6 +30,7 @@ import {
HIGHLIGHTED_FIELDS_CELL_TEST_ID,
HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID,
} from './test_ids';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../../common/endpoint/service/response_actions/constants';
interface LinkFieldCellProps {
/**
@ -117,11 +116,11 @@ export const HighlightedFieldsCell: VFC<HighlightedFieldsCellProps> = ({
originalField,
}) => {
const isSentinelOneAgentIdField = useMemo(
() => originalField === SENTINEL_ONE_AGENT_ID_FIELD,
() => originalField === RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one,
[originalField]
);
const isCrowdstrikeAgentIdField = useMemo(
() => originalField === CROWDSTRIKE_AGENT_ID_FIELD,
() => originalField === RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike,
[originalField]
);
const agentType: ResponseActionAgentType = useMemo(() => {

View file

@ -12,7 +12,9 @@ import {
mockDataFormattedForFieldBrowserWithOverridenField,
} from '../mocks/mock_data_formatted_for_field_browser';
import { useHighlightedFields } from './use_highlighted_fields';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../common/utils/sentinelone_alert_check';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../../common/endpoint/service/response_actions/constants';
jest.mock('../../../../common/experimental_features_service');
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
@ -104,7 +106,7 @@ describe('useHighlightedFields', () => {
useHighlightedFields({
dataFormattedForFieldBrowser: dataFormattedForFieldBrowser.concat({
category: 'observer',
field: `observer.${SENTINEL_ONE_AGENT_ID_FIELD}`,
field: `observer.${RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one}`,
values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'],
originalValue: ['deb35a20-70f8-458e-a64a-c9e6f7575893'],
isObjectArray: false,
@ -154,7 +156,7 @@ describe('useHighlightedFields', () => {
},
{
category: 'observer',
field: SENTINEL_ONE_AGENT_ID_FIELD,
field: RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one,
values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'],
originalValue: ['deb35a20-70f8-458e-a64a-c9e6f7575893'],
isObjectArray: false,

View file

@ -8,15 +8,8 @@
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { find, isEmpty } from 'lodash/fp';
import { ALERT_RULE_TYPE } from '@kbn/rule-data-utils';
import {
CROWDSTRIKE_AGENT_ID_FIELD,
isAlertFromCrowdstrikeEvent,
} from '../../../../common/utils/crowdstrike_alert_check';
import {
SENTINEL_ONE_AGENT_ID_FIELD,
isAlertFromSentinelOneEvent,
} from '../../../../common/utils/sentinelone_alert_check';
import { isAlertFromEndpointEvent } from '../../../../common/utils/endpoint_alert_check';
import { useAlertResponseActionsSupport } from '../../../../common/hooks/endpoint/use_alert_response_actions_support';
import { isResponseActionsAlertAgentIdField } from '../../../../common/lib/endpoint';
import {
getEventCategoriesFromData,
getEventFieldsToDisplay,
@ -53,6 +46,7 @@ export const useHighlightedFields = ({
dataFormattedForFieldBrowser,
investigationFields,
}: UseHighlightedFieldsParams): UseHighlightedFieldsResult => {
const responseActionsSupport = useAlertResponseActionsSupport(dataFormattedForFieldBrowser);
const eventCategories = getEventCategoriesFromData(dataFormattedForFieldBrowser);
const eventCodeField = find(
@ -99,26 +93,12 @@ export const useHighlightedFields = ({
field.id = field.legacyId;
}
// if the field is agent.id and the event is not an endpoint event we skip it
// If the field is one used by a supported Response Actions agentType,
// but the alert field is not the one that the agentType on the alert host uses,
// then exit and return accumulator
if (
field.id === 'agent.id' &&
!isAlertFromEndpointEvent({ data: dataFormattedForFieldBrowser })
) {
return acc;
}
// if the field is observer.serial_number and the event is not a sentinel one event we skip it
if (
field.id === SENTINEL_ONE_AGENT_ID_FIELD &&
!isAlertFromSentinelOneEvent({ data: dataFormattedForFieldBrowser })
) {
return acc;
}
// if the field is crowdstrike.event.DeviceId and the event is not a crowdstrike event we skip it
if (
field.id === CROWDSTRIKE_AGENT_ID_FIELD &&
!isAlertFromCrowdstrikeEvent({ data: dataFormattedForFieldBrowser })
isResponseActionsAlertAgentIdField(field.id) &&
responseActionsSupport.details.agentIdField !== field.id
) {
return acc;
}

View file

@ -10,7 +10,7 @@ import { EuiHealth } from '@elastic/eui';
import type { EntityTableRows } from '../../shared/components/entity_table/types';
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
import { EndpointAgentStatus } from '../../../../common/components/agents/agent_status';
import { EndpointAgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import type { HostItem } from '../../../../../common/search_strategy';
import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy';

View file

@ -19,7 +19,7 @@ import type { CommandExecutionComponentProps } from '../../console/types';
import { FormattedError } from '../../formatted_error';
import { ConsoleCodeBlock } from '../../console/components/console_code_block';
import { POLICY_STATUS_TO_TEXT } from '../../../pages/endpoint_hosts/view/host_constants';
import { getAgentStatusText } from '../../../../common/components/agents/agent_status_text';
import { getAgentStatusText } from '../../../../common/components/endpoint/agents/agent_status_text';
export const EndpointStatusActionResult = memo<
CommandExecutionComponentProps<

View file

@ -6,7 +6,7 @@
*/
import React, { memo } from 'react';
import { AgentStatus } from '../../../../../../common/components/agents/agent_status';
import { AgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { useAgentStatusHook } from '../../../../../hooks/agents/use_get_agent_status';
import type { ThirdPartyAgentInfo } from '../../../../../../../common/types';
import { HeaderAgentInfo } from '../header_agent_info';

View file

@ -7,7 +7,7 @@
import React, { memo } from 'react';
import { EuiSkeletonText } from '@elastic/eui';
import { EndpointAgentStatus } from '../../../../../../common/components/agents/agent_status';
import { EndpointAgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { HeaderAgentInfo } from '../header_agent_info';
import { useGetEndpointDetails } from '../../../../../hooks';
import type { Platform } from '../platforms';

View file

@ -6,7 +6,7 @@
*/
import React, { memo } from 'react';
import { AgentStatus } from '../../../../../../common/components/agents/agent_status';
import { AgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { useAgentStatusHook } from '../../../../../hooks/agents/use_get_agent_status';
import { useIsExperimentalFeatureEnabled } from '../../../../../../common/hooks/use_experimental_features';
import type { ThirdPartyAgentInfo } from '../../../../../../../common/types';

View file

@ -9,6 +9,7 @@ import React, { memo, useMemo } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useAgentStatusHook } from '../../../hooks/agents/use_get_agent_status';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
@ -27,16 +28,10 @@ export const OfflineCallout = memo<OfflineCalloutProps>(({ agentType, endpointId
const isCrowdstrikeAgent = agentType === 'crowdstrike';
const getAgentStatus = useAgentStatusHook();
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled(
'responseActionsSentinelOneV1Enabled'
);
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const crowdstrikeManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
const isAgentTypeEnabled = useMemo(() => {
return isAgentTypeAndActionSupported(agentType);
}, [agentType]);
const { data: endpointDetails } = useGetEndpointDetails(endpointId, {
refetchInterval: 10000,
@ -45,9 +40,7 @@ export const OfflineCallout = memo<OfflineCalloutProps>(({ agentType, endpointId
const { data } = getAgentStatus([endpointId], agentType, {
enabled:
(sentinelOneManualHostActionsEnabled && isSentinelOneAgent) ||
(crowdstrikeManualHostActionsEnabled && isCrowdstrikeAgent) ||
(isEndpointAgent && agentStatusClientEnabled),
(isEndpointAgent && agentStatusClientEnabled) || (!isEndpointAgent && isAgentTypeEnabled),
});
const showOfflineCallout = useMemo(
() =>
@ -64,11 +57,7 @@ export const OfflineCallout = memo<OfflineCalloutProps>(({ agentType, endpointId
]
);
if (
(isEndpointAgent && !endpointDetails) ||
(isSentinelOneV1Enabled && isSentinelOneAgent && !data) ||
(crowdstrikeManualHostActionsEnabled && isCrowdstrikeAgent && !data)
) {
if ((isEndpointAgent && !endpointDetails) || (isAgentTypeEnabled && !data)) {
return null;
}

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { isActionSupportedByAgentType } from '../../../../../common/endpoint/service/response_actions/is_response_action_supported';
import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint';
import { getRbacControl } from '../../../../../common/endpoint/service/response_actions/utils';
import { UploadActionResult } from '../command_render_components/upload_action';
import { ArgumentFileSelector } from '../../console_argument_selectors';
@ -139,6 +139,7 @@ const COMMENT_ARG_ABOUT = i18n.translate(
export interface GetEndpointConsoleCommandsOptions {
endpointAgentId: string;
agentType: ResponseActionAgentType;
/** Applicable only for Endpoint Agents */
endpointCapabilities: ImmutableArray<string>;
endpointPrivileges: EndpointPrivileges;
}
@ -556,43 +557,30 @@ export const getEndpointConsoleCommands = ({
}
};
/** @private */
const disableCommand = (command: CommandDefinition, agentType: ResponseActionAgentType) => {
command.helpDisabled = true;
command.helpHidden = true;
command.validate = () =>
UPGRADE_AGENT_FOR_RESPONDER(agentType, command.name as ConsoleResponseActionCommands);
};
/** @private */
const adjustCommandsForSentinelOne = ({
commandList,
}: {
commandList: CommandDefinition[];
}): CommandDefinition[] => {
const featureFlags = ExperimentalFeaturesService.get();
const isHostIsolationEnabled = featureFlags.responseActionsSentinelOneV1Enabled;
const isGetFileFeatureEnabled = featureFlags.responseActionsSentinelOneGetFileEnabled;
const disableCommand = (command: CommandDefinition) => {
command.helpDisabled = true;
command.helpHidden = true;
command.validate = () =>
UPGRADE_AGENT_FOR_RESPONDER('sentinel_one', command.name as ConsoleResponseActionCommands);
};
return commandList.map((command) => {
const agentSupportsResponseAction =
command.name === 'status'
? false
: isActionSupportedByAgentType(
'sentinel_one',
RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP[
command.name as ConsoleResponseActionCommands
],
'manual'
);
// If command is not supported by SentinelOne - disable it
if (
!agentSupportsResponseAction ||
(command.name === 'get-file' && !isGetFileFeatureEnabled) ||
(command.name === 'isolate' && !isHostIsolationEnabled) ||
(command.name === 'release' && !isHostIsolationEnabled)
command.name === 'status' ||
!isAgentTypeAndActionSupported(
'sentinel_one',
RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP[command.name as ConsoleResponseActionCommands],
'manual'
)
) {
disableCommand(command);
disableCommand(command, 'sentinel_one');
}
return command;
@ -605,35 +593,16 @@ const adjustCommandsForCrowdstrike = ({
}: {
commandList: CommandDefinition[];
}): CommandDefinition[] => {
const featureFlags = ExperimentalFeaturesService.get();
const isHostIsolationEnabled = featureFlags.responseActionsCrowdstrikeManualHostIsolationEnabled;
const disableCommand = (command: CommandDefinition) => {
command.helpDisabled = true;
command.helpHidden = true;
command.validate = () =>
UPGRADE_AGENT_FOR_RESPONDER('crowdstrike', command.name as ConsoleResponseActionCommands);
};
return commandList.map((command) => {
const agentSupportsResponseAction =
command.name === 'status'
? false
: isActionSupportedByAgentType(
'crowdstrike',
RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP[
command.name as ConsoleResponseActionCommands
],
'manual'
);
// If command is not supported by Crowdstrike - disable it
if (
!agentSupportsResponseAction ||
(command.name === 'isolate' && !isHostIsolationEnabled) ||
(command.name === 'release' && !isHostIsolationEnabled)
command.name === 'status' ||
!isAgentTypeAndActionSupported(
'crowdstrike',
RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP[command.name as ConsoleResponseActionCommands],
'manual'
)
) {
disableCommand(command);
disableCommand(command, 'crowdstrike');
}
return command;

View file

@ -9,7 +9,7 @@ import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { PendingActionsResponse } from '../../../../common/endpoint/types';
import { fetchPendingActionsByAgentId } from '../../../common/lib/endpoint_pending_actions';
import { fetchPendingActionsByAgentId } from '../../../common/lib/endpoint/endpoint_pending_actions';
/**
* Retrieves the pending actions against the given Endpoint `agent.id`'s

View file

@ -8,7 +8,7 @@
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { isolateHost } from '../../../common/lib/endpoint_isolation';
import { isolateHost } from '../../../common/lib/endpoint/endpoint_isolation';
import type {
HostIsolationRequestBody,
ResponseActionApiResponse,

View file

@ -12,7 +12,7 @@ import type {
HostIsolationRequestBody,
ResponseActionApiResponse,
} from '../../../../common/endpoint/types';
import { unIsolateHost } from '../../../common/lib/endpoint_isolation';
import { unIsolateHost } from '../../../common/lib/endpoint/endpoint_isolation';
/**
* Create host release requests

View file

@ -23,8 +23,8 @@ import {
HOST_METADATA_LIST_ROUTE,
METADATA_TRANSFORMS_STATUS_ROUTE,
} from '../../../../common/endpoint/constants';
import type { PendingActionsHttpMockInterface } from '../../../common/lib/endpoint_pending_actions/mocks';
import { pendingActionsHttpMock } from '../../../common/lib/endpoint_pending_actions/mocks';
import type { PendingActionsHttpMockInterface } from '../../../common/lib/endpoint/endpoint_pending_actions/mocks';
import { pendingActionsHttpMock } from '../../../common/lib/endpoint/endpoint_pending_actions/mocks';
import { TRANSFORM_STATES } from '../../../../common/constants';
import type { TransformStatsResponse } from './types';
import type {

View file

@ -39,7 +39,7 @@ import {
hostIsolationHttpMocks,
hostIsolationRequestBodyMock,
hostIsolationResponseMock,
} from '../../../../common/lib/endpoint_isolation/mocks';
} from '../../../../common/lib/endpoint/endpoint_isolation/mocks';
import { endpointPageHttpMock, failedTransformStateMock } from '../mocks';
import { HOST_METADATA_LIST_ROUTE } from '../../../../../common/endpoint/constants';

Some files were not shown because too many files have changed in this diff Show more