mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
7528264531
commit
f820f78807
121 changed files with 2596 additions and 2737 deletions
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 });
|
||||
|
|
@ -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;
|
|
@ -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 {
|
|
@ -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 &
|
|
@ -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';
|
||||
|
|
@ -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', {
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { useResponderActionItem } from './use_responder_action_item';
|
||||
export * from '../from_alerts/__mocks__';
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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(
|
|
@ -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';
|
|
@ -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';
|
|
@ -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(
|
||||
({
|
|
@ -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(
|
||||
({
|
|
@ -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;
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
]);
|
||||
};
|
|
@ -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
|
|
@ -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;
|
|
@ -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';
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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__';
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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]);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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(() => {
|
|
@ -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';
|
|
@ -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'],
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 };
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
]);
|
||||
};
|
|
@ -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;
|
|
@ -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 (
|
|
@ -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 {
|
|
@ -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'];
|
|
@ -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
|
|
@ -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: [
|
|
@ -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';
|
|
@ -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] : '';
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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,
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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({
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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}`);
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
]);
|
||||
};
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
),
|
||||
|
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue